Inputs: review r2 fixes

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 13:02:39 -04:00
1 parent deddc7bd34
commit b63ee97d15
25 files changed
+330 -83

No files matched your search

+14 -18
View File
@@ -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
+1 -1
View File
@@ -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 {
+1 -1
View File
@@ -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` 为空时必填。 |
+3 -2
View File
@@ -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 清空。
+1 -1
View File
@@ -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。
+8 -6
View File
@@ -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 未公开为自定义元素设置数值范围的接口。
+1 -1
View File
@@ -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`)只表示可编辑元素持有键盘,不表示正在组合。
- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。
+1 -1
View File
@@ -49,6 +49,6 @@ Tab 进入组并落在已选项;Up/Down/Left/Right 移动并直接选择,循
## 限制
- 每个选项以 `Value` 为键,选项重新排序后焦点仍跟随原来的值。
- 每个选项按 `Value` 相等性取得稳定键(首次出现的序号),选项重新排序后焦点仍跟随原来的值;组件记住见过的值,长期不断变化的选项集合会让这份记录增长。
- 方向键对横向与纵向组都同时响应上下左右(MyGo `RadioGroup` 固定使用 `Horizontal|Vertical`)。
+177
View File
@@ -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"))
}
}
+87 -27
View File
@@ -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()
}
+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 = 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)
+13 -5
View File
@@ -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
+10 -9
View File
@@ -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")
Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 4.1 KiB

+3 -3
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 || 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)
+7 -5
View File
@@ -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 {