Merge branch 'worktree-agent-a085d9a07ef787c18'
No files matched your search
@@ -29,6 +29,9 @@ type ButtonOptions struct {
|
|||||||
Label string
|
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.
|
// Button is a push button. Loading keeps its size and blocks clicks.
|
||||||
func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
|
func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
|
||||||
if label == "" && opts.Label == "" {
|
if label == "" && opts.Label == "" {
|
||||||
@@ -36,8 +39,13 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
|
|||||||
}
|
}
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(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).
|
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)
|
Disabled(opts.Disabled || opts.Loading)
|
||||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
if opts.Label != "" {
|
if opts.Label != "" {
|
||||||
@@ -49,25 +57,13 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
|
|||||||
b.Border(theme.BorderWidth, border)
|
b.Border(theme.BorderWidth, border)
|
||||||
}
|
}
|
||||||
b.Children(func() {
|
b.Children(func() {
|
||||||
switch {
|
if opts.Loading {
|
||||||
case opts.Loading && opts.Icon != nil:
|
Spinner(c, SpinnerOptions{Size: buttonSpinnerSize, Color: fg})
|
||||||
// The spinner takes the icon's 16 DIP slot.
|
} else if opts.Icon != nil {
|
||||||
Spinner(c, SpinnerOptions{Size: 16, Color: fg})
|
ui.Icon(c, opts.Icon).Size(buttonSpinnerSize, buttonSpinnerSize)
|
||||||
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 != "" {
|
if label != "" {
|
||||||
t := ui.Text(c, label).SingleLine()
|
ui.Text(c, label).SingleLine()
|
||||||
if opts.Loading && opts.Icon == nil {
|
|
||||||
t.TextColor(fg.Alpha(0))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
return b
|
return b
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *u
|
|||||||
wrap.Children(func() {
|
wrap.Children(func() {
|
||||||
row = ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false)
|
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()
|
wrap.FocusRing(false).Focus()
|
||||||
}
|
}
|
||||||
if row.Changed() && opts.Indeterminate && !*value {
|
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。 |
|
| `label` | `string` | 必填(仅图标时可为空) | 按钮文字;为空时必须提供 `Label`,否则 panic。 |
|
||||||
| `Variant` | `ButtonVariant` | `Primary` | `Primary`、`Secondary`、`Outline`、`Ghost`、`Danger`。 |
|
| `Variant` | `ButtonVariant` | `Primary` | `Primary`、`Secondary`、`Outline`、`Ghost`、`Danger`。 |
|
||||||
| `Icon` | `*ui.SVG` | `nil` | 文字前的 16 DIP 图标。 |
|
| `Icon` | `*ui.SVG` | `nil` | 文字前的 16 DIP 图标。 |
|
||||||
| `Loading` | `bool` | `false` | 有图标时加载标记占用图标位置;纯文字时标记居中覆盖在隐藏的文字上,宽度不变;阻止点击。 |
|
| `Loading` | `bool` | `false` | 加载标记出现在前置 16 DIP 图标位置,文字保持可见;有图标时替换图标,纯文字时从左右内边距中让出该位置,宽度不变;阻止点击。 |
|
||||||
| `Disabled` | `bool` | `false` | 不可点击、不进入 Tab 序列。 |
|
| `Disabled` | `bool` | `false` | 不可点击、不进入 Tab 序列。 |
|
||||||
| `Label` | `string` | `""` | 无障碍名称;`label` 为空时必填。 |
|
| `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` 通过让编辑器编辑一个副本并在同帧还原实现,撤销栈里可能出现被还原的步骤。
|
- MyGo 没有公开的只读输入接口(只读模式仅用于 `Text.Selectable`,`editor.go:77`)。`ReadOnly` 通过让编辑器编辑一个副本并在同帧还原实现,撤销栈里可能出现被还原的步骤。
|
||||||
- 清空按钮不进入 Tab 序列(与 MyGo SearchField 相同),键盘用 Escape 清空。
|
- 清空按钮不进入 Tab 序列(与 MyGo SearchField 相同),键盘用 Escape 清空。
|
||||||
@@ -45,5 +45,5 @@ if r.Submitted() {
|
|||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- `MaxLength` 只提示,不截断输入,以免打断输入法组合与粘贴。
|
- `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。
|
- 只读实现方式同 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` 同用。 |
|
| `StepperOnly` | `bool` | `false` | 只显示数值与上下箭头,不可输入;必须给出 `Min < Max`,且不能与 `AllowEmpty` 同用。 |
|
||||||
| `Placeholder` | `string` | `""` | 空值时的占位。 |
|
| `Placeholder` | `string` | `""` | 空值时的占位。 |
|
||||||
| `Label` | `string` | `""` | 无障碍名称。 |
|
| `Label` | `string` | `""` | 无障碍名称。 |
|
||||||
| `Disabled` | `bool` | `false` | 禁用。 |
|
| `Disabled` | `bool` | `false` | 禁用;任一祖先容器禁用时同样生效。 |
|
||||||
| `Error` | `string` | `""` | 调用方给出的错误。 |
|
| `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。
|
- 输入模式下的加减按钮不进入 Tab 序列,键盘用 Up/Down。
|
||||||
- 仅步进模式复用 MyGo `ui.Stepper`,箭头高度来自主题间距,不随密度的控件高度变化。
|
- 仅步进模式不使用 MyGo `ui.Stepper`:它的箭头不检查禁用(`ui/indicators.go:216`),吸附固定按 9 位小数取整(`ui/widgets.go` 中 `snap`),步长 1e-10 时无法前进。组件自绘箭头并自行计算;按住箭头不会连续步进,需重复点击或使用方向键。
|
||||||
- MyGo `ui.Stepper` 的箭头不检查禁用(`indicators.go:216`),禁用时组件向它传入副本,保证值不被修改。
|
- 仅步进模式要求有界,以便 Home/End 有目标值。
|
||||||
- MyGo 的步进器吸附依赖有限边界(`widgets.go` 中 `snap`),所以仅步进模式要求有界。
|
- 仅步进模式的值通过无障碍描述朗读,MyGo 未公开为自定义元素设置数值范围的接口。
|
||||||
@@ -48,5 +48,5 @@ if r.Moved() {
|
|||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- MyGo 单行编辑器把 Shift+Enter 当作普通提交且不公开修饰键(`editor.go:489`,`submitMods` 未导出),组件用 `HandleInput` 先截获 Shift+Enter。
|
- 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`)。
|
- 方向键对横向与纵向组都同时响应上下左右(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.
|
// clamp rounds v to the precision, then bounds it; the bounds sit on that grid.
|
||||||
func (s numberInputSpec) clamp(v float64) float64 {
|
func (s numberInputSpec) clamp(v float64) float64 {
|
||||||
p := math.Pow(10, float64(s.decimals))
|
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.
|
// 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 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.
|
// Bounds move inward onto the precision grid, so clamped values keep both.
|
||||||
p := math.Pow(10, float64(s.decimals))
|
p := math.Pow(10, float64(s.decimals))
|
||||||
s.lo = math.Ceil(math.Round(s.lo*p*1e6)/1e6) / p
|
s.lo = numberInputCeil(s.lo*p)/p + 0 // + 0 turns -0 into 0
|
||||||
s.hi = math.Floor(math.Round(s.hi*p*1e6)/1e6) / p
|
s.hi = numberInputFloor(s.hi*p)/p + 0
|
||||||
if s.lo > s.hi {
|
if s.lo > s.hi {
|
||||||
panic("mujica: NumberInput has no value in range at this precision")
|
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
|
r.changed = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
stepBy := func(n float64) {
|
stepBy := func(n int) { set(s.stepBy(*value, n)) }
|
||||||
base := *value
|
|
||||||
if math.IsNaN(base) {
|
|
||||||
base = math.Max(s.lo, math.Min(s.hi, 0))
|
|
||||||
}
|
|
||||||
set(base + n*s.step)
|
|
||||||
}
|
|
||||||
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
look := &textInputLook{disabled: opts.Disabled, err: opts.Error}
|
||||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||||
text := ui.Local(frame, "text", func() string { return s.format(*value) })
|
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.
|
// Set from outside: the text follows, and a typing error goes.
|
||||||
*text, *invalid = s.format(*value), ""
|
*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)
|
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||||
if in.Changed() && !opts.Disabled {
|
if in.Changed() && !look.disabled {
|
||||||
*invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set)
|
*invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set)
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case opts.Disabled:
|
case look.disabled:
|
||||||
case in.Shortcut(0, ui.KeyUp):
|
case in.Shortcut(0, ui.KeyUp):
|
||||||
stepBy(1)
|
stepBy(1)
|
||||||
*text, *invalid = s.format(*value), ""
|
*text, *invalid = s.format(*value), ""
|
||||||
@@ -151,7 +170,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe
|
|||||||
for _, b := range []struct {
|
for _, b := range []struct {
|
||||||
name string
|
name string
|
||||||
icon string
|
icon string
|
||||||
delta float64
|
delta int
|
||||||
can bool
|
can bool
|
||||||
}{
|
}{
|
||||||
{msg(c, "number_input.decrease"), "minus", -1, math.IsNaN(*value) || *value > s.lo},
|
{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()
|
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 {
|
func numberInputStepper(c *ui.Context, value *float64, s numberInputSpec, opts NumberInputOptions) InputResult {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
var r InputResult
|
var r InputResult
|
||||||
r.Element = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(opts.Disabled)
|
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() {
|
r.Element.Children(func() {
|
||||||
ui.Row(c).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).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)
|
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.
|
border := k.ControlBorder
|
||||||
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)
|
|
||||||
if opts.Error != "" {
|
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)
|
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.Input = st
|
||||||
r.changed = st.Changed() && !opts.Disabled
|
|
||||||
})
|
})
|
||||||
if opts.Error != "" {
|
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 {
|
if r.changed {
|
||||||
*value = s.clamp(*value)
|
|
||||||
slog.Debug("mujica: number_input step", "value", *value)
|
slog.Debug("mujica: number_input step", "value", *value)
|
||||||
}
|
}
|
||||||
return r
|
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"
|
mode = "shown"
|
||||||
}
|
}
|
||||||
ui.Row(c).Key(mode).Grow(1).Children(func() {
|
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)
|
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||||
if !*shown {
|
if !*shown {
|
||||||
in.Password()
|
in.Password()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
r.changed = in.Changed() && !opts.Disabled
|
r.changed = in.Changed() && !look.disabled
|
||||||
r.submitted = in.Submitted() && !opts.Disabled
|
r.submitted = in.Submitted() && !look.disabled
|
||||||
if passwordInputToggle(c, *shown) {
|
if passwordInputToggle(c, *shown) {
|
||||||
*shown = !*shown
|
*shown = !*shown
|
||||||
slog.Debug("mujica: password_input reveal", "shown", *shown)
|
slog.Debug("mujica: password_input reveal", "shown", *shown)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package mujica
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"slices"
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"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
|
var r InputResult
|
||||||
// Disabled on a wrapper blocks the bases' clicks before they choose.
|
// Disabled on a wrapper blocks the bases' clicks before they choose.
|
||||||
wrap := ui.Box(c).Shrink(0).Disabled(opts.Disabled)
|
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() {
|
wrap.Children(func() {
|
||||||
r.Element = ui.RadioGroup(c, func() {
|
r.Element = ui.RadioGroup(c, func() {
|
||||||
for _, it := range items {
|
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
|
r.changed = true
|
||||||
slog.Debug("mujica: radio_group choose", "label", it.Label)
|
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 {
|
if opts.Horizontal {
|
||||||
r.Element.Row().Gap(d.Unit() * 6)
|
r.Element.Row().Gap(d.Unit() * 6)
|
||||||
}
|
}
|
||||||
if opts.Disabled && wrap.FocusWithin() {
|
if wrap.IsDisabled() && wrap.FocusWithin() {
|
||||||
wrap.FocusRing(false).Focus()
|
wrap.FocusRing(false).Focus()
|
||||||
}
|
}
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
// radioGroupItem builds one radio and reports whether it was chosen.
|
// 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.
|
// Keyed by value so focus follows the item, not its position.
|
||||||
var row *ui.Element
|
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() {
|
item.Children(func() {
|
||||||
row = ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false)
|
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()
|
item.FocusRing(false).Focus()
|
||||||
}
|
}
|
||||||
on := *value == it.Value
|
on := *value == it.Value
|
||||||
|
|||||||
@@ -59,8 +59,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn
|
|||||||
name = place
|
name = place
|
||||||
}
|
}
|
||||||
var r SearchInputResult
|
var r SearchInputResult
|
||||||
|
look := &textInputLook{disabled: opts.Disabled}
|
||||||
step := func(d int) {
|
step := func(d int) {
|
||||||
if opts.Matches == 0 || opts.Disabled {
|
if opts.Matches == 0 || look.disabled {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
*opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches
|
*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 {
|
if opts.Find {
|
||||||
*opts.Current = max(0, min(*opts.Current, opts.Matches-1))
|
*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"
|
mac := runtime.GOOS == "darwin"
|
||||||
switch {
|
switch {
|
||||||
case mac && c.Shortcut(ui.Cmd, ui.KeyG), !mac && c.Shortcut(0, ui.KeyF3):
|
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)
|
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)
|
ui.Icon(c, icons.Must("search")).Size(16, 16).Shrink(0).TextColor(k.TextMuted)
|
||||||
back := ui.Local(frame, "back", func() bool { return false })
|
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)
|
in := textInputEditor(c, frame, query, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name)
|
||||||
r.changed = in.Changed() && !opts.Disabled
|
r.changed = in.Changed() && !look.disabled
|
||||||
if opts.Find {
|
if opts.Find {
|
||||||
// Shift+Enter would reach the editor as a plain submit.
|
// Shift+Enter would reach the editor as a plain submit.
|
||||||
in.HandleInput(func(ev ui.InputEvent) bool {
|
in.HandleInput(func(ev ui.InputEvent) bool {
|
||||||
@@ -102,9 +103,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn
|
|||||||
step(1)
|
step(1)
|
||||||
}
|
}
|
||||||
} else {
|
} 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 = ""
|
*query = ""
|
||||||
r.changed = true
|
r.changed = true
|
||||||
slog.Debug("mujica: search_input clear")
|
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
|
var r InputResult
|
||||||
look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: errMsg, area: true}
|
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 {
|
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)
|
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||||
if opts.MinHeight > 0 {
|
if opts.MinHeight > 0 {
|
||||||
in.MinHeight(opts.MinHeight)
|
in.MinHeight(opts.MinHeight)
|
||||||
@@ -63,7 +63,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult {
|
|||||||
if opts.MaxHeight > 0 {
|
if opts.MaxHeight > 0 {
|
||||||
in.MaxHeight(opts.MaxHeight)
|
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.
|
// Taken before the editor, which would make a plain Enter a newline.
|
||||||
submit := ui.Local(frame, "submit", func() bool { return false })
|
submit := ui.Local(frame, "submit", func() bool { return false })
|
||||||
in.HandleInput(func(ev ui.InputEvent) bool {
|
in.HandleInput(func(ev ui.InputEvent) bool {
|
||||||
@@ -73,7 +73,7 @@ func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult {
|
|||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
r.submitted = *submit && !opts.Disabled
|
r.submitted = *submit && !look.disabled
|
||||||
*submit = false
|
*submit = false
|
||||||
return in
|
return in
|
||||||
}, count)
|
}, count)
|
||||||
|
|||||||
@@ -53,11 +53,11 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult
|
|||||||
if opts.Leading != nil {
|
if opts.Leading != nil {
|
||||||
opts.Leading()
|
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)
|
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||||
r.changed = in.Changed() && !opts.ReadOnly && !opts.Disabled
|
r.changed = in.Changed() && !opts.ReadOnly && !look.disabled
|
||||||
r.submitted = in.Submitted() && !opts.Disabled
|
r.submitted = in.Submitted() && !look.disabled
|
||||||
if opts.Clearable && *value != "" && !opts.ReadOnly && !opts.Disabled {
|
if opts.Clearable && *value != "" && !opts.ReadOnly && !look.disabled {
|
||||||
if textInputClear(c, in) {
|
if textInputClear(c, in) {
|
||||||
*value = ""
|
*value = ""
|
||||||
r.changed = true
|
r.changed = true
|
||||||
@@ -75,7 +75,7 @@ func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult
|
|||||||
// textInputFade eases hover, focus and error colors.
|
// textInputFade eases hover, focus and error colors.
|
||||||
var textInputFade = ui.ElementTransition{Colors: true, Duration: theme.StateDuration}
|
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 {
|
type textInputLook struct {
|
||||||
disabled, readOnly, area bool
|
disabled, readOnly, area bool
|
||||||
err string
|
err string
|
||||||
@@ -86,6 +86,8 @@ func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Ele
|
|||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
outer = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(look.disabled)
|
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() {
|
outer.Children(func() {
|
||||||
frame := ui.Row(c).Radius(theme.ControlRadius).Gap(d.Unit() * 2).Cursor(ui.CursorText)
|
frame := ui.Row(c).Radius(theme.ControlRadius).Gap(d.Unit() * 2).Cursor(ui.CursorText)
|
||||||
if look.area {
|
if look.area {
|
||||||
|
|||||||