Inputs: components 021-030

This commit is contained in:
ZacharyZhang-NY committed 2026-10-05 12:17:01 -04:00
1 parent a61ac6168a
commit 1b025b5fad
64 files changed
+3090 -11

No files matched your search

+124
View File
@@ -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}
}
}
+84
View File
@@ -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})
})
})
}
+47 -10
View File
@@ -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-<density>.
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")})
})
}
+134
View File
@@ -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
}
+104
View File
@@ -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)
}
+43
View File
@@ -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` 混合黑色得到,未单独定义变量。
+42
View File
@@ -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,提示随之不出现;名称仍作为无障碍描述保留。
+53
View File
@@ -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)。
+49
View File
@@ -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 清空。
+49
View File
@@ -0,0 +1,49 @@
# 025 TextArea 多行文本框
## 用途
多行文本编辑,可设最小与最大高度,超出最大高度后在框内滚动;可显示字数;提交快捷键可配置。编辑核心是 MyGo `ui.TextAreaBase`,只排版可见段落,适合长文本。
## 最小示例
```go
r := mujica.TextArea(c, &notes, 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。
+49
View File
@@ -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`),所以仅步进模式要求有界。
+42
View File
@@ -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`),因此遮蔽与显示两种模式各用一个编辑器,切换时撤销历史和光标位置不保留,焦点留在切换按钮上。
- 显示模式下内容是普通文本,可复制,辅助技术可朗读——这是用户主动选择的结果。
+51
View File
@@ -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: &current})
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。
- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。
+51
View File
@@ -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` 未导出)。半选时组件在无障碍描述中加入“部分选中”,但角色状态仍报告为未选中。
+52
View File
@@ -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`)。
+130
View File
@@ -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, &notes, 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: &current})
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)
}
+356 -1
View File
@@ -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, &notes, 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: &current})
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)
}
})
}},
}
+33
View File
@@ -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)
}
+78
View File
@@ -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})
})
})
}
+22
View File
@@ -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 字"},
})
}
+227
View File
@@ -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
}
+141
View File
@@ -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"})
})
})
}
+75
View File
@@ -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()
}
+101
View File
@@ -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"})
})
})
}
+90
View File
@@ -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()
}
+99
View File
@@ -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})
})
})
}
+140
View File
@@ -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)
}
}
+129
View File
@@ -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})
})
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+88
View File
@@ -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))
}
}
+94
View File
@@ -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})
})
})
}
+172
View File
@@ -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
}
+141
View File
@@ -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)
}