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