Merge branch 'worktree-agent-a3c9c595e531146d2'
No files matched your search
@@ -51,8 +51,7 @@ type colorPickerState struct {
|
|||||||
bad string
|
bad string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ColorPicker is a swatch button opening a panel to pick *color: a saturation and
|
// ColorPicker is a swatch button opening a panel that picks *color.
|
||||||
// brightness square, hue and opacity sliders, HEX, RGB and opacity fields, and presets.
|
|
||||||
func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts {
|
func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
d := Density(c)
|
d := Density(c)
|
||||||
@@ -92,7 +91,9 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
|
|||||||
st.sync(*color)
|
st.sync(*color)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if trigger.Clicked() {
|
if opts.Disabled {
|
||||||
|
st.open = false
|
||||||
|
} else if trigger.Clicked() {
|
||||||
st.open = !st.open
|
st.open = !st.open
|
||||||
}
|
}
|
||||||
border := k.ControlBorder
|
border := k.ControlBorder
|
||||||
@@ -139,7 +140,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
|
|||||||
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
|
ui.Grid(c).ColumnTracks(ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1), ui.Fr(1)).Gap(d.Unit() * 1.5).Children(func() {
|
||||||
for _, sw := range presets {
|
for _, sw := range presets {
|
||||||
hex := strings.ToUpper(colorPickerHex(sw))
|
hex := strings.ToUpper(colorPickerHex(sw))
|
||||||
b := ui.ButtonBase(c).Height(d.Unit()*6).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
|
b := ui.ButtonBase(c).Height(theme.MinHitSize).Radius(theme.ControlRadius).Border(theme.BorderWidth, k.Border).Label(hex).Clip()
|
||||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||||
chosen := *color == sw
|
chosen := *color == sw
|
||||||
b.Selected(chosen).Draw(func(p *ui.Painter, r ui.Rect) {
|
b.Selected(chosen).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||||
@@ -159,7 +160,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if st.wasOpen && !st.open {
|
if st.wasOpen && !st.open && !opts.Disabled {
|
||||||
trigger.Focus()
|
trigger.Focus()
|
||||||
}
|
}
|
||||||
st.wasOpen = st.open
|
st.wasOpen = st.open
|
||||||
@@ -269,7 +270,7 @@ func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set
|
|||||||
}{{"R", msg(c, "color_picker.red"), &st.r}, {"G", msg(c, "color_picker.green"), &st.g},
|
}{{"R", msg(c, "color_picker.red"), &st.r}, {"G", msg(c, "color_picker.green"), &st.g},
|
||||||
{"B", msg(c, "color_picker.blue"), &st.b}, {"A %", msg(c, "color_picker.percent"), &st.alpha}} {
|
{"B", msg(c, "color_picker.blue"), &st.b}, {"A %", msg(c, "color_picker.percent"), &st.alpha}} {
|
||||||
ui.Column(c).Gap(d.Unit()).Children(func() {
|
ui.Column(c).Gap(d.Unit()).Children(func() {
|
||||||
ui.Text(c, f.name).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
ui.Text(c, f.name).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||||
in := InputGroup(c, f.text, InputGroupOptions{Label: f.label, Invalid: st.bad == f.name})
|
in := InputGroup(c, f.text, InputGroupOptions{Label: f.label, Invalid: st.bad == f.name})
|
||||||
editing = editing || in.Input.Focused()
|
editing = editing || in.Input.Focused()
|
||||||
if !in.Changed() {
|
if !in.Changed() {
|
||||||
@@ -296,7 +297,7 @@ func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set
|
|||||||
st.sync(*color)
|
st.sync(*color)
|
||||||
}
|
}
|
||||||
if st.bad != "" {
|
if st.bad != "" {
|
||||||
ui.Text(c, msg(c, "color_picker.invalid")).FontSize(theme.CaptionSize).TextColor(k.Danger).Role(ui.RoleStatus)
|
ui.Text(c, msg(c, "color_picker.invalid")).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Danger).Role(ui.RoleStatus)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,3 +124,36 @@ func TestColorPickerSnapshot(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disabling an open picker closes it before keys can change the color.
|
||||||
|
func TestColorPickerDisabledWhileOpen(t *testing.T) {
|
||||||
|
col, n := ui.Hex("#808080"), 0
|
||||||
|
opts := ColorPickerOptions{Label: "Shade"}
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
if ColorPicker(c, &col, opts).Changed() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}, 400, 560)
|
||||||
|
tt.Key(0, ui.KeyTab)
|
||||||
|
tt.Key(0, ui.KeyEnter)
|
||||||
|
if !tt.Focused("Saturation and brightness") {
|
||||||
|
t.Fatal("not open")
|
||||||
|
}
|
||||||
|
opts.Disabled = true
|
||||||
|
tt.Key(0, ui.KeyUp)
|
||||||
|
tt.Key(0, ui.KeyRight)
|
||||||
|
if col != ui.Hex("#808080") || n != 0 || tt.HasText("HEX") {
|
||||||
|
t.Fatalf("disabled picker changed to %v", col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestColorPickerSwatchHitArea(t *testing.T) {
|
||||||
|
col, n := ui.Hex("#7D2034"), 0
|
||||||
|
tt := colorPickerTester(&col, ColorPickerOptions{Label: "Accent"}, &n)
|
||||||
|
_ = tt.Click("Accent")
|
||||||
|
r, ok := tt.Find("#355E80")
|
||||||
|
if !ok || r.W < 28 || r.H < 28 {
|
||||||
|
t.Fatalf("swatch %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ ui.Render(view, 400, 300, 1)
|
|||||||
|
|
||||||
| 字段 | 类型 | 默认值 | 含义 |
|
| 字段 | 类型 | 默认值 | 含义 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `FormOptions.Horizontal` | `bool` | false | 标签放在控件左侧一列,右对齐(MyGo `ui.Form` 布局)。 |
|
| `FormOptions.Horizontal` | `bool` | false | 标签放在控件左侧一列,右对齐。 |
|
||||||
| `FormOptions.SubmitLabel` | `string` | 内置“Submit/提交” | 提交按钮文字。 |
|
| `FormOptions.SubmitLabel` | `string` | 内置“Submit/提交” | 提交按钮文字。 |
|
||||||
| `FormOptions.ResetLabel` | `string` | 内置“Reset/重置” | 重置按钮文字。 |
|
| `FormOptions.ResetLabel` | `string` | 内置“Reset/重置” | 重置按钮文字。 |
|
||||||
| `FormItemOptions[T].Label` | `string` | 必填 | 字段标签;空值 panic。 |
|
| `FormItemOptions[T].Label` | `string` | 必填 | 字段标签;空值 panic。 |
|
||||||
@@ -56,4 +56,4 @@ Tab / Shift+Tab 在字段间移动,离开字段即校验。提交失败后焦
|
|||||||
- 控件由调用方构建,`FormItem` 的 control 回调必须返回可获取焦点的元素,用于错误定位。
|
- 控件由调用方构建,`FormItem` 的 control 回调必须返回可获取焦点的元素,用于错误定位。
|
||||||
- 校验在失焦与提交时触发;已触碰字段在值变化时重新同步校验,异步校验只在下一次失焦或提交时重跑。
|
- 校验在失焦与提交时触发;已触碰字段在值变化时重新同步校验,异步校验只在下一次失焦或提交时重跑。
|
||||||
- 异步结果以版本号对齐:值变化后旧版本的 `Resolve` 返回 false 并被丢弃。`FormState` 的方法只能在界面线程调用,后台任务需经 `Window.Update` 回到界面线程。
|
- 异步结果以版本号对齐:值变化后旧版本的 `Resolve` 返回 false 并被丢弃。`FormState` 的方法只能在界面线程调用,后台任务需经 `Window.Update` 回到界面线程。
|
||||||
- 必填标记以“ *”附在标签文字后,屏幕阅读器会一起读出;MyGo 的 Field 标签不支持富文本,无法单独着色。
|
- 必填标记“*”只作视觉显示,辅助技术通过说明“必填”获知。
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
## 用途
|
## 用途
|
||||||
|
|
||||||
为单个控件提供标签、必填标记、说明与错误文字。标签通过 MyGo `ui.Field` 关联控件:屏幕阅读器以标签命名控件,并读出说明与错误;点击标签聚焦控件。
|
为单个控件提供标签、必填标记、说明与错误文字。标签、说明与错误显式关联到 control 返回的元素:屏幕阅读器以标签命名该元素并读出说明、必填与错误;点击标签聚焦该元素。
|
||||||
|
|
||||||
## 最小示例
|
## 最小示例
|
||||||
|
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
email := ""
|
email := ""
|
||||||
view := func(c *ui.Context) {
|
view := func(c *ui.Context) {
|
||||||
mujica.Use(c, mujica.Settings{})
|
mujica.Use(c, mujica.Settings{})
|
||||||
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
|
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element {
|
||||||
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
|
return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ui.Render(view, 400, 300, 1)
|
ui.Render(view, 400, 300, 1)
|
||||||
@@ -24,11 +24,11 @@ ui.Render(view, 400, 300, 1)
|
|||||||
| 字段 | 类型 | 默认值 | 含义 |
|
| 字段 | 类型 | 默认值 | 含义 |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `label` | `string` | 必填 | 标签文字;空值 panic。 |
|
| `label` | `string` | 必填 | 标签文字;空值 panic。 |
|
||||||
| `FormFieldOptions.Required` | `bool` | false | 在标签后加“ *”。 |
|
| `FormFieldOptions.Required` | `bool` | false | 在标签后显示危险色“*”,并向辅助技术说明“必填”。 |
|
||||||
| `FormFieldOptions.Description` | `string` | "" | 控件下方的说明。 |
|
| `FormFieldOptions.Description` | `string` | "" | 控件下方的说明。 |
|
||||||
| `FormFieldOptions.Error` | `string` | "" | 控件下方的错误,并把控件标记为无效。 |
|
| `FormFieldOptions.Error` | `string` | "" | 控件下方的错误,并把控件标记为无效。 |
|
||||||
| `FormFieldOptions.Horizontal` | `bool` | false | 标签在左、控件在右。 |
|
| `FormFieldOptions.Horizontal` | `bool` | false | 标签在左、控件在右。 |
|
||||||
| `control` | `func()` | 必填 | 构建控件;其中第一个可获取焦点的元素即被关联的控件。 |
|
| `control` | `func() *ui.Element` | 必填 | 构建控件并返回要关联的可聚焦元素(如 `InputGroupParts.Input`);返回 nil 时 panic。 |
|
||||||
|
|
||||||
## 状态
|
## 状态
|
||||||
|
|
||||||
@@ -44,6 +44,6 @@ Tab 直接到达控件;点击标签聚焦控件,或切换复选框、开关
|
|||||||
|
|
||||||
## 限制
|
## 限制
|
||||||
|
|
||||||
- 标签、说明与错误文字由 MyGo `ui.Field` 绘制,使用主题颜色(文字、TextMuted、Danger);字号与间距跟随 MyGo。
|
- control 返回的元素的 Label 会被字段标签覆盖。
|
||||||
- 错误只把边框染红的效果对 MyGo 自带输入框生效;MujicaUI 的 InputGroup 等需同时传入 `Invalid: true`。
|
- Error 只把元素标记为无效;InputGroup 等的红色边框需同时传入 `Invalid: true`。
|
||||||
- 左右布局时多个字段需放在同一个 `ui.Form` 或 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
|
- 左右布局时多个字段需放在 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。
|
||||||
@@ -30,8 +30,8 @@ func ExampleFormField() {
|
|||||||
email := ""
|
email := ""
|
||||||
view := func(c *ui.Context) {
|
view := func(c *ui.Context) {
|
||||||
mujica.Use(c, mujica.Settings{})
|
mujica.Use(c, mujica.Settings{})
|
||||||
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
|
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element {
|
||||||
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
|
return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ui.Render(view, 400, 300, 1)
|
ui.Render(view, 400, 300, 1)
|
||||||
|
|||||||
@@ -177,10 +177,12 @@ var formPages = []page{
|
|||||||
e = "This name is already in the guest list."
|
e = "This name is already in the guest list."
|
||||||
}
|
}
|
||||||
mujica.FormField(c, "Guest name", mujica.FormFieldOptions{Required: d.required, Horizontal: d.horizontal,
|
mujica.FormField(c, "Guest name", mujica.FormFieldOptions{Required: d.required, Horizontal: d.horizontal,
|
||||||
Description: "As written on the invitation.", Error: e}, func() {
|
Description: "As written on the invitation.", Error: e}, func() *ui.Element {
|
||||||
if mujica.InputGroup(c, &d.value, mujica.InputGroupOptions{Invalid: d.withError}).Changed() {
|
g := mujica.InputGroup(c, &d.value, mujica.InputGroupOptions{Invalid: d.withError})
|
||||||
|
if g.Changed() {
|
||||||
log(fmt.Sprintf("value %q", d.value))
|
log(fmt.Sprintf("value %q", d.value))
|
||||||
}
|
}
|
||||||
|
return g.Input
|
||||||
})
|
})
|
||||||
}},
|
}},
|
||||||
{ID: "043", Name: "InputGroup", ZhName: "输入组合", Category: "Forms", Code: formCode["InputGroup"],
|
{ID: "043", Name: "InputGroup", ZhName: "输入组合", Category: "Forms", Code: formCode["InputGroup"],
|
||||||
@@ -346,8 +348,8 @@ ui.Render(view, 400, 300, 1)`,
|
|||||||
"FormField": `email := ""
|
"FormField": `email := ""
|
||||||
view := func(c *ui.Context) {
|
view := func(c *ui.Context) {
|
||||||
mujica.Use(c, mujica.Settings{})
|
mujica.Use(c, mujica.Settings{})
|
||||||
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() {
|
mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element {
|
||||||
mujica.InputGroup(c, &email, mujica.InputGroupOptions{})
|
return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
ui.Render(view, 400, 300, 1)`,
|
ui.Render(view, 400, 300, 1)`,
|
||||||
|
|||||||
@@ -65,8 +65,7 @@ type fileDropZoneState struct {
|
|||||||
rejected []fileDropZoneReject
|
rejected []fileDropZoneReject
|
||||||
}
|
}
|
||||||
|
|
||||||
// FileDropZone takes files dropped from the system or chosen with its Browse button,
|
// FileDropZone checks dropped or browsed files and lists them; it never reads or uploads them.
|
||||||
// checks type, count and size, and lists them in *files. It never reads or uploads them.
|
|
||||||
func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) FileDropZoneParts {
|
func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) FileDropZoneParts {
|
||||||
if opts.MaxFiles < 0 || opts.MaxSize < 0 {
|
if opts.MaxFiles < 0 || opts.MaxSize < 0 {
|
||||||
panic("mujica: FileDropZone limits must not be negative")
|
panic("mujica: FileDropZone limits must not be negative")
|
||||||
@@ -172,7 +171,7 @@ func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions)
|
|||||||
zone.Children(func() {
|
zone.Children(func() {
|
||||||
ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
|
ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText)
|
||||||
ui.Text(c, hint).TextAlign(ui.Center)
|
ui.Text(c, hint).TextAlign(ui.Center)
|
||||||
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).TextAlign(ui.Center)
|
ui.Text(c, fileDropZoneRules(c, opts)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).TextAlign(ui.Center)
|
||||||
b := Button(c, msg(c, "file_drop_zone.browse"), ButtonOptions{Variant: Outline, Loading: st.browse != nil, Disabled: opts.Disabled})
|
b := Button(c, msg(c, "file_drop_zone.browse"), ButtonOptions{Variant: Outline, Loading: st.browse != nil, Disabled: opts.Disabled})
|
||||||
if b.Clicked() {
|
if b.Clicked() {
|
||||||
ch := make(chan filePickerResult, 1)
|
ch := make(chan filePickerResult, 1)
|
||||||
@@ -221,7 +220,7 @@ func fileDropZoneList(c *ui.Context, st *fileDropZoneState, files *[]DroppedFile
|
|||||||
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
|
BorderWidth(0, 0, theme.BorderWidth, 0).BorderColor(k.Border).Children(func() {
|
||||||
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color)
|
ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color)
|
||||||
ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
|
ui.Text(c, name).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
|
||||||
ui.Text(c, detail).TextColor(color).FontSize(theme.CaptionSize).SingleLine().Grow(1)
|
ui.Text(c, detail).TextColor(color).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Grow(1)
|
||||||
if remove != nil {
|
if remove != nil {
|
||||||
x := ui.ButtonBase(c).Size(28, 28).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
x := ui.ButtonBase(c).Size(28, 28).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
||||||
Label(fmt.Sprintf(msg(c, "file_drop_zone.remove"), name)).Disabled(disabled)
|
Label(fmt.Sprintf(msg(c, "file_drop_zone.remove"), name)).Disabled(disabled)
|
||||||
|
|||||||
@@ -55,8 +55,7 @@ type filePickerState struct {
|
|||||||
err string
|
err string
|
||||||
}
|
}
|
||||||
|
|
||||||
// FilePicker opens the system file or folder dialog and stores the chosen paths in *paths.
|
// FilePicker opens the system file dialog off the UI thread and stores the chosen paths.
|
||||||
// The dialog runs off the UI thread; the button shows loading until it closes.
|
|
||||||
func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePickerParts {
|
func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePickerParts {
|
||||||
for _, f := range opts.Filters {
|
for _, f := range opts.Filters {
|
||||||
for _, ext := range f.Extensions {
|
for _, ext := range f.Extensions {
|
||||||
@@ -118,16 +117,16 @@ func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePick
|
|||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case st.err != "":
|
case st.err != "":
|
||||||
ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus)
|
ui.Text(c, st.err).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
|
||||||
case len(*paths) == 0:
|
case len(*paths) == 0:
|
||||||
ui.Text(c, msg(c, "file_picker.none")).TextColor(k.TextMuted).FontSize(theme.CaptionSize)
|
ui.Text(c, msg(c, "file_picker.none")).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
|
||||||
default:
|
default:
|
||||||
ui.Column(c).Gap(d.Unit()).Children(func() {
|
ui.Column(c).Gap(d.Unit()).Children(func() {
|
||||||
for _, p := range *paths {
|
for _, p := range *paths {
|
||||||
ui.Row(c).Key(p).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
ui.Row(c).Key(p).Gap(d.Unit() * 2).AlignItems(ui.Center).Children(func() {
|
||||||
ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted)
|
ui.Icon(c, icon).Size(14, 14).TextColor(k.TextMuted)
|
||||||
ui.Text(c, filepath.Base(p)).SingleLine()
|
ui.Text(c, filepath.Base(p)).SingleLine()
|
||||||
ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(theme.CaptionSize).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
|
ui.Text(c, filepath.Dir(p)).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).SingleLine().Ellipsis("…").MinWidth(0).Shrink(1)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ import (
|
|||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormState is a form's validation and submit state; keep it beside the form's values.
|
// FormState is a form's check and submit state; use it on the UI thread only.
|
||||||
// Its methods run on the UI thread: deliver async results through Window.Update.
|
|
||||||
type FormState struct {
|
type FormState struct {
|
||||||
fields map[string]*formFieldState
|
fields map[string]*formFieldState
|
||||||
order []string
|
order []string
|
||||||
@@ -22,6 +21,7 @@ type FormState struct {
|
|||||||
requested bool
|
requested bool
|
||||||
focus string
|
focus string
|
||||||
formError string
|
formError string
|
||||||
|
generation int
|
||||||
}
|
}
|
||||||
|
|
||||||
type formFieldState struct {
|
type formFieldState struct {
|
||||||
@@ -33,6 +33,7 @@ type formFieldState struct {
|
|||||||
asyncFor uint64
|
asyncFor uint64
|
||||||
asyncErr string
|
asyncErr string
|
||||||
pending bool
|
pending bool
|
||||||
|
hasAsync bool
|
||||||
check func() string
|
check func() string
|
||||||
startAsync func()
|
startAsync func()
|
||||||
}
|
}
|
||||||
@@ -45,8 +46,7 @@ func (s *FormState) Version(name string) uint64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve delivers the async check of name's value at version; "" means valid.
|
// Resolve delivers name's async result for version, "" if valid; stale versions return false.
|
||||||
// It reports false and drops the result when the value has changed since.
|
|
||||||
func (s *FormState) Resolve(name string, version uint64, err string) bool {
|
func (s *FormState) Resolve(name string, version uint64, err string) bool {
|
||||||
f := s.fields[name]
|
f := s.fields[name]
|
||||||
if f == nil || f.version != version {
|
if f == nil || f.version != version {
|
||||||
@@ -98,8 +98,9 @@ type FormOptions struct {
|
|||||||
|
|
||||||
// FormScope registers a Form's fields with FormItem.
|
// FormScope registers a Form's fields with FormItem.
|
||||||
type FormScope struct {
|
type FormScope struct {
|
||||||
c *ui.Context
|
c *ui.Context
|
||||||
st *FormState
|
st *FormState
|
||||||
|
horizontal bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormParts are the parts of a Form.
|
// FormParts are the parts of a Form.
|
||||||
@@ -127,8 +128,7 @@ type FormItemOptions[T any] struct {
|
|||||||
Async func(v T, version uint64)
|
Async func(v T, version uint64)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form lays out the fields that fields registers, validates them on blur and submit,
|
// Form lays out and checks its fields and shows submit and reset buttons.
|
||||||
// and shows submit and reset buttons. It never stores or sends the values itself.
|
|
||||||
func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormScope)) FormParts {
|
func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormScope)) FormParts {
|
||||||
if st.fields == nil {
|
if st.fields == nil {
|
||||||
st.fields = map[string]*formFieldState{}
|
st.fields = map[string]*formFieldState{}
|
||||||
@@ -139,13 +139,16 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco
|
|||||||
parts := FormParts{}
|
parts := FormParts{}
|
||||||
col := ui.Column(c).Gap(d.Unit() * 5)
|
col := ui.Column(c).Gap(d.Unit() * 5)
|
||||||
parts.Element = col
|
parts.Element = col
|
||||||
scope := &FormScope{c: c, st: st}
|
scope := &FormScope{c: c, st: st, horizontal: opts.Horizontal}
|
||||||
col.Children(func() {
|
col.Children(func() {
|
||||||
if opts.Horizontal {
|
// A new key after Reset drops the controls' drafts and errors.
|
||||||
ui.Form(c, func() { fields(scope) })
|
ui.Box(c).Key(st.generation).Children(func() {
|
||||||
} else {
|
if opts.Horizontal {
|
||||||
ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) })
|
formFieldGrid(c, func() { fields(scope) })
|
||||||
}
|
} else {
|
||||||
|
ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) })
|
||||||
|
}
|
||||||
|
})
|
||||||
if st.formError != "" {
|
if st.formError != "" {
|
||||||
ui.Text(c, st.formError).TextColor(k.Danger).Role(ui.RoleStatus)
|
ui.Text(c, st.formError).TextColor(k.Danger).Role(ui.RoleStatus)
|
||||||
}
|
}
|
||||||
@@ -198,7 +201,12 @@ func formSettle(c *ui.Context, st *FormState) bool {
|
|||||||
if st.Error(name) != "" && first == "" {
|
if st.Error(name) != "" && first == "" {
|
||||||
first = name
|
first = name
|
||||||
}
|
}
|
||||||
pending = pending || st.fields[name].pending
|
f := st.fields[name]
|
||||||
|
// A value edited since its check owes a new one.
|
||||||
|
if f.hasAsync && f.err == "" && f.asyncFor != f.version {
|
||||||
|
f.startAsync()
|
||||||
|
pending = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
switch {
|
switch {
|
||||||
case first != "":
|
case first != "":
|
||||||
@@ -221,12 +229,12 @@ func formReset(st *FormState) {
|
|||||||
r()
|
r()
|
||||||
}
|
}
|
||||||
st.fields = map[string]*formFieldState{}
|
st.fields = map[string]*formFieldState{}
|
||||||
|
st.generation++
|
||||||
st.tried, st.awaiting, st.requested, st.focus, st.formError = false, false, false, "", ""
|
st.tried, st.awaiting, st.requested, st.focus, st.formError = false, false, false, "", ""
|
||||||
slog.Debug("mujica: form reset")
|
slog.Debug("mujica: form reset")
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormItem registers field name, labels the control that control returns, and shows its error.
|
// FormItem registers field name around the focusable element control returns.
|
||||||
// control must return the element to focus when the field has the first error.
|
|
||||||
func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
|
func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T], control func() *ui.Element) *ui.Element {
|
||||||
c, st := f.c, f.st
|
c, st := f.c, f.st
|
||||||
if name == "" || opts.Label == "" {
|
if name == "" || opts.Label == "" {
|
||||||
@@ -243,6 +251,7 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
|||||||
fs = &formFieldState{snapshot: snap, version: st.seq}
|
fs = &formFieldState{snapshot: snap, version: st.seq}
|
||||||
st.fields[name] = fs
|
st.fields[name] = fs
|
||||||
}
|
}
|
||||||
|
fs.hasAsync = opts.Async != nil
|
||||||
fs.check = func() string {
|
fs.check = func() string {
|
||||||
if opts.Required && formEmpty(*value) {
|
if opts.Required && formEmpty(*value) {
|
||||||
return msg(c, "form.required")
|
return msg(c, "form.required")
|
||||||
@@ -273,12 +282,10 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
|||||||
desc = msg(c, "form.checking")
|
desc = msg(c, "form.checking")
|
||||||
}
|
}
|
||||||
var ctrl *ui.Element
|
var ctrl *ui.Element
|
||||||
field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, func() {
|
field := formFieldBuild(c, opts.Label, FormFieldOptions{Required: opts.Required, Description: desc, Error: st.Error(name)}, f.horizontal, func() *ui.Element {
|
||||||
ctrl = control()
|
ctrl = control()
|
||||||
|
return ctrl
|
||||||
})
|
})
|
||||||
if ctrl == nil {
|
|
||||||
panic("mujica: FormItem " + name + ": control returned nil")
|
|
||||||
}
|
|
||||||
focused := ctrl.FocusWithin()
|
focused := ctrl.FocusWithin()
|
||||||
if fs.focused && !focused {
|
if fs.focused && !focused {
|
||||||
fs.touched = true
|
fs.touched = true
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package mujica
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
|
|
||||||
|
"mujicaui/theme"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FormFieldOptions configure a FormField.
|
// FormFieldOptions configure a FormField.
|
||||||
@@ -15,24 +17,70 @@ type FormFieldOptions struct {
|
|||||||
Horizontal bool
|
Horizontal bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// FormField labels the control that control builds and adds help and error text.
|
// FormField labels the element control returns and shows help and error text for it.
|
||||||
// The label names the control and a click on it focuses the control.
|
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *ui.Element {
|
||||||
func FormField(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element {
|
if !opts.Horizontal {
|
||||||
|
return formFieldBuild(c, label, opts, false, control)
|
||||||
|
}
|
||||||
|
var f *ui.Element
|
||||||
|
formFieldGrid(c, func() { f = formFieldBuild(c, label, opts, true, control) })
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// formFieldGrid holds fields whose labels sit in a column beside their controls.
|
||||||
|
func formFieldGrid(c *ui.Context, fields func()) *ui.Element {
|
||||||
|
d := Density(c)
|
||||||
|
return ui.Grid(c).ColumnTracks(ui.FitContent(), ui.Fr(1)).GapX(d.Unit() * 3).GapY(d.Unit() * 4).
|
||||||
|
AlignItems(ui.Start).Children(fields)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formFieldBuild builds the label and the control column; beside makes them two grid cells.
|
||||||
|
func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, beside bool, control func() *ui.Element) *ui.Element {
|
||||||
if label == "" {
|
if label == "" {
|
||||||
panic("mujica: FormField needs a label")
|
panic("mujica: FormField needs a label")
|
||||||
}
|
}
|
||||||
if opts.Horizontal {
|
k := Tokens(c)
|
||||||
var f *ui.Element
|
d := Density(c)
|
||||||
ui.Form(c, func() { f = formFieldBuild(c, label, opts, control) })
|
var head, content, ctrl *ui.Element
|
||||||
return f
|
body := func() {
|
||||||
|
head = ui.Row(c).Gap(d.Unit()).AlignItems(ui.Center)
|
||||||
|
if beside {
|
||||||
|
head.MinHeight(d.ControlHeight()).Justify(ui.End)
|
||||||
|
}
|
||||||
|
var text *ui.Element
|
||||||
|
head.Children(func() {
|
||||||
|
text = ui.Text(c, label).FontWeight(500).SingleLine()
|
||||||
|
if opts.Required {
|
||||||
|
ui.Text(c, "*").TextColor(k.Danger).Role(ui.RoleNone)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
clicked := head.Clicked()
|
||||||
|
content = ui.Column(c).Gap(d.Unit() * 1.5).MinWidth(0)
|
||||||
|
content.Children(func() {
|
||||||
|
ctrl = control()
|
||||||
|
if ctrl == nil {
|
||||||
|
panic("mujica: FormField " + label + ": control returned nil")
|
||||||
|
}
|
||||||
|
if opts.Description != "" {
|
||||||
|
ui.Text(c, opts.Description).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
|
||||||
|
}
|
||||||
|
if opts.Error != "" {
|
||||||
|
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleNone)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
ctrl.Label(label).Description(opts.Description).Error(opts.Error)
|
||||||
|
if opts.Required {
|
||||||
|
ctrl.Description(msg(c, "form.required"))
|
||||||
|
}
|
||||||
|
if ctrl.IsDisabled() {
|
||||||
|
text.TextColor(k.TextMuted)
|
||||||
|
} else if clicked {
|
||||||
|
ctrl.Focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return formFieldBuild(c, label, opts, control)
|
if beside {
|
||||||
}
|
body()
|
||||||
|
return content
|
||||||
// formFieldBuild builds the field in the layout the caller set up.
|
|
||||||
func formFieldBuild(c *ui.Context, label string, opts FormFieldOptions, control func()) *ui.Element {
|
|
||||||
if opts.Required {
|
|
||||||
label += " *"
|
|
||||||
}
|
}
|
||||||
return ui.Field(c, label, control).Description(opts.Description).Error(opts.Error)
|
return ui.Column(c).Gap(d.Unit() * 1.5).Children(body)
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package mujica
|
package mujica
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"slices"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/egoist/mygo/ui"
|
"github.com/egoist/mygo/ui"
|
||||||
@@ -10,29 +11,59 @@ func TestFormFieldLabelFocusesControl(t *testing.T) {
|
|||||||
name := ""
|
name := ""
|
||||||
tt := ui.NewTester(func(c *ui.Context) {
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
Use(c, Settings{})
|
Use(c, Settings{})
|
||||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() {
|
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() *ui.Element {
|
||||||
InputGroup(c, &name, InputGroupOptions{})
|
return InputGroup(c, &name, InputGroupOptions{}).Input
|
||||||
})
|
})
|
||||||
}, 400, 200)
|
}, 400, 200)
|
||||||
if err := tt.Click("Name *"); err != nil {
|
if err := tt.Click("Name"); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
tt.Type("Sakiko")
|
tt.Type("Sakiko")
|
||||||
if name != "Sakiko" {
|
if name != "Sakiko" {
|
||||||
t.Fatalf("label click did not focus the control: %q", name)
|
t.Fatalf("label click did not focus the control: %q", name)
|
||||||
}
|
}
|
||||||
if !tt.Focused("Name *") || !tt.HasText("As on your seal.") {
|
if !tt.Focused("Name") || !tt.HasText("As on your seal.") || !tt.HasText("*") {
|
||||||
t.Fatalf("texts %q", tt.Texts())
|
t.Fatalf("texts %q", tt.Texts())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The label goes to the returned input, not the first focusable element before it.
|
||||||
|
func TestFormFieldTargetsReturnedControl(t *testing.T) {
|
||||||
|
tags, site := []string{"rose"}, ""
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
ui.Column(c).Gap(12).Children(func() {
|
||||||
|
FormField(c, "Flowers", FormFieldOptions{Error: "Too few."}, func() *ui.Element {
|
||||||
|
return TagsInput(c, &tags, TagsInputOptions{}).Input
|
||||||
|
})
|
||||||
|
FormField(c, "Site", FormFieldOptions{}, func() *ui.Element {
|
||||||
|
return InputGroup(c, &site, InputGroupOptions{Buttons: []InputGroupButton{{Label: "Back", Leading: true}}}).Input
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, 500, 300)
|
||||||
|
_ = tt.Click("Flowers")
|
||||||
|
tt.Type("ivy")
|
||||||
|
tt.Key(0, ui.KeyEnter)
|
||||||
|
if !slices.Equal(tags, []string{"rose", "ivy"}) || !tt.Focused("Flowers") {
|
||||||
|
t.Fatalf("tags %q", tags)
|
||||||
|
}
|
||||||
|
if _, ok := tt.Find("Remove rose"); !ok {
|
||||||
|
t.Fatal("remove button lost its own name")
|
||||||
|
}
|
||||||
|
_ = tt.Click("Site")
|
||||||
|
tt.Type("court")
|
||||||
|
if site != "court" || tt.Focused("Back") {
|
||||||
|
t.Fatalf("site %q", site)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFormFieldErrorAndLayout(t *testing.T) {
|
func TestFormFieldErrorAndLayout(t *testing.T) {
|
||||||
v := ""
|
v := ""
|
||||||
horizontal := false
|
horizontal := false
|
||||||
tt := ui.NewTester(func(c *ui.Context) {
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
Use(c, Settings{})
|
Use(c, Settings{})
|
||||||
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() {
|
FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() *ui.Element {
|
||||||
InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"})
|
return InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}).Input
|
||||||
})
|
})
|
||||||
}, 500, 200)
|
}, 500, 200)
|
||||||
if !tt.HasText("Enter an email address.") {
|
if !tt.HasText("Enter an email address.") {
|
||||||
@@ -60,7 +91,7 @@ func TestFormFieldNeedsLabel(t *testing.T) {
|
|||||||
}()
|
}()
|
||||||
ui.Render(func(c *ui.Context) {
|
ui.Render(func(c *ui.Context) {
|
||||||
Use(c, Settings{})
|
Use(c, Settings{})
|
||||||
FormField(c, "", FormFieldOptions{}, func() {})
|
FormField(c, "", FormFieldOptions{}, func() *ui.Element { return ui.Box(c) })
|
||||||
}, 100, 100, 1)
|
}, 100, 100, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +99,11 @@ func TestFormFieldSnapshot(t *testing.T) {
|
|||||||
a, b := "Sakiko Togawa", "uika@"
|
a, b := "Sakiko Togawa", "uika@"
|
||||||
snapshot(t, "form_field", 520, 260, func(c *ui.Context) {
|
snapshot(t, "form_field", 520, 260, func(c *ui.Context) {
|
||||||
ui.Column(c).Padding(20).Gap(16).Children(func() {
|
ui.Column(c).Padding(20).Gap(16).Children(func() {
|
||||||
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() {
|
FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() *ui.Element {
|
||||||
InputGroup(c, &a, InputGroupOptions{})
|
return InputGroup(c, &a, InputGroupOptions{}).Input
|
||||||
})
|
})
|
||||||
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() {
|
FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() *ui.Element {
|
||||||
InputGroup(c, &b, InputGroupOptions{Invalid: true})
|
return InputGroup(c, &b, InputGroupOptions{Invalid: true}).Input
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ func TestFormSubmitFailureFocusesFirstErrorAndKeepsValues(t *testing.T) {
|
|||||||
if p.submits != 0 || !tt.HasText("Required") || !tt.HasText("Enter an email address.") {
|
if p.submits != 0 || !tt.HasText("Required") || !tt.HasText("Enter an email address.") {
|
||||||
t.Fatalf("submits %d texts %q", p.submits, tt.Texts())
|
t.Fatalf("submits %d texts %q", p.submits, tt.Texts())
|
||||||
}
|
}
|
||||||
if !tt.Focused("Name *") || p.email != "bad" {
|
if !tt.Focused("Name") || p.email != "bad" {
|
||||||
t.Fatal("first error not focused or value lost")
|
t.Fatal("first error not focused or value lost")
|
||||||
}
|
}
|
||||||
if got := tt.Announcements(); len(got) == 0 || got[len(got)-1] != "Some fields need attention" {
|
if got := tt.Announcements(); len(got) == 0 || got[len(got)-1] != "Some fields need attention" {
|
||||||
@@ -181,10 +181,72 @@ func TestFormResetRestoresInitialValues(t *testing.T) {
|
|||||||
func TestFormHorizontalLayout(t *testing.T) {
|
func TestFormHorizontalLayout(t *testing.T) {
|
||||||
p := &formProbe{horizontal: true}
|
p := &formProbe{horizontal: true}
|
||||||
tt := ui.NewTester(p.run, 600, 500)
|
tt := ui.NewTester(p.run, 600, 500)
|
||||||
name, _ := tt.Find("Name *")
|
star, _ := tt.Find("*")
|
||||||
email, _ := tt.Find("Email")
|
email, _ := tt.Find("Email")
|
||||||
if name.X+name.W != email.X+email.W {
|
input, _ := tt.Find("Name")
|
||||||
t.Fatalf("labels not right-aligned: %v %v", name, email)
|
if star.X+star.W != email.X+email.W || input.X >= email.X {
|
||||||
|
t.Fatalf("labels not right-aligned: %v %v", star, email)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An edit during a pending check must be checked again before the submit goes out.
|
||||||
|
func TestFormEditWhilePendingRechecks(t *testing.T) {
|
||||||
|
p := &formProbe{name: "Uika", tags: []string{"x"}, email: "a@b", useAsync: true}
|
||||||
|
tt := ui.NewTester(p.run, 600, 500)
|
||||||
|
_ = tt.Click("Submit")
|
||||||
|
if len(p.asyncCalls) != 1 {
|
||||||
|
t.Fatalf("calls %v", p.asyncCalls)
|
||||||
|
}
|
||||||
|
_ = tt.Click("Email")
|
||||||
|
tt.Key(ui.Cmd, ui.KeyA)
|
||||||
|
tt.Type("taken@b")
|
||||||
|
tt.Frame()
|
||||||
|
if p.submits != 0 || len(p.asyncCalls) != 2 || p.asyncValues[1] != "taken@b" {
|
||||||
|
t.Fatalf("submits %d values %q", p.submits, p.asyncValues)
|
||||||
|
}
|
||||||
|
if p.st.Resolve("email", p.asyncCalls[0], "") {
|
||||||
|
t.Fatal("stale result accepted")
|
||||||
|
}
|
||||||
|
p.st.Resolve("email", p.asyncCalls[1], "Taken")
|
||||||
|
tt.Frame()
|
||||||
|
tt.Frame()
|
||||||
|
if p.submits != 0 || !tt.HasText("Taken") {
|
||||||
|
t.Fatalf("submitted unchecked value: %d", p.submits)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset also drops drafts and errors kept inside the controls.
|
||||||
|
func TestFormResetClearsControlState(t *testing.T) {
|
||||||
|
var st FormState
|
||||||
|
tags, seen := []string{}, ""
|
||||||
|
tt := ui.NewTester(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||||
|
FormItem(f, "tags", &tags, FormItemOptions[[]string]{Label: "Tags"}, func() *ui.Element {
|
||||||
|
return TagsInput(c, &tags, TagsInputOptions{Validate: func(s string) string {
|
||||||
|
seen = s
|
||||||
|
return "Invalid tag"
|
||||||
|
}}).Input
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}, 600, 400)
|
||||||
|
_ = tt.Click("Tags")
|
||||||
|
tt.Type("bad tag")
|
||||||
|
tt.Key(0, ui.KeyEnter)
|
||||||
|
if !tt.HasText("Invalid tag") {
|
||||||
|
t.Fatal("no tag error")
|
||||||
|
}
|
||||||
|
_ = tt.Click("Reset")
|
||||||
|
tt.Frame()
|
||||||
|
if tt.HasText("Invalid tag") {
|
||||||
|
t.Fatal("tag error survived reset")
|
||||||
|
}
|
||||||
|
seen = ""
|
||||||
|
_ = tt.Click("Tags")
|
||||||
|
tt.Type("x")
|
||||||
|
tt.Key(0, ui.KeyEnter)
|
||||||
|
if seen != "x" {
|
||||||
|
t.Fatalf("draft survived reset: %q", seen)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ func (p InputGroupParts) Changed() bool { return p.Input.Changed() }
|
|||||||
// Submitted reports whether Enter was pressed in the input.
|
// Submitted reports whether Enter was pressed in the input.
|
||||||
func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() }
|
func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() }
|
||||||
|
|
||||||
// InputGroup joins a single-line input with text and buttons inside one border.
|
// InputGroup joins an input, fixed text and buttons, each its own Tab stop, in one border.
|
||||||
// Every button keeps its own place in the Tab order.
|
|
||||||
func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts {
|
func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts {
|
||||||
for _, b := range opts.Buttons {
|
for _, b := range opts.Buttons {
|
||||||
if b.IconOnly && b.Icon == nil {
|
if b.IconOnly && b.Icon == nil {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ func init() {
|
|||||||
"transfer.all": {"Select all in %s", "全选%s"},
|
"transfer.all": {"Select all in %s", "全选%s"},
|
||||||
"transfer.search": {"Search", "搜索"},
|
"transfer.search": {"Search", "搜索"},
|
||||||
"transfer.search_in": {"Search %s", "搜索%s"},
|
"transfer.search_in": {"Search %s", "搜索%s"},
|
||||||
|
"transfer.partial": {"%d of %d selected", "已选 %d / %d 项"},
|
||||||
"transfer.empty": {"No items", "暂无条目"},
|
"transfer.empty": {"No items", "暂无条目"},
|
||||||
"transfer.no_match": {"No matches", "无匹配结果"},
|
"transfer.no_match": {"No matches", "无匹配结果"},
|
||||||
"form.submit": {"Submit", "提交"},
|
"form.submit": {"Submit", "提交"},
|
||||||
|
|||||||
@@ -45,8 +45,7 @@ type pinInputState struct {
|
|||||||
compose string
|
compose string
|
||||||
}
|
}
|
||||||
|
|
||||||
// PinInput edits a fixed-length code of digits, one per cell, keeping leading zeros.
|
// PinInput edits a fixed-length digit code, one cell per digit, keeping leading zeros.
|
||||||
// Typing fills the next cell, Backspace clears the last, and paste fills them all.
|
|
||||||
func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts {
|
func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts {
|
||||||
n := opts.Length
|
n := opts.Length
|
||||||
if n == 0 {
|
if n == 0 {
|
||||||
@@ -144,9 +143,9 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts
|
|||||||
cell.Children(func() {
|
cell.Children(func() {
|
||||||
switch {
|
switch {
|
||||||
case showCompose:
|
case showCompose:
|
||||||
ui.Text(c, compose).FontSize(theme.H3Size).Underline().SingleLine()
|
ui.Text(c, compose).FontSize(FontSize(c, theme.H3Size)).Underline().SingleLine()
|
||||||
case ch != "":
|
case ch != "":
|
||||||
ui.Text(c, ch).FontSize(theme.H3Size).FontFeatures("tnum").SingleLine()
|
ui.Text(c, ch).FontSize(FontSize(c, theme.H3Size)).FontFeatures("tnum").SingleLine()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
caret := focused && i == active && len(*value) < n && !showCompose
|
caret := focused && i == active && len(*value) < n && !showCompose
|
||||||
|
|||||||
@@ -47,7 +47,6 @@ type tagsInputState struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// TagsInput edits a list of unique tags shown as chips before a text input.
|
// TagsInput edits a list of unique tags shown as chips before a text input.
|
||||||
// Enter or a separator adds the text; Backspace in the empty input removes the last tag.
|
|
||||||
func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputParts {
|
func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputParts {
|
||||||
if opts.Max < 0 {
|
if opts.Max < 0 {
|
||||||
panic("mujica: TagsInput Max must not be negative")
|
panic("mujica: TagsInput Max must not be negative")
|
||||||
@@ -102,11 +101,10 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
|||||||
Padding(d.Unit(), d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled)
|
Padding(d.Unit(), d.Unit()*2).Radius(theme.ControlRadius).Background(k.Surface).Disabled(opts.Disabled)
|
||||||
box.Children(func() {
|
box.Children(func() {
|
||||||
for i, tag := range *tags {
|
for i, tag := range *tags {
|
||||||
chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Gap(d.Unit()).Padding(0, d.Unit(), 0, d.Unit()*2).
|
chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Padding(0, 0, 0, d.Unit()*2).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
|
||||||
MinHeight(d.ControlHeight() - d.Unit()*3).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0)
|
|
||||||
chip.Children(func() {
|
chip.Children(func() {
|
||||||
ui.Text(c, tag).SingleLine()
|
ui.Text(c, tag).SingleLine()
|
||||||
x := ui.ButtonBase(c).Size(20, 20).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
x := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).TextColor(k.TextMuted).
|
||||||
Label(fmt.Sprintf(msg(c, "tags_input.remove"), tag))
|
Label(fmt.Sprintf(msg(c, "tags_input.remove"), tag))
|
||||||
internal.Ring(x, k.Focus, theme.ControlRadius)
|
internal.Ring(x, k.Focus, theme.ControlRadius)
|
||||||
if x.Hovered() {
|
if x.Hovered() {
|
||||||
@@ -135,7 +133,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
if st.err != "" {
|
if st.err != "" {
|
||||||
ui.Text(c, st.err).TextColor(k.Danger).FontSize(theme.CaptionSize).Role(ui.RoleStatus)
|
ui.Text(c, st.err).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize)).Role(ui.RoleStatus)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
in := parts.Input
|
in := parts.Input
|
||||||
|
|||||||
@@ -115,3 +115,12 @@ func TestTagsInputSnapshot(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTagsInputRemoveHitArea(t *testing.T) {
|
||||||
|
tags, n := []string{"rose"}, 0
|
||||||
|
tt := tagsInputTester(&tags, TagsInputOptions{}, &n)
|
||||||
|
r, ok := tt.Find("Remove rose")
|
||||||
|
if !ok || r.W < 28 || r.H < 28 {
|
||||||
|
t.Fatalf("remove button %v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
@@ -43,8 +43,7 @@ type transferState struct {
|
|||||||
query [2]string
|
query [2]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transfer moves items between a source list and *target, the IDs chosen, in order.
|
// Transfer moves items between a source list and *target, the chosen IDs in order.
|
||||||
// Each side has search, check boxes with select-all, and move buttons between them.
|
|
||||||
func Transfer(c *ui.Context, target *[]string, items []TransferItem, opts TransferOptions) TransferParts {
|
func Transfer(c *ui.Context, target *[]string, items []TransferItem, opts TransferOptions) TransferParts {
|
||||||
byID := make(map[string]TransferItem, len(items))
|
byID := make(map[string]TransferItem, len(items))
|
||||||
for _, it := range items {
|
for _, it := range items {
|
||||||
@@ -158,6 +157,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
|
|||||||
all := checked > 0 && checked == enabled
|
all := checked > 0 && checked == enabled
|
||||||
box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0)
|
box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0)
|
||||||
transferMark(c, box, all, checked > 0 && !all)
|
transferMark(c, box, all, checked > 0 && !all)
|
||||||
|
box.Description(transferPartial(c, checked, enabled))
|
||||||
if box.Changed() {
|
if box.Changed() {
|
||||||
for _, it := range shown {
|
for _, it := range shown {
|
||||||
if !it.Disabled {
|
if !it.Disabled {
|
||||||
@@ -166,7 +166,7 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ui.Text(c, title).Font(theme.SerifFont).FontWeight(600).TextColor(k.Text).Grow(1)
|
ui.Text(c, title).Font(theme.SerifFont).FontWeight(600).TextColor(k.Text).Grow(1)
|
||||||
ui.Text(c, fmt.Sprintf("%d / %d", checked, len(items))).TextColor(k.TextMuted).FontSize(theme.CaptionSize).FontFeatures("tnum")
|
ui.Text(c, fmt.Sprintf("%d / %d", checked, len(items))).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum")
|
||||||
})
|
})
|
||||||
ui.Box(c).Padding(d.Unit() * 2).Children(func() {
|
ui.Box(c).Padding(d.Unit() * 2).Children(func() {
|
||||||
InputGroup(c, &st.query[side], InputGroupOptions{Placeholder: msg(c, "transfer.search"), Label: fmt.Sprintf(msg(c, "transfer.search_in"), title)})
|
InputGroup(c, &st.query[side], InputGroupOptions{Placeholder: msg(c, "transfer.search"), Label: fmt.Sprintf(msg(c, "transfer.search_in"), title)})
|
||||||
@@ -202,6 +202,14 @@ func transferPanel(c *ui.Context, st *transferState, side int, title string, ite
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// transferPartial describes a select-all box that is neither clear nor full.
|
||||||
|
func transferPartial(c *ui.Context, checked, enabled int) string {
|
||||||
|
if checked == 0 || checked == enabled {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(msg(c, "transfer.partial"), checked, enabled)
|
||||||
|
}
|
||||||
|
|
||||||
// transferMark draws a check box's square inside box, which takes the focus ring.
|
// transferMark draws a check box's square inside box, which takes the focus ring.
|
||||||
func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) {
|
func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) {
|
||||||
k := Tokens(c)
|
k := Tokens(c)
|
||||||
|
|||||||
@@ -104,3 +104,15 @@ func TestTransferSnapshot(t *testing.T) {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MyGo's tester cannot read descriptions, so this checks the text the select-all box gets.
|
||||||
|
func TestTransferPartialDescription(t *testing.T) {
|
||||||
|
var got [3]string
|
||||||
|
ui.Render(func(c *ui.Context) {
|
||||||
|
Use(c, Settings{})
|
||||||
|
got = [3]string{transferPartial(c, 2, 3), transferPartial(c, 3, 3), transferPartial(c, 0, 3)}
|
||||||
|
}, 100, 100, 1)
|
||||||
|
if got != [3]string{"2 of 3 selected", "", ""} {
|
||||||
|
t.Fatalf("descriptions %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||