Inputs: components 021-030
No files matched your search
@@ -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}
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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")})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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` 混合黑色得到,未单独定义变量。
|
||||
@@ -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,提示随之不出现;名称仍作为无障碍描述保留。
|
||||
@@ -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)。
|
||||
@@ -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 清空。
|
||||
@@ -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。
|
||||
@@ -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`),所以仅步进模式要求有界。
|
||||
@@ -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`),因此遮蔽与显示两种模式各用一个编辑器,切换时撤销历史和光标位置不保留,焦点留在切换按钮上。
|
||||
- 显示模式下内容是普通文本,可复制,辅助技术可朗读——这是用户主动选择的结果。
|
||||
@@ -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。
|
||||
- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。
|
||||
@@ -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` 未导出)。半选时组件在无障碍描述中加入“部分选中”,但角色状态仍报告为未选中。
|
||||
@@ -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`)。
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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 字"},
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 7.5 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||