diff --git a/button_group.go b/button_group.go new file mode 100644 index 0000000..b3de134 --- /dev/null +++ b/button_group.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// ButtonGroupItem is one button of a ButtonGroup. Give Label, Icon or both. +type ButtonGroupItem struct { + Label string + Icon *ui.SVG + // Name names an icon-only item for assistive technology. + Name string + Disabled bool +} + +// ButtonGroupOptions configure a ButtonGroup. +type ButtonGroupOptions struct { + Vertical bool + // Label names the group for assistive technology. + Label string + Disabled bool +} + +// ButtonGroupResult is the group element and the item clicked this frame. +type ButtonGroupResult struct { + *ui.Element + clicked int +} + +// ClickedItem returns the index of the item clicked this frame, or -1. +func (r ButtonGroupResult) ClickedItem() int { return r.clicked } + +// ButtonGroup joins buttons in one bordered strip; arrows move between them. +func ButtonGroup(c *ui.Context, items []ButtonGroupItem, opts ButtonGroupOptions) ButtonGroupResult { + if len(items) == 0 { + panic("mujica: ButtonGroup needs items") + } + k := Tokens(c) + d := Density(c) + orient, last := ui.Horizontal, len(items)-1 + g := ui.Row(c).AlignItems(ui.Stretch).Shrink(0) + if opts.Vertical { + orient = ui.Vertical + g.Column() + } + g.FocusGroup(orient).Role(ui.RoleToolbar).Label(opts.Label).Disabled(opts.Disabled) + r := ButtonGroupResult{Element: g, clicked: -1} + g.Children(func() { + for i, it := range items { + if it.Label == "" && it.Icon == nil { + panic("mujica: ButtonGroupItem needs a Label or an Icon") + } + 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) + if it.Name != "" { + b.Label(it.Name) + } + bg := k.Surface + if b.Pressed() { + bg = k.Selection + } else if b.Hovered() { + bg = k.SurfaceHover + } + b.Background(bg).TextColor(k.Text).Transition(textInputFade) + b.Radius(buttonGroupCorners(i, last, opts.Vertical)...) + b.Border(theme.BorderWidth, k.ControlBorder).BorderWidth(buttonGroupEdges(i, opts.Vertical)...) + b.Children(func() { + if it.Icon != nil { + ui.Icon(c, it.Icon).Size(16, 16) + } + if it.Label != "" { + ui.Text(c, it.Label).SingleLine() + } + }) + if b.Clicked() { + r.clicked = i + slog.Debug("mujica: button_group click", "item", i) + } + } + }) + return r +} + +// buttonGroupCorners rounds only the outer corners of item i of 0..last. +func buttonGroupCorners(i, last int, vertical bool) []float32 { + var tl, tr, br, bl float32 + r := theme.ControlRadius + first, end := i == 0, i == last + if vertical { + if first { + tl, tr = r, r + } + if end { + br, bl = r, r + } + } else { + if first { + tl, bl = r, r + } + if end { + tr, br = r, r + } + } + return []float32{tl, tr, br, bl} +} + +// buttonGroupEdges draws each shared edge once: items after the first skip the leading one. +func buttonGroupEdges(i int, vertical bool) []float32 { + w := theme.BorderWidth + switch { + case i == 0: + return []float32{w} + case vertical: + return []float32{0, w, w, w} + default: + return []float32{w, w, w, 0} + } +} diff --git a/button_group_test.go b/button_group_test.go new file mode 100644 index 0000000..ead0aa7 --- /dev/null +++ b/button_group_test.go @@ -0,0 +1,84 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +var buttonGroupTestItems = []ButtonGroupItem{{Label: "Left"}, {Label: "Center", Disabled: true}, {Label: "Right"}} + +func TestButtonGroupClickAndArrows(t *testing.T) { + var got []int + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if i := ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Label: "Align"}).ClickedItem(); i >= 0 { + got = append(got, i) + } + }, 400, 200) + _ = tt.Click("Center") + _ = tt.Click("Left") + tt.Key(0, ui.KeyRight) + if !tt.Focused("Right") { + t.Fatal("Right arrow did not skip the disabled item to Right") + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyRight) + if !tt.Focused("Left") { + t.Fatal("arrows do not wrap round") + } + if len(got) != 2 || got[0] != 0 || got[1] != 2 { + t.Fatalf("clicked %v, want [0 2]", got) + } +} + +func TestButtonGroupOneTabStop(t *testing.T) { + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Vertical: true}) + Button(c, "After", ButtonOptions{}) + }, 400, 300) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Left") { + t.Fatal("Tab did not enter the group") + } + tt.Key(0, ui.KeyDown) + if !tt.Focused("Right") { + t.Fatal("Down did not move in a vertical group") + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("After") { + t.Fatal("Tab did not leave the group") + } +} + +func TestButtonGroupDisabledGroup(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if ButtonGroup(c, buttonGroupTestItems, ButtonGroupOptions{Disabled: true}).ClickedItem() >= 0 { + n++ + } + }, 400, 200) + _ = tt.Click("Left") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatalf("disabled group clicked %d", n) + } +} + +func TestButtonGroupSnapshot(t *testing.T) { + snapshot(t, "button_group", 420, 200, func(c *ui.Context) { + ui.Row(c).Padding(24).Gap(24).AlignItems(ui.Start).Children(func() { + ButtonGroup(c, []ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month", Disabled: true}}, ButtonGroupOptions{}) + ButtonGroup(c, []ButtonGroupItem{ + {Icon: icons.Must("bold"), Name: "Bold"}, + {Icon: icons.Must("italic"), Name: "Italic"}, + {Icon: icons.Must("underline"), Name: "Underline"}, + }, ButtonGroupOptions{Vertical: true}) + }) + }) +} diff --git a/button_test.go b/button_test.go index de99f0d..8a1bc96 100644 --- a/button_test.go +++ b/button_test.go @@ -1,10 +1,12 @@ package mujica import ( + "strings" "testing" "github.com/egoist/mygo/ui" + "mujicaui/icons" "mujicaui/theme" ) @@ -80,16 +82,51 @@ func TestButtonMinHeight(t *testing.T) { } } -func TestButtonSnapshot(t *testing.T) { - snapshot(t, "button", 640, 140, func(c *ui.Context) { - ui.Row(c).Padding(24).Gap(12).Wrap().Children(func() { - Button(c, "Primary", ButtonOptions{}) - Button(c, "Secondary", ButtonOptions{Variant: Secondary}) - Button(c, "Outline", ButtonOptions{Variant: Outline}) - Button(c, "Ghost", ButtonOptions{Variant: Ghost}) - Button(c, "Delete", ButtonOptions{Variant: Danger}) - Button(c, "Saving", ButtonOptions{Loading: true}) - Button(c, "Disabled", ButtonOptions{Disabled: true}) +func TestButtonIconAndDanger(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "", ButtonOptions{Variant: Danger, Icon: icons.Must("trash-2"), Label: "Delete"}).Clicked() { + n++ + } + }, 300, 200) + if err := tt.Click("Delete"); err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("icon button clicked %d times", n) + } +} + +// buttonDensitySnapshots snapshots view at Compact and Comfortable as name-. +func buttonDensitySnapshots(t *testing.T, name string, w, h int, view func(c *ui.Context)) { + t.Helper() + for _, d := range []theme.Density{theme.Compact, theme.Comfortable} { + snapshot(t, name+"-"+strings.ToLower(d.String()), w, h, func(c *ui.Context) { + m := Light + if Tokens(c).Dark { + m = Dark + } + Use(c, Settings{Mode: m, Density: d}) + view(c) }) + } +} + +func TestButtonSnapshot(t *testing.T) { + snapshot(t, "button", 640, 140, buttonSnapshotView) + buttonDensitySnapshots(t, "button", 640, 160, buttonSnapshotView) +} + +func buttonSnapshotView(c *ui.Context) { + ui.Row(c).Padding(24).Gap(12).Wrap().Children(func() { + Button(c, "Primary", ButtonOptions{}) + Button(c, "Secondary", ButtonOptions{Variant: Secondary}) + Button(c, "Outline", ButtonOptions{Variant: Outline}) + Button(c, "Ghost", ButtonOptions{Variant: Ghost}) + Button(c, "Delete", ButtonOptions{Variant: Danger}) + Button(c, "Saving", ButtonOptions{Loading: true}) + Button(c, "Disabled", ButtonOptions{Disabled: true}) + Button(c, "Add", ButtonOptions{Variant: Secondary, Icon: icons.Must("plus")}) }) } diff --git a/checkbox.go b/checkbox.go new file mode 100644 index 0000000..de9b5a4 --- /dev/null +++ b/checkbox.go @@ -0,0 +1,134 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// CheckboxOptions configure a Checkbox. +type CheckboxOptions struct { + // Indeterminate shows the mixed mark; a click then checks the box. + Indeterminate bool + Disabled bool +} + +// checkboxSize is the box edge in DIPs. +const checkboxSize float32 = 16 + +// Checkbox toggles *value; a click on the box or its label toggles, as does Space. +func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element { + if value == nil { + panic("mujica: Checkbox needs a value") + } + k := Tokens(c) + d := Density(c) + row := ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false).Disabled(opts.Disabled) + if row.Changed() && opts.Indeterminate && !*value { + // From mixed, a click always checks. + *value = true + } + if opts.Indeterminate { + row.Description(msg(c, "checkbox.mixed")) + } + 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) + } + box.Transition(textInputFade) + box.DrawOver(func(p *ui.Painter, r ui.Rect) { + switch { + case mixed: + p.Fill(ui.Rect{X: r.X + r.W*0.25, Y: r.Y + r.H/2 - 1, W: r.W * 0.5, H: 2}, k.OnAccent, 1) + case on: + var path ui.Path + path.MoveTo(r.X+r.W*0.24, r.Y+r.H*0.52).LineTo(r.X+r.W*0.42, r.Y+r.H*0.7).LineTo(r.X+r.W*0.77, r.Y+r.H*0.3) + p.StrokePath(&path, 2, k.OnAccent) + } + if row.FocusVisible() { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + }) + if label != "" { + ui.Text(c, label) + } + }) + if row.Changed() { + slog.Debug("mujica: checkbox toggle", "label", label, "checked", *value) + } + return row +} + +// CheckboxItem is one box of a CheckboxGroup. +type CheckboxItem struct { + Label string + Value *bool + Disabled bool +} + +// CheckboxGroupOptions configure a CheckboxGroup. +type CheckboxGroupOptions struct { + Disabled bool +} + +// CheckboxGroup shows a select-all box over items: checked, mixed or clear. +func CheckboxGroup(c *ui.Context, label string, items []CheckboxItem, opts CheckboxGroupOptions) InputResult { + if len(items) == 0 { + panic("mujica: CheckboxGroup needs items") + } + d := Density(c) + on, enabled, enabledOn := 0, 0, 0 + for _, it := range items { + if it.Value == nil { + panic("mujica: CheckboxItem needs a Value") + } + if *it.Value { + on++ + } + if !it.Disabled { + enabled++ + if *it.Value { + enabledOn++ + } + } + } + var r InputResult + r.Element = ui.Column(c).Shrink(0).Disabled(opts.Disabled || enabled == 0) + r.Element.Children(func() { + all := on == len(items) + head := Checkbox(c, &all, label, CheckboxOptions{Indeterminate: on > 0 && !all}) + r.Input = head + if head.Changed() { + // Select-all leaves disabled items alone. + target := enabledOn < enabled + for _, it := range items { + if !it.Disabled { + *it.Value = target + } + } + r.changed = true + slog.Debug("mujica: checkbox_group select_all", "label", label, "checked", target) + } + ui.Column(c).Padding(0, 0, 0, checkboxSize+d.Unit()*2).Role(ui.RoleGroup).Label(label).Children(func() { + for _, it := range items { + if Checkbox(c, it.Value, it.Label, CheckboxOptions{Disabled: it.Disabled}).Changed() { + r.changed = true + } + } + }) + }) + return r +} diff --git a/checkbox_test.go b/checkbox_test.go new file mode 100644 index 0000000..d8befba --- /dev/null +++ b/checkbox_test.go @@ -0,0 +1,104 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestCheckboxLabelClickAndSpace(t *testing.T) { + on, changes := false, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Checkbox(c, &on, "Remember me", CheckboxOptions{}).Changed() { + changes++ + } + }, 300, 200) + _ = tt.Click("Remember me") + if !on || changes != 1 { + t.Fatalf("label click: %v changes %d", on, changes) + } + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if on || changes != 2 { + t.Fatalf("Space: %v changes %d", on, changes) + } +} + +func TestCheckboxIndeterminateChecks(t *testing.T) { + for _, start := range []bool{false, true} { + on := start + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Checkbox(c, &on, "Some", CheckboxOptions{Indeterminate: true}) + }, 300, 200) + _ = tt.Click("Some") + if !on { + t.Fatalf("from mixed with %v, click left the box clear", start) + } + } +} + +func TestCheckboxDisabled(t *testing.T) { + on := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + Checkbox(c, &on, "Off", CheckboxOptions{Disabled: true}) + }, 300, 200) + _ = tt.Click("Off") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if on || tt.Focused("Off") { + t.Fatalf("disabled box changed %v or took focus", on) + } +} + +func TestCheckboxGroupSelectAll(t *testing.T) { + mail, cal, locked := true, false, false + changes := 0 + items := []CheckboxItem{{Label: "Mail", Value: &mail}, {Label: "Calendar", Value: &cal}, {Label: "Admin", Value: &locked, Disabled: true}} + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if CheckboxGroup(c, "Notifications", items, CheckboxGroupOptions{}).Changed() { + changes++ + } + }, 300, 300) + _ = tt.Click("Notifications") + if !mail || !cal || locked || changes != 1 { + t.Fatalf("select all: mail %v cal %v admin %v changes %d", mail, cal, locked, changes) + } + _ = tt.Click("Notifications") + if mail || cal || locked { + t.Fatalf("clear all: mail %v cal %v admin %v", mail, cal, locked) + } + _ = tt.Click("Calendar") + if !cal || changes != 3 { + t.Fatalf("item click: cal %v changes %d", cal, changes) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("Notifications") { + t.Fatal("Tab did not reach the select-all box") + } + tt.Key(0, ui.KeySpace) + if !mail || !cal { + t.Fatalf("Space on select-all: mail %v cal %v", mail, cal) + } +} + +func checkboxSnapshotView(c *ui.Context) { + a, b, m, x, y := false, true, false, true, false + ui.Row(c).Padding(24).Gap(32).AlignItems(ui.Start).Children(func() { + ui.Column(c).Children(func() { + Checkbox(c, &a, "Unchecked", CheckboxOptions{}) + Checkbox(c, &b, "Checked", CheckboxOptions{}) + Checkbox(c, &m, "Indeterminate", CheckboxOptions{Indeterminate: true}) + Checkbox(c, &b, "Disabled", CheckboxOptions{Disabled: true}) + }) + CheckboxGroup(c, "Notifications", []CheckboxItem{{Label: "Mail", Value: &x}, {Label: "Calendar", Value: &y}}, CheckboxGroupOptions{}) + }) +} + +func TestCheckboxSnapshot(t *testing.T) { + snapshot(t, "checkbox", 420, 220, checkboxSnapshotView) + buttonDensitySnapshots(t, "checkbox", 420, 260, checkboxSnapshotView) +} diff --git a/docs/components/021-button.md b/docs/components/021-button.md new file mode 100644 index 0000000..02824ad --- /dev/null +++ b/docs/components/021-button.md @@ -0,0 +1,43 @@ +# 021 Button 按钮 + +## 用途 + +触发一个操作。提供主按钮、次按钮、描边、无底色和危险五种变体,可带图标,可显示加载与禁用。基于 MyGo `ui.ButtonBase`。 + +## 最小示例 + +```go +if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Clicked() { + fmt.Println("saved") +} +``` + +签名:`func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `label` | `string` | 必填(仅图标时可为空) | 按钮文字。 | +| `Variant` | `ButtonVariant` | `Primary` | `Primary`、`Secondary`、`Outline`、`Ghost`、`Danger`。 | +| `Icon` | `*ui.SVG` | `nil` | 文字前的 16 DIP 图标。 | +| `Loading` | `bool` | `false` | 以菱形加载标记替换图标,保留尺寸与文字,阻止点击。 | +| `Disabled` | `bool` | `false` | 不可点击、不进入 Tab 序列。 | +| `Label` | `string` | `""` | 无文字按钮的无障碍名称。 | + +## 状态 + +默认、悬停(底色加深或出现浅底)、按下、键盘聚焦(2 DIP 酒红焦点环,外留 2 DIP)、加载、禁用(MyGo 统一减淡)。高度不低于当前密度的控件高度(28/36/44 DIP)。 + +## 事件 + +`Clicked()`:鼠标在按钮内释放、Enter 或 Space 时为真,一次操作只报告一次。加载与禁用时始终为假。 + +## 键盘操作 + +Tab / Shift+Tab 聚焦;Enter 或 Space 触发。 + +## 限制 + +- 加载标记在减少动画模式下静止显示。 +- 危险变体的悬停与按下色由 `DangerFill` 混合黑色得到,未单独定义变量。 diff --git a/docs/components/022-icon-button.md b/docs/components/022-icon-button.md new file mode 100644 index 0000000..9efa694 --- /dev/null +++ b/docs/components/022-icon-button.md @@ -0,0 +1,42 @@ +# 022 IconButton 图标按钮 + +## 用途 + +只显示图标的方形按钮,用于工具栏与紧凑操作。操作名称必填,供辅助技术朗读,并作为默认提示文字。建立在 Button 之上。 + +## 最小示例 + +```go +if mujica.IconButton(c, icons.Must("settings"), "Settings", mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() { + fmt.Println("open settings") +} +``` + +签名:`func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) *ui.Element` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `icon` | `*ui.SVG` | 必填 | 图标;为 nil 时 panic。 | +| `label` | `string` | 必填 | 操作名称;为空时 panic。 | +| `Variant` | `ButtonVariant` | `Primary` | 与 Button 相同的五种变体;工具栏通常用 `Ghost`。 | +| `Tooltip` | `string` | 操作名称 | 悬停约 600 毫秒后显示的提示。 | +| `Loading` | `bool` | `false` | 显示加载标记并阻止点击。 | +| `Disabled` | `bool` | `false` | 禁用。 | + +## 状态 + +同 Button:悬停、按下、聚焦、加载、禁用。边长等于当前密度的控件高度,不小于 28 DIP。 + +## 事件 + +`Clicked()`,一次操作一次。 + +## 键盘操作 + +Tab 聚焦;Enter 或 Space 触发。 + +## 限制 + +- 禁用时不显示提示:MyGo 的 `Hovered` 对禁用元素返回 false,提示随之不出现;名称仍作为无障碍描述保留。 diff --git a/docs/components/023-button-group.md b/docs/components/023-button-group.md new file mode 100644 index 0000000..c394325 --- /dev/null +++ b/docs/components/023-button-group.md @@ -0,0 +1,53 @@ +# 023 ButtonGroup 按钮组 + +## 用途 + +把若干按钮拼成一条,共用边框与尺寸,只有外侧圆角。整个组是一个 Tab 停靠点,组内用方向键移动。基于 `ui.ButtonBase` 与 MyGo 焦点组 `FocusGroup`。 + +## 最小示例 + +```go +items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}} +if i := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Label: "Range"}).ClickedItem(); i >= 0 { + fmt.Println("chose", items[i].Label) +} +``` + +签名:`func ButtonGroup(c *ui.Context, items []ButtonGroupItem, opts ButtonGroupOptions) ButtonGroupResult` + +## 参数 + +`ButtonGroupItem` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Label` | `string` | `""` | 文字;与 `Icon` 至少给一个,否则 panic。 | +| `Icon` | `*ui.SVG` | `nil` | 图标。 | +| `Name` | `string` | `""` | 仅图标项的无障碍名称。 | +| `Disabled` | `bool` | `false` | 单项禁用;方向键跳过该项。 | + +`ButtonGroupOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Vertical` | `bool` | `false` | 纵向排列,使用上下方向键。 | +| `Label` | `string` | `""` | 组的无障碍名称(工具栏角色)。 | +| `Disabled` | `bool` | `false` | 禁用整组。 | + +`items` 为空时 panic。 + +## 状态 + +每项有悬停、按下、聚焦、禁用;相邻项共用一条 1 DIP 分隔边。 + +## 事件 + +`ClickedItem()` 返回本帧被点击项的下标,无点击时为 -1。 + +## 键盘操作 + +Tab 进入组(回到上次聚焦项);横向组 Left/Right、纵向组 Up/Down 移动并循环;Home、End 到首末项;Enter 或 Space 触发。 + +## 限制 + +- 组内没有选中概念;需要保持选中请使用 ToggleGroup(033)。 diff --git a/docs/components/024-text-input.md b/docs/components/024-text-input.md new file mode 100644 index 0000000..88649a9 --- /dev/null +++ b/docs/components/024-text-input.md @@ -0,0 +1,49 @@ +# 024 TextInput 文本输入框 + +## 用途 + +单行文本编辑。支持占位、前后插槽、清空、只读与错误。编辑核心是 MyGo `ui.TextInputBase`,因此保留选择、撤销重做、剪贴板、右键菜单与输入法组合。 + +## 最小示例 + +```go +r := mujica.TextInput(c, &name, mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: true}) +if r.Submitted() { + fmt.Println("hello", name) +} +``` + +签名:`func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult` + +`InputResult` 内嵌外层 `*ui.Element`,`Input` 字段是内部编辑元素,`Changed()`、`Submitted()` 报告事件。 + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*string` | 必填 | 编辑的字符串;nil 时 panic。 | +| `Placeholder` | `string` | `""` | 空值时的占位文字(TextMuted 色)。 | +| `Label` | `string` | `""` | 无障碍名称,也供测试查找。 | +| `Leading` / `Trailing` | `func()` | `nil` | 在框内左侧、右侧构建内容,如图标或单位。 | +| `Clearable` | `bool` | `false` | 有内容时显示清空按钮;Escape 也会清空。 | +| `Disabled` | `bool` | `false` | 禁用,不可聚焦。 | +| `ReadOnly` | `bool` | `false` | 可聚焦、选择、复制,不可修改。 | +| `Error` | `string` | `""` | 非空时边框变为 Danger,并在下方显示该文字;辅助技术同时朗读。 | + +## 状态 + +默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。颜色变化 120 毫秒过渡,减少动画时直接切换。 + +## 事件 + +- `Changed()`:输入、粘贴、撤销或清空改变了值。只读时不会报告。 +- `Submitted()`:在框内按 Enter。 + +## 键盘操作 + +Tab 聚焦(全选内容);Enter 提交;Escape 清空(仅 `Clearable` 且有内容时,否则交给外层);Cmd/Ctrl+Z、Shift+Cmd+Z 撤销重做;Cmd/Ctrl+A/C/X/V;输入法组合期间不提交。 + +## 限制 + +- MyGo 没有公开的只读输入接口(只读模式仅用于 `Text.Selectable`,`editor.go:77`)。`ReadOnly` 通过让编辑器编辑一个副本并在同帧还原实现,撤销栈里可能出现被还原的步骤。 +- 清空按钮不进入 Tab 序列(与 MyGo SearchField 相同),键盘用 Escape 清空。 diff --git a/docs/components/025-text-area.md b/docs/components/025-text-area.md new file mode 100644 index 0000000..32205d8 --- /dev/null +++ b/docs/components/025-text-area.md @@ -0,0 +1,49 @@ +# 025 TextArea 多行文本框 + +## 用途 + +多行文本编辑,可设最小与最大高度,超出最大高度后在框内滚动;可显示字数;提交快捷键可配置。编辑核心是 MyGo `ui.TextAreaBase`,只排版可见段落,适合长文本。 + +## 最小示例 + +```go +r := mujica.TextArea(c, ¬es, mujica.TextAreaOptions{Label: "Notes", MaxHeight: 160, MaxLength: 200}) +if r.Submitted() { + fmt.Println("send", notes) +} +``` + +签名:`func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*string` | 必填 | 编辑的字符串。 | +| `Placeholder` | `string` | `""` | 占位文字。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `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` 为零时使用默认值。 | +| `Disabled` | `bool` | `false` | 禁用。 | +| `ReadOnly` | `bool` | `false` | 只读,可选择复制。 | +| `Error` | `string` | `""` | 错误文字;为空且超出 `MaxLength` 时自动显示超出提示。 | + +## 状态 + +默认、悬停、聚焦、只读、错误、禁用,表现同 TextInput。 + +## 事件 + +`Changed()`:内容被用户改变。`Submitted()`:按下提交快捷键。 + +## 键盘操作 + +Enter 与 Shift+Enter 换行;提交快捷键提交;方向键、PageUp/PageDown 移动光标并滚动;撤销、剪贴板与输入法同 TextInput。 + +## 限制 + +- `MaxLength` 只提示,不截断输入,以免打断输入法组合与粘贴。 +- 提交快捷键不能是不带修饰键的 Enter:MyGo 编辑器先接收 Enter 并插入换行(`editor.go:161`)。 +- 只读实现方式同 TextInput。 diff --git a/docs/components/026-number-input.md b/docs/components/026-number-input.md new file mode 100644 index 0000000..82865f7 --- /dev/null +++ b/docs/components/026-number-input.md @@ -0,0 +1,49 @@ +# 026 NumberInput 数字输入框 + +## 用途 + +输入与步进数值,支持范围、步长、精度与空值。合并 MyGo 的 Number input(输入模式)与 Stepper(`StepperOnly` 仅步进模式)。 + +## 最小示例 + +```go +if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Label: "Copies"}).Changed() { + fmt.Println("copies", copies) +} +``` + +签名:`func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*float64` | 必填 | 数值;空值用 `math.NaN()` 表示,仅在 `AllowEmpty` 时允许。 | +| `Min` / `Max` | `float64` | 都为 0 | 范围;相等(含零值)表示不限范围;`Min > Max` 时 panic。 | +| `Step` | `float64` | `1` | 方向键与按钮的步长;负数 panic。 | +| `Precision` | `int` | 取 `Step` 的小数位 | 保留与显示的小数位。 | +| `AllowEmpty` | `bool` | `false` | 允许清空,值变为 NaN;从空值步进时从 0(夹在范围内)开始。 | +| `StepperOnly` | `bool` | `false` | 只显示数值与上下箭头,不可输入;必须给出 `Min < Max`,且不能与 `AllowEmpty` 同用。 | +| `Placeholder` | `string` | `""` | 空值时的占位。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 禁用。 | +| `Error` | `string` | `""` | 调用方给出的错误。 | + +## 状态 + +输入模式同 TextInput;输入非数字或超出范围时显示内置错误(“请输入数字”“请输入 a 到 b 之间的数字”),值保持上一次有效值;失去焦点后文本恢复为当前值并清除该错误。到达边界时对应按钮禁用。 + +## 事件 + +`Changed()`:值因输入、方向键、按钮或步进器改变。 + +## 键盘操作 + +输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到最小、End 到最大;按住箭头持续步进。 + +## 限制 + +- 输入模式下的加减按钮不进入 Tab 序列,键盘用 Up/Down。 +- 仅步进模式复用 MyGo `ui.Stepper`,箭头高度来自主题间距,不随密度的控件高度变化。 +- MyGo `ui.Stepper` 的箭头不检查禁用(`indicators.go:216`),禁用时组件向它传入副本,保证值不被修改。 +- MyGo 的步进器吸附依赖有限边界(`widgets.go` 中 `snap`),所以仅步进模式要求有界。 diff --git a/docs/components/027-password-input.md b/docs/components/027-password-input.md new file mode 100644 index 0000000..20d4cab --- /dev/null +++ b/docs/components/027-password-input.md @@ -0,0 +1,42 @@ +# 027 PasswordInput 密码输入框 + +## 用途 + +遮蔽显示的密码输入,带显示/隐藏切换。允许粘贴;遮蔽时不允许复制与剪切。基于 `ui.TextInputBase(...).Password()`,辅助技术读不到遮蔽中的值。组件从不记录密码内容。 + +## 最小示例 + +```go +if mujica.PasswordInput(c, &password, mujica.PasswordInputOptions{Label: "Password"}).Submitted() { + fmt.Println("signing in") +} +``` + +签名:`func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) InputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `value` | `*string` | 必填 | 密码。 | +| `Placeholder` | `string` | `""` | 占位文字。 | +| `Label` | `string` | `""` | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 禁用(切换按钮同时禁用)。 | +| `Error` | `string` | `""` | 错误文字。 | + +## 状态 + +默认、悬停、聚焦、错误、禁用;遮蔽 / 显示两种模式,切换按钮的图标与名称(“显示密码”“隐藏密码”)随之变化。 + +## 事件 + +`Changed()`:内容改变。`Submitted()`:Enter。日志只写切换事件(`mujica: password_input reveal`,含是否显示),不写内容。 + +## 键盘操作 + +Tab 依次到输入框、切换按钮;切换按钮上 Space 或 Enter 切换;输入框中 Enter 提交,Cmd/Ctrl+V 粘贴。 + +## 限制 + +- MyGo 的 `Password()` 一旦设置无法撤销(`editor.go:791`),因此遮蔽与显示两种模式各用一个编辑器,切换时撤销历史和光标位置不保留,焦点留在切换按钮上。 +- 显示模式下内容是普通文本,可复制,辅助技术可朗读——这是用户主动选择的结果。 diff --git a/docs/components/028-search-input.md b/docs/components/028-search-input.md new file mode 100644 index 0000000..8d074bc --- /dev/null +++ b/docs/components/028-search-input.md @@ -0,0 +1,51 @@ +# 028 SearchInput 搜索框 + +## 用途 + +搜索模式:放大镜、输入、清空与提交。查找栏模式(`Find`):在此基础上显示匹配数量、上一项与下一项,对应 MyGo 的 Search field 与 Find bar。匹配由调用方计算。 + +## 最小示例 + +```go +matches := 0 +if query != "" { + matches = strings.Count(text, query) +} +r := mujica.SearchInput(c, &query, mujica.SearchInputOptions{Find: true, Matches: matches, Current: ¤t}) +if r.Moved() { + fmt.Println("show match", current) +} +``` + +签名:`func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchInputResult` + +## 参数 + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `query` | `*string` | 必填 | 查询文字。 | +| `Placeholder` | `string` | “搜索”或“查找” | 占位文字。 | +| `Label` | `string` | 占位文字 | 无障碍名称。 | +| `Disabled` | `bool` | `false` | 禁用(查找快捷键也停用)。 | +| `Find` | `bool` | `false` | 开启查找栏模式。 | +| `Matches` | `int` | `0` | 匹配数量;负数 panic。 | +| `Current` | `*int` | `nil` | 当前匹配下标;`Find` 时必填,组件会把它夹在有效范围内。 | + +## 状态 + +同 TextInput;查找栏模式下显示“第 n 项,共 m 项”或“无匹配”(状态角色,变化时可被朗读),无匹配时上一项/下一项禁用。 + +## 事件 + +- `Changed()`:查询改变(含清空)。 +- `Submitted()`:搜索模式下 Enter。 +- `Moved()`:查找栏模式下当前匹配改变。 + +## 键盘操作 + +搜索模式:Enter 提交,Escape 清空。查找栏模式:Enter 下一项,Shift+Enter 上一项,macOS 上 Cmd+G / Shift+Cmd+G、其他平台 F3 / Shift+F3(窗口级),循环到首尾;Escape 清空。 + +## 限制 + +- MyGo 单行编辑器把 Shift+Enter 当作普通提交且不公开修饰键(`editor.go:489`,`submitMods` 未导出),组件用 `HandleInput` 先截获 Shift+Enter。 +- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。 diff --git a/docs/components/029-checkbox.md b/docs/components/029-checkbox.md new file mode 100644 index 0000000..2e88b84 --- /dev/null +++ b/docs/components/029-checkbox.md @@ -0,0 +1,51 @@ +# 029 Checkbox 复选框 + +## 用途 + +切换布尔值,支持选中、未选、半选;点击方框或标签都会切换。`CheckboxGroup` 提供组内全选(对应 MyGo CheckboxGroup)。基于 `ui.CheckboxBase`。 + +## 最小示例 + +```go +mujica.CheckboxGroup(c, "Notifications", []mujica.CheckboxItem{ + {Label: "Mail", Value: &mail}, + {Label: "Calendar", Value: &calendar}, +}, mujica.CheckboxGroupOptions{}) +``` + +签名: + +```go +func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *ui.Element +func CheckboxGroup(c *ui.Context, label string, items []CheckboxItem, opts CheckboxGroupOptions) InputResult +``` + +## 参数 + +`CheckboxOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Indeterminate` | `bool` | `false` | 显示半选横线;此时点击总是变为选中。是否半选由调用方决定。 | +| `Disabled` | `bool` | `false` | 禁用。 | + +`CheckboxItem`:`Label string`、`Value *bool`(必填)、`Disabled bool`。`CheckboxGroupOptions`:`Disabled bool`。`items` 为空时 panic。 + +## 状态 + +未选(Surface 底、ControlBorder 边)、悬停与按下(AccentText 边)、选中(酒红底、象牙白勾)、半选(酒红底、象牙白横线)、键盘聚焦(方框外焦点环)、禁用。行高不低于密度控件高度。 + +全选框:全部选中为选中,部分选中为半选,全不选为未选。点击时若可用项未全选则全部勾选,否则全部清除;禁用项保持不变。 + +## 事件 + +- `Checkbox(...).Changed()`:用户切换了值。 +- `CheckboxGroup(...).Changed()`:全选或任一项被切换。 + +## 键盘操作 + +Tab 聚焦(每个复选框一个停靠点);Space 切换。 + +## 限制 + +- MyGo 只有内部字段能把复选框报告为“mixed”(`access.go:307`,`checked` 未导出)。半选时组件在无障碍描述中加入“部分选中”,但角色状态仍报告为未选中。 diff --git a/docs/components/030-radio-group.md b/docs/components/030-radio-group.md new file mode 100644 index 0000000..f421a8e --- /dev/null +++ b/docs/components/030-radio-group.md @@ -0,0 +1,52 @@ +# 030 RadioGroup 单选组 + +## 用途 + +在一组选项中选一个,横向或纵向排列;选项值是任意可比较类型,作为稳定标识。基于 `ui.RadioGroup` 与 `ui.RadioBase`,整组是一个 Tab 停靠点。 + +## 最小示例 + +```go +items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}} +if mujica.RadioGroup(c, &size, items, mujica.RadioGroupOptions{Label: "Size"}).Changed() { + fmt.Println("size", size) +} +``` + +签名:`func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opts RadioGroupOptions) InputResult` + +## 参数 + +`RadioItem[T]` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Value` | `T` | 必填 | 稳定选项值;重复时 panic。 | +| `Label` | `string` | `""` | 标签,点击标签也会选择。 | +| `Disabled` | `bool` | `false` | 单项禁用,方向键跳过。 | + +`RadioGroupOptions` + +| 字段 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `Horizontal` | `bool` | `false` | 横向排列。 | +| `Label` | `string` | `""` | 组的无障碍名称。 | +| `Disabled` | `bool` | `false` | 禁用整组。 | + +`value` 为 nil 或 `items` 为空时 panic。`*value` 不在选项中时没有选中项。 + +## 状态 + +未选(空心圆)、悬停与按下(AccentText 边)、选中(酒红实心圆与象牙白圆点)、键盘聚焦、禁用。 + +## 事件 + +`Changed()`:用户选择了另一个值(点击、Space 或方向键)。 + +## 键盘操作 + +Tab 进入组并落在已选项;Up/Down/Left/Right 移动并直接选择,循环,跳过禁用项;Home、End 选择首末可用项。 + +## 限制 + +- 方向键对横向与纵向组都同时响应上下左右(MyGo `RadioGroup` 固定使用 `Horizontal|Vertical`)。 diff --git a/example_input_test.go b/example_input_test.go new file mode 100644 index 0000000..9828a07 --- /dev/null +++ b/example_input_test.go @@ -0,0 +1,130 @@ +package mujica_test + +import ( + "fmt" + "strings" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +func ExampleButton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Clicked() { + fmt.Println("saved") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleIconButton() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.IconButton(c, icons.Must("settings"), "Settings", mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() { + fmt.Println("open settings") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleButtonGroup() { + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}} + if i := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Label: "Range"}).ClickedItem(); i >= 0 { + fmt.Println("chose", items[i].Label) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTextInput() { + var name string + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TextInput(c, &name, mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: true}) + if r.Submitted() { + fmt.Println("hello", name) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleTextArea() { + var notes string + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + r := mujica.TextArea(c, ¬es, mujica.TextAreaOptions{Label: "Notes", MaxHeight: 160, MaxLength: 200}) + if r.Submitted() { + fmt.Println("send", notes) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleNumberInput() { + copies := 1.0 + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Label: "Copies"}).Changed() { + fmt.Println("copies", copies) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExamplePasswordInput() { + var password string + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + if mujica.PasswordInput(c, &password, mujica.PasswordInputOptions{Label: "Password"}).Submitted() { + fmt.Println("signing in") + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleSearchInput() { + var query string + current := 0 + text := "rose lys rose crown rose" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + matches := 0 + if query != "" { + matches = strings.Count(text, query) + } + r := mujica.SearchInput(c, &query, mujica.SearchInputOptions{Find: true, Matches: matches, Current: ¤t}) + if r.Moved() { + fmt.Println("show match", current) + } + } + ui.Render(view, 400, 300, 1) +} + +func ExampleCheckbox() { + var mail, calendar bool + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + mujica.CheckboxGroup(c, "Notifications", []mujica.CheckboxItem{ + {Label: "Mail", Value: &mail}, + {Label: "Calendar", Value: &calendar}, + }, mujica.CheckboxGroupOptions{}) + } + ui.Render(view, 400, 300, 1) +} + +func ExampleRadioGroup() { + size := "m" + view := func(c *ui.Context) { + mujica.Use(c, mujica.Settings{}) + items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}} + if mujica.RadioGroup(c, &size, items, mujica.RadioGroupOptions{Label: "Size"}).Changed() { + fmt.Println("size", size) + } + } + ui.Render(view, 400, 300, 1) +} diff --git a/examples/gallery/pages_input.go b/examples/gallery/pages_input.go index 72bd7b6..f1db221 100644 --- a/examples/gallery/pages_input.go +++ b/examples/gallery/pages_input.go @@ -1,3 +1,358 @@ package main -var inputPages = []page{} +import ( + "fmt" + "math" + "strings" + + "github.com/egoist/mygo/ui" + + mujica "mujicaui" + "mujicaui/icons" +) + +const inputCategory = "Inputs" + +// inputVariants lists the button variants for variant pickers. +var inputVariants = []mujica.RadioItem[mujica.ButtonVariant]{ + {Value: mujica.Primary, Label: "Primary"}, {Value: mujica.Secondary, Label: "Secondary"}, + {Value: mujica.Outline, Label: "Outline"}, {Value: mujica.Ghost, Label: "Ghost"}, {Value: mujica.Danger, Label: "Danger"}, +} + +// inputSection titles one part of a demo. +func inputSection(c *ui.Context, title string, fn func()) { + ui.Column(c).Gap(8).Children(func() { + ui.Text(c, title).FontWeight(600).TextColor(mujica.Tokens(c).TextMuted) + fn() + }) +} + +// inputFlags builds one checkbox per named flag, logging each toggle. +func inputFlags(c *ui.Context, log func(string), flags map[string]*bool, order ...string) { + ui.Row(c).Gap(16).Wrap().Children(func() { + for _, name := range order { + if mujica.Checkbox(c, flags[name], name, mujica.CheckboxOptions{}).Changed() { + log(fmt.Sprintf("option %s = %v", name, *flags[name])) + } + } + }) +} + +type inputButtonDemo struct { + variant mujica.ButtonVariant + icon, loading, disabled bool +} + +type inputTextDemo struct { + value, err string + clearable, readOnly, disabled, sl, fail bool +} + +type inputAreaDemo struct { + value string + max float64 + readOnly, disabled bool +} + +type inputNumberDemo struct { + value, step float64 + empty, stepper, disabled, fail bool +} + +type inputSearchDemo struct { + query, find string + current int + disabled bool +} + +type inputCheckDemo struct { + single, mixed, disabled bool + items [4]bool +} + +type inputGroupDemo struct { + vertical, disabled bool +} + +type inputRadioDemo struct { + value string + horizontal, disabledAll bool +} + +var inputPages = []page{ + {ID: "021", Name: "Button", ZhName: "按钮", Category: inputCategory, Keys: "Tab 聚焦;Enter 或 Space 触发。", + Code: `if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Clicked() { + fmt.Println("saved") +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "021", func() inputButtonDemo { return inputButtonDemo{} }) + inputSection(c, "Parameters", func() { + mujica.RadioGroup(c, &s.variant, inputVariants, mujica.RadioGroupOptions{Horizontal: true, Label: "Variant"}) + inputFlags(c, log, map[string]*bool{"Icon": &s.icon, "Loading": &s.loading, "Disabled": &s.disabled}, "Icon", "Loading", "Disabled") + }) + inputSection(c, "Live", func() { + opts := mujica.ButtonOptions{Variant: s.variant, Loading: s.loading, Disabled: s.disabled} + if s.icon { + opts.Icon = icons.Must("check") + } + ui.Row(c).Children(func() { + if mujica.Button(c, "Save changes", opts).Clicked() { + log("Button clicked") + } + }) + }) + inputSection(c, "Variants", func() { + ui.Row(c).Gap(12).Wrap().Children(func() { + for _, v := range inputVariants { + if mujica.Button(c, v.Label, mujica.ButtonOptions{Variant: v.Value}).Clicked() { + log(v.Label + " clicked") + } + } + mujica.Button(c, "Loading", mujica.ButtonOptions{Loading: true}) + mujica.Button(c, "Disabled", mujica.ButtonOptions{Disabled: true}) + }) + }) + }}, + {ID: "022", Name: "IconButton", ZhName: "图标按钮", Category: inputCategory, Keys: "Tab 聚焦;Enter 或 Space 触发;悬停显示提示。", + Code: `if mujica.IconButton(c, icons.Must("settings"), "Settings", mujica.IconButtonOptions{Variant: mujica.Ghost}).Clicked() { + fmt.Println("open settings") +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "022", func() inputButtonDemo { return inputButtonDemo{variant: mujica.Ghost} }) + inputSection(c, "Parameters", func() { + mujica.RadioGroup(c, &s.variant, inputVariants, mujica.RadioGroupOptions{Horizontal: true, Label: "Variant"}) + inputFlags(c, log, map[string]*bool{"Loading": &s.loading, "Disabled": &s.disabled}, "Loading", "Disabled") + }) + inputSection(c, "Live", func() { + ui.Row(c).Gap(8).Children(func() { + for _, n := range []string{"bold", "italic", "underline", "copy", "trash-2"} { + opts := mujica.IconButtonOptions{Variant: s.variant, Loading: s.loading, Disabled: s.disabled} + if mujica.IconButton(c, icons.Must(n), n, opts).Clicked() { + log("IconButton " + n + " clicked") + } + } + }) + }) + }}, + {ID: "023", Name: "ButtonGroup", ZhName: "按钮组", Category: inputCategory, Keys: "Tab 进入组;方向键在组内移动并跳过禁用项;Enter 或 Space 触发。", + Code: `items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week"}, {Label: "Month"}} +if i := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Label: "Range"}).ClickedItem(); i >= 0 { + fmt.Println("chose", items[i].Label) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "023", func() inputGroupDemo { return inputGroupDemo{} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Vertical": &s.vertical, "Disable group": &s.disabled}, "Vertical", "Disable group") + }) + inputSection(c, "Live", func() { + items := []mujica.ButtonGroupItem{{Label: "Day"}, {Label: "Week", Disabled: true}, {Label: "Month"}, {Icon: icons.Must("calendar"), Name: "Pick a date"}} + r := mujica.ButtonGroup(c, items, mujica.ButtonGroupOptions{Vertical: s.vertical, Disabled: s.disabled, Label: "Range"}) + if i := r.ClickedItem(); i >= 0 { + log(fmt.Sprintf("ButtonGroup item %d clicked", i)) + } + }) + }}, + {ID: "024", Name: "TextInput", ZhName: "文本输入框", Category: inputCategory, Keys: "Tab 聚焦;Enter 提交;Escape 清空(可清空时);Cmd/Ctrl+Z 撤销、+C/X/V 剪贴板;支持输入法组合。", + Code: `r := mujica.TextInput(c, &name, mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: true}) +if r.Submitted() { + fmt.Println("hello", name) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "024", func() inputTextDemo { return inputTextDemo{clearable: true, sl: true} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Clearable": &s.clearable, "Slots": &s.sl, "ReadOnly": &s.readOnly, "Disabled": &s.disabled}, "Clearable", "Slots", "ReadOnly", "Disabled") + mujica.TextInput(c, &s.err, mujica.TextInputOptions{Label: "Error message", Placeholder: "Error message (empty for none)"}) + }) + inputSection(c, "Live", func() { + opts := mujica.TextInputOptions{Label: "Name", Placeholder: "Your name", Clearable: s.clearable, ReadOnly: s.readOnly, Disabled: s.disabled, Error: s.err} + if s.sl { + opts.Leading = func() { ui.Icon(c, icons.Must("user")).Size(16, 16).TextColor(mujica.Tokens(c).TextMuted) } + opts.Trailing = func() { ui.Text(c, fmt.Sprintf("%d", len([]rune(s.value)))).TextColor(mujica.Tokens(c).TextMuted) } + } + r := mujica.TextInput(c, &s.value, opts) + if r.Changed() { + log(fmt.Sprintf("TextInput changed: %q", s.value)) + } + if r.Submitted() { + log(fmt.Sprintf("TextInput submitted: %q", s.value)) + } + }) + inputSection(c, "States", func() { + ui.Row(c).Gap(12).Children(func() { + e, ro, off := "1789-13-01", "Read only", "Disabled" + mujica.TextInput(c, &e, mujica.TextInputOptions{Label: "Error state", Error: "No such date"}).Grow(1) + mujica.TextInput(c, &ro, mujica.TextInputOptions{Label: "Read-only state", ReadOnly: true}).Grow(1) + mujica.TextInput(c, &off, mujica.TextInputOptions{Label: "Disabled state", Disabled: true}).Grow(1) + }) + }) + }}, + {ID: "025", Name: "TextArea", ZhName: "多行文本框", Category: inputCategory, Keys: "Enter 换行;Cmd/Ctrl+Enter 提交(可配置);滚轮与方向键滚动;撤销、剪贴板与输入法同 TextInput。", + Code: `r := mujica.TextArea(c, ¬es, mujica.TextAreaOptions{Label: "Notes", MaxHeight: 160, MaxLength: 200}) +if r.Submitted() { + fmt.Println("send", notes) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "025", func() inputAreaDemo { return inputAreaDemo{max: 120} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"ReadOnly": &s.readOnly, "Disabled": &s.disabled}, "ReadOnly", "Disabled") + if mujica.NumberInput(c, &s.max, mujica.NumberInputOptions{Min: 0, Max: 1000, Step: 10, Label: "MaxLength"}).Changed() { + log(fmt.Sprintf("option MaxLength = %v", s.max)) + } + }) + inputSection(c, "Live", func() { + r := mujica.TextArea(c, &s.value, mujica.TextAreaOptions{Label: "Notes", Placeholder: "Write a dedication…", MinHeight: 80, MaxHeight: 160, + MaxLength: int(s.max), ReadOnly: s.readOnly, Disabled: s.disabled}) + if r.Changed() { + log(fmt.Sprintf("TextArea changed: %d characters", len([]rune(s.value)))) + } + if r.Submitted() { + log("TextArea submitted") + } + }) + }}, + {ID: "026", Name: "NumberInput", ZhName: "数字输入框", Category: inputCategory, Keys: "Up/Down 按步长增减;步进模式下 Up/Down/Left/Right、Home、End。", + Code: `if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Label: "Copies"}).Changed() { + fmt.Println("copies", copies) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "026", func() inputNumberDemo { return inputNumberDemo{value: 3, step: 1} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"AllowEmpty": &s.empty, "StepperOnly": &s.stepper, "Disabled": &s.disabled, "Error": &s.fail}, "AllowEmpty", "StepperOnly", "Disabled", "Error") + steps := []mujica.RadioItem[float64]{{Value: 1, Label: "Step 1"}, {Value: 0.5, Label: "Step 0.5"}, {Value: 0.01, Label: "Step 0.01"}} + if mujica.RadioGroup(c, &s.step, steps, mujica.RadioGroupOptions{Horizontal: true, Label: "Step"}).Changed() { + log(fmt.Sprintf("option Step = %v", s.step)) + } + }) + if s.stepper { + s.empty = false + } + if !s.empty && math.IsNaN(s.value) { + s.value = 0 + } + inputSection(c, "Live", func() { + opts := mujica.NumberInputOptions{Min: 0, Max: 10, Step: s.step, AllowEmpty: s.empty, StepperOnly: s.stepper, Disabled: s.disabled, Label: "Quantity", Placeholder: "Empty"} + if s.fail { + opts.Error = "Not in stock" + } + ui.Box(c).Width(240).Children(func() { + if mujica.NumberInput(c, &s.value, opts).Changed() { + log(fmt.Sprintf("NumberInput changed: %v", s.value)) + } + }) + }) + inputSection(c, "States", func() { + ui.Row(c).Gap(12).AlignItems(ui.Start).Children(func() { + a, b, e := 4.0, 2.0, 7.5 + mujica.NumberInput(c, &a, mujica.NumberInputOptions{Min: 1, Max: 8, StepperOnly: true, Label: "Stepper state"}) + mujica.NumberInput(c, &b, mujica.NumberInputOptions{Label: "Disabled state", Disabled: true}).Width(160) + mujica.NumberInput(c, &e, mujica.NumberInputOptions{Step: 0.5, Label: "Error state", Error: "Must be whole"}).Width(160) + }) + }) + }}, + {ID: "027", Name: "PasswordInput", ZhName: "密码输入框", Category: inputCategory, Keys: "Tab 依次聚焦输入框与显隐按钮;Space 或 Enter 切换显隐;Enter 在输入框中提交;粘贴可用,遮蔽时不可复制。", + Code: `if mujica.PasswordInput(c, &password, mujica.PasswordInputOptions{Label: "Password"}).Submitted() { + fmt.Println("signing in") +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "027", func() inputTextDemo { return inputTextDemo{} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled, "Error": &s.fail}, "Disabled", "Error") + }) + inputSection(c, "Live", func() { + opts := mujica.PasswordInputOptions{Label: "Password", Placeholder: "Password", Disabled: s.disabled} + if s.fail { + opts.Error = "At least 8 characters" + } + r := mujica.PasswordInput(c, &s.value, opts) + if r.Changed() { + // The contents never reach the log. + log(fmt.Sprintf("PasswordInput changed: %d characters", len([]rune(s.value)))) + } + if r.Submitted() { + log("PasswordInput submitted") + } + }) + }}, + {ID: "028", Name: "SearchInput", ZhName: "搜索框", Category: inputCategory, Keys: "Enter 提交(搜索模式)或下一项(查找栏);Shift+Enter 上一项;Cmd+G / F3 下一项,Shift 加上为上一项;Escape 清空。", + Code: `matches := 0 +if query != "" { + matches = strings.Count(text, query) +} +r := mujica.SearchInput(c, &query, mujica.SearchInputOptions{Find: true, Matches: matches, Current: ¤t}) +if r.Moved() { + fmt.Println("show match", current) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "028", func() inputSearchDemo { return inputSearchDemo{} }) + const text = "The rose of the court, the rose of the chapel, and the rose of the garden all bloom in May." + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Disabled": &s.disabled}, "Disabled") + }) + inputSection(c, "Search mode", func() { + r := mujica.SearchInput(c, &s.query, mujica.SearchInputOptions{Disabled: s.disabled}) + if r.Changed() { + log(fmt.Sprintf("SearchInput changed: %q", s.query)) + } + if r.Submitted() { + log(fmt.Sprintf("SearchInput submitted: %q", s.query)) + } + }) + inputSection(c, "Find bar mode", func() { + n := 0 + if s.find != "" { + n = strings.Count(text, s.find) + } + r := mujica.SearchInput(c, &s.find, mujica.SearchInputOptions{Find: true, Matches: n, Current: &s.current, Disabled: s.disabled}) + if r.Changed() { + log(fmt.Sprintf("find query: %q, %d matches", s.find, n)) + } + if r.Moved() { + log(fmt.Sprintf("find moved to match %d", s.current+1)) + } + ui.Text(c, text).TextColor(mujica.Tokens(c).TextMuted) + }) + }}, + {ID: "029", Name: "Checkbox", ZhName: "复选框", Category: inputCategory, Keys: "Tab 聚焦;Space 切换;点击标签同样切换;全选框在组内勾选全部或清空。", + Code: `mujica.CheckboxGroup(c, "Notifications", []mujica.CheckboxItem{ + {Label: "Mail", Value: &mail}, + {Label: "Calendar", Value: &calendar}, +}, mujica.CheckboxGroupOptions{})`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "029", func() inputCheckDemo { return inputCheckDemo{items: [4]bool{true, false, false, true}} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Indeterminate": &s.mixed, "Disabled": &s.disabled}, "Indeterminate", "Disabled") + }) + inputSection(c, "Live", func() { + if mujica.Checkbox(c, &s.single, "Accept the terms", mujica.CheckboxOptions{Indeterminate: s.mixed, Disabled: s.disabled}).Changed() { + s.mixed = false + log(fmt.Sprintf("Checkbox changed: %v", s.single)) + } + }) + inputSection(c, "Group with select all", func() { + items := []mujica.CheckboxItem{ + {Label: "Mail", Value: &s.items[0]}, {Label: "Calendar", Value: &s.items[1]}, + {Label: "Messages", Value: &s.items[2]}, {Label: "Security alerts (required)", Value: &s.items[3], Disabled: true}, + } + if mujica.CheckboxGroup(c, "Notifications", items, mujica.CheckboxGroupOptions{Disabled: s.disabled}).Changed() { + log(fmt.Sprintf("CheckboxGroup changed: %v", s.items)) + } + }) + }}, + {ID: "030", Name: "RadioGroup", ZhName: "单选组", Category: inputCategory, Keys: "Tab 进入组并落在已选项;方向键选择并跳过禁用项;Home、End 选择首末项。", + Code: `items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}} +if mujica.RadioGroup(c, &size, items, mujica.RadioGroupOptions{Label: "Size"}).Changed() { + fmt.Println("size", size) +}`, + Demo: func(c *ui.Context, log func(string)) { + s := ui.Local(c.Root(), "030", func() inputRadioDemo { return inputRadioDemo{value: "m"} }) + inputSection(c, "Parameters", func() { + inputFlags(c, log, map[string]*bool{"Horizontal": &s.horizontal, "Disabled": &s.disabledAll}, "Horizontal", "Disabled") + }) + inputSection(c, "Live", func() { + items := []mujica.RadioItem[string]{{Value: "s", Label: "Small"}, {Value: "m", Label: "Medium"}, {Value: "l", Label: "Large"}, {Value: "xl", Label: "Sold out", Disabled: true}} + if mujica.RadioGroup(c, &s.value, items, mujica.RadioGroupOptions{Horizontal: s.horizontal, Disabled: s.disabledAll, Label: "Size"}).Changed() { + log("RadioGroup chose " + s.value) + } + }) + }}, +} diff --git a/icon_button.go b/icon_button.go new file mode 100644 index 0000000..5de2c78 --- /dev/null +++ b/icon_button.go @@ -0,0 +1,33 @@ +package mujica + +import ( + "github.com/egoist/mygo/ui" +) + +// IconButtonOptions configure an IconButton. +type IconButtonOptions struct { + // Variant is the emphasis; the zero value is Primary, as for Button. + Variant ButtonVariant + // Tooltip shows on hover; empty uses the button's name. + Tooltip string + Loading bool + Disabled bool +} + +// IconButton is a square button showing only icon; label names its action. +func IconButton(c *ui.Context, icon *ui.SVG, label string, opts IconButtonOptions) *ui.Element { + if icon == nil { + panic("mujica: IconButton needs an icon") + } + if label == "" { + panic("mujica: IconButton needs an action name") + } + h := Density(c).ControlHeight() + b := Button(c, "", ButtonOptions{Variant: opts.Variant, Icon: icon, Loading: opts.Loading, Disabled: opts.Disabled, Label: label}) + b.Padding(0).Size(h, h) + tip := opts.Tooltip + if tip == "" { + tip = label + } + return b.Tooltip(tip) +} diff --git a/icon_button_test.go b/icon_button_test.go new file mode 100644 index 0000000..9b0794a --- /dev/null +++ b/icon_button_test.go @@ -0,0 +1,78 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func TestIconButtonClickByNameAndKeyboard(t *testing.T) { + n := 0 + var w, h float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Children(func() { + b := IconButton(c, icons.Must("settings"), "Settings", IconButtonOptions{Variant: Ghost}) + w, h = b.Bounds().W, b.Bounds().H + if b.Clicked() { + n++ + } + }) + }, 300, 200) + if err := tt.Click("Settings"); err != nil { + t.Fatal(err) + } + tt.Key(0, ui.KeyEnter) + if n != 2 { + t.Fatalf("clicks %d", n) + } + if w != 36 || h != 36 { + t.Fatalf("size %vx%v, want 36 square", w, h) + } +} + +func TestIconButtonBlockedStates(t *testing.T) { + for _, opts := range []IconButtonOptions{{Disabled: true}, {Loading: true}} { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if IconButton(c, icons.Must("trash-2"), "Delete", opts).Clicked() { + n++ + } + }, 300, 200) + _ = tt.Click("Delete") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if n != 0 { + t.Fatalf("%+v: clicked %d", opts, n) + } + } +} + +func TestIconButtonNeedsName(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without an action name") + } + }() + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + IconButton(c, icons.Must("x"), "", IconButtonOptions{}) + }, 100, 100) +} + +func TestIconButtonSnapshot(t *testing.T) { + snapshot(t, "icon_button", 360, 90, func(c *ui.Context) { + ui.Row(c).Padding(24).Gap(12).Children(func() { + IconButton(c, icons.Must("plus"), "Add", IconButtonOptions{}) + IconButton(c, icons.Must("settings"), "Settings", IconButtonOptions{Variant: Secondary}) + IconButton(c, icons.Must("star"), "Favorite", IconButtonOptions{Variant: Outline}) + IconButton(c, icons.Must("copy"), "Copy", IconButtonOptions{Variant: Ghost}) + IconButton(c, icons.Must("trash-2"), "Delete", IconButtonOptions{Variant: Danger}) + IconButton(c, icons.Must("refresh-cw"), "Refresh", IconButtonOptions{Loading: true}) + IconButton(c, icons.Must("lock"), "Lock", IconButtonOptions{Disabled: true}) + }) + }) +} diff --git a/messages_input.go b/messages_input.go new file mode 100644 index 0000000..5797897 --- /dev/null +++ b/messages_input.go @@ -0,0 +1,22 @@ +package mujica + +func init() { + addMessages(map[string][2]string{ + "text_input.clear": {"Clear", "清空"}, + "number_input.increase": {"Increase", "增加"}, + "number_input.decrease": {"Decrease", "减少"}, + "number_input.invalid": {"Enter a number", "请输入数字"}, + "number_input.range": {"Enter a number from %s to %s", "请输入 %s 到 %s 之间的数字"}, + "password_input.show": {"Show password", "显示密码"}, + "password_input.hide": {"Hide password", "隐藏密码"}, + "search_input.search": {"Search", "搜索"}, + "search_input.find": {"Find", "查找"}, + "search_input.previous": {"Previous match", "上一项"}, + "search_input.next": {"Next match", "下一项"}, + "search_input.count": {"%d of %d", "第 %d 项,共 %d 项"}, + "search_input.none": {"No matches", "无匹配"}, + "checkbox.mixed": {"Partly selected", "部分选中"}, + "text_area.count": {"%d of %d characters", "%d / %d 字"}, + "text_area.over": {"%d characters over the limit", "超出 %d 字"}, + }) +} diff --git a/number_input.go b/number_input.go new file mode 100644 index 0000000..a781378 --- /dev/null +++ b/number_input.go @@ -0,0 +1,227 @@ +package mujica + +import ( + "fmt" + "log/slog" + "math" + "strconv" + "strings" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// NumberInputOptions configure a NumberInput. +type NumberInputOptions struct { + // Min and Max bound the value; equal bounds (the zero value) mean no bounds. + Min, Max float64 + // Step is what Up, Down and the buttons add; zero means 1. + Step float64 + // Precision is the decimals kept and shown; zero takes the decimals of Step. + Precision int + // AllowEmpty lets the field be empty, which sets the value to NaN. + AllowEmpty bool + // StepperOnly shows the value with step buttons and no typing; it needs bounds. + StepperOnly bool + Placeholder string + // Label names the field for assistive technology. + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// numberInputSpec is NumberInputOptions resolved. +type numberInputSpec struct { + lo, hi, step float64 + decimals int +} + +func (s numberInputSpec) format(v float64) string { + if math.IsNaN(v) { + return "" + } + return strconv.FormatFloat(v, 'f', s.decimals, 64) +} + +// clamp bounds v and rounds it to the precision. +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 +} + +func numberInputResolve(opts NumberInputOptions) numberInputSpec { + s := numberInputSpec{lo: opts.Min, hi: opts.Max, step: opts.Step, decimals: opts.Precision} + if s.lo > s.hi { + panic("mujica: NumberInput Min is above Max") + } + if s.step < 0 || opts.Precision < 0 { + panic("mujica: NumberInput Step and Precision cannot be negative") + } + if s.lo == s.hi { + if opts.StepperOnly { + panic("mujica: NumberInput StepperOnly needs Min < Max") + } + s.lo, s.hi = math.Inf(-1), math.Inf(1) + } + if s.step == 0 { + s.step = 1 + } + if s.decimals == 0 { + if t := strconv.FormatFloat(s.step, 'f', -1, 64); strings.Contains(t, ".") { + s.decimals = len(t) - strings.IndexByte(t, '.') - 1 + } + } + return s +} + +// NumberInput edits *value as a number; Up and Down step it. +func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputResult { + if value == nil { + panic("mujica: NumberInput needs a value") + } + if opts.StepperOnly && opts.AllowEmpty { + panic("mujica: NumberInput StepperOnly cannot be empty") + } + if math.IsNaN(*value) && !opts.AllowEmpty { + panic("mujica: NumberInput value is NaN without AllowEmpty") + } + s := numberInputResolve(opts) + if opts.StepperOnly { + return numberInputStepper(c, value, s, opts) + } + var r InputResult + set := func(v float64) { + if !math.IsNaN(v) { + v = s.clamp(v) + } + if v != *value && !(math.IsNaN(v) && math.IsNaN(*value)) { + *value = v + r.changed = true + } + } + stepBy := func(n float64) { + base := *value + if math.IsNaN(base) { + base = math.Max(s.lo, math.Min(s.hi, 0)) + } + set(base + n*s.step) + } + look := &textInputLook{disabled: opts.Disabled, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + text := ui.Local(frame, "text", func() string { return s.format(*value) }) + invalid := ui.Local(frame, "invalid", func() string { return "" }) + in := ui.TextInputBase(c, text).Grow(1).MinWidth(Density(c).Unit() * 12). + Placeholder(opts.Placeholder).Label(opts.Label) + if in.Changed() { + *invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set) + } + switch { + case in.Shortcut(0, ui.KeyUp): + stepBy(1) + *text, *invalid = s.format(*value), "" + case in.Shortcut(0, ui.KeyDown): + stepBy(-1) + *text, *invalid = s.format(*value), "" + } + if !in.Focused() { + *text, *invalid = s.format(*value), "" + } + for _, b := range []struct { + name string + icon string + delta float64 + can bool + }{ + {msg(c, "number_input.decrease"), "minus", -1, math.IsNaN(*value) || *value > s.lo}, + {msg(c, "number_input.increase"), "plus", 1, math.IsNaN(*value) || *value < s.hi}, + } { + if numberInputButton(c, b.icon, b.name, b.can) { + stepBy(b.delta) + *text, *invalid = s.format(*value), "" + in.Focus() + } + } + if look.err == "" { + look.err = *invalid + } + return in + }, nil) + if r.changed { + slog.Debug("mujica: number_input change", "value", *value) + } + return r +} + +// numberInputParse applies typed text and returns why it is invalid, if it is. +func numberInputParse(c *ui.Context, s numberInputSpec, text string, allowEmpty bool, set func(float64)) string { + t := strings.TrimSpace(text) + if t == "" && allowEmpty { + set(math.NaN()) + return "" + } + v, err := strconv.ParseFloat(t, 64) + if err != nil || math.IsNaN(v) || math.IsInf(v, 0) { + return msg(c, "number_input.invalid") + } + if v < s.lo || v > s.hi { + return fmt.Sprintf(msg(c, "number_input.range"), s.format(s.lo), s.format(s.hi)) + } + set(v) + return "" +} + +// numberInputButton builds a pointer-only step button; Up and Down are its keys. +func numberInputButton(c *ui.Context, icon, name string, can bool) bool { + k := Tokens(c) + b := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Center().Radius(theme.ControlRadius). + Role(ui.RoleButton).Label(name).Cursor(ui.CursorPointer).Disabled(!can) + fg := k.TextMuted + if b.Pressed() { + b.Background(k.Selection) + fg = k.Text + } else if b.Hovered() { + b.Background(k.SurfaceHover) + fg = k.Text + } + b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(14, 14).TextColor(fg) }) + return b.Clicked() +} + +// numberInputStepper shows the value beside MyGo's stepper, restyled. +func numberInputStepper(c *ui.Context, value *float64, s numberInputSpec, opts NumberInputOptions) InputResult { + k := Tokens(c) + d := Density(c) + var r InputResult + r.Element = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(opts.Disabled) + r.Element.Children(func() { + ui.Row(c).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).Children(func() { + ui.Text(c, s.format(*value)).FontFeatures("tnum").MinWidth(d.Unit() * 8).TextAlign(ui.End) + // MyGo's stepper arrows ignore Disabled: give them a copy. + target := value + if opts.Disabled { + held := *value + target = &held + } + st := ui.Stepper(c, target, s.lo, s.hi, s.step).Label(opts.Label).Width(d.Unit()*6). + Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder) + if opts.Error != "" { + st.Error(opts.Error).Border(theme.BorderWidth, k.Danger) + } + internal.Ring(st, k.Focus, theme.ControlRadius) + r.Input = st + r.changed = st.Changed() && !opts.Disabled + }) + if opts.Error != "" { + ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize) + } + }) + if r.changed { + *value = s.clamp(*value) + slog.Debug("mujica: number_input step", "value", *value) + } + return r +} diff --git a/number_input_test.go b/number_input_test.go new file mode 100644 index 0000000..5259557 --- /dev/null +++ b/number_input_test.go @@ -0,0 +1,141 @@ +package mujica + +import ( + "math" + "testing" + + "github.com/egoist/mygo/ui" +) + +func numberInputTester(v *float64, changes *int, opts NumberInputOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if NumberInput(c, v, opts).Changed() { + *changes++ + } + Button(c, "Elsewhere", ButtonOptions{}) + }, 400, 300) +} + +func TestNumberInputStepsAndBounds(t *testing.T) { + v, n := 9.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 0, Max: 10, Label: "Copies"}) + _ = tt.Click("Copies") + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyUp) + if v != 10 || n != 1 { + t.Fatalf("value %v changes %d, want 10 and 1", v, n) + } + _ = tt.Click("Decrease") + _ = tt.Click("Decrease") + if v != 8 || n != 3 { + t.Fatalf("after buttons %v changes %d", v, n) + } + _ = tt.Click("Increase") + _ = tt.Click("Increase") + _ = tt.Click("Increase") + if v != 10 { + t.Fatalf("increase passed Max: %v", v) + } +} + +func TestNumberInputTypingPrecisionAndErrors(t *testing.T) { + v, n := 1.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 0, Max: 2, Step: 0.25, Label: "Ratio"}) + _ = tt.Click("Ratio") + tt.Command("selectAll") + tt.Type("1.5") + if v != 1.5 { + t.Fatalf("typed value %v", v) + } + tt.Key(0, ui.KeyDown) + if v != 1.25 { + t.Fatalf("Down from 1.5 by 0.25 gave %v", v) + } + tt.Command("selectAll") + tt.Type("abc") + if v != 1.25 || !tt.HasText("Enter a number") { + t.Fatalf("invalid text changed value to %v or showed no error: %q", v, tt.Texts()) + } + tt.Command("selectAll") + tt.Type("7") + if v != 1.25 || !tt.HasText("Enter a number from 0.00 to 2.00") { + 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") + } +} + +func TestNumberInputEmpty(t *testing.T) { + v, n := 3.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{AllowEmpty: true, Label: "Limit"}) + _ = tt.Click("Limit") + tt.Command("selectAll") + tt.Key(0, ui.KeyBackspace) + if !math.IsNaN(v) { + t.Fatalf("emptied value %v, want NaN", v) + } + tt.Key(0, ui.KeyUp) + if v != 1 { + t.Fatalf("Up from empty gave %v, want 1", v) + } +} + +func TestNumberInputStepperOnly(t *testing.T) { + v, n := 2.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 1, Max: 3, StepperOnly: true, Label: "Guests"}) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyUp) + tt.Key(0, ui.KeyUp) + if v != 3 || n != 1 || !tt.HasText("3") { + t.Fatalf("value %v changes %d texts %q", v, n, tt.Texts()) + } + tt.Key(0, ui.KeyHome) + if v != 1 { + t.Fatalf("Home gave %v", v) + } +} + +func TestNumberInputDisabled(t *testing.T) { + for _, stepper := range []bool{false, true} { + v, n := 2.0, 0 + tt := numberInputTester(&v, &n, NumberInputOptions{Min: 1, Max: 3, StepperOnly: stepper, Disabled: true, Label: "Off"}) + _ = tt.Click("Increase") + _ = tt.Click("Off") + tt.Key(0, ui.KeyUp) + if v != 2 || n != 0 { + t.Fatalf("stepper %v: disabled changed to %v (%d changes)", stepper, v, n) + } + } +} + +func TestNumberInputBadOptionsPanic(t *testing.T) { + for _, opts := range []NumberInputOptions{{Min: 3, Max: 1}, {StepperOnly: true}, {Step: -1}} { + func() { + defer func() { + if recover() == nil { + t.Errorf("%+v: no panic", opts) + } + }() + v := 0.0 + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + NumberInput(c, &v, opts) + }, 200, 100) + }() + } +} + +func TestNumberInputSnapshot(t *testing.T) { + snapshot(t, "number_input", 420, 260, func(c *ui.Context) { + a, b, e, s := 12.0, math.NaN(), 7.5, 4.0 + ui.Column(c).Padding(24).Gap(12).Width(260).Children(func() { + NumberInput(c, &a, NumberInputOptions{Min: 0, Max: 99, Label: "A"}) + NumberInput(c, &b, NumberInputOptions{AllowEmpty: true, Placeholder: "No limit", Label: "B"}) + NumberInput(c, &e, NumberInputOptions{Step: 0.5, Label: "E", Error: "Must be whole"}) + NumberInput(c, &s, NumberInputOptions{Min: 1, Max: 8, StepperOnly: true, Label: "S"}) + }) + }) +} diff --git a/password_input.go b/password_input.go new file mode 100644 index 0000000..25d07ca --- /dev/null +++ b/password_input.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// PasswordInputOptions configure a PasswordInput. +type PasswordInputOptions struct { + Placeholder string + // Label names the field for assistive technology. + Label string + Disabled bool + // Error marks the value invalid and shows the message below. + Error string +} + +// PasswordInput is a masked field with a show and hide toggle. +func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) InputResult { + if value == nil { + panic("mujica: PasswordInput needs a value") + } + var r InputResult + look := &textInputLook{disabled: opts.Disabled, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + shown := ui.Local(frame, "shown", func() bool { return false }) + var in *ui.Element + // MyGo's mask cannot be lifted, so each mode keeps its own editor. + mode := "masked" + if *shown { + mode = "shown" + } + ui.Row(c).Key(mode).Grow(1).Children(func() { + in = ui.TextInputBase(c, value).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() + if passwordInputToggle(c, *shown) { + *shown = !*shown + slog.Debug("mujica: password_input reveal", "shown", *shown) + } + return in + }, nil) + return r +} + +// passwordInputToggle builds the show or hide button and reports a click. +func passwordInputToggle(c *ui.Context, shown bool) bool { + k := Tokens(c) + name, icon := msg(c, "password_input.show"), "eye" + if shown { + name, icon = msg(c, "password_input.hide"), "eye-off" + } + b := ui.ButtonBase(c).Size(theme.MinHitSize, theme.MinHitSize).Radius(theme.ControlRadius).Label(name).Tooltip(name) + internal.Ring(b, k.Focus, theme.ControlRadius) + fg := k.TextMuted + if b.Pressed() { + b.Background(k.Selection) + fg = k.Text + } else if b.Hovered() { + b.Background(k.SurfaceHover) + fg = k.Text + } + b.Children(func() { ui.Icon(c, icons.Must(icon)).Size(16, 16).TextColor(fg) }) + return b.Clicked() +} diff --git a/password_input_test.go b/password_input_test.go new file mode 100644 index 0000000..72f82c0 --- /dev/null +++ b/password_input_test.go @@ -0,0 +1,101 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestPasswordInputMaskPasteAndReveal(t *testing.T) { + pw, changes, submits := "", 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := PasswordInput(c, &pw, PasswordInputOptions{Label: "Password"}) + if r.Changed() { + changes++ + } + if r.Submitted() { + submits++ + } + }, 400, 200) + _ = tt.Click("Password") + tt.SetClipboard("s3cr") + tt.Command("paste") + tt.Type("et") + if pw != "s3cret" || changes != 2 { + t.Fatalf("value %q changes %d", pw, changes) + } + tt.SetClipboard("") + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "" { + t.Fatalf("masked field copied %q", tt.Clipboard()) + } + for _, s := range tt.Texts() { + if s == "s3cret" { + t.Fatal("masked password shows in the texts") + } + } + if err := tt.Click("Show password"); err != nil { + t.Fatal(err) + } + if _, ok := tt.Find("Hide password"); !ok || pw != "s3cret" { + t.Fatalf("after reveal: value %q, texts %q", pw, tt.Texts()) + } + _ = tt.Click("Password") + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "s3cret" { + t.Fatalf("revealed field copied %q", tt.Clipboard()) + } + tt.Key(0, ui.KeyEnter) + if submits != 1 { + t.Fatalf("submits %d", submits) + } + _ = tt.Click("Hide password") + if _, ok := tt.Find("Show password"); !ok { + t.Fatal("hide did not mask again") + } +} + +func TestPasswordInputToggleByKeyboard(t *testing.T) { + pw := "x" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PasswordInput(c, &pw, PasswordInputOptions{Label: "PIN"}) + }, 400, 200) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeySpace) + if _, ok := tt.Find("Hide password"); !ok { + t.Fatal("Space on the toggle did not reveal") + } +} + +func TestPasswordInputDisabled(t *testing.T) { + pw := "locked" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + PasswordInput(c, &pw, PasswordInputOptions{Label: "Off", Disabled: true}) + }, 400, 200) + _ = tt.Click("Show password") + _ = tt.Click("Off") + tt.Type("zz") + if pw != "locked" { + t.Fatalf("disabled value %q", pw) + } + if _, ok := tt.Find("Show password"); !ok { + t.Fatal("disabled toggle revealed") + } +} + +func TestPasswordInputSnapshot(t *testing.T) { + snapshot(t, "password_input", 420, 220, func(c *ui.Context) { + a, b, e := "", "versailles", "short" + ui.Column(c).Padding(24).Gap(12).Width(320).Children(func() { + PasswordInput(c, &a, PasswordInputOptions{Label: "A", Placeholder: "Password"}) + PasswordInput(c, &b, PasswordInputOptions{Label: "B"}) + PasswordInput(c, &e, PasswordInputOptions{Label: "E", Error: "At least 8 characters"}) + }) + }) +} diff --git a/radio_group.go b/radio_group.go new file mode 100644 index 0000000..df0b3d4 --- /dev/null +++ b/radio_group.go @@ -0,0 +1,90 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// RadioItem is one choice of a RadioGroup; Value is its stable identity. +type RadioItem[T comparable] struct { + Value T + Label string + Disabled bool +} + +// RadioGroupOptions configure a RadioGroup. +type RadioGroupOptions struct { + // Horizontal lays the choices in a row; the default is a column. + Horizontal bool + // Label names the group for assistive technology. + Label string + Disabled bool +} + +// RadioGroup chooses one item's Value into *value; arrows move the choice. +func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opts RadioGroupOptions) InputResult { + if value == nil { + panic("mujica: RadioGroup needs a value") + } + if len(items) == 0 { + panic("mujica: RadioGroup needs items") + } + seen := make(map[T]bool, len(items)) + for _, it := range items { + if seen[it.Value] { + panic("mujica: RadioGroup has a duplicate value") + } + seen[it.Value] = true + } + 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) + } + } + }).Label(opts.Label).Disabled(opts.Disabled).Gap(0) + if opts.Horizontal { + r.Element.Row().Gap(d.Unit() * 6) + } + 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) + 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) + } + dot.Transition(textInputFade) + dot.DrawOver(func(p *ui.Painter, r ui.Rect) { + if on { + s := r.W * 0.375 + p.Fill(ui.Rect{X: r.X + (r.W-s)/2, Y: r.Y + (r.H-s)/2, W: s, H: s}, k.OnAccent, s/2) + } + if row.FocusVisible() { + internal.FocusRing(p, r, checkboxSize/2, k.Focus) + } + }) + ui.Text(c, it.Label) + }) + return row.Changed() +} diff --git a/radio_group_test.go b/radio_group_test.go new file mode 100644 index 0000000..e4a2099 --- /dev/null +++ b/radio_group_test.go @@ -0,0 +1,99 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" +) + +var radioGroupTestItems = []RadioItem[string]{ + {Value: "s", Label: "Small"}, + {Value: "m", Label: "Medium", Disabled: true}, + {Value: "l", Label: "Large"}, +} + +func radioGroupTester(v *string, changes *int, opts RadioGroupOptions) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if RadioGroup(c, v, radioGroupTestItems, opts).Changed() { + *changes++ + } + Button(c, "After", ButtonOptions{}) + }, 400, 300) +} + +func TestRadioGroupClickAndArrows(t *testing.T) { + v, n := "s", 0 + tt := radioGroupTester(&v, &n, RadioGroupOptions{Label: "Size"}) + _ = tt.Click("Medium") + if v != "s" || n != 0 { + t.Fatalf("disabled item chosen: %q", v) + } + _ = tt.Click("Large") + if v != "l" || n != 1 { + t.Fatalf("click: %q changes %d", v, n) + } + tt.Key(0, ui.KeyDown) + if v != "s" || n != 2 { + t.Fatalf("Down wraps past the disabled item to Small, got %q changes %d", v, n) + } + tt.Key(0, ui.KeyUp) + if v != "l" { + t.Fatalf("Up skipped to %q", v) + } + tt.Key(0, ui.KeyHome) + if v != "s" { + t.Fatalf("Home chose %q", v) + } +} + +func TestRadioGroupTabStopsAtChosen(t *testing.T) { + v, n := "l", 0 + tt := radioGroupTester(&v, &n, RadioGroupOptions{Horizontal: true}) + tt.Key(0, ui.KeyTab) + if !tt.Focused("Large") { + t.Fatal("Tab did not go to the chosen radio") + } + tt.Key(0, ui.KeyLeft) + if v != "s" { + t.Fatalf("Left in a row chose %q", v) + } + tt.Key(0, ui.KeyTab) + if !tt.Focused("After") { + t.Fatal("Tab did not leave the group") + } +} + +func TestRadioGroupDisabledGroup(t *testing.T) { + v, n := "s", 0 + tt := radioGroupTester(&v, &n, RadioGroupOptions{Disabled: true}) + _ = tt.Click("Large") + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyDown) + if v != "s" || n != 0 { + t.Fatalf("disabled group chose %q", v) + } +} + +func TestRadioGroupDuplicateValuePanics(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for duplicate values") + } + }() + v := 1 + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + RadioGroup(c, &v, []RadioItem[int]{{Value: 1, Label: "A"}, {Value: 1, Label: "B"}}, RadioGroupOptions{}) + }, 200, 100) +} + +func TestRadioGroupSnapshot(t *testing.T) { + snapshot(t, "radio_group", 460, 220, func(c *ui.Context) { + v, h := "m", 2 + ui.Column(c).Padding(24).Gap(16).Children(func() { + RadioGroup(c, &v, []RadioItem[string]{{Value: "s", Label: "Sonnet"}, {Value: "m", Label: "Madrigal"}, {Value: "o", Label: "Ode", Disabled: true}}, RadioGroupOptions{}) + RadioGroup(c, &h, []RadioItem[int]{{Value: 1, Label: "Matins"}, {Value: 2, Label: "Vespers"}, {Value: 3, Label: "Compline"}}, RadioGroupOptions{Horizontal: true}) + }) + }) +} diff --git a/search_input.go b/search_input.go new file mode 100644 index 0000000..7a1af8f --- /dev/null +++ b/search_input.go @@ -0,0 +1,140 @@ +package mujica + +import ( + "fmt" + "log/slog" + "runtime" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// SearchInputOptions configure a SearchInput. +type SearchInputOptions struct { + // Placeholder is empty for the built-in "Search" or "Find". + Placeholder string + // Label names the field; empty uses the placeholder. + Label string + Disabled bool + // Find turns on find-bar mode: match count, previous and next. + Find bool + // Matches is how many matches the caller found for the query. + Matches int + // Current is the chosen match index; Find mode requires it. + Current *int +} + +// SearchInputResult adds find-bar moves to InputResult. +type SearchInputResult struct { + InputResult + moved bool +} + +// Moved reports that the user went to another match this frame. +func (r SearchInputResult) Moved() bool { return r.moved } + +// SearchInput is a search field with clear, or a find bar with match navigation. +func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchInputResult { + if query == nil { + panic("mujica: SearchInput needs a query") + } + if opts.Find && opts.Current == nil { + panic("mujica: SearchInput Find mode needs Current") + } + if opts.Matches < 0 { + panic("mujica: SearchInput Matches is negative") + } + k := Tokens(c) + place := opts.Placeholder + if place == "" { + place = msg(c, "search_input.search") + if opts.Find { + place = msg(c, "search_input.find") + } + } + name := opts.Label + if name == "" { + name = place + } + var r SearchInputResult + step := func(d int) { + if opts.Matches == 0 { + return + } + *opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches + r.moved = true + slog.Debug("mujica: search_input move", "current", *opts.Current) + } + if opts.Find { + *opts.Current = max(0, min(*opts.Current, opts.Matches-1)) + if !opts.Disabled { + mac := runtime.GOOS == "darwin" + switch { + case mac && c.Shortcut(ui.Cmd, ui.KeyG), !mac && c.Shortcut(0, ui.KeyF3): + step(1) + case mac && c.Shortcut(ui.Shift|ui.Cmd, ui.KeyG), !mac && c.Shortcut(ui.Shift, ui.KeyF3): + step(-1) + } + } + } + look := &textInputLook{disabled: opts.Disabled} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + ui.Icon(c, icons.Must("search")).Size(16, 16).Shrink(0).TextColor(k.TextMuted) + back := ui.Local(frame, "back", func() bool { return false }) + in := ui.TextInputBase(c, query).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name) + r.changed = in.Changed() + if opts.Find { + // Shift+Enter would reach the editor as a plain submit. + in.HandleInput(func(ev ui.InputEvent) bool { + if ev.Kind == ui.InputKeyDown && ev.Key == ui.KeyEnter && ev.Mods == ui.Shift { + *back = true + return true + } + return false + }) + switch { + case *back: + *back = false + step(-1) + case in.Submitted(): + step(1) + } + } else { + r.submitted = in.Submitted() + } + if *query != "" && textInputClear(c, in) { + *query = "" + r.changed = true + slog.Debug("mujica: search_input clear") + } + if opts.Find { + searchInputFindControls(c, *query, opts.Matches, *opts.Current, step) + } + return in + }, nil) + return r +} + +// searchInputFindControls shows the match count with previous and next buttons. +func searchInputFindControls(c *ui.Context, query string, matches, current int, step func(int)) { + k := Tokens(c) + status := msg(c, "search_input.none") + switch { + case query == "": + status = "" + 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"). + 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} + if IconButton(c, icons.Must("chevron-up"), msg(c, "search_input.previous"), opts).Size(theme.MinHitSize, theme.MinHitSize).MinHeight(theme.MinHitSize).Clicked() { + step(-1) + } + if IconButton(c, icons.Must("chevron-down"), msg(c, "search_input.next"), opts).Size(theme.MinHitSize, theme.MinHitSize).MinHeight(theme.MinHitSize).Clicked() { + step(1) + } +} diff --git a/search_input_test.go b/search_input_test.go new file mode 100644 index 0000000..dc6cfb3 --- /dev/null +++ b/search_input_test.go @@ -0,0 +1,129 @@ +package mujica + +import ( + "runtime" + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestSearchInputSearchClearSubmit(t *testing.T) { + q, changes, submits := "", 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := SearchInput(c, &q, SearchInputOptions{}) + if r.Changed() { + changes++ + } + if r.Submitted() { + submits++ + } + }, 400, 200) + _ = tt.Click("Search") + tt.Type("rose") + tt.Key(0, ui.KeyEnter) + if q != "rose" || changes != 1 || submits != 1 { + t.Fatalf("query %q changes %d submits %d", q, changes, submits) + } + _ = tt.Click("Clear") + if q != "" || changes != 2 { + t.Fatalf("after clear %q changes %d", q, changes) + } + tt.Type("lys") + tt.Key(0, ui.KeyEscape) + if q != "" || changes != 4 { + t.Fatalf("after Escape %q changes %d", q, changes) + } +} + +// searchInputFindTester counts matches of the query in a fixed text. +func searchInputFindTester(q *string, cur, moves *int, locale Locale) *ui.Tester { + const text = "rose lys rose crown rose" + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{Locale: locale}) + n := 0 + if *q != "" { + n = strings.Count(text, *q) + } + if SearchInput(c, q, SearchInputOptions{Find: true, Matches: n, Current: cur, Label: "Find in page"}).Moved() { + *moves++ + } + }, 500, 200) +} + +func TestSearchInputFindBarNavigation(t *testing.T) { + q, cur, moves := "", 0, 0 + tt := searchInputFindTester(&q, &cur, &moves, "") + _ = tt.Click("Find in page") + tt.Type("rose") + if !tt.HasText("1 of 3") { + t.Fatalf("count missing: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeyEnter) + if cur != 2 || !tt.HasText("3 of 3") { + t.Fatalf("after two Enters current %d texts %q", cur, tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + if cur != 0 { + t.Fatalf("Enter did not wrap, current %d", cur) + } + tt.Key(ui.Shift, ui.KeyEnter) + if cur != 2 || q != "rose" { + t.Fatalf("Shift+Enter: current %d query %q", cur, q) + } + _ = tt.Click("Previous match") + _ = tt.Click("Next match") + _ = tt.Click("Next match") + if cur != 0 || moves != 7 { + t.Fatalf("buttons: current %d moves %d", cur, moves) + } + if runtime.GOOS == "darwin" { + tt.Key(ui.Cmd, ui.KeyG) + } else { + tt.Key(0, ui.KeyF3) + } + if cur != 1 { + t.Fatalf("find-next shortcut: current %d", cur) + } +} + +func TestSearchInputFindNoMatches(t *testing.T) { + q, cur, moves := "", 0, 0 + tt := searchInputFindTester(&q, &cur, &moves, ZhCN) + _ = tt.Click("Find in page") + tt.Type("tulip") + if !tt.HasText("无匹配") { + t.Fatalf("no-match status missing: %q", tt.Texts()) + } + tt.Key(0, ui.KeyEnter) + _ = tt.Click("下一项") + if cur != 0 || moves != 0 { + t.Fatalf("moved without matches: current %d moves %d", cur, moves) + } +} + +func TestSearchInputFindNeedsCurrent(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic without Current") + } + }() + q := "" + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + SearchInput(c, &q, SearchInputOptions{Find: true}) + }, 200, 100) +} + +func TestSearchInputSnapshot(t *testing.T) { + snapshot(t, "search_input", 460, 220, func(c *ui.Context) { + a, b, f, cur := "", "Marie", "rose", 1 + ui.Column(c).Padding(24).Gap(12).Width(400).Children(func() { + SearchInput(c, &a, SearchInputOptions{}) + SearchInput(c, &b, SearchInputOptions{}) + SearchInput(c, &f, SearchInputOptions{Find: true, Matches: 3, Current: &cur}) + }) + }) +} diff --git a/testdata/snapshots/darwin/button-comfortable-dark.png b/testdata/snapshots/darwin/button-comfortable-dark.png new file mode 100644 index 0000000..6167924 Binary files /dev/null and b/testdata/snapshots/darwin/button-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/button-comfortable-light.png b/testdata/snapshots/darwin/button-comfortable-light.png new file mode 100644 index 0000000..cf71679 Binary files /dev/null and b/testdata/snapshots/darwin/button-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/button-compact-dark.png b/testdata/snapshots/darwin/button-compact-dark.png new file mode 100644 index 0000000..73e1a88 Binary files /dev/null and b/testdata/snapshots/darwin/button-compact-dark.png differ diff --git a/testdata/snapshots/darwin/button-compact-light.png b/testdata/snapshots/darwin/button-compact-light.png new file mode 100644 index 0000000..6eab1a0 Binary files /dev/null and b/testdata/snapshots/darwin/button-compact-light.png differ diff --git a/testdata/snapshots/darwin/button-dark.png b/testdata/snapshots/darwin/button-dark.png index 2653916..df1f5e4 100644 Binary files a/testdata/snapshots/darwin/button-dark.png and b/testdata/snapshots/darwin/button-dark.png differ diff --git a/testdata/snapshots/darwin/button-light.png b/testdata/snapshots/darwin/button-light.png index b85d2e5..c977763 100644 Binary files a/testdata/snapshots/darwin/button-light.png and b/testdata/snapshots/darwin/button-light.png differ diff --git a/testdata/snapshots/darwin/button_group-dark.png b/testdata/snapshots/darwin/button_group-dark.png new file mode 100644 index 0000000..e8eb370 Binary files /dev/null and b/testdata/snapshots/darwin/button_group-dark.png differ diff --git a/testdata/snapshots/darwin/button_group-light.png b/testdata/snapshots/darwin/button_group-light.png new file mode 100644 index 0000000..db5d436 Binary files /dev/null and b/testdata/snapshots/darwin/button_group-light.png differ diff --git a/testdata/snapshots/darwin/checkbox-comfortable-dark.png b/testdata/snapshots/darwin/checkbox-comfortable-dark.png new file mode 100644 index 0000000..f50031b Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/checkbox-comfortable-light.png b/testdata/snapshots/darwin/checkbox-comfortable-light.png new file mode 100644 index 0000000..4d2cfdd Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/checkbox-compact-dark.png b/testdata/snapshots/darwin/checkbox-compact-dark.png new file mode 100644 index 0000000..6d34b57 Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-compact-dark.png differ diff --git a/testdata/snapshots/darwin/checkbox-compact-light.png b/testdata/snapshots/darwin/checkbox-compact-light.png new file mode 100644 index 0000000..546718d Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-compact-light.png differ diff --git a/testdata/snapshots/darwin/checkbox-dark.png b/testdata/snapshots/darwin/checkbox-dark.png new file mode 100644 index 0000000..8a1d8c7 Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-dark.png differ diff --git a/testdata/snapshots/darwin/checkbox-light.png b/testdata/snapshots/darwin/checkbox-light.png new file mode 100644 index 0000000..116207f Binary files /dev/null and b/testdata/snapshots/darwin/checkbox-light.png differ diff --git a/testdata/snapshots/darwin/icon_button-dark.png b/testdata/snapshots/darwin/icon_button-dark.png new file mode 100644 index 0000000..ff1961b Binary files /dev/null and b/testdata/snapshots/darwin/icon_button-dark.png differ diff --git a/testdata/snapshots/darwin/icon_button-light.png b/testdata/snapshots/darwin/icon_button-light.png new file mode 100644 index 0000000..6a4ca9b Binary files /dev/null and b/testdata/snapshots/darwin/icon_button-light.png differ diff --git a/testdata/snapshots/darwin/number_input-dark.png b/testdata/snapshots/darwin/number_input-dark.png new file mode 100644 index 0000000..959270c Binary files /dev/null and b/testdata/snapshots/darwin/number_input-dark.png differ diff --git a/testdata/snapshots/darwin/number_input-light.png b/testdata/snapshots/darwin/number_input-light.png new file mode 100644 index 0000000..74f4781 Binary files /dev/null and b/testdata/snapshots/darwin/number_input-light.png differ diff --git a/testdata/snapshots/darwin/password_input-dark.png b/testdata/snapshots/darwin/password_input-dark.png new file mode 100644 index 0000000..e893138 Binary files /dev/null and b/testdata/snapshots/darwin/password_input-dark.png differ diff --git a/testdata/snapshots/darwin/password_input-light.png b/testdata/snapshots/darwin/password_input-light.png new file mode 100644 index 0000000..cec5085 Binary files /dev/null and b/testdata/snapshots/darwin/password_input-light.png differ diff --git a/testdata/snapshots/darwin/radio_group-dark.png b/testdata/snapshots/darwin/radio_group-dark.png new file mode 100644 index 0000000..6f77a86 Binary files /dev/null and b/testdata/snapshots/darwin/radio_group-dark.png differ diff --git a/testdata/snapshots/darwin/radio_group-light.png b/testdata/snapshots/darwin/radio_group-light.png new file mode 100644 index 0000000..609f18c Binary files /dev/null and b/testdata/snapshots/darwin/radio_group-light.png differ diff --git a/testdata/snapshots/darwin/search_input-dark.png b/testdata/snapshots/darwin/search_input-dark.png new file mode 100644 index 0000000..fb6d0e4 Binary files /dev/null and b/testdata/snapshots/darwin/search_input-dark.png differ diff --git a/testdata/snapshots/darwin/search_input-light.png b/testdata/snapshots/darwin/search_input-light.png new file mode 100644 index 0000000..0bfc594 Binary files /dev/null and b/testdata/snapshots/darwin/search_input-light.png differ diff --git a/testdata/snapshots/darwin/text_area-dark.png b/testdata/snapshots/darwin/text_area-dark.png new file mode 100644 index 0000000..d6c2290 Binary files /dev/null and b/testdata/snapshots/darwin/text_area-dark.png differ diff --git a/testdata/snapshots/darwin/text_area-light.png b/testdata/snapshots/darwin/text_area-light.png new file mode 100644 index 0000000..b474615 Binary files /dev/null and b/testdata/snapshots/darwin/text_area-light.png differ diff --git a/testdata/snapshots/darwin/text_input-comfortable-dark.png b/testdata/snapshots/darwin/text_input-comfortable-dark.png new file mode 100644 index 0000000..943f914 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-comfortable-dark.png differ diff --git a/testdata/snapshots/darwin/text_input-comfortable-light.png b/testdata/snapshots/darwin/text_input-comfortable-light.png new file mode 100644 index 0000000..281d895 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-comfortable-light.png differ diff --git a/testdata/snapshots/darwin/text_input-compact-dark.png b/testdata/snapshots/darwin/text_input-compact-dark.png new file mode 100644 index 0000000..3ddd459 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-compact-dark.png differ diff --git a/testdata/snapshots/darwin/text_input-compact-light.png b/testdata/snapshots/darwin/text_input-compact-light.png new file mode 100644 index 0000000..30cc1d1 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-compact-light.png differ diff --git a/testdata/snapshots/darwin/text_input-dark.png b/testdata/snapshots/darwin/text_input-dark.png new file mode 100644 index 0000000..18bde1f Binary files /dev/null and b/testdata/snapshots/darwin/text_input-dark.png differ diff --git a/testdata/snapshots/darwin/text_input-light.png b/testdata/snapshots/darwin/text_input-light.png new file mode 100644 index 0000000..d600c90 Binary files /dev/null and b/testdata/snapshots/darwin/text_input-light.png differ diff --git a/text_area.go b/text_area.go new file mode 100644 index 0000000..e87274b --- /dev/null +++ b/text_area.go @@ -0,0 +1,88 @@ +package mujica + +import ( + "fmt" + "unicode/utf8" + + "github.com/egoist/mygo/ui" + + "mujicaui/theme" +) + +// TextAreaOptions configure a TextArea. +type TextAreaOptions struct { + Placeholder string + // Label names the field for assistive technology. + Label string + // MinHeight is the least height in DIPs; zero fits three lines. + MinHeight float32 + // MaxHeight caps the height in DIPs, then the text scrolls; zero grows freely. + MaxHeight float32 + // MaxLength shows a character count against it; zero hides the count. + MaxLength int + // SubmitKey with SubmitMods submits; a zero key means Cmd+Enter. + SubmitKey ui.Key + SubmitMods ui.Modifiers + Disabled bool + ReadOnly bool + // Error marks the value invalid and shows the message below. + Error string +} + +// TextArea is a multi-line field editing *value. +func TextArea(c *ui.Context, value *string, opts TextAreaOptions) InputResult { + if value == nil { + panic("mujica: TextArea needs a value") + } + if opts.MinHeight < 0 || opts.MaxHeight < 0 || opts.MaxHeight > 0 && opts.MaxHeight < opts.MinHeight { + panic("mujica: TextArea needs 0 <= MinHeight <= MaxHeight") + } + if opts.MaxLength < 0 { + panic("mujica: TextArea MaxLength is negative") + } + key, mods := opts.SubmitKey, opts.SubmitMods + if key == 0 { + key, mods = ui.KeyEnter, ui.Cmd + } + d := Density(c) + n := utf8.RuneCountInString(*value) + over := opts.MaxLength > 0 && n > opts.MaxLength + errMsg := opts.Error + if errMsg == "" && over { + errMsg = fmt.Sprintf(msg(c, "text_area.over"), n-opts.MaxLength) + } + count := textAreaCount(c, n, opts.MaxLength) + 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). + Placeholder(opts.Placeholder).Label(opts.Label) + if opts.MinHeight > 0 { + in.MinHeight(opts.MinHeight) + } + if opts.MaxHeight > 0 { + in.MaxHeight(opts.MaxHeight) + } + r.changed = in.Changed() && !opts.ReadOnly + r.submitted = in.Shortcut(mods, key) + return in + }, count) + return r +} + +// textAreaCount builds the character count against max, or nil without one. +func textAreaCount(c *ui.Context, n, max int) func() { + if max == 0 { + return nil + } + return func() { + k := Tokens(c) + color := k.TextMuted + over := n > max + if over { + color = k.Danger + } + ui.Text(c, fmt.Sprintf("%d / %d", n, max)).TextColor(color).FontSize(theme.CaptionSize). + FontFeatures("tnum").Shrink(0).Label(fmt.Sprintf(msg(c, "text_area.count"), n, max)) + } +} diff --git a/text_area_test.go b/text_area_test.go new file mode 100644 index 0000000..4e51b59 --- /dev/null +++ b/text_area_test.go @@ -0,0 +1,94 @@ +package mujica + +import ( + "strings" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestTextAreaNewlinesAndSubmit(t *testing.T) { + s, submits := "", 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TextArea(c, &s, TextAreaOptions{Label: "Notes"}).Submitted() { + submits++ + } + }, 400, 300) + _ = tt.Click("Notes") + tt.Type("one") + tt.Key(0, ui.KeyEnter) + tt.Type("two") + if s != "one\ntwo" || submits != 0 { + t.Fatalf("value %q submits %d", s, submits) + } + tt.Key(ui.Cmd, ui.KeyEnter) + if submits != 1 || s != "one\ntwo" { + t.Fatalf("Cmd+Enter: value %q submits %d", s, submits) + } +} + +func TestTextAreaCustomSubmitKey(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, SubmitMods: ui.Ctrl | ui.Shift}).Submitted() { + submits++ + } + }, 400, 300) + _ = tt.Click("Chat") + tt.Type("hi") + tt.Key(ui.Cmd, ui.KeyEnter) + tt.Key(ui.Ctrl|ui.Shift, ui.KeyEnter) + if submits != 1 { + t.Fatalf("submits %d", submits) + } +} + +func TestTextAreaHeightsAndCount(t *testing.T) { + s := strings.Repeat("line\n", 40) + "end" + var h float32 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).AlignItems(ui.Start).Width(300).Children(func() { + r := TextArea(c, &s, TextAreaOptions{Label: "Log", MinHeight: 60, MaxHeight: 120, MaxLength: 100}) + h = r.Input.Bounds().H + }) + }, 400, 400) + tt.Frame() + if h != 120 { + t.Fatalf("height %v, want the 120 cap", h) + } + if !tt.HasText("203 of 100 characters") || !tt.HasText("103 characters over the limit") { + t.Fatalf("count missing: %q", tt.Texts()) + } + s = "ok" + tt.Frame() + if h < 60 || h >= 120 || !tt.HasText("2 of 100 characters") { + t.Fatalf("height %v texts %q", h, tt.Texts()) + } +} + +func TestTextAreaBadOptionsPanic(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic for MaxHeight below MinHeight") + } + }() + s := "" + ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TextArea(c, &s, TextAreaOptions{MinHeight: 100, MaxHeight: 50}) + }, 300, 200) +} + +func TestTextAreaSnapshot(t *testing.T) { + snapshot(t, "text_area", 420, 360, func(c *ui.Context) { + a, b, e := "", "Les lys d’or sur fond d’azur.\nUne cour, un bal, un serment.", "Trop long" + ui.Column(c).Padding(24).Gap(12).Width(360).Children(func() { + TextArea(c, &a, TextAreaOptions{Label: "A", Placeholder: "Write a dedication…"}) + TextArea(c, &b, TextAreaOptions{Label: "B", MaxLength: 120}) + TextArea(c, &e, TextAreaOptions{Label: "E", MinHeight: 40, MaxLength: 5}) + }) + }) +} diff --git a/text_input.go b/text_input.go new file mode 100644 index 0000000..07107cc --- /dev/null +++ b/text_input.go @@ -0,0 +1,172 @@ +package mujica + +import ( + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/internal" + "mujicaui/theme" +) + +// InputResult is a value input's outer element and its events. +type InputResult struct { + *ui.Element + // Input is the text field inside, for focus and keys. + Input *ui.Element + changed bool + submitted bool +} + +// Changed reports that the user changed the value this frame. +func (r InputResult) Changed() bool { return r.changed } + +// Submitted reports Enter, or the submit shortcut of a TextArea. +func (r InputResult) Submitted() bool { return r.submitted } + +// TextInputOptions configure a TextInput. +type TextInputOptions struct { + Placeholder string + // Label names the field for assistive technology. + Label string + // Leading and Trailing build content inside the field's edges. + Leading func() + Trailing func() + // Clearable shows a clear button while there is text; Escape clears too. + Clearable bool + Disabled bool + // ReadOnly keeps the text selectable and copyable but not editable. + ReadOnly bool + // Error marks the value invalid and shows the message below. + Error string +} + +// TextInput is a single-line field editing *value. +func TextInput(c *ui.Context, value *string, opts TextInputOptions) InputResult { + if value == nil { + panic("mujica: TextInput needs a value") + } + var r InputResult + look := &textInputLook{disabled: opts.Disabled, readOnly: opts.ReadOnly, err: opts.Error} + r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element { + if opts.Leading != nil { + opts.Leading() + } + in := textInputEditor(c, frame, value, opts.ReadOnly, false).Grow(1).MinWidth(Density(c).Unit() * 10). + Placeholder(opts.Placeholder).Label(opts.Label) + r.changed = in.Changed() && !opts.ReadOnly + r.submitted = in.Submitted() + if opts.Clearable && *value != "" && !opts.ReadOnly && !opts.Disabled { + if textInputClear(c, in) { + *value = "" + r.changed = true + slog.Debug("mujica: text_input clear") + } + } + if opts.Trailing != nil { + opts.Trailing() + } + return in + }, nil) + return r +} + +// textInputFade eases hover, focus and error colors. +var textInputFade = ui.ElementTransition{Colors: true, Duration: theme.StateDuration} + +// textInputLook is the state a field frame shows. +type textInputLook struct { + disabled, readOnly, area bool + err string +} + +// textInputFrame builds the bordered field around what build returns as the input. +func textInputFrame(c *ui.Context, look *textInputLook, build func(frame *ui.Element) *ui.Element, footer func()) (outer, in *ui.Element) { + k := Tokens(c) + d := Density(c) + outer = ui.Column(c).Gap(d.Unit()).Shrink(0).Disabled(look.disabled) + outer.Children(func() { + frame := ui.Row(c).Radius(theme.ControlRadius).Gap(d.Unit() * 2).Cursor(ui.CursorText) + if look.area { + frame.AlignItems(ui.Stretch) + } else { + frame.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3) + } + frame.Children(func() { in = build(frame) }) + in.FocusRing(false) + if look.err != "" { + in.Error(look.err) + } + if frame.Clicked() { + in.Focus() + } + bg, border := k.Surface, k.ControlBorder + switch { + case look.readOnly: + bg, border = k.Background, k.Border + case frame.Hovered(): + border = k.ControlBorder.Mix(k.Text, 0.35) + } + switch { + case look.err != "": + border = k.Danger + case in.Focused() && !look.readOnly: + border = k.Focus + } + frame.Background(bg).Border(theme.BorderWidth, border).Transition(textInputFade) + frame.DrawOver(func(p *ui.Painter, r ui.Rect) { + if in.FocusVisible() { + internal.FocusRing(p, r, theme.ControlRadius, k.Focus) + } + }) + if look.err == "" && footer == nil { + return + } + 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) + } else { + ui.Spacer(c) + } + if footer != nil { + footer() + } + }) + }) + 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 { + return base(c, value) + } + shown := ui.Local(frame, "readonly", func() string { return *value }) + *shown = *value + in := base(c, shown) + *shown = *value + return in +} + +// textInputClear builds the clear button and reports a clear by click or Escape. +func textInputClear(c *ui.Context, in *ui.Element) bool { + k := Tokens(c) + b := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Center().Radius(theme.ControlRadius). + Role(ui.RoleButton).Label(msg(c, "text_input.clear")).Cursor(ui.CursorPointer) + fg := k.TextMuted + if b.Hovered() { + b.Background(k.SurfaceHover) + fg = k.Text + } + b.Children(func() { ui.Icon(c, icons.Must("x")).Size(14, 14).TextColor(fg) }) + if b.Clicked() || in.Shortcut(0, ui.KeyEscape) { + in.Focus() + return true + } + return false +} diff --git a/text_input_test.go b/text_input_test.go new file mode 100644 index 0000000..8758dbf --- /dev/null +++ b/text_input_test.go @@ -0,0 +1,141 @@ +package mujica + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" +) + +func TestTextInputTypingAndSubmit(t *testing.T) { + name, changes, submits := "", 0, 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + r := TextInput(c, &name, TextInputOptions{Label: "Name", Placeholder: "Your name"}) + if r.Changed() { + changes++ + } + if r.Submitted() { + submits++ + } + }, 400, 200) + if err := tt.Click("Name"); err != nil { + t.Fatal(err) + } + tt.Type("Ada") + tt.Key(0, ui.KeyEnter) + if name != "Ada" || changes == 0 || submits != 1 { + t.Fatalf("name %q changes %d submits %d", name, changes, submits) + } +} + +func TestTextInputUndoClipboardIME(t *testing.T) { + s := "" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TextInput(c, &s, TextInputOptions{Label: "Note"}) + }, 400, 200) + _ = tt.Click("Note") + tt.Compose("にほ", 2) + if s != "" { + t.Fatalf("composition leaked into value: %q", s) + } + tt.Type("日本") + tt.Command("selectAll") + tt.Command("copy") + if tt.Clipboard() != "日本" { + t.Fatalf("clipboard %q", tt.Clipboard()) + } + tt.Type("x") + tt.Command("undo") + if s != "日本" { + t.Fatalf("after undo %q", s) + } +} + +func TestTextInputClear(t *testing.T) { + s, changes := "draft", 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if TextInput(c, &s, TextInputOptions{Label: "Draft", Clearable: true}).Changed() { + changes++ + } + }, 400, 200) + if err := tt.Click("Clear"); err != nil { + t.Fatal(err) + } + if s != "" || changes != 1 { + t.Fatalf("after clear %q, changes %d", s, changes) + } + if _, ok := tt.Find("Clear"); ok { + t.Fatal("clear button stays on an empty field") + } + tt.Type("again") + tt.Key(0, ui.KeyEscape) + if s != "" || changes != 3 { + t.Fatalf("after Escape %q, changes %d", s, changes) + } +} + +func TestTextInputReadOnlyAndDisabled(t *testing.T) { + ro, off := "fixed", "locked" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Column(c).Gap(8).Children(func() { + TextInput(c, &ro, TextInputOptions{Label: "RO", ReadOnly: true, Clearable: true}) + TextInput(c, &off, TextInputOptions{Label: "Off", Disabled: true}) + }) + }, 400, 200) + _ = tt.Click("RO") + tt.Command("selectAll") + tt.Command("copy") + tt.Type("zz") + tt.Key(0, ui.KeyBackspace) + if ro != "fixed" || tt.Clipboard() != "fixed" { + t.Fatalf("read-only value %q clipboard %q", ro, tt.Clipboard()) + } + if _, ok := tt.Find("Clear"); ok { + t.Fatal("read-only field offers clear") + } + _ = tt.Click("Off") + tt.Type("zz") + if off != "locked" || tt.Focused("Off") { + t.Fatalf("disabled value %q focused %v", off, tt.Focused("Off")) + } +} + +func TestTextInputSlotsAndError(t *testing.T) { + s := "x" + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + TextInput(c, &s, TextInputOptions{ + Label: "Price", + Leading: func() { ui.Text(c, "¥") }, + Trailing: func() { ui.Text(c, "CNY") }, + Error: "Not a price", + }) + }, 400, 200) + for _, want := range []string{"¥", "CNY", "Not a price"} { + if !tt.HasText(want) { + t.Fatalf("missing %q in %q", want, tt.Texts()) + } + } +} + +func TestTextInputSnapshot(t *testing.T) { + view := func(c *ui.Context) { + a, b, e, ro, dis := "", "Elisabeth", "1789-13-01", "Read only", "Disabled" + ui.Column(c).Padding(24).Gap(12).Width(360).Children(func() { + TextInput(c, &a, TextInputOptions{Placeholder: "Placeholder", Label: "A"}) + TextInput(c, &b, TextInputOptions{Label: "B", Clearable: true, Leading: func() { + ui.Icon(c, icons.Must("user")).Size(16, 16).TextColor(Tokens(c).TextMuted) + }}) + TextInput(c, &e, TextInputOptions{Label: "E", Error: "No such date"}) + TextInput(c, &ro, TextInputOptions{Label: "R", ReadOnly: true}) + TextInput(c, &dis, TextInputOptions{Label: "D", Disabled: true}) + }) + } + snapshot(t, "text_input", 420, 320, view) + buttonDensitySnapshots(t, "text_input", 420, 360, view) +}