Inputs: review r1 fixes

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:33:01 -04:00
1 parent 2a232930dd
commit ab8d9318c3
29 files changed
+417 -84

No files matched your search

+20 -5
View File
@@ -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
+4 -1
View File
@@ -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)
+29 -12
View File
@@ -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
+3 -3
View File
@@ -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` 为空时必填。 |
## 状态
+1 -1
View File
@@ -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`
+1 -1
View File
@@ -32,7 +32,7 @@ if r.Submitted() {
## 状态
默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。颜色变化 120 毫秒过渡,减少动画时直接切换。
默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。聚焦时被禁用会放弃焦点,已排队的按键不再修改值、不触发事件。颜色变化 120 毫秒过渡,减少动画时直接切换。
## 事件
+4 -4
View File
@@ -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。
+2 -2
View File
@@ -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 之间的数字”),值保持上一次有效值;失去焦点后错误文本与提示保留,直到用户改正,或调用方从外部改变了值。值在聚焦期间被外部修改时,输入框立即显示新值。到达边界时对应按钮禁用。
## 事件
+1
View File
@@ -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;只有未被输入法消费的按键会触发提交或跳转。
- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。
+1 -1
View File
@@ -33,7 +33,7 @@ func CheckboxGroup(c *ui.Context, label string, items []CheckboxItem, opts Check
## 状态
未选(Surface 底、ControlBorder 边)、悬停与按下(AccentText 边)、选中(酒红底、象牙白勾)、半选(酒红底、象牙白横线)、键盘聚焦(方框外焦点环)、禁用。行高不低于密度控件高度。
未选(Surface 底、ControlBorder 边)、悬停与按下(AccentText 边)、选中(酒红底、象牙白勾;悬停与按下时底色加深、边框变为正文色)、半选(酒红底、象牙白横线)、键盘聚焦(方框外焦点环)、禁用。行高不低于密度控件高度。
全选框:全部选中为选中,部分选中为半选,全不选为未选。点击时若可用项未全选则全部勾选,否则全部清除;禁用项保持不变。
+3 -1
View File
@@ -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`)。
+187
View File
@@ -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))
}
}
}
+24 -6
View File
@@ -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
+76 -3
View File
@@ -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 {
+3 -3
View File
@@ -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)
+26 -19
View File
@@ -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 {
+6 -6
View File
@@ -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}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

+14 -4
View File
@@ -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))
}
}
+12 -12
View File
@@ -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