diff --git a/button.go b/button.go index 83cfa26..d3c2e2f 100644 --- a/button.go +++ b/button.go @@ -29,6 +29,9 @@ type ButtonOptions struct { Label string } +// buttonSpinnerSize is the leading icon slot, which the spinner takes while loading. +const buttonSpinnerSize float32 = 16 + // Button is a push button. Loading keeps its size and blocks clicks. func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element { if label == "" && opts.Label == "" { @@ -36,8 +39,13 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element { } k := Tokens(c) d := Density(c) + pad, gap := d.Unit()*4, d.Unit()*2 + if opts.Loading && opts.Icon == nil && label != "" { + // The spinner slot comes out of the padding, so the width holds. + pad -= (buttonSpinnerSize + gap) / 2 + } b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize). - Padding(0, d.Unit()*4).Gap(d.Unit() * 2).Radius(theme.ControlRadius). + Padding(0, pad).Gap(gap).Radius(theme.ControlRadius). Disabled(opts.Disabled || opts.Loading) internal.Ring(b, k.Focus, theme.ControlRadius) if opts.Label != "" { @@ -49,25 +57,13 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element { b.Border(theme.BorderWidth, border) } b.Children(func() { - switch { - case opts.Loading && opts.Icon != nil: - // The spinner takes the icon's 16 DIP slot. - Spinner(c, SpinnerOptions{Size: 16, Color: fg}) - case opts.Loading && label == "": - Spinner(c, SpinnerOptions{Size: 16, Color: fg}) - case opts.Loading: - // Over the hidden label, so the width stays. - ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Children(func() { - Spinner(c, SpinnerOptions{Size: 16, Color: fg}) - }) - case opts.Icon != nil: - ui.Icon(c, opts.Icon).Size(16, 16) + if opts.Loading { + Spinner(c, SpinnerOptions{Size: buttonSpinnerSize, Color: fg}) + } else if opts.Icon != nil { + ui.Icon(c, opts.Icon).Size(buttonSpinnerSize, buttonSpinnerSize) } if label != "" { - t := ui.Text(c, label).SingleLine() - if opts.Loading && opts.Icon == nil { - t.TextColor(fg.Alpha(0)) - } + ui.Text(c, label).SingleLine() } }) return b diff --git a/checkbox.go b/checkbox.go index c294aea..5a420ee 100644 --- a/checkbox.go +++ b/checkbox.go @@ -32,7 +32,7 @@ func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *u wrap.Children(func() { row = ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false) }) - if opts.Disabled && wrap.FocusWithin() { + if wrap.IsDisabled() && wrap.FocusWithin() { wrap.FocusRing(false).Focus() } if row.Changed() && opts.Indeterminate && !*value { diff --git a/docs/components/021-button.md b/docs/components/021-button.md index ec90fe8..e22d459 100644 --- a/docs/components/021-button.md +++ b/docs/components/021-button.md @@ -21,7 +21,7 @@ if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Cli | `label` | `string` | 必填(仅图标时可为空) | 按钮文字;为空时必须提供 `Label`,否则 panic。 | | `Variant` | `ButtonVariant` | `Primary` | `Primary`、`Secondary`、`Outline`、`Ghost`、`Danger`。 | | `Icon` | `*ui.SVG` | `nil` | 文字前的 16 DIP 图标。 | -| `Loading` | `bool` | `false` | 有图标时加载标记占用图标位置;纯文字时标记居中覆盖在隐藏的文字上,宽度不变;阻止点击。 | +| `Loading` | `bool` | `false` | 加载标记出现在前置 16 DIP 图标位置,文字保持可见;有图标时替换图标,纯文字时从左右内边距中让出该位置,宽度不变;阻止点击。 | | `Disabled` | `bool` | `false` | 不可点击、不进入 Tab 序列。 | | `Label` | `string` | `""` | 无障碍名称;`label` 为空时必填。 | diff --git a/docs/components/024-text-input.md b/docs/components/024-text-input.md index d363468..32d4e5f 100644 --- a/docs/components/024-text-input.md +++ b/docs/components/024-text-input.md @@ -32,7 +32,7 @@ if r.Submitted() { ## 状态 -默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。聚焦时被禁用会放弃焦点,已排队的按键不再修改值、不触发事件。颜色变化 120 毫秒过渡,减少动画时直接切换。 +默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。禁用来自自身或任一祖先容器;聚焦时被禁用会放弃焦点,已排队的按键不再修改值、不触发事件。颜色变化 120 毫秒过渡,减少动画时直接切换。 ## 事件 @@ -41,9 +41,10 @@ if r.Submitted() { ## 键盘操作 -Tab 聚焦(全选内容);Enter 提交;Escape 清空(仅 `Clearable` 且有内容时,否则交给外层);Cmd/Ctrl+Z、Shift+Cmd+Z 撤销重做;Cmd/Ctrl+A/C/X/V;输入法组合期间不提交。 +Tab 聚焦(全选内容);Enter 提交;Escape 清空(仅 `Clearable` 且有内容时,否则交给外层);Cmd/Ctrl+Z、Shift+Cmd+Z 撤销重做;Cmd/Ctrl+A/C/X/V;组合中的文本不会写入值。 ## 限制 +- 输入法组合期间无法阻止 Enter 提交:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。各平台输入法在组合时通常自己消费 Enter。 - MyGo 没有公开的只读输入接口(只读模式仅用于 `Text.Selectable`,`editor.go:77`)。`ReadOnly` 通过让编辑器编辑一个副本并在同帧还原实现,撤销栈里可能出现被还原的步骤。 - 清空按钮不进入 Tab 序列(与 MyGo SearchField 相同),键盘用 Escape 清空。 diff --git a/docs/components/025-text-area.md b/docs/components/025-text-area.md index c4f1b51..1204c78 100644 --- a/docs/components/025-text-area.md +++ b/docs/components/025-text-area.md @@ -45,5 +45,5 @@ if r.Submitted() { ## 限制 - `MaxLength` 只提示,不截断输入,以免打断输入法组合与粘贴。 -- 输入法组合期间无法阻止提交快捷键:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交。 +- 输入法组合期间无法阻止提交快捷键:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。 - 只读实现方式同 TextInput。 diff --git a/docs/components/026-number-input.md b/docs/components/026-number-input.md index 2c67ff0..17e1ec3 100644 --- a/docs/components/026-number-input.md +++ b/docs/components/026-number-input.md @@ -2,7 +2,7 @@ ## 用途 -输入与步进数值,支持范围、步长、精度与空值。合并 MyGo 的 Number input(输入模式)与 Stepper(`StepperOnly` 仅步进模式)。 +输入与步进数值,支持范围、步长、精度与空值。合并 MyGo 的 Number input(输入模式)与 Stepper(`StepperOnly` 仅步进模式);两种模式的步进计算都由组件完成。 ## 最小示例 @@ -26,7 +26,7 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab | `StepperOnly` | `bool` | `false` | 只显示数值与上下箭头,不可输入;必须给出 `Min < Max`,且不能与 `AllowEmpty` 同用。 | | `Placeholder` | `string` | `""` | 空值时的占位。 | | `Label` | `string` | `""` | 无障碍名称。 | -| `Disabled` | `bool` | `false` | 禁用。 | +| `Disabled` | `bool` | `false` | 禁用;任一祖先容器禁用时同样生效。 | | `Error` | `string` | `""` | 调用方给出的错误。 | ## 状态 @@ -39,11 +39,13 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab ## 键盘操作 -输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到最小、End 到最大;按住箭头持续步进。 +步进沿以 `Min`(无界时为 0)为起点、`Step` 为间隔的网格移动,结果按精度取整,并停在范围内(范围先向内取到精度上,如 `Max` 0.29999999、步长 0.1 时最大值为 0.2)。不在网格上的值先吸附到相邻网格点。 + +输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到 `Min`、End 到 `Max`(如 [0, 10] 步长 3 时 End 为 10,再 Down 为 9)。 ## 限制 - 输入模式下的加减按钮不进入 Tab 序列,键盘用 Up/Down。 -- 仅步进模式复用 MyGo `ui.Stepper`,箭头高度来自主题间距,不随密度的控件高度变化。 -- MyGo `ui.Stepper` 的箭头不检查禁用(`indicators.go:216`),禁用时组件向它传入副本,保证值不被修改。 -- MyGo 的步进器吸附依赖有限边界(`widgets.go` 中 `snap`),所以仅步进模式要求有界。 +- 仅步进模式不使用 MyGo `ui.Stepper`:它的箭头不检查禁用(`ui/indicators.go:216`),吸附固定按 9 位小数取整(`ui/widgets.go` 中 `snap`),步长 1e-10 时无法前进。组件自绘箭头并自行计算;按住箭头不会连续步进,需重复点击或使用方向键。 +- 仅步进模式要求有界,以便 Home/End 有目标值。 +- 仅步进模式的值通过无障碍描述朗读,MyGo 未公开为自定义元素设置数值范围的接口。 diff --git a/docs/components/028-search-input.md b/docs/components/028-search-input.md index afec587..c1d6ca8 100644 --- a/docs/components/028-search-input.md +++ b/docs/components/028-search-input.md @@ -48,5 +48,5 @@ if r.Moved() { ## 限制 - MyGo 单行编辑器把 Shift+Enter 当作普通提交且不公开修饰键(`editor.go:489`,`submitMods` 未导出),组件用 `HandleInput` 先截获 Shift+Enter。 -- 输入法组合期间无法阻止提交与匹配跳转:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交或跳转。 +- 输入法组合期间无法阻止提交与匹配跳转:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交或跳转。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。 - 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。 diff --git a/docs/components/030-radio-group.md b/docs/components/030-radio-group.md index 85e7886..3f6c9b7 100644 --- a/docs/components/030-radio-group.md +++ b/docs/components/030-radio-group.md @@ -49,6 +49,6 @@ Tab 进入组并落在已选项;Up/Down/Left/Right 移动并直接选择,循 ## 限制 -- 每个选项以 `Value` 为键,选项重新排序后焦点仍跟随原来的值。 +- 每个选项按 `Value` 相等性取得稳定键(首次出现的序号),选项重新排序后焦点仍跟随原来的值;组件记住见过的值,长期不断变化的选项集合会让这份记录增长。 - 方向键对横向与纵向组都同时响应上下左右(MyGo `RadioGroup` 固定使用 `Horizontal|Vertical`)。 diff --git a/input_review2_test.go b/input_review2_test.go new file mode 100644 index 0000000..4544940 --- /dev/null +++ b/input_review2_test.go @@ -0,0 +1,177 @@ +package mujica + +import ( + "image/color" + "runtime" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +func TestInputsObeyDisabledAncestor(t *testing.T) { + text, area, num, find, cur := "", "", 5.0, "a", 0 + off, events := false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Gap(8).Disabled(off).Children(func() { + for _, r := range []InputResult{ + TextInput(c, &text, TextInputOptions{Label: "Text"}), + TextArea(c, &area, TextAreaOptions{Label: "Area", SubmitKey: ui.KeyEnter}), + NumberInput(c, &num, NumberInputOptions{Label: "Num"}), + } { + if r.Changed() || r.Submitted() { + events++ + } + } + if SearchInput(c, &find, SearchInputOptions{Label: "Find", Find: true, Matches: 3, Current: &cur}).Moved() { + events++ + } + }) + }, 400, 500) + next := func() { + if runtime.GOOS == "darwin" { + tt.Key(ui.Cmd, ui.KeyG) + } else { + tt.Key(0, ui.KeyF3) + } + } + for _, name := range []string{"Text", "Area", "Num"} { + _ = tt.Click(name) + events = 0 + off = true + tt.Type("x") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyUp) + next() + if events != 0 { + t.Errorf("%s: %d events under a disabled ancestor", name, events) + } + off = false + tt.Frame() + } + if text != "" || area != "" || num != 5 || cur != 0 { + t.Fatalf("values changed: %q %q %v current %d", text, area, num, cur) + } +} + +// buttonLabelInk counts pixels in the label's box that differ from its first pixel. +func buttonLabelInk(tt *ui.Tester, label string) int { + r, _ := tt.Find(label) + img := tt.Image() + bg := img.At(int(r.X), int(r.Y)) + n := 0 + for y := int(r.Y); y < int(r.Y+r.H); y++ { + for x := int(r.X); x < int(r.X+r.W); x++ { + if !sameColor(img.At(x, y), bg) { + n++ + } + } + } + return n +} + +func sameColor(a, b color.Color) bool { + r1, g1, b1, a1 := a.RGBA() + r2, g2, b2, a2 := b.RGBA() + return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2 +} + +func TestButtonLoadingKeepsLabelAndWidth(t *testing.T) { + for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} { + for _, icon := range []*ui.SVG{nil, icons.Must("check")} { + loading := false + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Density: d}) + ui.Row(c).Children(func() { + w = Button(c, "Save", ButtonOptions{Icon: icon, Loading: loading}).Bounds().W + }) + }, 300, 200) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.Frame() + before := w + loading = true + tt.Frame() + tt.Frame() + if w != before { + t.Errorf("%v icon %v: width %v -> %v", d, icon != nil, before, w) + } + // The spinner sits beside the label, never over it, and the label has ink. + sp, _ := tt.Find("Loading") + lb, _ := tt.Find("Save") + if sp.X+sp.W > lb.X || buttonLabelInk(tt, "Save") == 0 { + t.Errorf("%v icon %v: label hidden while loading (spinner %v, label %v)", d, icon != nil, sp, lb) + } + } + } +} + +func numberInputStepTester(v *float64, opts NumberInputOptions) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NumberInput(c, v, opts) + }, 400, 200) + tt.Key(0, ui.KeyTab) + return tt +} + +func TestNumberInputStepGrid(t *testing.T) { + for _, stepper := range []bool{false, true} { + v := 0.2 + tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 0.29999999, Step: 0.1, StepperOnly: stepper}) + tt.Key(0, ui.KeyUp) + if v != 0.2 { + t.Errorf("stepper %v: Up past Max 0.29999999 gave %v", stepper, v) + } + v = 0 + tt = numberInputStepTester(&v, NumberInputOptions{Min: -1, Max: 1, Step: 1e-10, StepperOnly: stepper}) + tt.Key(0, ui.KeyUp) + if v != 1e-10 { + t.Errorf("stepper %v: Up by 1e-10 from 0 gave %v", stepper, v) + } + } + v := 0.0 + tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 10, Step: 3, StepperOnly: true}) + var got []float64 + for _, k := range []ui.Key{ui.KeyUp, ui.KeyEnd, ui.KeyDown, ui.KeyHome, ui.KeyDown} { + tt.Key(0, k) + got = append(got, v) + } + want := []float64{3, 10, 9, 0, 0} + for i := range want { + if got[i] != want[i] { + t.Fatalf("stepper [0,10] by 3: got %v, want %v", got, want) + } + } +} + +// radioSameName prints the same for every value, as fmt keys would see it. +type radioSameName struct{ id int } + +func (radioSameName) String() string { return "same" } + +func TestRadioGroupKeysByEquality(t *testing.T) { + v := radioSameName{0} + items := []RadioItem[radioSameName]{{Value: radioSameName{1}, Label: "One"}, {Value: radioSameName{2}, Label: "Two"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + RadioGroup(c, &v, items, RadioGroupOptions{}) + }, 300, 200) + _ = tt.Click("Two") + if v.id != 2 { + t.Fatalf("click chose %v", v) + } + tt.Key(0, ui.KeyUp) + if v.id != 1 { + t.Fatalf("Up chose %v", v) + } + items[0], items[1] = items[1], items[0] + tt.Frame() + tt.Key(0, ui.KeySpace) + if v.id != 1 || !tt.Focused("One") { + t.Fatalf("after reorder chose %v, One focused %v", v, tt.Focused("One")) + } +} diff --git a/number_input.go b/number_input.go index efed56a..d3d3d1d 100644 --- a/number_input.go +++ b/number_input.go @@ -50,9 +50,34 @@ func (s numberInputSpec) format(v float64) string { // clamp rounds v to the precision, then bounds it; the bounds sit on that grid. func (s numberInputSpec) clamp(v float64) float64 { p := math.Pow(10, float64(s.decimals)) - return math.Max(s.lo, math.Min(s.hi, math.Round(v*p)/p)) + return math.Max(s.lo, math.Min(s.hi, math.Round(v*p)/p)) + 0 // + 0 turns -0 into 0 } +// stepBy moves v n steps along the grid of Step from Min, stopping at the bounds. +func (s numberInputSpec) stepBy(v float64, n int) float64 { + if math.IsNaN(v) { + // From empty, the first step lands next to zero. + v = s.clamp(0) + } + base := s.lo + if math.IsInf(base, 0) { + base = 0 + } + k := (v - base) / s.step + if n > 0 { + k = numberInputFloor(k) + float64(n) + } else { + k = numberInputCeil(k) + float64(n) + } + return s.clamp(base + k*s.step) +} + +// numberInputFloor floors x, forgiving floating-point error just below an integer. +func numberInputFloor(x float64) float64 { return math.Floor(x + math.Max(1e-9, 1e-12*math.Abs(x))) } + +// numberInputCeil ceils x, forgiving floating-point error just above an integer. +func numberInputCeil(x float64) float64 { return math.Ceil(x - math.Max(1e-9, 1e-12*math.Abs(x))) } + // numberInputSame reports equal values, counting NaN (empty) as equal. func numberInputSame(a, b float64) bool { return a == b || math.IsNaN(a) && math.IsNaN(b) } @@ -80,8 +105,8 @@ func numberInputResolve(opts NumberInputOptions) numberInputSpec { } // Bounds move inward onto the precision grid, so clamped values keep both. p := math.Pow(10, float64(s.decimals)) - s.lo = math.Ceil(math.Round(s.lo*p*1e6)/1e6) / p - s.hi = math.Floor(math.Round(s.hi*p*1e6)/1e6) / p + s.lo = numberInputCeil(s.lo*p)/p + 0 // + 0 turns -0 into 0 + s.hi = numberInputFloor(s.hi*p)/p + 0 if s.lo > s.hi { panic("mujica: NumberInput has no value in range at this precision") } @@ -113,13 +138,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe r.changed = true } } - stepBy := func(n float64) { - base := *value - if math.IsNaN(base) { - base = math.Max(s.lo, math.Min(s.hi, 0)) - } - set(base + n*s.step) - } + stepBy := func(n int) { set(s.stepBy(*value, n)) } look := &textInputLook{disabled: opts.Disabled, err: opts.Error} r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { text := ui.Local(frame, "text", func() string { return s.format(*value) }) @@ -129,13 +148,13 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe // Set from outside: the text follows, and a typing error goes. *text, *invalid = s.format(*value), "" } - in := textInputEditor(c, frame, text, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 12). + in := textInputEditor(c, frame, text, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 12). Placeholder(opts.Placeholder).Label(opts.Label) - if in.Changed() && !opts.Disabled { + if in.Changed() && !look.disabled { *invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set) } switch { - case opts.Disabled: + case look.disabled: case in.Shortcut(0, ui.KeyUp): stepBy(1) *text, *invalid = s.format(*value), "" @@ -151,7 +170,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe for _, b := range []struct { name string icon string - delta float64 + delta int can bool }{ {msg(c, "number_input.decrease"), "minus", -1, math.IsNaN(*value) || *value > s.lo}, @@ -209,37 +228,78 @@ func numberInputButton(c *ui.Context, icon, name string, can bool) bool { return b.Clicked() } -// numberInputStepper shows the value beside MyGo's stepper, restyled. +// numberInputStepper shows the value beside up and down arrows; it steps itself. func numberInputStepper(c *ui.Context, value *float64, s numberInputSpec, opts NumberInputOptions) InputResult { k := Tokens(c) d := Density(c) var r InputResult r.Element = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(opts.Disabled) + disabled := r.Element.IsDisabled() + set := func(v float64) { + if v != *value && !disabled { + *value = v + r.changed = true + } + } r.Element.Children(func() { ui.Row(c).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).Children(func() { ui.Text(c, s.format(*value)).FontFeatures("tnum").MinWidth(d.Unit() * 8).TextAlign(ui.End) - // MyGo's stepper arrows ignore Disabled: give them a copy. - target := value - if opts.Disabled { - held := *value - target = &held - } - st := ui.Stepper(c, target, s.lo, s.hi, s.step).Label(opts.Label).Width(d.Unit()*6). - Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) + border := k.ControlBorder if opts.Error != "" { - st.Error(opts.Error).Border(theme.BorderWidth, k.Danger) + border = k.Danger } + st := ui.Column(c).Focusable().Shrink(0).Width(d.Unit()*6).Radius(theme.ControlRadius).Clip(). + Background(k.Surface).Border(theme.BorderWidth, border).Role(ui.RoleStepper).Label(opts.Label). + Description(s.format(*value)).Error(opts.Error) internal.Ring(st, k.Focus, theme.ControlRadius) + switch { + case st.Shortcut(0, ui.KeyUp), st.Shortcut(0, ui.KeyRight): + set(s.stepBy(*value, 1)) + case st.Shortcut(0, ui.KeyDown), st.Shortcut(0, ui.KeyLeft): + set(s.stepBy(*value, -1)) + case st.Shortcut(0, ui.KeyHome): + set(s.lo) + case st.Shortcut(0, ui.KeyEnd): + set(s.hi) + } + st.Children(func() { + if numberInputArrow(c, "chevron-up", *value < s.hi, false) { + set(s.stepBy(*value, 1)) + st.Focus() + } + if numberInputArrow(c, "chevron-down", *value > s.lo, true) { + set(s.stepBy(*value, -1)) + st.Focus() + } + }) r.Input = st - r.changed = st.Changed() && !opts.Disabled }) if opts.Error != "" { - ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize) + ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)) } }) if r.changed { - *value = s.clamp(*value) slog.Debug("mujica: number_input step", "value", *value) } return r } + +// numberInputArrow builds one half of the stepper and reports a click. +func numberInputArrow(c *ui.Context, icon string, can, lower bool) bool { + k := Tokens(c) + h := Density(c).ControlHeight()/2 - theme.BorderWidth + a := ui.Box(c).Height(h).Center().Role(ui.RoleNone).Cursor(ui.CursorPointer).Disabled(!can) + if lower { + a.BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border) + } + fg := k.TextMuted + if a.Pressed() { + a.Background(k.Selection) + fg = k.Text + } else if a.Hovered() { + a.Background(k.SurfaceHover) + fg = k.Text + } + a.Children(func() { ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(fg) }) + return a.Clicked() +} diff --git a/password_input.go b/password_input.go index 4c025c1..f71816c 100644 --- a/password_input.go +++ b/password_input.go @@ -36,14 +36,14 @@ func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) Inpu mode = "shown" } ui.Row(c).Key(mode).Grow(1).Children(func() { - in = textInputEditor(c, frame, value, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). + in = textInputEditor(c, frame, value, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). Placeholder(opts.Placeholder).Label(opts.Label) if !*shown { in.Password() } }) - r.changed = in.Changed() && !opts.Disabled - r.submitted = in.Submitted() && !opts.Disabled + r.changed = in.Changed() && !look.disabled + r.submitted = in.Submitted() && !look.disabled if passwordInputToggle(c, *shown) { *shown = !*shown slog.Debug("mujica: password_input reveal", "shown", *shown) diff --git a/radio_group.go b/radio_group.go index b0ac1a8..9f6b11c 100644 --- a/radio_group.go +++ b/radio_group.go @@ -2,6 +2,7 @@ package mujica import ( "log/slog" + "slices" "github.com/egoist/mygo/ui" @@ -45,10 +46,17 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt var r InputResult // Disabled on a wrapper blocks the bases' clicks before they choose. wrap := ui.Box(c).Shrink(0).Disabled(opts.Disabled) + // Keys by first appearance of each value: fmt keys would merge equal-looking values. + known := ui.Local(wrap, "values", func() []T { return nil }) wrap.Children(func() { r.Element = ui.RadioGroup(c, func() { for _, it := range items { - if radioGroupItem(c, k, d, value, it) { + key := slices.Index(*known, it.Value) + if key < 0 { + key = len(*known) + *known = append(*known, it.Value) + } + if radioGroupItem(c, k, d, value, it, key) { r.changed = true slog.Debug("mujica: radio_group choose", "label", it.Label) } @@ -58,21 +66,21 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt if opts.Horizontal { r.Element.Row().Gap(d.Unit() * 6) } - if opts.Disabled && wrap.FocusWithin() { + if wrap.IsDisabled() && wrap.FocusWithin() { wrap.FocusRing(false).Focus() } return r } // radioGroupItem builds one radio and reports whether it was chosen. -func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T]) bool { +func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T], key int) bool { // Keyed by value so focus follows the item, not its position. var row *ui.Element - item := ui.Box(c).Key(it.Value).Disabled(it.Disabled) + item := ui.Box(c).Key(key).Disabled(it.Disabled) item.Children(func() { row = ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false) }) - if it.Disabled && item.FocusWithin() { + if item.IsDisabled() && item.FocusWithin() { item.FocusRing(false).Focus() } on := *value == it.Value diff --git a/search_input.go b/search_input.go index 176b042..ead6c59 100644 --- a/search_input.go +++ b/search_input.go @@ -59,8 +59,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn name = place } var r SearchInputResult + look := &textInputLook{disabled: opts.Disabled} step := func(d int) { - if opts.Matches == 0 || opts.Disabled { + if opts.Matches == 0 || look.disabled { return } *opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches @@ -69,7 +70,10 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn } if opts.Find { *opts.Current = max(0, min(*opts.Current, opts.Matches-1)) - if !opts.Disabled { + } + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + if opts.Find { + // Built inside the frame, where step knows of disabled ancestors. mac := runtime.GOOS == "darwin" switch { case mac && c.Shortcut(ui.Cmd, ui.KeyG), !mac && c.Shortcut(0, ui.KeyF3): @@ -78,13 +82,10 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn step(-1) } } - } - look := &textInputLook{disabled: opts.Disabled} - r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { ui.Icon(c, icons.Must("search")).Size(16, 16).Shrink(0).TextColor(k.TextMuted) back := ui.Local(frame, "back", func() bool { return false }) - in := textInputEditor(c, frame, query, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name) - r.changed = in.Changed() && !opts.Disabled + in := textInputEditor(c, frame, query, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name) + r.changed = in.Changed() && !look.disabled if opts.Find { // Shift+Enter would reach the editor as a plain submit. in.HandleInput(func(ev ui.InputEvent) bool { @@ -102,9 +103,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn step(1) } } else { - r.submitted = in.Submitted() && !opts.Disabled + r.submitted = in.Submitted() && !look.disabled } - if *query != "" && !opts.Disabled && textInputClear(c, in) { + if *query != "" && !look.disabled && textInputClear(c, in) { *query = "" r.changed = true slog.Debug("mujica: search_input clear") diff --git a/testdata/snapshots/darwin/button-comfortable-dark.png b/testdata/snapshots/darwin/button-comfortable-dark.png index afce442..309ee17 100644 Binary files a/testdata/snapshots/darwin/button-comfortable-dark.png and b/testdata/snapshots/darwin/button-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/button-comfortable-light.png b/testdata/snapshots/darwin/button-comfortable-light.png index 0980b9b..492caf4 100644 Binary files a/testdata/snapshots/darwin/button-comfortable-light.png and b/testdata/snapshots/darwin/button-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/button-compact-dark.png b/testdata/snapshots/darwin/button-compact-dark.png index e0868f3..a78d663 100644 Binary files a/testdata/snapshots/darwin/button-compact-dark.png and b/testdata/snapshots/darwin/button-compact-dark.png differ diff --git a/testdata/snapshots/darwin/button-compact-light.png b/testdata/snapshots/darwin/button-compact-light.png index 8afc2ba..0ea1a55 100644 Binary files a/testdata/snapshots/darwin/button-compact-light.png and b/testdata/snapshots/darwin/button-compact-light.png differ diff --git a/testdata/snapshots/darwin/button-dark.png b/testdata/snapshots/darwin/button-dark.png index 365330b..962fc23 100644 Binary files a/testdata/snapshots/darwin/button-dark.png and b/testdata/snapshots/darwin/button-dark.png differ diff --git a/testdata/snapshots/darwin/button-light.png b/testdata/snapshots/darwin/button-light.png index dbf5a78..5944471 100644 Binary files a/testdata/snapshots/darwin/button-light.png and b/testdata/snapshots/darwin/button-light.png differ diff --git a/testdata/snapshots/darwin/number_input-dark.png b/testdata/snapshots/darwin/number_input-dark.png index 959270c..102c51f 100644 Binary files a/testdata/snapshots/darwin/number_input-dark.png and b/testdata/snapshots/darwin/number_input-dark.png differ diff --git a/testdata/snapshots/darwin/number_input-light.png b/testdata/snapshots/darwin/number_input-light.png index 74f4781..f3ab0a9 100644 Binary files a/testdata/snapshots/darwin/number_input-light.png and b/testdata/snapshots/darwin/number_input-light.png differ diff --git a/testdata/snapshots/darwin/spinner-dark.png b/testdata/snapshots/darwin/spinner-dark.png index a34bd86..1a9fe92 100644 Binary files a/testdata/snapshots/darwin/spinner-dark.png and b/testdata/snapshots/darwin/spinner-dark.png differ diff --git a/testdata/snapshots/darwin/spinner-light.png b/testdata/snapshots/darwin/spinner-light.png index aed49d0..0353909 100644 Binary files a/testdata/snapshots/darwin/spinner-light.png and b/testdata/snapshots/darwin/spinner-light.png differ diff --git a/text_area.go b/text_area.go index 7dc8eac..b9e82b8 100644 --- a/text_area.go +++ b/text_area.go @@ -55,7 +55,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { var r InputResult look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: errMsg, area: true} r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { - in := textInputEditor(c, frame, value, opts.ReadOnly || opts.Disabled, ui.TextAreaBase).Grow(1).Padding(d.Unit()*2, d.Unit()*3). + in := textInputEditor(c, frame, value, opts.ReadOnly || look.disabled, ui.TextAreaBase).Grow(1).Padding(d.Unit()*2, d.Unit()*3). Placeholder(opts.Placeholder).Label(opts.Label) if opts.MinHeight > 0 { in.MinHeight(opts.MinHeight) @@ -63,7 +63,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { if opts.MaxHeight > 0 { in.MaxHeight(opts.MaxHeight) } - r.changed = in.Changed() && !opts.ReadOnly && !opts.Disabled + r.changed = in.Changed() && !opts.ReadOnly && !look.disabled // Taken before the editor, which would make a plain Enter a newline. submit := ui.Local(frame, "submit", func() bool { return false }) in.HandleInput(func(ev ui.InputEvent) bool { @@ -73,7 +73,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { } return false }) - r.submitted = *submit && !opts.Disabled + r.submitted = *submit && !look.disabled *submit = false return in }, count) diff --git a/text_input.go b/text_input.go index acacf0d..961e082 100644 --- a/text_input.go +++ b/text_input.go @@ -53,11 +53,11 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult if opts.Leading != nil { opts.Leading() } - in := textInputEditor(c, frame, value, opts.ReadOnly || opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). + in := textInputEditor(c, frame, value, opts.ReadOnly || look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). Placeholder(opts.Placeholder).Label(opts.Label) - r.changed = in.Changed() && !opts.ReadOnly && !opts.Disabled - r.submitted = in.Submitted() && !opts.Disabled - if opts.Clearable && *value != "" && !opts.ReadOnly && !opts.Disabled { + r.changed = in.Changed() && !opts.ReadOnly && !look.disabled + r.submitted = in.Submitted() && !look.disabled + if opts.Clearable && *value != "" && !opts.ReadOnly && !look.disabled { if textInputClear(c, in) { *value = "" r.changed = true @@ -75,7 +75,7 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult // textInputFade eases hover, focus and error colors. var textInputFade = ui.ElementTransition{Colors: true, Duration: theme.StateDuration} -// textInputLook is the state a field frame shows. +// textInputLook is the state a field frame shows; disabled gains any disabled ancestor. type textInputLook struct { disabled, readOnly, area bool err string @@ -86,6 +86,8 @@ func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Ele k := Tokens(c) d := Density(c) outer = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(look.disabled) + // One disabled state, the field's own or an ancestor's, for build to obey. + look.disabled = outer.IsDisabled() outer.Children(func() { frame := ui.Row(c).Radius(theme.ControlRadius).Gap(d.Unit() * 2).Cursor(ui.CursorText) if look.area {