diff --git a/button.go b/button.go index 27b6d0c..83cfa26 100644 --- a/button.go +++ b/button.go @@ -25,12 +25,15 @@ type ButtonOptions struct { Icon *ui.SVG Loading bool Disabled bool - // Label names an icon-only button for assistive technology. + // Label names an icon-only button for assistive technology; required without a label. Label string } // 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 == "" { + panic("mujica: Button without a label needs Label to name it") + } k := Tokens(c) d := Density(c) b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize). @@ -46,13 +49,25 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element { b.Border(theme.BorderWidth, border) } b.Children(func() { - if opts.Loading { - Spinner(c, SpinnerOptions{Size: 14, Color: fg}) - } else if opts.Icon != nil { + 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 label != "" { - ui.Text(c, label).SingleLine() + t := ui.Text(c, label).SingleLine() + if opts.Loading && opts.Icon == nil { + t.TextColor(fg.Alpha(0)) + } } }) return b diff --git a/button_group.go b/button_group.go index b3de134..ab3d6b8 100644 --- a/button_group.go +++ b/button_group.go @@ -9,7 +9,7 @@ import ( "mujicaui/theme" ) -// ButtonGroupItem is one button of a ButtonGroup. Give Label, Icon or both. +// ButtonGroupItem is one button of a ButtonGroup. Give Label, Icon or both; icon-only needs Name. type ButtonGroupItem struct { Label string Icon *ui.SVG @@ -55,6 +55,9 @@ func ButtonGroup(c *ui.Context, items []ButtonGroupItem, opts ButtonGroupOptions if it.Label == "" && it.Icon == nil { panic("mujica: ButtonGroupItem needs a Label or an Icon") } + if it.Label == "" && it.Name == "" { + panic("mujica: icon-only ButtonGroupItem needs a Name") + } b := ui.ButtonBase(c).Key(i).MinHeight(d.ControlHeight()).MinWidth(d.ControlHeight()). Padding(0, d.Unit()*3).Gap(d.Unit() * 2).Disabled(it.Disabled) internal.Ring(b, k.Focus, theme.ControlRadius) diff --git a/checkbox.go b/checkbox.go index de9b5a4..c294aea 100644 --- a/checkbox.go +++ b/checkbox.go @@ -26,7 +26,15 @@ func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *u } k := Tokens(c) d := Density(c) - row := ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false).Disabled(opts.Disabled) + // Disabled on a wrapper blocks the base's click before it toggles. + var row *ui.Element + wrap := ui.Row(c).Shrink(0).Disabled(opts.Disabled) + wrap.Children(func() { + row = ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false) + }) + if opts.Disabled && wrap.FocusWithin() { + wrap.FocusRing(false).Focus() + } if row.Changed() && opts.Indeterminate && !*value { // From mixed, a click always checks. *value = true @@ -37,17 +45,9 @@ func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *u on, mixed := *value, opts.Indeterminate hover, pressed := row.Hovered(), row.Pressed() row.Children(func() { - box := ui.Box(c).Size(checkboxSize, checkboxSize).Shrink(0).Radius(theme.ControlRadius) - switch { - case on || mixed: - box.Background(k.Accent).Border(theme.BorderWidth, k.AccentText) - case pressed: - box.Background(k.Selection).Border(theme.BorderWidth, k.AccentText) - case hover: - box.Background(k.SurfaceHover).Border(theme.BorderWidth, k.AccentText) - default: - box.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) - } + bg, border := checkboxColors(k, on || mixed, hover, pressed) + box := ui.Box(c).Size(checkboxSize, checkboxSize).Shrink(0).Radius(theme.ControlRadius). + Background(bg).Border(theme.BorderWidth, border) box.Transition(textInputFade) box.DrawOver(func(p *ui.Painter, r ui.Rect) { switch { @@ -72,6 +72,23 @@ func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *u return row } +// checkboxColors gives a check box or radio mark its fill and edge. +func checkboxColors(k theme.Tokens, on, hover, pressed bool) (bg, border ui.Color) { + switch { + case on && pressed: + return k.AccentPressed, k.Text + case on && hover: + return k.AccentHover, k.Text + case on: + return k.Accent, k.AccentText + case pressed: + return k.Selection, k.AccentText + case hover: + return k.SurfaceHover, k.AccentText + } + return k.Surface, k.ControlBorder +} + // CheckboxItem is one box of a CheckboxGroup. type CheckboxItem struct { Label string diff --git a/docs/components/021-button.md b/docs/components/021-button.md index 02824ad..ec90fe8 100644 --- a/docs/components/021-button.md +++ b/docs/components/021-button.md @@ -18,12 +18,12 @@ if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Cli | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | -| `label` | `string` | 必填(仅图标时可为空) | 按钮文字。 | +| `label` | `string` | 必填(仅图标时可为空) | 按钮文字;为空时必须提供 `Label`,否则 panic。 | | `Variant` | `ButtonVariant` | `Primary` | `Primary`、`Secondary`、`Outline`、`Ghost`、`Danger`。 | | `Icon` | `*ui.SVG` | `nil` | 文字前的 16 DIP 图标。 | -| `Loading` | `bool` | `false` | 以菱形加载标记替换图标,保留尺寸与文字,阻止点击。 | +| `Loading` | `bool` | `false` | 有图标时加载标记占用图标位置;纯文字时标记居中覆盖在隐藏的文字上,宽度不变;阻止点击。 | | `Disabled` | `bool` | `false` | 不可点击、不进入 Tab 序列。 | -| `Label` | `string` | `""` | 无文字按钮的无障碍名称。 | +| `Label` | `string` | `""` | 无障碍名称;`label` 为空时必填。 | ## 状态 diff --git a/docs/components/023-button-group.md b/docs/components/023-button-group.md index c394325..db7a2fd 100644 --- a/docs/components/023-button-group.md +++ b/docs/components/023-button-group.md @@ -23,7 +23,7 @@ if i := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Label: "Range"}). | --- | --- | --- | --- | | `Label` | `string` | `""` | 文字;与 `Icon` 至少给一个,否则 panic。 | | `Icon` | `*ui.SVG` | `nil` | 图标。 | -| `Name` | `string` | `""` | 仅图标项的无障碍名称。 | +| `Name` | `string` | `""` | 仅图标项的无障碍名称;仅图标且为空时 panic。 | | `Disabled` | `bool` | `false` | 单项禁用;方向键跳过该项。 | `ButtonGroupOptions` diff --git a/docs/components/024-text-input.md b/docs/components/024-text-input.md index 88649a9..d363468 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 毫秒过渡,减少动画时直接切换。 ## 事件 diff --git a/docs/components/025-text-area.md b/docs/components/025-text-area.md index 32205d8..c4f1b51 100644 --- a/docs/components/025-text-area.md +++ b/docs/components/025-text-area.md @@ -25,14 +25,14 @@ if r.Submitted() { | `MinHeight` | `float32` | `0`(三行) | 编辑区最小高度,DIP。 | | `MaxHeight` | `float32` | `0`(随内容增长) | 编辑区最大高度,超出后滚动;小于 `MinHeight` 时 panic。 | | `MaxLength` | `int` | `0`(不显示) | 显示“当前 / 上限”字数(按 Unicode 字符计);超出时变红并提示超出数量。 | -| `SubmitKey` / `SubmitMods` | `ui.Key` / `ui.Modifiers` | Cmd+Enter(非 macOS 为 Ctrl+Enter) | 提交快捷键;`SubmitKey` 为零时使用默认值。 | +| `SubmitKey` / `SubmitMods` | `ui.Key` / `ui.Modifiers` | Cmd+Enter(非 macOS 为 Ctrl+Enter) | 提交快捷键;`SubmitKey` 为零时使用默认值。可设为不带修饰键的 Enter,此时 Shift+Enter 换行。 | | `Disabled` | `bool` | `false` | 禁用。 | | `ReadOnly` | `bool` | `false` | 只读,可选择复制。 | | `Error` | `string` | `""` | 错误文字;为空且超出 `MaxLength` 时自动显示超出提示。 | ## 状态 -默认、悬停、聚焦、只读、错误、禁用,表现同 TextInput。 +默认、悬停、聚焦、只读、错误、禁用,表现同 TextInput;聚焦时被禁用会放弃焦点,之后的输入与提交都不生效。 ## 事件 @@ -40,10 +40,10 @@ if r.Submitted() { ## 键盘操作 -Enter 与 Shift+Enter 换行;提交快捷键提交;方向键、PageUp/PageDown 移动光标并滚动;撤销、剪贴板与输入法同 TextInput。 +未配置为提交键时 Enter 与 Shift+Enter 换行;提交快捷键在编辑器处理前被截获并提交,不插入换行;方向键、PageUp/PageDown 移动光标并滚动;撤销、剪贴板与输入法同 TextInput。 ## 限制 - `MaxLength` 只提示,不截断输入,以免打断输入法组合与粘贴。 -- 提交快捷键不能是不带修饰键的 Enter:MyGo 编辑器先接收 Enter 并插入换行(`editor.go:161`)。 +- 输入法组合期间无法阻止提交快捷键:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交。 - 只读实现方式同 TextInput。 diff --git a/docs/components/026-number-input.md b/docs/components/026-number-input.md index 82865f7..2c67ff0 100644 --- a/docs/components/026-number-input.md +++ b/docs/components/026-number-input.md @@ -21,7 +21,7 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab | `value` | `*float64` | 必填 | 数值;空值用 `math.NaN()` 表示,仅在 `AllowEmpty` 时允许。 | | `Min` / `Max` | `float64` | 都为 0 | 范围;相等(含零值)表示不限范围;`Min > Max` 时 panic。 | | `Step` | `float64` | `1` | 方向键与按钮的步长;负数 panic。 | -| `Precision` | `int` | 取 `Step` 的小数位 | 保留与显示的小数位。 | +| `Precision` | `int` | 取 `Step` 的小数位 | 保留与显示的小数位。范围边界向内取到该精度上(如 [0.11, 0.29] 精度 1 时有效值只有 0.2);范围内没有任何该精度的值时 panic。 | | `AllowEmpty` | `bool` | `false` | 允许清空,值变为 NaN;从空值步进时从 0(夹在范围内)开始。 | | `StepperOnly` | `bool` | `false` | 只显示数值与上下箭头,不可输入;必须给出 `Min < Max`,且不能与 `AllowEmpty` 同用。 | | `Placeholder` | `string` | `""` | 空值时的占位。 | @@ -31,7 +31,7 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab ## 状态 -输入模式同 TextInput;输入非数字或超出范围时显示内置错误(“请输入数字”“请输入 a 到 b 之间的数字”),值保持上一次有效值;失去焦点后文本恢复为当前值并清除该错误。到达边界时对应按钮禁用。 +输入模式同 TextInput;输入非数字或超出范围时显示内置错误(“请输入数字”“请输入 a 到 b 之间的数字”),值保持上一次有效值;失去焦点后错误文本与提示保留,直到用户改正,或调用方从外部改变了值。值在聚焦期间被外部修改时,输入框立即显示新值。到达边界时对应按钮禁用。 ## 事件 diff --git a/docs/components/028-search-input.md b/docs/components/028-search-input.md index 8d074bc..afec587 100644 --- a/docs/components/028-search-input.md +++ b/docs/components/028-search-input.md @@ -48,4 +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;只有未被输入法消费的按键会触发提交或跳转。 - 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。 diff --git a/docs/components/029-checkbox.md b/docs/components/029-checkbox.md index 2e88b84..d71bae6 100644 --- a/docs/components/029-checkbox.md +++ b/docs/components/029-checkbox.md @@ -33,7 +33,7 @@ func CheckboxGroup(c *ui.Context, label string, items []CheckboxItem, opts Check ## 状态 -未选(Surface 底、ControlBorder 边)、悬停与按下(AccentText 边)、选中(酒红底、象牙白勾)、半选(酒红底、象牙白横线)、键盘聚焦(方框外焦点环)、禁用。行高不低于密度控件高度。 +未选(Surface 底、ControlBorder 边)、悬停与按下(AccentText 边)、选中(酒红底、象牙白勾;悬停与按下时底色加深、边框变为正文色)、半选(酒红底、象牙白横线)、键盘聚焦(方框外焦点环)、禁用。行高不低于密度控件高度。 全选框:全部选中为选中,部分选中为半选,全不选为未选。点击时若可用项未全选则全部勾选,否则全部清除;禁用项保持不变。 diff --git a/docs/components/030-radio-group.md b/docs/components/030-radio-group.md index f421a8e..85e7886 100644 --- a/docs/components/030-radio-group.md +++ b/docs/components/030-radio-group.md @@ -37,7 +37,7 @@ if mujica.RadioGroup(c, &size, items, mujica.RadioGroupOptions{Label: "Size"}).C ## 状态 -未选(空心圆)、悬停与按下(AccentText 边)、选中(酒红实心圆与象牙白圆点)、键盘聚焦、禁用。 +未选(空心圆)、悬停与按下(AccentText 边)、选中(酒红实心圆与象牙白圆点;悬停与按下时底色加深、边框变为正文色)、键盘聚焦、禁用。 ## 事件 @@ -49,4 +49,6 @@ Tab 进入组并落在已选项;Up/Down/Left/Right 移动并直接选择,循 ## 限制 +- 每个选项以 `Value` 为键,选项重新排序后焦点仍跟随原来的值。 + - 方向键对横向与纵向组都同时响应上下左右(MyGo `RadioGroup` 固定使用 `Horizontal|Vertical`)。 diff --git a/input_review_test.go b/input_review_test.go new file mode 100644 index 0000000..df53518 --- /dev/null +++ b/input_review_test.go @@ -0,0 +1,187 @@ +package mujica + +import ( + "bytes" + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func TestButtonLoadingKeepsWidth(t *testing.T) { + for _, icon := range []*ui.SVG{nil, icons.Must("check")} { + loading := false + var w float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + w = Button(c, "Save", ButtonOptions{Icon: icon, Loading: loading}).Bounds().W + }) + }, 300, 200) + tt.Frame() + before := w + loading = true + tt.Frame() + tt.Frame() + if w != before || !tt.HasText("Save") { + t.Fatalf("icon %v: width %v -> %v, texts %q", icon != nil, before, w, tt.Texts()) + } + } +} + +func inputReviewPanics(t *testing.T, name string, view func(c *ui.Context)) { + t.Helper() + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + view(c) + }, 200, 100) +} + +func TestIconOnlyButtonsNeedNames(t *testing.T) { + inputReviewPanics(t, "Button", func(c *ui.Context) { + Button(c, "", ButtonOptions{Icon: icons.Must("x")}) + }) + inputReviewPanics(t, "ButtonGroup", func(c *ui.Context) { + ButtonGroup(c, []ButtonGroupItem{{Icon: icons.Must("bold")}}, ButtonGroupOptions{}) + }) +} + +func TestTextInputDisabledWhileFocused(t *testing.T) { + s, off, focused, changes := "", false, false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := TextInput(c, &s, TextInputOptions{Label: "Name", Disabled: off}) + focused = r.Input.Focused() + if r.Changed() || r.Submitted() { + changes++ + } + }, 400, 200) + _ = tt.Click("Name") + tt.Type("a") + off = true + tt.Type("b") + tt.Key(0, ui.KeyEnter) + tt.Frame() + if s != "a" || changes != 1 || focused { + t.Fatalf("value %q events %d focused %v", s, changes, focused) + } +} + +func TestTextAreaDisabledWhileFocused(t *testing.T) { + s, off := "", false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TextArea(c, &s, TextAreaOptions{Label: "Notes", Disabled: off}) + }, 400, 300) + _ = tt.Click("Notes") + off = true + tt.Type("late") + if s != "" { + t.Fatalf("disabled area took %q", s) + } +} + +func TestCheckboxDisabledWhileFocused(t *testing.T) { + on, off, focused := false, false, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + focused = Checkbox(c, &on, "Sync", CheckboxOptions{Disabled: off}).Focused() + }, 300, 200) + tt.Key(0, ui.KeyTab) + off = true + tt.Key(0, ui.KeySpace) + tt.Frame() + if on || focused { + t.Fatalf("checked %v focused %v", on, focused) + } +} + +func TestRadioGroupDisabledWhileFocused(t *testing.T) { + v, off := "a", false + items := []RadioItem[string]{{Value: "a", Label: "A"}, {Value: "b", Label: "B"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + RadioGroup(c, &v, items, RadioGroupOptions{Disabled: off}) + }, 300, 200) + tt.Key(0, ui.KeyTab) + off = true + tt.Key(0, ui.KeyDown) + tt.Key(0, ui.KeySpace) + if v != "a" { + t.Fatalf("disabled group chose %q", v) + } +} + +func TestTextAreaPlainEnterSubmits(t *testing.T) { + s, submits := "", 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TextArea(c, &s, TextAreaOptions{Label: "Chat", SubmitKey: ui.KeyEnter}).Submitted() { + submits++ + } + }, 400, 300) + _ = tt.Click("Chat") + tt.Type("hi") + tt.Key(0, ui.KeyEnter) + tt.Key(ui.Shift, ui.KeyEnter) + if submits != 1 || s != "hi\n" { + t.Fatalf("submits %d value %q", submits, s) + } +} + +func TestRadioGroupFocusFollowsValue(t *testing.T) { + v := "" + items := []RadioItem[string]{{Value: "a", Label: "A"}, {Value: "b", Label: "B"}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + RadioGroup(c, &v, items, RadioGroupOptions{}) + }, 300, 200) + tt.Key(0, ui.KeyTab) + items[0], items[1] = items[1], items[0] + tt.Frame() + tt.Key(0, ui.KeySpace) + if v != "a" { + t.Fatalf("Space after reordering chose %q, want a", v) + } +} + +// inputReviewStates renders view plain, hovered and pressed at the named element. +func inputReviewStates(t *testing.T, name string, view func(c *ui.Context)) (plain, hover, pressed []byte) { + t.Helper() + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Mode: Dark}) + view(c) + }, 300, 200) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + r, ok := tt.Find(name) + if !ok { + t.Fatalf("no %q", name) + } + plain = encode(t, tt.Image()) + tt.Move(r.X+r.W/2, r.Y+r.H/2) + hover = encode(t, tt.Image()) + tt.Press(r.X+r.W/2, r.Y+r.H/2) + pressed = encode(t, tt.Image()) + return +} + +func TestCheckedMarksShowHoverAndPress(t *testing.T) { + on, v := true, "a" + for name, view := range map[string]func(c *ui.Context){ + "Sync": func(c *ui.Context) { Checkbox(c, &on, "Sync", CheckboxOptions{}) }, + "A": func(c *ui.Context) { + RadioGroup(c, &v, []RadioItem[string]{{Value: "a", Label: "A"}}, RadioGroupOptions{}) + }, + } { + plain, hover, pressed := inputReviewStates(t, name, view) + if bytes.Equal(plain, hover) || bytes.Equal(hover, pressed) { + t.Errorf("%s: checked state hides hover (%v) or press (%v)", name, bytes.Equal(plain, hover), bytes.Equal(hover, pressed)) + } + } +} diff --git a/number_input.go b/number_input.go index a781378..efed56a 100644 --- a/number_input.go +++ b/number_input.go @@ -47,12 +47,15 @@ func (s numberInputSpec) format(v float64) string { return strconv.FormatFloat(v, 'f', s.decimals, 64) } -// clamp bounds v and rounds it to the precision. +// 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.Round(math.Max(s.lo, math.Min(s.hi, v))*p) / p + return math.Max(s.lo, math.Min(s.hi, math.Round(v*p)/p)) } +// numberInputSame reports equal values, counting NaN (empty) as equal. +func numberInputSame(a, b float64) bool { return a == b || math.IsNaN(a) && math.IsNaN(b) } + func numberInputResolve(opts NumberInputOptions) numberInputSpec { s := numberInputSpec{lo: opts.Min, hi: opts.Max, step: opts.Step, decimals: opts.Precision} if s.lo > s.hi { @@ -75,6 +78,13 @@ func numberInputResolve(opts NumberInputOptions) numberInputSpec { s.decimals = len(t) - strings.IndexByte(t, '.') - 1 } } + // 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 + if s.lo > s.hi { + panic("mujica: NumberInput has no value in range at this precision") + } return s } @@ -114,12 +124,18 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { text := ui.Local(frame, "text", func() string { return s.format(*value) }) invalid := ui.Local(frame, "invalid", func() string { return "" }) - in := ui.TextInputBase(c, text).Grow(1).MinWidth(Density(c).Unit() * 12). + seen := ui.Local(frame, "seen", func() float64 { return *value }) + if !numberInputSame(*seen, *value) { + // 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). Placeholder(opts.Placeholder).Label(opts.Label) - if in.Changed() { + if in.Changed() && !opts.Disabled { *invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set) } switch { + case opts.Disabled: case in.Shortcut(0, ui.KeyUp): stepBy(1) *text, *invalid = s.format(*value), "" @@ -127,9 +143,11 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe stepBy(-1) *text, *invalid = s.format(*value), "" } - if !in.Focused() { - *text, *invalid = s.format(*value), "" + if !in.Focused() && *invalid == "" { + // Leaving shows the value rounded; invalid text stays to be fixed. + *text = s.format(*value) } + defer func() { *seen = *value }() for _, b := range []struct { name string icon string diff --git a/number_input_test.go b/number_input_test.go index 5259557..1109778 100644 --- a/number_input_test.go +++ b/number_input_test.go @@ -63,8 +63,81 @@ func TestNumberInputTypingPrecisionAndErrors(t *testing.T) { t.Fatalf("out of range: value %v texts %q", v, tt.Texts()) } _ = tt.Click("Elsewhere") - if tt.HasText("Enter a number") { - t.Fatal("error stays after leaving the field, which shows the value again") + if v != 1.25 || !tt.HasText("Enter a number from 0.00 to 2.00") { + t.Fatalf("leaving dropped the error: value %v texts %q", v, tt.Texts()) + } +} + +func TestNumberInputKeepsInvalidTextAfterBlur(t *testing.T) { + v, n := 1.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Label: "Count"}) + _ = tt.Click("Count") + tt.Command("selectAll") + tt.Type("abc") + _ = tt.Click("Elsewhere") + if !tt.HasText("Enter a number") || v != 1 { + t.Fatalf("after blur: value %v texts %q", v, tt.Texts()) + } + _ = tt.Click("Count") + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "abc" { + t.Fatalf("invalid text replaced by %q", tt.Clipboard()) + } + tt.Type("4") + if v != 4 || tt.HasText("Enter a number") { + t.Fatalf("correction: value %v texts %q", v, tt.Texts()) + } +} + +func TestNumberInputSyncsExternalValue(t *testing.T) { + v, n := 1.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Label: "Level"}) + _ = tt.Click("Level") + v = 9 + tt.Frame() + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "9" { + t.Fatalf("focused field shows %q after the value became 9", tt.Clipboard()) + } + tt.Key(0, ui.KeyUp) + if v != 10 { + t.Fatalf("Up after external change gave %v", v) + } +} + +func TestNumberInputRangeOnPrecisionGrid(t *testing.T) { + v, n := 0.2, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 0.11, Max: 0.29, Step: 0.1, Label: "Fine"}) + _ = tt.Click("Fine") + tt.Key(0, ui.KeyUp) + if v != 0.2 { + t.Fatalf("stepping up left the range: %v", v) + } + tt.Key(0, ui.KeyDown) + if v != 0.2 { + t.Fatalf("stepping down left the range: %v", v) + } +} + +func TestNumberInputDisabledWhileFocused(t *testing.T) { + v, n, off, focused := 5.0, 0, false, false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := NumberInput(c, &v, NumberInputOptions{Label: "Live", Disabled: off}) + if r.Changed() { + n++ + } + focused = r.Input.Focused() + }, 400, 300) + _ = tt.Click("Live") + off = true + tt.Key(0, ui.KeyUp) + tt.Type("7") + tt.Frame() + if v != 5 || n != 0 || focused { + t.Fatalf("disabled while focused: value %v changes %d focused %v", v, n, focused) } } @@ -112,7 +185,7 @@ func TestNumberInputDisabled(t *testing.T) { } func TestNumberInputBadOptionsPanic(t *testing.T) { - for _, opts := range []NumberInputOptions{{Min: 3, Max: 1}, {StepperOnly: true}, {Step: -1}} { + for _, opts := range []NumberInputOptions{{Min: 3, Max: 1}, {StepperOnly: true}, {Step: -1}, {Min: 0.11, Max: 0.19, Precision: 1}} { func() { defer func() { if recover() == nil { diff --git a/password_input.go b/password_input.go index 25d07ca..4c025c1 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 = ui.TextInputBase(c, value).Grow(1).MinWidth(Density(c).Unit() * 10). + in = textInputEditor(c, frame, value, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). Placeholder(opts.Placeholder).Label(opts.Label) if !*shown { in.Password() } }) - r.changed = in.Changed() - r.submitted = in.Submitted() + r.changed = in.Changed() && !opts.Disabled + r.submitted = in.Submitted() && !opts.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 df0b3d4..b0ac1a8 100644 --- a/radio_group.go +++ b/radio_group.go @@ -43,37 +43,44 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt k := Tokens(c) d := Density(c) var r InputResult - r.Element = ui.RadioGroup(c, func() { - for _, it := range items { - if radioGroupItem(c, k, d, value, it) { - r.changed = true - slog.Debug("mujica: radio_group choose", "label", it.Label) + // Disabled on a wrapper blocks the bases' clicks before they choose. + wrap := ui.Box(c).Shrink(0).Disabled(opts.Disabled) + wrap.Children(func() { + r.Element = ui.RadioGroup(c, func() { + for _, it := range items { + if radioGroupItem(c, k, d, value, it) { + r.changed = true + slog.Debug("mujica: radio_group choose", "label", it.Label) + } } - } - }).Label(opts.Label).Disabled(opts.Disabled).Gap(0) + }).Label(opts.Label).Gap(0) + }) if opts.Horizontal { r.Element.Row().Gap(d.Unit() * 6) } + if opts.Disabled && 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 { - row := ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false).Disabled(it.Disabled) + // 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.Children(func() { + row = ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false) + }) + if it.Disabled && item.FocusWithin() { + item.FocusRing(false).Focus() + } on := *value == it.Value hover, pressed := row.Hovered(), row.Pressed() row.Children(func() { - dot := ui.Box(c).Size(checkboxSize, checkboxSize).Shrink(0).Radius(checkboxSize / 2) - switch { - case on: - dot.Background(k.Accent).Border(theme.BorderWidth, k.AccentText) - case pressed: - dot.Background(k.Selection).Border(theme.BorderWidth, k.AccentText) - case hover: - dot.Background(k.SurfaceHover).Border(theme.BorderWidth, k.AccentText) - default: - dot.Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) - } + bg, border := checkboxColors(k, on, hover, pressed) + dot := ui.Box(c).Size(checkboxSize, checkboxSize).Shrink(0).Radius(checkboxSize/2). + Background(bg).Border(theme.BorderWidth, border) dot.Transition(textInputFade) dot.DrawOver(func(p *ui.Painter, r ui.Rect) { if on { diff --git a/search_input.go b/search_input.go index 7a1af8f..176b042 100644 --- a/search_input.go +++ b/search_input.go @@ -60,7 +60,7 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn } var r SearchInputResult step := func(d int) { - if opts.Matches == 0 { + if opts.Matches == 0 || opts.Disabled { return } *opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches @@ -83,8 +83,8 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn 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 := ui.TextInputBase(c, query).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name) - r.changed = in.Changed() + 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 if opts.Find { // Shift+Enter would reach the editor as a plain submit. in.HandleInput(func(ev ui.InputEvent) bool { @@ -102,9 +102,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn step(1) } } else { - r.submitted = in.Submitted() + r.submitted = in.Submitted() && !opts.Disabled } - if *query != "" && textInputClear(c, in) { + if *query != "" && !opts.Disabled && textInputClear(c, in) { *query = "" r.changed = true slog.Debug("mujica: search_input clear") @@ -127,7 +127,7 @@ func searchInputFindControls(c *ui.Context, query string, matches, current int, case matches > 0: status = fmt.Sprintf(msg(c, "search_input.count"), current+1, matches) } - ui.Text(c, status).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum"). + ui.Text(c, status).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum"). SingleLine().Shrink(0).Role(ui.RoleStatus) ui.Box(c).Width(theme.BorderWidth).Height(16).Background(k.Border).Shrink(0) opts := IconButtonOptions{Variant: Ghost, Disabled: matches == 0} diff --git a/testdata/snapshots/darwin/button-comfortable-dark.png b/testdata/snapshots/darwin/button-comfortable-dark.png index 6167924..afce442 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 cf71679..0980b9b 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 73e1a88..e0868f3 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 6eab1a0..8afc2ba 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 df1f5e4..365330b 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 c977763..dbf5a78 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/icon_button-dark.png b/testdata/snapshots/darwin/icon_button-dark.png index ff1961b..c4e2187 100644 Binary files a/testdata/snapshots/darwin/icon_button-dark.png and b/testdata/snapshots/darwin/icon_button-dark.png differ diff --git a/testdata/snapshots/darwin/icon_button-light.png b/testdata/snapshots/darwin/icon_button-light.png index 6a4ca9b..79e0330 100644 Binary files a/testdata/snapshots/darwin/icon_button-light.png and b/testdata/snapshots/darwin/icon_button-light.png differ diff --git a/testdata/snapshots/darwin/spinner-dark.png b/testdata/snapshots/darwin/spinner-dark.png index 6c3d1a1..a34bd86 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 be8951a..aed49d0 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 e87274b..7dc8eac 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, true).Grow(1).Padding(d.Unit()*2, d.Unit()*3). + in := textInputEditor(c, frame, value, opts.ReadOnly || opts.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,8 +63,18 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { if opts.MaxHeight > 0 { in.MaxHeight(opts.MaxHeight) } - r.changed = in.Changed() && !opts.ReadOnly - r.submitted = in.Shortcut(mods, key) + r.changed = in.Changed() && !opts.ReadOnly && !opts.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 { + if ev.Kind == ui.InputKeyDown && ev.Key == key && ev.Mods == mods { + *submit = true + return true + } + return false + }) + r.submitted = *submit && !opts.Disabled + *submit = false return in }, count) return r @@ -82,7 +92,7 @@ func textAreaCount(c *ui.Context, n, max int) func() { if over { color = k.Danger } - ui.Text(c, fmt.Sprintf("%d / %d", n, max)).TextColor(color).FontSize(theme.CaptionSize). + ui.Text(c, fmt.Sprintf("%d / %d", n, max)).TextColor(color).FontSize(FontSize(c, theme.CaptionSize)). FontFeatures("tnum").Shrink(0).Label(fmt.Sprintf(msg(c, "text_area.count"), n, max)) } } diff --git a/text_input.go b/text_input.go index 07107cc..acacf0d 100644 --- a/text_input.go +++ b/text_input.go @@ -53,10 +53,10 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult if opts.Leading != nil { opts.Leading() } - in := textInputEditor(c, frame, value, opts.ReadOnly, false).Grow(1).MinWidth(Density(c).Unit() * 10). + in := textInputEditor(c, frame, value, opts.ReadOnly || opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10). Placeholder(opts.Placeholder).Label(opts.Label) - r.changed = in.Changed() && !opts.ReadOnly - r.submitted = in.Submitted() + r.changed = in.Changed() && !opts.ReadOnly && !opts.Disabled + r.submitted = in.Submitted() && !opts.Disabled if opts.Clearable && *value != "" && !opts.ReadOnly && !opts.Disabled { if textInputClear(c, in) { *value = "" @@ -95,6 +95,10 @@ func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Ele } frame.Children(func() { in = build(frame) }) in.FocusRing(false) + if look.disabled && outer.FocusWithin() { + // A field disabled while focused gives the focus up. + outer.FocusRing(false).Focus() + } if look.err != "" { in.Error(look.err) } @@ -125,7 +129,7 @@ func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Ele } ui.Row(c).Gap(d.Unit() * 2).AlignItems(ui.Start).Children(func() { if look.err != "" { - ui.Text(c, look.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Grow(1) + ui.Text(c, look.err).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Grow(1) } else { ui.Spacer(c) } @@ -137,16 +141,12 @@ func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Ele return outer, in } -// textInputEditor builds the MyGo editor; read-only edits are undone. -func textInputEditor(c *ui.Context, frame *ui.Element, value *string, readOnly, area bool) *ui.Element { - base := ui.TextInputBase - if area { - base = ui.TextAreaBase - } - if !readOnly { +// textInputEditor builds the MyGo editor; a locked one edits a copy, undone at once. +func textInputEditor(c *ui.Context, frame *ui.Element, value *string, locked bool, base func(*ui.Context, *string) *ui.Element) *ui.Element { + if !locked { return base(c, value) } - shown := ui.Local(frame, "readonly", func() string { return *value }) + shown := ui.Local(frame, "locked", func() string { return *value }) *shown = *value in := base(c, shown) *shown = *value