diff --git a/color_picker.go b/color_picker.go index a4f6724..de993b6 100644 --- a/color_picker.go +++ b/color_picker.go @@ -51,8 +51,7 @@ type colorPickerState struct { bad string } -// ColorPicker is a swatch button opening a panel to pick *color: a saturation and -// brightness square, hue and opacity sliders, HEX, RGB and opacity fields, and presets. +// ColorPicker is a swatch button opening a panel that picks *color. func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorPickerParts { k := Tokens(c) d := Density(c) @@ -92,7 +91,9 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP st.sync(*color) } } - if trigger.Clicked() { + if opts.Disabled { + st.open = false + } else if trigger.Clicked() { st.open = !st.open } 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() { for _, sw := range presets { 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) chosen := *color == sw 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() } 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}, {"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.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}) editing = editing || in.Input.Focused() if !in.Changed() { @@ -296,7 +297,7 @@ func colorPickerFields(c *ui.Context, st *colorPickerState, color *ui.Color, set st.sync(*color) } 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) } }) } diff --git a/color_picker_test.go b/color_picker_test.go index b5dc1a5..1b5e3d0 100644 --- a/color_picker_test.go +++ b/color_picker_test.go @@ -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) + } +} diff --git a/docs/components/041-form.md b/docs/components/041-form.md index fa36aa6..f2efe30 100644 --- a/docs/components/041-form.md +++ b/docs/components/041-form.md @@ -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.ResetLabel` | `string` | 内置“Reset/重置” | 重置按钮文字。 | | `FormItemOptions[T].Label` | `string` | 必填 | 字段标签;空值 panic。 | @@ -56,4 +56,4 @@ Tab / Shift+Tab 在字段间移动,离开字段即校验。提交失败后焦 - 控件由调用方构建,`FormItem` 的 control 回调必须返回可获取焦点的元素,用于错误定位。 - 校验在失焦与提交时触发;已触碰字段在值变化时重新同步校验,异步校验只在下一次失焦或提交时重跑。 - 异步结果以版本号对齐:值变化后旧版本的 `Resolve` 返回 false 并被丢弃。`FormState` 的方法只能在界面线程调用,后台任务需经 `Window.Update` 回到界面线程。 -- 必填标记以“ *”附在标签文字后,屏幕阅读器会一起读出;MyGo 的 Field 标签不支持富文本,无法单独着色。 +- 必填标记“*”只作视觉显示,辅助技术通过说明“必填”获知。 diff --git a/docs/components/042-form-field.md b/docs/components/042-form-field.md index 9e3f6e0..eaca697 100644 --- a/docs/components/042-form-field.md +++ b/docs/components/042-form-field.md @@ -4,7 +4,7 @@ ## 用途 -为单个控件提供标签、必填标记、说明与错误文字。标签通过 MyGo `ui.Field` 关联控件:屏幕阅读器以标签命名控件,并读出说明与错误;点击标签聚焦控件。 +为单个控件提供标签、必填标记、说明与错误文字。标签、说明与错误显式关联到 control 返回的元素:屏幕阅读器以标签命名该元素并读出说明、必填与错误;点击标签聚焦该元素。 ## 最小示例 @@ -12,8 +12,8 @@ email := "" view := func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) - mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() { - mujica.InputGroup(c, &email, mujica.InputGroupOptions{}) + mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element { + return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input }) } ui.Render(view, 400, 300, 1) @@ -24,11 +24,11 @@ ui.Render(view, 400, 300, 1) | 字段 | 类型 | 默认值 | 含义 | | --- | --- | --- | --- | | `label` | `string` | 必填 | 标签文字;空值 panic。 | -| `FormFieldOptions.Required` | `bool` | false | 在标签后加“ *”。 | +| `FormFieldOptions.Required` | `bool` | false | 在标签后显示危险色“*”,并向辅助技术说明“必填”。 | | `FormFieldOptions.Description` | `string` | "" | 控件下方的说明。 | | `FormFieldOptions.Error` | `string` | "" | 控件下方的错误,并把控件标记为无效。 | | `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。 -- 错误只把边框染红的效果对 MyGo 自带输入框生效;MujicaUI 的 InputGroup 等需同时传入 `Invalid: true`。 -- 左右布局时多个字段需放在同一个 `ui.Form` 或 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。 +- control 返回的元素的 Label 会被字段标签覆盖。 +- Error 只把元素标记为无效;InputGroup 等的红色边框需同时传入 `Invalid: true`。 +- 左右布局时多个字段需放在 MujicaUI `Form(Horizontal)` 中才能对齐标签宽度;单独使用时只按自身标签宽度排列。 diff --git a/example_form_test.go b/example_form_test.go index 0873135..e580dcf 100644 --- a/example_form_test.go +++ b/example_form_test.go @@ -30,8 +30,8 @@ func ExampleFormField() { email := "" view := func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) - mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() { - mujica.InputGroup(c, &email, mujica.InputGroupOptions{}) + mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element { + return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input }) } ui.Render(view, 400, 300, 1) diff --git a/examples/gallery/pages_form.go b/examples/gallery/pages_form.go index fbab963..9adba88 100644 --- a/examples/gallery/pages_form.go +++ b/examples/gallery/pages_form.go @@ -177,10 +177,12 @@ var formPages = []page{ e = "This name is already in the guest list." } mujica.FormField(c, "Guest name", mujica.FormFieldOptions{Required: d.required, Horizontal: d.horizontal, - Description: "As written on the invitation.", Error: e}, func() { - if mujica.InputGroup(c, &d.value, mujica.InputGroupOptions{Invalid: d.withError}).Changed() { + Description: "As written on the invitation.", Error: e}, func() *ui.Element { + g := mujica.InputGroup(c, &d.value, mujica.InputGroupOptions{Invalid: d.withError}) + if g.Changed() { log(fmt.Sprintf("value %q", d.value)) } + return g.Input }) }}, {ID: "043", Name: "InputGroup", ZhName: "输入组合", Category: "Forms", Code: formCode["InputGroup"], @@ -346,8 +348,8 @@ ui.Render(view, 400, 300, 1)`, "FormField": `email := "" view := func(c *ui.Context) { mujica.Use(c, mujica.Settings{}) - mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() { - mujica.InputGroup(c, &email, mujica.InputGroupOptions{}) + mujica.FormField(c, "Email", mujica.FormFieldOptions{Required: true, Description: "For receipts."}, func() *ui.Element { + return mujica.InputGroup(c, &email, mujica.InputGroupOptions{}).Input }) } ui.Render(view, 400, 300, 1)`, diff --git a/file_drop_zone.go b/file_drop_zone.go index 8b1a588..1f0a979 100644 --- a/file_drop_zone.go +++ b/file_drop_zone.go @@ -65,8 +65,7 @@ type fileDropZoneState struct { rejected []fileDropZoneReject } -// FileDropZone takes files dropped from the system or chosen with its Browse button, -// checks type, count and size, and lists them in *files. It never reads or uploads them. +// FileDropZone checks dropped or browsed files and lists them; it never reads or uploads them. func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) FileDropZoneParts { if opts.MaxFiles < 0 || opts.MaxSize < 0 { panic("mujica: FileDropZone limits must not be negative") @@ -172,7 +171,7 @@ func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions) zone.Children(func() { ui.Icon(c, icons.Must("upload")).Size(24, 24).TextColor(k.AccentText) 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}) if b.Clicked() { 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() { ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(color) 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 { 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) diff --git a/file_picker.go b/file_picker.go index 44942e8..ffd2b91 100644 --- a/file_picker.go +++ b/file_picker.go @@ -55,8 +55,7 @@ type filePickerState struct { err string } -// FilePicker opens the system file or folder dialog and stores the chosen paths in *paths. -// The dialog runs off the UI thread; the button shows loading until it closes. +// FilePicker opens the system file dialog off the UI thread and stores the chosen paths. func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePickerParts { for _, f := range opts.Filters { for _, ext := range f.Extensions { @@ -118,16 +117,16 @@ func FilePicker(c *ui.Context, paths *[]string, opts FilePickerOptions) FilePick } switch { 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: - 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: ui.Column(c).Gap(d.Unit()).Children(func() { for _, p := range *paths { 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.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) }) } }) diff --git a/form.go b/form.go index 278e916..bb89323 100644 --- a/form.go +++ b/form.go @@ -9,8 +9,7 @@ import ( "github.com/egoist/mygo/ui" ) -// FormState is a form's validation and submit state; keep it beside the form's values. -// Its methods run on the UI thread: deliver async results through Window.Update. +// FormState is a form's check and submit state; use it on the UI thread only. type FormState struct { fields map[string]*formFieldState order []string @@ -22,6 +21,7 @@ type FormState struct { requested bool focus string formError string + generation int } type formFieldState struct { @@ -33,6 +33,7 @@ type formFieldState struct { asyncFor uint64 asyncErr string pending bool + hasAsync bool check func() string startAsync func() } @@ -45,8 +46,7 @@ func (s *FormState) Version(name string) uint64 { return 0 } -// Resolve delivers the async check of name's value at version; "" means valid. -// It reports false and drops the result when the value has changed since. +// Resolve delivers name's async result for version, "" if valid; stale versions return false. func (s *FormState) Resolve(name string, version uint64, err string) bool { f := s.fields[name] if f == nil || f.version != version { @@ -98,8 +98,9 @@ type FormOptions struct { // FormScope registers a Form's fields with FormItem. type FormScope struct { - c *ui.Context - st *FormState + c *ui.Context + st *FormState + horizontal bool } // FormParts are the parts of a Form. @@ -127,8 +128,7 @@ type FormItemOptions[T any] struct { Async func(v T, version uint64) } -// Form lays out the fields that fields registers, validates them on blur and submit, -// and shows submit and reset buttons. It never stores or sends the values itself. +// Form lays out and checks its fields and shows submit and reset buttons. func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormScope)) FormParts { if st.fields == nil { st.fields = map[string]*formFieldState{} @@ -139,13 +139,16 @@ func Form(c *ui.Context, st *FormState, opts FormOptions, fields func(f *FormSco parts := FormParts{} col := ui.Column(c).Gap(d.Unit() * 5) parts.Element = col - scope := &FormScope{c: c, st: st} + scope := &FormScope{c: c, st: st, horizontal: opts.Horizontal} col.Children(func() { - if opts.Horizontal { - ui.Form(c, func() { fields(scope) }) - } else { - ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) }) - } + // A new key after Reset drops the controls' drafts and errors. + ui.Box(c).Key(st.generation).Children(func() { + if opts.Horizontal { + formFieldGrid(c, func() { fields(scope) }) + } else { + ui.Column(c).Gap(d.Unit() * 4).Children(func() { fields(scope) }) + } + }) if st.formError != "" { 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 == "" { 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 { case first != "": @@ -221,12 +229,12 @@ func formReset(st *FormState) { r() } st.fields = map[string]*formFieldState{} + st.generation++ st.tried, st.awaiting, st.requested, st.focus, st.formError = false, false, false, "", "" slog.Debug("mujica: form reset") } -// FormItem registers field name, labels the control that control returns, and shows its error. -// control must return the element to focus when the field has the first error. +// FormItem registers field name around the focusable element control returns. 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 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} st.fields[name] = fs } + fs.hasAsync = opts.Async != nil fs.check = func() string { if opts.Required && formEmpty(*value) { 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") } 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() + return ctrl }) - if ctrl == nil { - panic("mujica: FormItem " + name + ": control returned nil") - } focused := ctrl.FocusWithin() if fs.focused && !focused { fs.touched = true diff --git a/form_field.go b/form_field.go index 517840f..47f9e8c 100644 --- a/form_field.go +++ b/form_field.go @@ -2,6 +2,8 @@ package mujica import ( "github.com/egoist/mygo/ui" + + "mujicaui/theme" ) // FormFieldOptions configure a FormField. @@ -15,24 +17,70 @@ type FormFieldOptions struct { Horizontal bool } -// FormField labels the control that control builds and adds help and error text. -// 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 { +// FormField labels the element control returns and shows help and error text for it. +func FormField(c *ui.Context, label string, opts FormFieldOptions, control func() *ui.Element) *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 == "" { panic("mujica: FormField needs a label") } - if opts.Horizontal { - var f *ui.Element - ui.Form(c, func() { f = formFieldBuild(c, label, opts, control) }) - return f + k := Tokens(c) + d := Density(c) + var head, content, ctrl *ui.Element + 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) -} - -// 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 += " *" + if beside { + body() + return content } - return ui.Field(c, label, control).Description(opts.Description).Error(opts.Error) + return ui.Column(c).Gap(d.Unit() * 1.5).Children(body) } diff --git a/form_field_test.go b/form_field_test.go index c6ca7f6..80e61d3 100644 --- a/form_field_test.go +++ b/form_field_test.go @@ -1,6 +1,7 @@ package mujica import ( + "slices" "testing" "github.com/egoist/mygo/ui" @@ -10,29 +11,59 @@ func TestFormFieldLabelFocusesControl(t *testing.T) { name := "" tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) - FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() { - InputGroup(c, &name, InputGroupOptions{}) + FormField(c, "Name", FormFieldOptions{Required: true, Description: "As on your seal."}, func() *ui.Element { + return InputGroup(c, &name, InputGroupOptions{}).Input }) }, 400, 200) - if err := tt.Click("Name *"); err != nil { + if err := tt.Click("Name"); err != nil { t.Fatal(err) } tt.Type("Sakiko") if name != "Sakiko" { 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()) } } +// 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) { v := "" horizontal := false tt := ui.NewTester(func(c *ui.Context) { Use(c, Settings{}) - FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() { - InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}) + FormField(c, "Email", FormFieldOptions{Error: "Enter an email address.", Horizontal: horizontal}, func() *ui.Element { + return InputGroup(c, &v, InputGroupOptions{Prefix: "mailto:"}).Input }) }, 500, 200) if !tt.HasText("Enter an email address.") { @@ -60,7 +91,7 @@ func TestFormFieldNeedsLabel(t *testing.T) { }() ui.Render(func(c *ui.Context) { Use(c, Settings{}) - FormField(c, "", FormFieldOptions{}, func() {}) + FormField(c, "", FormFieldOptions{}, func() *ui.Element { return ui.Box(c) }) }, 100, 100, 1) } @@ -68,11 +99,11 @@ func TestFormFieldSnapshot(t *testing.T) { a, b := "Sakiko Togawa", "uika@" snapshot(t, "form_field", 520, 260, func(c *ui.Context) { ui.Column(c).Padding(20).Gap(16).Children(func() { - FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() { - InputGroup(c, &a, InputGroupOptions{}) + FormField(c, "Name", FormFieldOptions{Required: true, Description: "As written on the invitation."}, func() *ui.Element { + return InputGroup(c, &a, InputGroupOptions{}).Input }) - FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() { - InputGroup(c, &b, InputGroupOptions{Invalid: true}) + FormField(c, "Email", FormFieldOptions{Horizontal: true, Error: "Enter a full email address."}, func() *ui.Element { + return InputGroup(c, &b, InputGroupOptions{Invalid: true}).Input }) }) }) diff --git a/form_test.go b/form_test.go index 2d00634..afe5ddc 100644 --- a/form_test.go +++ b/form_test.go @@ -86,7 +86,7 @@ func TestFormSubmitFailureFocusesFirstErrorAndKeepsValues(t *testing.T) { if p.submits != 0 || !tt.HasText("Required") || !tt.HasText("Enter an email address.") { 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") } 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) { p := &formProbe{horizontal: true} tt := ui.NewTester(p.run, 600, 500) - name, _ := tt.Find("Name *") + star, _ := tt.Find("*") email, _ := tt.Find("Email") - if name.X+name.W != email.X+email.W { - t.Fatalf("labels not right-aligned: %v %v", name, email) + input, _ := tt.Find("Name") + 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) } } diff --git a/input_group.go b/input_group.go index 2db0177..8f0b136 100644 --- a/input_group.go +++ b/input_group.go @@ -49,8 +49,7 @@ func (p InputGroupParts) Changed() bool { return p.Input.Changed() } // Submitted reports whether Enter was pressed in the input. func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() } -// InputGroup joins a single-line input with text and buttons inside one border. -// Every button keeps its own place in the Tab order. +// InputGroup joins an input, fixed text and buttons, each its own Tab stop, in one border. func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts { for _, b := range opts.Buttons { if b.IconOnly && b.Icon == nil { diff --git a/messages_form.go b/messages_form.go index 96ca640..8366836 100644 --- a/messages_form.go +++ b/messages_form.go @@ -42,6 +42,7 @@ func init() { "transfer.all": {"Select all in %s", "全选%s"}, "transfer.search": {"Search", "搜索"}, "transfer.search_in": {"Search %s", "搜索%s"}, + "transfer.partial": {"%d of %d selected", "已选 %d / %d 项"}, "transfer.empty": {"No items", "暂无条目"}, "transfer.no_match": {"No matches", "无匹配结果"}, "form.submit": {"Submit", "提交"}, diff --git a/pin_input.go b/pin_input.go index 99f05e0..6dc4f9d 100644 --- a/pin_input.go +++ b/pin_input.go @@ -45,8 +45,7 @@ type pinInputState struct { compose string } -// PinInput edits a fixed-length code of digits, one per cell, keeping leading zeros. -// Typing fills the next cell, Backspace clears the last, and paste fills them all. +// PinInput edits a fixed-length digit code, one cell per digit, keeping leading zeros. func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts { n := opts.Length if n == 0 { @@ -144,9 +143,9 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts cell.Children(func() { switch { case showCompose: - ui.Text(c, compose).FontSize(theme.H3Size).Underline().SingleLine() + ui.Text(c, compose).FontSize(FontSize(c, theme.H3Size)).Underline().SingleLine() 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 diff --git a/tags_input.go b/tags_input.go index 3669bf0..424e876 100644 --- a/tags_input.go +++ b/tags_input.go @@ -47,7 +47,6 @@ type tagsInputState struct { } // 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 { if opts.Max < 0 { 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) box.Children(func() { 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). - MinHeight(d.ControlHeight() - d.Unit()*3).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0) + chip := ui.Row(c).Key(tag).AlignItems(ui.Center).Padding(0, 0, 0, d.Unit()*2).Radius(theme.ControlRadius).Background(k.Selection).Shrink(0) chip.Children(func() { 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)) internal.Ring(x, k.Focus, theme.ControlRadius) if x.Hovered() { @@ -135,7 +133,7 @@ func TagsInput(c *ui.Context, tags *[]string, opts TagsInputOptions) TagsInputPa }) }) 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 diff --git a/tags_input_test.go b/tags_input_test.go index 2d2dae2..da8aeb3 100644 --- a/tags_input_test.go +++ b/tags_input_test.go @@ -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) + } +} diff --git a/testdata/snapshots/darwin/color_picker-dark.png b/testdata/snapshots/darwin/color_picker-dark.png index 494495e..00f3d75 100644 Binary files a/testdata/snapshots/darwin/color_picker-dark.png and b/testdata/snapshots/darwin/color_picker-dark.png differ diff --git a/testdata/snapshots/darwin/color_picker-light.png b/testdata/snapshots/darwin/color_picker-light.png index 233bb64..804c5b8 100644 Binary files a/testdata/snapshots/darwin/color_picker-light.png and b/testdata/snapshots/darwin/color_picker-light.png differ diff --git a/testdata/snapshots/darwin/form-dark.png b/testdata/snapshots/darwin/form-dark.png index 64b96f9..5b7add7 100644 Binary files a/testdata/snapshots/darwin/form-dark.png and b/testdata/snapshots/darwin/form-dark.png differ diff --git a/testdata/snapshots/darwin/form-light.png b/testdata/snapshots/darwin/form-light.png index 7690b2a..f318ed7 100644 Binary files a/testdata/snapshots/darwin/form-light.png and b/testdata/snapshots/darwin/form-light.png differ diff --git a/testdata/snapshots/darwin/form_field-dark.png b/testdata/snapshots/darwin/form_field-dark.png index d6b2a7e..6efd9bc 100644 Binary files a/testdata/snapshots/darwin/form_field-dark.png and b/testdata/snapshots/darwin/form_field-dark.png differ diff --git a/testdata/snapshots/darwin/form_field-light.png b/testdata/snapshots/darwin/form_field-light.png index c330d79..ed40940 100644 Binary files a/testdata/snapshots/darwin/form_field-light.png and b/testdata/snapshots/darwin/form_field-light.png differ diff --git a/testdata/snapshots/darwin/tags_input-dark.png b/testdata/snapshots/darwin/tags_input-dark.png index 12ed2db..6662a49 100644 Binary files a/testdata/snapshots/darwin/tags_input-dark.png and b/testdata/snapshots/darwin/tags_input-dark.png differ diff --git a/testdata/snapshots/darwin/tags_input-light.png b/testdata/snapshots/darwin/tags_input-light.png index a349c5e..be8b8ae 100644 Binary files a/testdata/snapshots/darwin/tags_input-light.png and b/testdata/snapshots/darwin/tags_input-light.png differ diff --git a/transfer.go b/transfer.go index 47a3fc1..3b300d9 100644 --- a/transfer.go +++ b/transfer.go @@ -43,8 +43,7 @@ type transferState struct { query [2]string } -// Transfer moves items between a source list and *target, the IDs chosen, in order. -// Each side has search, check boxes with select-all, and move buttons between them. +// Transfer moves items between a source list and *target, the chosen IDs in order. func Transfer(c *ui.Context, target *[]string, items []TransferItem, opts TransferOptions) TransferParts { byID := make(map[string]TransferItem, len(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 box := ui.CheckboxBase(c, &all).Label(fmt.Sprintf(msg(c, "transfer.all"), title)).Disabled(enabled == 0) transferMark(c, box, all, checked > 0 && !all) + box.Description(transferPartial(c, checked, enabled)) if box.Changed() { for _, it := range shown { 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, 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() { 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. func transferMark(c *ui.Context, box *ui.Element, on, mixed bool) { k := Tokens(c) diff --git a/transfer_test.go b/transfer_test.go index fb366b0..427d4d2 100644 --- a/transfer_test.go +++ b/transfer_test.go @@ -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) + } +}