Inputs: review r2 fixes
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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` 为空时必填。 |
|
||||
|
||||
|
||||
@@ -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 清空。
|
||||
@@ -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。
|
||||
@@ -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 未公开为自定义元素设置数值范围的接口。
|
||||
@@ -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`)只表示可编辑元素持有键盘,不表示正在组合。
|
||||
- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。
|
||||
@@ -49,6 +49,6 @@ Tab 进入组并落在已选项;Up/Down/Left/Right 移动并直接选择,循
|
||||
|
||||
## 限制
|
||||
|
||||
- 每个选项以 `Value` 为键,选项重新排序后焦点仍跟随原来的值。
|
||||
- 每个选项按 `Value` 相等性取得稳定键(首次出现的序号),选项重新排序后焦点仍跟随原来的值;组件记住见过的值,长期不断变化的选项集合会让这份记录增长。
|
||||
|
||||
- 方向键对横向与纵向组都同时响应上下左右(MyGo `RadioGroup` 固定使用 `Horizontal|Vertical`)。
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 6.6 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 4.1 KiB |
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||