Merge branch 'main' into worktree-agent-a6a6a7e2b56f82ab2
This commit is contained in:
157 files changed
+2566
-710
No files matched your search
@@ -4,8 +4,8 @@ Ordered by PRD sections. Check an item only after its codex review passes. Revie
|
||||
|
||||
- [x] T01 §1–2 Module, MyGo v0.2.10 pin, reuse rules, AGENTS.md — r1 FAIL (line height, enum checks), r2 PASS
|
||||
- [x] T02 §3 Tokens, density, type, motion, contrast tests, `Use`, icons, i18n — r1 FAIL, r2 PASS
|
||||
- [ ] T03 §4.1 Layout 001–010
|
||||
- [ ] T04 §4.2 Display 011–020
|
||||
- [x] T03 §4.1 Layout 001–010 — r1 FAIL 8, r2 FAIL 2, r3 FAIL 1 (folded focus); fixed b34958b, verified by coordinator red/green after cap
|
||||
- [x] T04 §4.2 Display 011–020 — r1 FAIL 5, r2 FAIL 5, r3 FAIL 2 (image sizing, Retry clip); fixed e922651, verified by coordinator tests and snapshot after cap
|
||||
- [ ] T05 §4.3 Inputs 021–030
|
||||
- [ ] T06 §4.4 Choices 031–040
|
||||
- [ ] T07 §4.5 Forms 041–050
|
||||
|
||||
+13
-13
@@ -31,17 +31,18 @@ type AlertDialogResult struct {
|
||||
// alertDialogState is the typed confirmation, kept while the alert shows.
|
||||
type alertDialogState struct{ typed string }
|
||||
|
||||
// AlertDialog asks to confirm a destructive action while *open is true.
|
||||
// Cancel takes the focus, the backdrop ignores clicks, Escape cancels,
|
||||
// and Enter never confirms before ConfirmText is typed.
|
||||
// AlertDialog confirms a destructive action; Cancel has the focus.
|
||||
func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialogResult {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: AlertDialog needs a Title")
|
||||
}
|
||||
var res AlertDialogResult
|
||||
site, focus := overlaySite(c)
|
||||
if !*open {
|
||||
focus.closed(c, site)
|
||||
return res
|
||||
}
|
||||
overlayLayer(c, open)
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
confirmLabel, cancelLabel := opts.ConfirmLabel, opts.CancelLabel
|
||||
@@ -52,7 +53,7 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog
|
||||
cancelLabel = msg(c, "alert_dialog.cancel")
|
||||
}
|
||||
shown, backClicked := true, false
|
||||
ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
backClicked = back.Clicked()
|
||||
st := ui.Local(panel, "mujica.alert", func() alertDialogState { return alertDialogState{} })
|
||||
p := overlayEnter(c, panel)
|
||||
@@ -62,15 +63,16 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Shrink(0).Children(func() {
|
||||
ui.Icon(c, icons.Must("triangle-alert")).Size(22, 22).TextColor(k.Danger).Shrink(0).Margin(1, 0, 0, 0)
|
||||
ui.Column(c).Gap(u * 2).Grow(1).Children(func() {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600)
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600)
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
submitted := opts.ConfirmText != "" && alertDialogTyped(c, st, opts.ConfirmText)
|
||||
if opts.ConfirmText != "" {
|
||||
alertDialogTyped(c, st, opts.ConfirmText)
|
||||
}
|
||||
unlocked := opts.ConfirmText == "" || st.typed == opts.ConfirmText
|
||||
res.Confirmed = submitted && unlocked
|
||||
dialogActions(c, func() {
|
||||
if Button(c, cancelLabel, ButtonOptions{Variant: Secondary}).AutoFocus().Clicked() {
|
||||
res.Cancelled = true
|
||||
@@ -80,6 +82,7 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog
|
||||
}
|
||||
})
|
||||
})
|
||||
focus.shown(panel)
|
||||
if !shown && !backClicked {
|
||||
res.Cancelled = true // Escape
|
||||
}
|
||||
@@ -93,18 +96,15 @@ func AlertDialog(c *ui.Context, open *bool, opts AlertDialogOptions) AlertDialog
|
||||
return res
|
||||
}
|
||||
|
||||
// alertDialogTyped builds the confirmation field and reports Enter in it.
|
||||
func alertDialogTyped(c *ui.Context, st *alertDialogState, want string) bool {
|
||||
// alertDialogTyped builds the confirmation field; Enter in it never confirms, even mid-composition.
|
||||
func alertDialogTyped(c *ui.Context, st *alertDialogState, want string) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
prompt := fmt.Sprintf(msg(c, "alert_dialog.type_to_unlock"), want)
|
||||
submitted := false
|
||||
ui.Column(c).Gap(d.Unit()).Shrink(0).Children(func() {
|
||||
ui.Text(c, prompt).FontSize(theme.CaptionSize).TextColor(k.TextMuted)
|
||||
ui.Text(c, prompt).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted)
|
||||
in := ui.TextInputBase(c, &st.typed).Label(prompt).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3).
|
||||
Radius(theme.ControlRadius).Background(k.Surface).Border(theme.BorderWidth, k.ControlBorder)
|
||||
internal.Ring(in, k.Focus, theme.ControlRadius)
|
||||
submitted = in.Submitted()
|
||||
})
|
||||
return submitted
|
||||
}
|
||||
@@ -86,11 +86,33 @@ func TestAlertDialogConfirmTextGatesEnter(t *testing.T) {
|
||||
}
|
||||
tt.Type("e")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 0 || !a.open {
|
||||
t.Fatalf("Enter in the field confirmed: %d", a.confirmed)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Delete") {
|
||||
t.Fatal("Delete not reachable once unlocked")
|
||||
}
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 1 || a.open {
|
||||
t.Fatalf("typed target: confirmed %d open %v", a.confirmed, a.open)
|
||||
}
|
||||
}
|
||||
|
||||
// MyGo commits a composition on Enter and then handles the key (ui/editor.go:688).
|
||||
func TestAlertDialogEnterDuringCompositionDoesNotConfirm(t *testing.T) {
|
||||
a, tt := newAlertApp(AlertDialogOptions{ConfirmText: "archive"})
|
||||
if err := tt.Click("Type “archive” to confirm"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Compose("archive", 7)
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if a.confirmed != 0 || !a.open {
|
||||
t.Fatalf("Enter while composing confirmed: %d", a.confirmed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertDialogSnapshot(t *testing.T) {
|
||||
snapshot(t, "alert_dialog", 560, 320, func(c *ui.Context) {
|
||||
open := true
|
||||
|
||||
@@ -135,6 +135,7 @@ func breadcrumbMore(c *ui.Context, hidden []BreadcrumbItem, choose func(int)) {
|
||||
}
|
||||
b.Children(func() { ui.Icon(c, icons.Must("ellipsis")).Size(16, 16) })
|
||||
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
|
||||
internal.PopIn(c, panel, Motion(c, theme.PopupDuration))
|
||||
panel.Margin(u, 0, 0, 0).Padding(u).Gap(u*0.5).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(160).MaxWidth(320)
|
||||
for j, it := range hidden {
|
||||
|
||||
@@ -29,6 +29,9 @@ type ButtonOptions struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// buttonSpinnerSize is the leading icon slot, which the spinner takes while loading.
|
||||
const buttonSpinnerSize float32 = 16
|
||||
|
||||
// Button is a push button. Loading keeps its size and blocks clicks.
|
||||
func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
|
||||
if label == "" && opts.Label == "" {
|
||||
@@ -36,8 +39,13 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
|
||||
}
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
pad, gap := d.Unit()*4, d.Unit()*2
|
||||
if opts.Loading && opts.Icon == nil && label != "" {
|
||||
// The spinner slot comes out of the padding, so the width holds.
|
||||
pad -= (buttonSpinnerSize + gap) / 2
|
||||
}
|
||||
b := ui.ButtonBase(c).MinHeight(d.ControlHeight()).MinWidth(theme.MinHitSize).
|
||||
Padding(0, d.Unit()*4).Gap(d.Unit() * 2).Radius(theme.ControlRadius).
|
||||
Padding(0, pad).Gap(gap).Radius(theme.ControlRadius).
|
||||
Disabled(opts.Disabled || opts.Loading)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if opts.Label != "" {
|
||||
@@ -49,25 +57,13 @@ func Button(c *ui.Context, label string, opts ButtonOptions) *ui.Element {
|
||||
b.Border(theme.BorderWidth, border)
|
||||
}
|
||||
b.Children(func() {
|
||||
switch {
|
||||
case opts.Loading && opts.Icon != nil:
|
||||
// The spinner takes the icon's 16 DIP slot.
|
||||
Spinner(c, SpinnerOptions{Size: 16, Color: fg})
|
||||
case opts.Loading && label == "":
|
||||
Spinner(c, SpinnerOptions{Size: 16, Color: fg})
|
||||
case opts.Loading:
|
||||
// Over the hidden label, so the width stays.
|
||||
ui.Box(c).Absolute().Left(0).Top(0).Right(0).Bottom(0).Center().Children(func() {
|
||||
Spinner(c, SpinnerOptions{Size: 16, Color: fg})
|
||||
})
|
||||
case opts.Icon != nil:
|
||||
ui.Icon(c, opts.Icon).Size(16, 16)
|
||||
if opts.Loading {
|
||||
Spinner(c, SpinnerOptions{Size: buttonSpinnerSize, Color: fg})
|
||||
} else if opts.Icon != nil {
|
||||
ui.Icon(c, opts.Icon).Size(buttonSpinnerSize, buttonSpinnerSize)
|
||||
}
|
||||
if label != "" {
|
||||
t := ui.Text(c, label).SingleLine()
|
||||
if opts.Loading && opts.Icon == nil {
|
||||
t.TextColor(fg.Alpha(0))
|
||||
}
|
||||
ui.Text(c, label).SingleLine()
|
||||
}
|
||||
})
|
||||
return b
|
||||
|
||||
+8
-6
@@ -43,8 +43,7 @@ type CascaderLevel struct {
|
||||
type CascaderOptions struct {
|
||||
// Level returns the children under path; the empty path is the roots.
|
||||
Level func(path []string) CascaderLevel
|
||||
// Load fetches the children under path, once while Idle and on retry; it
|
||||
// should mark the level Loading and finish through Window.Update.
|
||||
// Load fetches the children under path while Idle and on retry; it marks the level Loading.
|
||||
Load func(path []string)
|
||||
// AnyLevel lets branches be chosen too; by default only leaves are.
|
||||
AnyLevel bool
|
||||
@@ -66,9 +65,7 @@ type cascaderState struct {
|
||||
|
||||
type cascaderKey struct{}
|
||||
|
||||
// Cascader chooses a path through levels the caller supplies, loading
|
||||
// children on demand. Up and Down move in a column, Right opens a branch,
|
||||
// Left goes back, Enter or Space chooses; Escape closes.
|
||||
// Cascader chooses a path through levels the caller supplies, loading children on demand.
|
||||
func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult {
|
||||
if opts.Level == nil {
|
||||
panic("mujica: Cascader needs Level")
|
||||
@@ -79,6 +76,9 @@ func Cascader(c *ui.Context, path *[]string, opts CascaderOptions) ChoiceResult
|
||||
tr.Label(opts.Label)
|
||||
}
|
||||
st := *ui.Local(tr, cascaderKey{}, func() *cascaderState { return &cascaderState{requested: map[string]bool{}} })
|
||||
if opts.Disabled {
|
||||
st.open = false
|
||||
}
|
||||
if tr.Clicked() || (!st.open && tr.Shortcut(0, ui.KeyDown)) {
|
||||
st.open = !st.open
|
||||
if st.open {
|
||||
@@ -186,7 +186,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader
|
||||
d := Density(c)
|
||||
full := append(slices.Clone(prefix), n.Value)
|
||||
depth := len(prefix)
|
||||
b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2).
|
||||
b := ui.ButtonBase(c).Key(n.Value).Disabled(n.Disabled).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||
Gap(d.Unit() * 2).Justify(ui.Start).Radius(theme.ControlRadius).FillWidth().Role(ui.RoleListItem)
|
||||
internal.Ring(b, k.Focus, theme.ControlRadius)
|
||||
if st.focusSet && (slices.Equal(st.focus, full) || first && slices.Equal(st.focus, append(slices.Clone(prefix), ""))) {
|
||||
@@ -214,6 +214,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader
|
||||
st.focus, st.focusSet = slices.Clone(prefix), true
|
||||
}
|
||||
chosen := len(*path) > depth && slices.Equal((*path)[:depth+1], full)
|
||||
b.Selected(chosen).Background(ui.Transparent).TextColor(k.Text)
|
||||
switch {
|
||||
case on:
|
||||
b.Background(k.Selection)
|
||||
@@ -224,6 +225,7 @@ func cascaderRow(c *ui.Context, st *cascaderState, path *[]string, opts Cascader
|
||||
b.TextColor(k.AccentText)
|
||||
}
|
||||
b.Children(func() {
|
||||
selectCheck(c, chosen)
|
||||
ui.Text(c, n.Label).SingleLine().Grow(1).Shrink(1)
|
||||
if !n.Leaf {
|
||||
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ func Checkbox(c *ui.Context, value *bool, label string, opts CheckboxOptions) *u
|
||||
wrap.Children(func() {
|
||||
row = ui.CheckboxBase(c, value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false)
|
||||
})
|
||||
if opts.Disabled && wrap.FocusWithin() {
|
||||
if wrap.IsDisabled() && wrap.FocusWithin() {
|
||||
wrap.FocusRing(false).Focus()
|
||||
}
|
||||
if row.Changed() && opts.Indeterminate && !*value {
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// choiceBetween builds control between two buttons, for Tab order checks.
|
||||
func choiceBetween(control func(c *ui.Context)) *ui.Tester {
|
||||
return ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(10).Gap(10).Width(300).Children(func() {
|
||||
Button(c, "Before", ButtonOptions{})
|
||||
control(c)
|
||||
Button(c, "After", ButtonOptions{})
|
||||
})
|
||||
}, 400, 400)
|
||||
}
|
||||
|
||||
// choiceTabSkips reports whether Tab from Before lands on After.
|
||||
func choiceTabSkips(t *testing.T, tt *ui.Tester) bool {
|
||||
t.Helper()
|
||||
if err := tt.Click("Before"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
return tt.Focused("After")
|
||||
}
|
||||
|
||||
func TestToggleDisabled(t *testing.T) {
|
||||
for _, disabled := range []bool{false, true} {
|
||||
on := false
|
||||
tt := choiceBetween(func(c *ui.Context) {
|
||||
Toggle(c, &on, "Italic", ToggleOptions{Disabled: disabled})
|
||||
})
|
||||
_ = tt.Click("Italic")
|
||||
if on == disabled {
|
||||
t.Fatalf("disabled=%v: click left on=%v", disabled, on)
|
||||
}
|
||||
if choiceTabSkips(t, tt) != disabled {
|
||||
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliderDisabled(t *testing.T) {
|
||||
for _, disabled := range []bool{false, true} {
|
||||
v := 30.0
|
||||
tt := choiceBetween(func(c *ui.Context) {
|
||||
Slider(c, &v, SliderOptions{Step: 1, Disabled: disabled, Label: "Volume"})
|
||||
})
|
||||
_ = tt.Click("Volume")
|
||||
if (v != 30) == disabled {
|
||||
t.Fatalf("disabled=%v: click left %v", disabled, v)
|
||||
}
|
||||
if choiceTabSkips(t, tt) != disabled {
|
||||
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeSliderDisabled(t *testing.T) {
|
||||
for _, disabled := range []bool{false, true} {
|
||||
low, high := 20.0, 40.0
|
||||
tt := choiceBetween(func(c *ui.Context) {
|
||||
RangeSlider(c, &low, &high, RangeSliderOptions{Step: 1, Disabled: disabled, Label: "Price"})
|
||||
})
|
||||
_ = tt.Click("Price")
|
||||
if (high != 40) == disabled || low != 20 {
|
||||
t.Fatalf("disabled=%v: click left %v..%v", disabled, low, high)
|
||||
}
|
||||
if choiceTabSkips(t, tt) != disabled {
|
||||
t.Fatalf("disabled=%v: Tab skip wrong", disabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Disabling a focused control in the frame a key arrives must block the key.
|
||||
func TestChoiceDisabledWhileFocused(t *testing.T) {
|
||||
disabled := false
|
||||
on, pressed, group := false, false, []string{"left"}
|
||||
items := []ToggleItem[string]{{Value: "left", Label: "Left"}, {Value: "right", Label: "Right"}}
|
||||
for name, build := range map[string]func(c *ui.Context){
|
||||
"switch": func(c *ui.Context) { Switch(c, &on, "Sync", SwitchOptions{Disabled: disabled}) },
|
||||
"toggle": func(c *ui.Context) { Toggle(c, &pressed, "Bold", ToggleOptions{Disabled: disabled}) },
|
||||
"group": func(c *ui.Context) {
|
||||
items[1].Disabled = disabled
|
||||
ToggleGroup(c, &group, items, ToggleGroupOptions{})
|
||||
},
|
||||
} {
|
||||
disabled, on, pressed, group = false, false, false, []string{"left"}
|
||||
tt := choiceBetween(build)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if name == "group" {
|
||||
tt.Key(0, ui.KeyRight)
|
||||
}
|
||||
disabled = true
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if on || pressed || group[0] != "left" {
|
||||
t.Errorf("%s: Space changed a disabled control: %v %v %v", name, on, pressed, group)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/internal"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// A popup left open must stop taking choices once its control is disabled.
|
||||
func TestChoicePopupsCloseWhenDisabled(t *testing.T) {
|
||||
disabled := false
|
||||
var multi []int
|
||||
var paths, ids []string
|
||||
f := newCascaderFake()
|
||||
cases := []struct {
|
||||
name, open, pick string
|
||||
build func(c *ui.Context)
|
||||
same func() bool
|
||||
}{
|
||||
{"multi", "Select…", "Lily", func(c *ui.Context) {
|
||||
MultiSelect(c, &multi, multiSelectTestOptions, MultiSelectOptions{Disabled: disabled})
|
||||
}, func() bool { return len(multi) == 0 }},
|
||||
{"cascader", "Select…", "Europe", func(c *ui.Context) {
|
||||
Cascader(c, &paths, CascaderOptions{Level: f.level, Load: f.load, AnyLevel: true, Disabled: disabled})
|
||||
}, func() bool { return len(paths) == 0 }},
|
||||
{"tree", "Select…", "Guard", func(c *ui.Context) {
|
||||
TreeSelect(c, &ids, treeSelectTestNodes, TreeSelectOptions{Disabled: disabled})
|
||||
}, func() bool { return len(ids) == 0 }},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
disabled = false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(10).Children(func() { tc.build(c) })
|
||||
}, 700, 500)
|
||||
if err := tt.Click(tc.open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.HasText(tc.pick) {
|
||||
t.Fatalf("%s: popup did not open", tc.name)
|
||||
}
|
||||
disabled = true
|
||||
tt.Frame()
|
||||
_ = tt.Click(tc.pick)
|
||||
if !tc.same() || tt.HasText(tc.pick) {
|
||||
t.Errorf("%s: disabled control took a choice or kept its popup", tc.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeSelectSingleMarksOwnNode(t *testing.T) {
|
||||
single := TreeSelectOptions{}
|
||||
court := &treeSelectTestNodes[0]
|
||||
if got := treeSelectCheck(court, []string{"court"}, single); got != internal.Checked {
|
||||
t.Fatalf("chosen parent shows %v", got)
|
||||
}
|
||||
if got := treeSelectCheck(court, []string{"king", "queen"}, single); got != internal.Unchecked {
|
||||
t.Fatalf("parent of chosen leaves shows %v", got)
|
||||
}
|
||||
var sel []string
|
||||
changes := 0
|
||||
tt := treeSelectTester(&sel, single, &changes)
|
||||
_ = tt.Click("Select…")
|
||||
_ = tt.Click("Court")
|
||||
if !slices.Equal(sel, []string{"court"}) {
|
||||
t.Fatalf("sel %v", sel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTreeSelectRightSkipsHiddenChildren(t *testing.T) {
|
||||
var sel []string
|
||||
changes := 0
|
||||
tt := treeSelectTester(&sel, TreeSelectOptions{Multiple: true}, &changes)
|
||||
_ = tt.Click("Select…")
|
||||
tt.Type("queen")
|
||||
tt.Key(0, ui.KeyDown)
|
||||
if !tt.Focused("Court") {
|
||||
t.Fatal("Down did not reach Court")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.Focused("Queen") {
|
||||
t.Fatal("Right did not reach the only shown child")
|
||||
}
|
||||
}
|
||||
|
||||
// pixel reads the light theme color at (x, y) of the last frame.
|
||||
func pixel(tt *ui.Tester, x, y float32) color.RGBA {
|
||||
return tt.Image().RGBAAt(int(x), int(y))
|
||||
}
|
||||
|
||||
func TestSelectChosenRowHasSelectionFill(t *testing.T) {
|
||||
v, changes := "banana", 0
|
||||
tt := selectTester(&v, selectTestOptions, SelectOptions{}, &changes)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Move(0, 0)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
blue, ok := tt.Find("Blueberry")
|
||||
if !ok {
|
||||
t.Fatal("popup closed")
|
||||
}
|
||||
h := theme.Default.ControlHeight()
|
||||
got := pixel(tt, blue.X+blue.W+20, blue.Y+blue.H/2-h)
|
||||
s := theme.Light().Selection
|
||||
if got.R != s.R || got.G != s.G || got.B != s.B {
|
||||
t.Fatalf("chosen row %v, want selection %v", got, s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoiceHitAreas(t *testing.T) {
|
||||
v, changes := "apple", 0
|
||||
tt := selectTester(&v, selectTestOptions, SelectOptions{Clearable: true}, &changes)
|
||||
if r, _ := tt.Find("Clear"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
|
||||
t.Errorf("clear button %v", r)
|
||||
}
|
||||
var sel []string
|
||||
tt = treeSelectTester(&sel, TreeSelectOptions{}, &changes)
|
||||
_ = tt.Click("Select…")
|
||||
if r, _ := tt.Find("Expand Court"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
|
||||
t.Errorf("tree arrow %v", r)
|
||||
}
|
||||
font := ""
|
||||
tt = ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Combobox(c, &font, comboboxTestFonts, ComboboxOptions{})
|
||||
}, 400, 300)
|
||||
if r, _ := tt.Find("Show options"); r.W < theme.MinHitSize || r.H < theme.MinHitSize {
|
||||
t.Errorf("combobox arrow %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoiceRowsFollowDensity(t *testing.T) {
|
||||
for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} {
|
||||
v, on := "apple", false
|
||||
var sw *ui.Element
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Density: d})
|
||||
ui.Column(c).Padding(10).AlignItems(ui.Start).Children(func() {
|
||||
sw = Switch(c, &on, "Wi-Fi", SwitchOptions{})
|
||||
Select(c, &v, selectTestOptions, SelectOptions{Label: "Fruit"})
|
||||
})
|
||||
}, 400, 500)
|
||||
if h := sw.Bounds().H; h < d.ControlHeight() {
|
||||
t.Errorf("%v: switch %v high", d, h)
|
||||
}
|
||||
_ = tt.Click("Fruit")
|
||||
a, _ := tt.Find("Cherry")
|
||||
b, _ := tt.Find("Date")
|
||||
if b.Y-a.Y < d.ControlHeight() {
|
||||
t.Errorf("%v: option rows %v apart", d, b.Y-a.Y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultiSelectCaptionFollowsTextScale(t *testing.T) {
|
||||
height := func(scale float32) float32 {
|
||||
values := []int{1}
|
||||
tt := multiSelectTester(&values, MultiSelectOptions{Max: 2}, new(int))
|
||||
tt.SetPreferences(ui.Preferences{TextScale: scale})
|
||||
_ = tt.Click("Rose")
|
||||
r, ok := tt.Find("1 of 2 chosen")
|
||||
if !ok {
|
||||
t.Fatal("limit note missing")
|
||||
}
|
||||
return r.H
|
||||
}
|
||||
if one, two := height(1), height(2); two < one*1.5 {
|
||||
t.Fatalf("caption %v high at 2x, %v at 1x", two, one)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComboboxCaretAfterChoice(t *testing.T) {
|
||||
r := &comboboxRun{}
|
||||
tt := comboboxTester(r, fixedFonts, ComboboxOptions{})
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Type("di")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if r.value != "Didot" {
|
||||
t.Fatalf("value %q", r.value)
|
||||
}
|
||||
tt.Type("x")
|
||||
if r.query != "Didotx" {
|
||||
t.Fatalf("typing after a choice gave %q", r.query)
|
||||
}
|
||||
}
|
||||
|
||||
// The first Tab enters a toggle group at its first enabled item; later at the last focused one.
|
||||
func TestToggleGroupTabEntry(t *testing.T) {
|
||||
value := []string{"right"}
|
||||
tt := choiceBetween(func(c *ui.Context) {
|
||||
ToggleGroup(c, &value, toggleGroupTestItems, ToggleGroupOptions{})
|
||||
})
|
||||
_ = tt.Click("Before")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Left") {
|
||||
t.Fatal("first Tab did not land on the first item")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
_ = tt.Click("Before")
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Center") {
|
||||
t.Fatal("Tab did not return to the last focused item")
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -91,13 +91,13 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
|
||||
st.sync(*color)
|
||||
}
|
||||
}
|
||||
if opts.Disabled {
|
||||
if trigger.IsDisabled() {
|
||||
st.open = false
|
||||
} else if trigger.Clicked() {
|
||||
st.open = !st.open
|
||||
}
|
||||
border := k.ControlBorder
|
||||
if trigger.Hovered() && !opts.Disabled {
|
||||
if trigger.Hovered() {
|
||||
border = k.ControlBorder.Mix(k.Text, 0.25)
|
||||
trigger.Background(k.SurfaceHover)
|
||||
}
|
||||
@@ -160,7 +160,7 @@ func ColorPicker(c *ui.Context, color *ui.Color, opts ColorPickerOptions) ColorP
|
||||
}
|
||||
})
|
||||
})
|
||||
if st.wasOpen && !st.open && !opts.Disabled {
|
||||
if st.wasOpen && !st.open && !trigger.IsDisabled() {
|
||||
trigger.Focus()
|
||||
}
|
||||
st.wasOpen = st.open
|
||||
@@ -212,10 +212,12 @@ func colorPickerSquare(c *ui.Context, st *colorPickerState, set func(colorPicker
|
||||
// colorPickerSlider is a channel slider from 0 to hi over the track track paints.
|
||||
func colorPickerSlider(c *ui.Context, v *float64, hi float64, name string, track func(p *ui.Painter, r ui.Rect)) *ui.Element {
|
||||
k := Tokens(c)
|
||||
const knob = 10
|
||||
s := ui.SliderBase(c, v, 0, hi).Height(18).PaddingX(knob / 2).FocusRing(false).Label(name)
|
||||
const knob, look = 10, 18
|
||||
s := ui.SliderBase(c, v, 0, hi).Height(theme.MinHitSize).PaddingX(knob / 2).FocusRing(false).Label(name)
|
||||
frac := float32(*v / hi)
|
||||
s.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
// The hit area is taller than the drawn slider, which sits in its middle.
|
||||
r = ui.Rect{X: r.X, Y: r.Y + (r.H-look)/2, W: r.W, H: look}
|
||||
bar := ui.Rect{X: r.X, Y: r.Y + 3, W: r.W, H: r.H - 6}
|
||||
p.Clip(bar, theme.ControlRadius, func() { track(p, bar) })
|
||||
p.Stroke(bar, k.Border, theme.ControlRadius, theme.BorderWidth)
|
||||
|
||||
+17
-1
@@ -4,6 +4,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func colorPickerTester(col *ui.Color, opts ColorPickerOptions, changes *int) *ui.Tester {
|
||||
@@ -111,7 +113,7 @@ func TestColorPickerDisabledAndParse(t *testing.T) {
|
||||
|
||||
func TestColorPickerSnapshot(t *testing.T) {
|
||||
a, b := ui.Hex("#7D2034"), ui.Hex("#355E8099")
|
||||
snapshot(t, "color_picker", 340, 520, func(c *ui.Context) {
|
||||
snapshot(t, "color_picker", 340, 560, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Gap(12).AlignItems(ui.Start).Children(func() {
|
||||
ColorPicker(c, &b, ColorPickerOptions{Disabled: true})
|
||||
p := ColorPicker(c, &a, ColorPickerOptions{})
|
||||
@@ -157,3 +159,17 @@ func TestColorPickerSwatchHitArea(t *testing.T) {
|
||||
t.Fatalf("swatch %v", r)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorPickerSliderHitAreaCompact(t *testing.T) {
|
||||
col := ui.Hex("#7D2034")
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Density: theme.Compact})
|
||||
ColorPicker(c, &col, ColorPickerOptions{Label: "Accent"})
|
||||
}, 400, 560)
|
||||
_ = tt.Click("Accent")
|
||||
for _, name := range []string{"Hue", "Opacity"} {
|
||||
if r, ok := tt.Find(name); !ok || r.H < 28 {
|
||||
t.Fatalf("%s slider %v", name, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-6
@@ -41,9 +41,7 @@ func (r ComboboxResult) QueryChanged() bool { return r.queryChanged }
|
||||
// comboboxKey keeps this file's state apart from MyGo's own.
|
||||
type comboboxKey struct{ name string }
|
||||
|
||||
// Combobox chooses *value by typing to filter options and picking one, as
|
||||
// an autocomplete does. Up and Down move, Enter or a click chooses, Escape
|
||||
// closes. With AllowCustom, Enter or leaving the field keeps typed text.
|
||||
// Combobox chooses *value by typing to filter options and picking one, or typed text with AllowCustom.
|
||||
func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptions) ComboboxResult {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
@@ -52,9 +50,21 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio
|
||||
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Background(k.Surface).Cursor(ui.CursorText).Disabled(opts.Disabled)
|
||||
query := ui.Local(f, comboboxKey{"query"}, func() string { return *value })
|
||||
wasFocused := ui.Local(f, comboboxKey{"focused"}, func() bool { return false })
|
||||
// MyGo keeps the caret on replaced text (ui/editor.go:117): a choice builds a fresh input.
|
||||
gen := ui.Local(f, comboboxKey{"input"}, func() int { return 0 })
|
||||
refocus := ui.Local(f, comboboxKey{"refocus"}, func() bool { return false })
|
||||
var p *ui.ComboboxParts
|
||||
f.Children(func() {
|
||||
p = ui.ComboboxBase(c, query)
|
||||
ui.Row(c).Key(*gen).Grow(1).Children(func() {
|
||||
p = ui.ComboboxBase(c, choiceTarget(query, opts.Disabled))
|
||||
})
|
||||
if *refocus {
|
||||
p.Input.Focus()
|
||||
*refocus = false
|
||||
}
|
||||
if opts.Disabled {
|
||||
p.SetOpen(false)
|
||||
}
|
||||
pad := max(0, (d.ControlHeight()-2-theme.BodySize*theme.LineHeight)/2)
|
||||
p.Input.Grow(1).MinWidth(80).Padding(pad, d.Unit()*3).Placeholder(opts.Placeholder)
|
||||
if opts.Label != "" {
|
||||
@@ -63,13 +73,16 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio
|
||||
if opts.Loading {
|
||||
Spinner(c, SpinnerOptions{Size: 14})
|
||||
}
|
||||
arrow := ui.Box(c).Size(16, 16).Shrink(0).Cursor(ui.CursorPointer)
|
||||
arrow := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Shrink(0).Cursor(ui.CursorPointer).
|
||||
Role(ui.RoleButton).Label(msg(c, "combobox.toggle"))
|
||||
if arrow.Clicked() {
|
||||
p.SetOpen(!p.Open())
|
||||
p.Input.Focus()
|
||||
}
|
||||
open := p.Open()
|
||||
arrow.Draw(func(pt *ui.Painter, r ui.Rect) { internal.Mark(pt, r, selectChevron(open), k.TextMuted) })
|
||||
arrow.Draw(func(pt *ui.Painter, r ui.Rect) {
|
||||
internal.Mark(pt, selectCenter(r, 16), selectChevron(open), k.TextMuted)
|
||||
})
|
||||
})
|
||||
in := p.Input
|
||||
if f.Clicked() {
|
||||
@@ -116,11 +129,13 @@ func Combobox(c *ui.Context, value *string, options []string, opts ComboboxOptio
|
||||
focused := in.Focused()
|
||||
if v, ok := p.Chosen(); ok {
|
||||
*value, *query = v, v
|
||||
*gen, *refocus = *gen+1, focused
|
||||
} else if in.Submitted() && opts.AllowCustom {
|
||||
*value = strings.TrimSpace(*query)
|
||||
} else if in.Submitted() && p.Open() && len(shown) > 0 {
|
||||
// Results came after typing left nothing highlighted: Enter takes the first.
|
||||
*value, *query = shown[0], shown[0]
|
||||
*gen, *refocus = *gen+1, true
|
||||
p.SetOpen(false)
|
||||
} else if *wasFocused && !focused {
|
||||
if opts.AllowCustom {
|
||||
|
||||
+2
-1
@@ -81,6 +81,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP
|
||||
res.Element = ui.DialogBase(c, open, func(back, panel *ui.Element) {
|
||||
ww, wh := c.Size()
|
||||
back.Background(ui.RGBA(0, 0, 0, 0.4)).Justify(ui.Start)
|
||||
internal.PopIn(c, back, Motion(c, theme.PopupDuration))
|
||||
panel.Margin(wh*0.12, 0, 0, 0).Width(560).MaxWidth(ww-32).MaxHeight(wh*0.76).
|
||||
Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Shadow(0, 12, 32, 0, ui.RGBA(0, 0, 0, 0.3)).AlignItems(ui.Stretch).Label(msg(c, "command_palette.label"))
|
||||
@@ -92,7 +93,7 @@ func CommandPalette(c *ui.Context, open *bool, commands []Command, opts CommandP
|
||||
})
|
||||
ui.Box(c).Height(1).Background(k.Ornament).Role(ui.RoleNone)
|
||||
if in.Changed() {
|
||||
st.hl = 0
|
||||
st.hl, st.reveal = 0, true
|
||||
}
|
||||
n := len(found)
|
||||
switch {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -100,6 +101,28 @@ func TestCommandPaletteAnnouncesHighlight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteFilterRevealsHighlight(t *testing.T) {
|
||||
var cmds []Command
|
||||
for i := range 30 {
|
||||
cmds = append(cmds, Command{ID: fmt.Sprint(i), Label: fmt.Sprintf("Command %02d", i), Keywords: []string{"royal"}})
|
||||
}
|
||||
open := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
CommandPalette(c, &open, cmds, CommandPaletteOptions{})
|
||||
}, 800, 600)
|
||||
tt.Key(0, ui.KeyUp) // wraps to the last, scrolling to the bottom
|
||||
tt.Frame()
|
||||
if r, _ := tt.Find("Command 00"); r.H > 0 {
|
||||
t.Fatalf("list did not scroll to the end: first row at %+v", r)
|
||||
}
|
||||
tt.Type("royal")
|
||||
tt.Frame()
|
||||
if r, _ := tt.Find("Command 00"); r.H == 0 {
|
||||
t.Fatal("highlighted first match is off screen after filtering")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandPaletteEmptyClickEscape(t *testing.T) {
|
||||
a := &paletteApp{open: true}
|
||||
tt := ui.NewTester(a.view, 800, 600)
|
||||
|
||||
+1
-5
@@ -17,11 +17,7 @@ type contextMenuState struct {
|
||||
x, y float32
|
||||
}
|
||||
|
||||
// ContextMenu gives target an in-window menu that build fills. A secondary
|
||||
// click opens it at the pointer; the menu key or Shift+F10, while the focus
|
||||
// is in target, opens it below target. It flips to stay in the window, and
|
||||
// the focus returns on close. target must be a container: the menu adds an
|
||||
// anchor child to it, and takes its HandleInput. It returns target.
|
||||
// ContextMenu gives the container target an in-window menu at the pointer or below the focus.
|
||||
func ContextMenu(c *ui.Context, target *ui.Element, opts ContextMenuOptions, build func(m *PopupMenu)) *ui.Element {
|
||||
if target == nil {
|
||||
panic("mujica: ContextMenu needs a target")
|
||||
|
||||
+10
-5
@@ -92,14 +92,19 @@ func TestContextMenuDisabled(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestContextMenuSnapshot(t *testing.T) {
|
||||
snapshot(t, "context_menu", 460, 320, func(c *ui.Context) {
|
||||
a := &menuApp{}
|
||||
a := &menuApp{}
|
||||
overlaySnapshot(t, "context_menu", 460, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).Children(func() {
|
||||
row := ui.Row(c).Height(36).FillWidth().Padding(0, 12).AlignItems(ui.Center).Background(Tokens(c).Selection)
|
||||
row.Children(func() { ui.Text(c, "Letter to the Countess") })
|
||||
anchor := ui.Box(c).Absolute().Left(140).Top(48).Size(1, 1)
|
||||
open := true
|
||||
popupMenuOpen(c, anchor, &open, a.build)
|
||||
ContextMenu(c, row, ContextMenuOptions{}, a.build)
|
||||
})
|
||||
}, func(tt *ui.Tester) {
|
||||
tt.RightClickAt(160, 40)
|
||||
r, ok := tt.Find("Pinned")
|
||||
if !ok {
|
||||
t.Fatal("menu did not open")
|
||||
}
|
||||
tt.Move(r.X+4, r.Y+4) // hover highlight on Pinned
|
||||
})
|
||||
}
|
||||
@@ -28,15 +28,17 @@ type DialogOptions struct {
|
||||
HideClose bool
|
||||
}
|
||||
|
||||
// Dialog shows a modal dialog while *open is true. It traps the focus,
|
||||
// makes the window inert and gives the focus back to the opener on close.
|
||||
// Dialog shows a modal dialog while *open is true, trapping and then restoring the focus.
|
||||
func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.Element {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: Dialog needs a Title")
|
||||
}
|
||||
site, focus := overlaySite(c)
|
||||
if !*open {
|
||||
focus.closed(c, site)
|
||||
return nil
|
||||
}
|
||||
overlayLayer(c, open)
|
||||
k := Tokens(c)
|
||||
shown, backClicked, closed := true, false, false
|
||||
panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
@@ -56,6 +58,7 @@ func Dialog(c *ui.Context, open *bool, opts DialogOptions, content func()) *ui.E
|
||||
dialogActions(c, opts.Actions)
|
||||
}
|
||||
})
|
||||
focus.shown(panel)
|
||||
switch {
|
||||
case closed:
|
||||
slog.Debug("mujica: dialog close", "by", "button")
|
||||
@@ -106,7 +109,7 @@ func dialogHeader(c *ui.Context, title, description string, decorated, closable
|
||||
clicked := false
|
||||
ui.Column(c).Gap(u).Shrink(0).Children(func() {
|
||||
ui.Row(c).Gap(u * 3).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(theme.H3Size).FontWeight(600).Grow(1)
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).FontWeight(600).Grow(1)
|
||||
if closable {
|
||||
clicked = dialogCloseButton(c, msg(c, closeKey)).Clicked()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package mujica
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -173,6 +174,53 @@ func dialogSnapshotView(d theme.Density, decorated bool) func(c *ui.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogEscapeHidesTooltipBeforeClosing(t *testing.T) {
|
||||
open := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
Dialog(c, &open, DialogOptions{Title: "Outer", HideClose: true}, func() {
|
||||
b := Button(c, "Seal", ButtonOptions{})
|
||||
Tooltip(c, b, "Seal with wax", TooltipOptions{Delay: time.Millisecond})
|
||||
})
|
||||
}, 600, 400)
|
||||
tt.Key(0, ui.KeyTab) // keyboard focus on Seal
|
||||
if !overlayUntil(tt, func() bool { return tt.HasText("Seal with wax") }) {
|
||||
t.Fatal("tooltip did not show in the dialog")
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Seal with wax") || !open {
|
||||
t.Fatalf("first Escape: tip shown %v, dialog open %v", tt.HasText("Seal with wax"), open)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if open {
|
||||
t.Fatal("second Escape did not close the dialog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogFocusFallsBackWhenOpenerIsGone(t *testing.T) {
|
||||
open, gone := false, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
Button(c, "First", ButtonOptions{})
|
||||
if !gone && Button(c, "Open", ButtonOptions{}).Clicked() {
|
||||
open, gone = true, true
|
||||
}
|
||||
Dialog(c, &open, DialogOptions{Title: "Gone"}, nil)
|
||||
Button(c, "Next", ButtonOptions{})
|
||||
})
|
||||
}, 600, 400)
|
||||
_ = tt.Click("Open")
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if open {
|
||||
t.Fatal("Escape did not close")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Next") {
|
||||
t.Fatal("focus did not fall back to the dialog's place in the window")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDialogSnapshot(t *testing.T) {
|
||||
snapshot(t, "dialog", 560, 360, dialogSnapshotView(theme.Default, true))
|
||||
snapshot(t, "dialog-compact", 560, 360, dialogSnapshotView(theme.Compact, false))
|
||||
|
||||
@@ -47,6 +47,7 @@ if bar.Clicked == "bell" {
|
||||
- 操作条目:悬停 SurfaceHover,按下 Selection,键盘焦点环。
|
||||
- 折叠:按“右组末项 → 左组首项”的顺序隐藏 `Secondary` 条目,直到放得下(按实际宽度计算,操作条目至少 28 DIP,含条目间距);显示 `+N` 按钮,无障碍名称为“另有 N 项”,点击或 Enter/Space 打开弹层,列出被隐藏的条目,其中的操作照常可用,执行后弹层关闭。主要条目永不隐藏。
|
||||
- 文本放大时状态栏随文字增高。
|
||||
- 焦点所在条目被折叠时,焦点移到收纳它的 `+N` 按钮上,不会丢失。
|
||||
- 进度:AccentText 填充,Border 轨道,描述为百分比。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -21,7 +21,7 @@ if mujica.Button(c, "Save", mujica.ButtonOptions{Icon: icons.Must("check")}).Cli
|
||||
| `label` | `string` | 必填(仅图标时可为空) | 按钮文字;为空时必须提供 `Label`,否则 panic。 |
|
||||
| `Variant` | `ButtonVariant` | `Primary` | `Primary`、`Secondary`、`Outline`、`Ghost`、`Danger`。 |
|
||||
| `Icon` | `*ui.SVG` | `nil` | 文字前的 16 DIP 图标。 |
|
||||
| `Loading` | `bool` | `false` | 有图标时加载标记占用图标位置;纯文字时标记居中覆盖在隐藏的文字上,宽度不变;阻止点击。 |
|
||||
| `Loading` | `bool` | `false` | 加载标记出现在前置 16 DIP 图标位置,文字保持可见;有图标时替换图标,纯文字时从左右内边距中让出该位置,宽度不变;阻止点击。 |
|
||||
| `Disabled` | `bool` | `false` | 不可点击、不进入 Tab 序列。 |
|
||||
| `Label` | `string` | `""` | 无障碍名称;`label` 为空时必填。 |
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ if r.Submitted() {
|
||||
|
||||
## 状态
|
||||
|
||||
默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。聚焦时被禁用会放弃焦点,已排队的按键不再修改值、不触发事件。颜色变化 120 毫秒过渡,减少动画时直接切换。
|
||||
默认(Surface 底、ControlBorder 边)、悬停(边框加深)、聚焦(边框 Focus 色;键盘聚焦另加焦点环)、只读(Background 底、Border 边)、错误、禁用。禁用来自自身或任一祖先容器;聚焦时被禁用会放弃焦点,已排队的按键不再修改值、不触发事件。颜色变化 120 毫秒过渡,减少动画时直接切换。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -41,9 +41,10 @@ if r.Submitted() {
|
||||
|
||||
## 键盘操作
|
||||
|
||||
Tab 聚焦(全选内容);Enter 提交;Escape 清空(仅 `Clearable` 且有内容时,否则交给外层);Cmd/Ctrl+Z、Shift+Cmd+Z 撤销重做;Cmd/Ctrl+A/C/X/V;输入法组合期间不提交。
|
||||
Tab 聚焦(全选内容);Enter 提交;Escape 清空(仅 `Clearable` 且有内容时,否则交给外层);Cmd/Ctrl+Z、Shift+Cmd+Z 撤销重做;Cmd/Ctrl+A/C/X/V;组合中的文本不会写入值。
|
||||
|
||||
## 限制
|
||||
|
||||
- 输入法组合期间无法阻止 Enter 提交:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。各平台输入法在组合时通常自己消费 Enter。
|
||||
- MyGo 没有公开的只读输入接口(只读模式仅用于 `Text.Selectable`,`editor.go:77`)。`ReadOnly` 通过让编辑器编辑一个副本并在同帧还原实现,撤销栈里可能出现被还原的步骤。
|
||||
- 清空按钮不进入 Tab 序列(与 MyGo SearchField 相同),键盘用 Escape 清空。
|
||||
@@ -45,5 +45,5 @@ if r.Submitted() {
|
||||
## 限制
|
||||
|
||||
- `MaxLength` 只提示,不截断输入,以免打断输入法组合与粘贴。
|
||||
- 输入法组合期间无法阻止提交快捷键:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交。
|
||||
- 输入法组合期间无法阻止提交快捷键:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。
|
||||
- 只读实现方式同 TextInput。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 用途
|
||||
|
||||
输入与步进数值,支持范围、步长、精度与空值。合并 MyGo 的 Number input(输入模式)与 Stepper(`StepperOnly` 仅步进模式)。
|
||||
输入与步进数值,支持范围、步长、精度与空值。合并 MyGo 的 Number input(输入模式)与 Stepper(`StepperOnly` 仅步进模式);两种模式的步进计算都由组件完成。
|
||||
|
||||
## 最小示例
|
||||
|
||||
@@ -26,7 +26,7 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab
|
||||
| `StepperOnly` | `bool` | `false` | 只显示数值与上下箭头,不可输入;必须给出 `Min < Max`,且不能与 `AllowEmpty` 同用。 |
|
||||
| `Placeholder` | `string` | `""` | 空值时的占位。 |
|
||||
| `Label` | `string` | `""` | 无障碍名称。 |
|
||||
| `Disabled` | `bool` | `false` | 禁用。 |
|
||||
| `Disabled` | `bool` | `false` | 禁用;任一祖先容器禁用时同样生效。 |
|
||||
| `Error` | `string` | `""` | 调用方给出的错误。 |
|
||||
|
||||
## 状态
|
||||
@@ -39,11 +39,13 @@ if mujica.NumberInput(c, &copies, mujica.NumberInputOptions{Min: 1, Max: 99, Lab
|
||||
|
||||
## 键盘操作
|
||||
|
||||
输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到最小、End 到最大;按住箭头持续步进。
|
||||
步进沿以 `Min`(无界时为 0)为起点、`Step` 为间隔的网格移动,结果按精度取整,并停在范围内(范围先向内取到精度上,如 `Max` 0.29999999、步长 0.1 时最大值为 0.2)。不在网格上的值先吸附到相邻网格点。
|
||||
|
||||
输入模式:Up/Down 按步长增减。仅步进模式:Up/Right 增加、Down/Left 减少、Home 到 `Min`、End 到 `Max`(如 [0, 10] 步长 3 时 End 为 10,再 Down 为 9)。
|
||||
|
||||
## 限制
|
||||
|
||||
- 输入模式下的加减按钮不进入 Tab 序列,键盘用 Up/Down。
|
||||
- 仅步进模式复用 MyGo `ui.Stepper`,箭头高度来自主题间距,不随密度的控件高度变化。
|
||||
- MyGo `ui.Stepper` 的箭头不检查禁用(`indicators.go:216`),禁用时组件向它传入副本,保证值不被修改。
|
||||
- MyGo 的步进器吸附依赖有限边界(`widgets.go` 中 `snap`),所以仅步进模式要求有界。
|
||||
- 仅步进模式不使用 MyGo `ui.Stepper`:它的箭头不检查禁用(`ui/indicators.go:216`),吸附固定按 9 位小数取整(`ui/widgets.go` 中 `snap`),步长 1e-10 时无法前进。组件自绘箭头并自行计算;按住箭头不会连续步进,需重复点击或使用方向键。
|
||||
- 仅步进模式要求有界,以便 Home/End 有目标值。
|
||||
- 仅步进模式的值通过无障碍描述朗读,MyGo 未公开为自定义元素设置数值范围的接口。
|
||||
@@ -48,5 +48,5 @@ if r.Moved() {
|
||||
## 限制
|
||||
|
||||
- MyGo 单行编辑器把 Shift+Enter 当作普通提交且不公开修饰键(`editor.go:489`,`submitMods` 未导出),组件用 `HandleInput` 先截获 Shift+Enter。
|
||||
- 输入法组合期间无法阻止提交与匹配跳转:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交或跳转。
|
||||
- 输入法组合期间无法阻止提交与匹配跳转:MyGo 不公开组合状态,组合文本存在未导出的 `editor.compose`(`ui/editor.go:73`),带编辑器的元素收不到 `InputCompose` 事件(`ui/input.go:689`)。各平台输入法在组合时通常自己消费 Enter;只有未被输入法消费的按键会触发提交或跳转。公开接口也无法间接得知:`Element.TextCaret`(`ui/handler.go:82`)只是自绘控件声明输入位置的设置方法,`Tester.TextCaret` 返回的 `Active`(`ui/headless.go:370-373`)只表示可编辑元素持有键盘,不表示正在组合。
|
||||
- 没有“完成/关闭”按钮;是否显示查找栏由调用方决定。
|
||||
@@ -49,6 +49,6 @@ Tab 进入组并落在已选项;Up/Down/Left/Right 移动并直接选择,循
|
||||
|
||||
## 限制
|
||||
|
||||
- 每个选项以 `Value` 为键,选项重新排序后焦点仍跟随原来的值。
|
||||
- 每个选项按 `Value` 相等性取得稳定键(首次出现的序号),选项重新排序后焦点仍跟随原来的值;组件记住见过的值,长期不断变化的选项集合会让这份记录增长。
|
||||
|
||||
- 方向键对横向与纵向组都同时响应上下左右(MyGo `RadioGroup` 固定使用 `Horizontal|Vertical`)。
|
||||
@@ -54,7 +54,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 键盘操作
|
||||
|
||||
- Tab:进入组,落在按下项(或上次聚焦项);再按 Tab 离开。
|
||||
- Tab:进入组。首次落在第一个可用项(与哪项按下无关);之后回到组内上次聚焦的项。再按 Tab 离开。
|
||||
- ←/→(纵向为 ↑/↓)、Home、End:在项之间移动焦点,跳过禁用项,首尾循环。
|
||||
- Space:按下或松开当前项。
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ ui.Render(view, 400, 300, 1)
|
||||
| `Step` | `float64` | 0 | 步长;值为 `Min + n·Step`。0 为连续。 |
|
||||
| `Marks` | `[]SliderMark` | nil | 刻度:`Value` 处画刻线,`Label` 非空时在下方显示文字。 |
|
||||
| `ShowValue` | `bool` | false | 悬停、拖动或聚焦时在滑块上方显示数值提示(预留高度,不改变布局)。 |
|
||||
| `Format` | `func(float64) string` | 按步长精度 | 数值文字。 |
|
||||
| `Format` | `func(float64) string` | 按 `Min`、`Step` 所需小数位(连续时至少 1 位) | 数值文字。 |
|
||||
| `Disabled` | `bool` | false | 禁用。 |
|
||||
| `Label` | `string` | "" | 无障碍名称。 |
|
||||
|
||||
@@ -44,5 +44,6 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- 基于 `ui.SliderBase`。MyGo 的方向键步长固定为范围的 1%(ui/base.go:116),本组件把每次按键结果换算为一个 `Step`;当 `Step` 小于范围的 1% 且滑块距端点不足 1% 时,方向键会直接到端点。
|
||||
- `SliderBase` 在构建时就处理指针(ui/base.go:111),禁用时组件改为让它操作副本,保证值不变。
|
||||
- 基于 `ui.SliderBase`,它向辅助技术报告 Slider 角色、范围与当前值(ui/base.go:128)。
|
||||
- MyGo 只把方向键、Home、End 交给 `SliderBase` 本身(ui/base.go:118,按键路由见 ui/input.go:495),并固定以范围的 1% 移动。本组件让它操作副本,按方向判断按键后自己按 `Step` 移动;距端点不足 1% 时 MyGo 的方向键与 Home/End 结果相同,组件改看松开的是哪个键(`HandleInput` 能收到 KeyUp),在键松开前不改值。
|
||||
- `SliderBase` 在构建时就处理指针(ui/base.go:111),禁用时外层先禁用,且只操作副本,保证值不变。
|
||||
@@ -25,9 +25,9 @@ ui.Render(view, 400, 300, 1)
|
||||
| `Step` | `float64` | 0 | 步长,0 为连续。 |
|
||||
| `Marks` | `[]SliderMark` | nil | 刻度与文字。 |
|
||||
| `ShowValue` | `bool` | false | 悬停、拖动或聚焦时显示两端数值;低端提示向左、高端向右,互不遮挡。 |
|
||||
| `Format` | `func(float64) string` | 按步长精度 | 数值文字。 |
|
||||
| `Format` | `func(float64) string` | 按 `Min`、`Step` 所需小数位(连续时至少 1 位) | 数值文字。 |
|
||||
| `Disabled` | `bool` | false | 禁用。 |
|
||||
| `Label` | `string` | "" | 区间名称;两个滑块分别读作“`Label` 最小值, 20”“`Label` 最大值, 80”。 |
|
||||
| `Label` | `string` | "" | 区间名称;两个滑块分别名为“`Label` 最小值”“`Label` 最大值”,数值与范围由 Slider 角色报告。 |
|
||||
|
||||
## 状态
|
||||
|
||||
@@ -46,5 +46,6 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- MyGo 自带的 `ui.RangeSlider` 使用玻璃质感滑块(ui/widgets.go:419),不符合本库风格;MyGo 也没有双滑块 Base,故本组件用公开的 `Dragged`、`PointerPosition`、`Shortcut` 组合实现。
|
||||
- 滑块的无障碍角色为 Slider,但数值范围属性只能由 `SliderBase` 设置(ui/base.go:128),因此当前值写在名称里(如“Price 最小值, 20”),屏幕阅读器读名称而非数值属性。
|
||||
- MyGo 自带的 `ui.RangeSlider` 使用玻璃质感滑块(ui/widgets.go:419),不符合本库风格;MyGo 也没有双滑块 Base,故每个滑块各是一个 `SliderBase`(报告 Slider 角色、范围与当前值,ui/base.go:128),拖动由外层的 `Dragged`、`PointerPosition` 处理。
|
||||
- 两个滑块的范围都报告为整条轨道 `Min`–`Max`;“不能越过另一端”这一动态上下限无法告诉辅助技术。
|
||||
- 按键处理与 Slider 相同:距端点不足 1% 时等到按键松开再移动(见 034 限制)。
|
||||
@@ -36,7 +36,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 状态
|
||||
|
||||
默认、悬停(边框加深)、展开(酒红边框、箭头朝上)、聚焦、禁用;弹层中高亮项(浅底)、已选项(勾号 + 酒红文字)、禁用项、空状态。
|
||||
默认、悬停(边框加深)、展开(酒红边框、箭头朝上)、聚焦、禁用;弹层中高亮项(浅底)、已选项(玫瑰底 + 勾号 + 酒红文字)、键盘高亮项另有左侧竖条、禁用项、空状态。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -52,4 +52,5 @@ ui.Render(view, 400, 300, 1)
|
||||
## 限制
|
||||
|
||||
- 基于 `ui.SelectBase`。其高亮索引没有公开的设置接口(ui/base.go:260),所以弹层打开时输入字母不移动高亮,只在弹层关闭时定位。
|
||||
- 清空按钮不占 Tab 位(与 MyGo 搜索框一致),键盘用 Backspace/Delete 清空。
|
||||
- 清空按钮不占 Tab 位(与 MyGo 搜索框一致),键盘用 Backspace/Delete 清空;它的命中区域为 28×28 DIP。
|
||||
- 选项行的角色为 ListItem,选中状态由 `SelectBase.Item` 报告。`SelectParts` 没有关闭弹层的接口(ui/base.go:310),所以选择器在弹层打开时被禁用后不再构建弹层;重新启用时弹层会再次显示。
|
||||
@@ -49,6 +49,6 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- 选项行基于 `ui.CheckboxBase` 自绘方框;全选的“半选”只在画面上表示,MyGo 没有为自定义元素设置 mixed 状态的公开接口(ui/element.go:282 的 checked 字段仅由内部设置),屏幕阅读器读作未选中。
|
||||
- 选项行基于 `ui.CheckboxBase` 自绘方框,选中状态由 MyGo 报告。全选的“半选”无法作为 mixed 状态报告:MyGo 没有为自定义元素设置它的公开接口(ui/element.go:282 的 checked 字段仅由内部设置)。组件改用 `Description` 读出“部分选中”,屏幕阅读器仍把复选框本身读作未选中。
|
||||
- 标签上的移除按钮不占 Tab 位,键盘用 Backspace 移除。
|
||||
- 列表未虚拟化,适合数百项以内。
|
||||
@@ -53,3 +53,4 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
- 基于 `ui.ComboboxBase`。它在没有选项时隐藏弹层(ui/combobox.go:191),所以“加载中”“无匹配”用一个不接收输入的独立浮层显示。
|
||||
- 异步结果在打字后才到达时 MyGo 不会自动高亮首项,组件让 Enter 选择首个结果。
|
||||
- MyGo 替换输入框文字时保留原光标位置(ui/editor.go:117),且不公开光标设置;组件在选择后重建输入框,使光标落在新文字末尾并保持焦点。
|
||||
@@ -58,3 +58,4 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
- `Load` 在视图构建中同步调用,不得阻塞;异步任务完成后请用 `Window.Update` 回到界面线程更新数据。
|
||||
- 列表未虚拟化,每列适合数百项以内。
|
||||
- 已选路径上的节点通过 `Selected` 报告选中;当前展开的分支无法报告 expanded 状态(ui/base.go:297 等处仅内部设置),仅在画面上用底色表示。
|
||||
@@ -55,5 +55,6 @@ ui.Render(view, 400, 300, 1)
|
||||
## 限制
|
||||
|
||||
- 树行基于 `ui.CheckboxBase` / `ui.ButtonBase` 自绘。MyGo 的 `TreeItem` 只能显示文字标签,无法放入复选框(ui/tree.go:44),故未复用。
|
||||
- 展开状态与半选状态只在画面上表示:MyGo 不对自定义元素公开 expanded 与 mixed 的设置(ui/base.go:297、ui/element.go:282 仅内部设置),屏幕阅读器读不到这两个状态;展开箭头带“展开/收起 + 节点名”的名称。
|
||||
- MyGo 不对自定义元素公开 expanded 与 mixed 状态的设置(ui/tree.go:98、ui/element.go:282 仅内部设置)。组件改用 `Description` 读出“已展开/已收起”“部分选中”,展开箭头带“展开/收起 + 节点名”的名称;屏幕阅读器读不到真正的 expanded/mixed 状态属性。单选行通过 `Selected` 报告选中,并显示勾号。
|
||||
- `CollapsibleBase` 的触发器能报告 expanded,但它自身要占一个焦点位,会让方向键在箭头与节点间交替停留,故未采用。
|
||||
- 列表未虚拟化,适合数千节点以内。
|
||||
@@ -55,3 +55,4 @@ if i, ok := mujica.Breadcrumb(c, path, mujica.BreadcrumbOptions{}).Chosen(); ok
|
||||
|
||||
- 完整名称提示对每一项都提供,MyGo 不报告文字是否被截断。
|
||||
- 未复用 MyGo `ui.Breadcrumbs`:它不支持折叠与提示,本组件用 `ButtonBase`、`PopoverBase` 组合实现。
|
||||
- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。
|
||||
@@ -51,3 +51,4 @@ if mujica.Pagination(c, &page, &size, mujica.PaginationOptions{Total: 420, PageS
|
||||
## 限制
|
||||
|
||||
- 页容量选择基于 MyGo `SelectBase`,弹层由 MujicaUI 绘制。
|
||||
- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。
|
||||
@@ -59,3 +59,5 @@ router.View(c, func(r *ui.Route) { ui.Text(c, r.Path()) })
|
||||
|
||||
- MyGo 历史按钮的提示文字固定为英文 “Back” / “Forward”(`ui/router.go:713`);本组件覆盖了无障碍名称,但提示仍为英文。
|
||||
- 历史按钮的外观来自 MyGo 主题按钮样式(经 `mujica.Use` 映射为 MujicaUI 颜色)。
|
||||
- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。
|
||||
- 下拉面板最高为锚点上方或下方可用空间,超出时在面板内滚动;方向键移动焦点时自动滚入视野。
|
||||
@@ -72,3 +72,5 @@ if id, ok := res.Chosen(); ok && id == "wrap" {
|
||||
- MyGo 没有 menu / menuitem 角色(`ui/access.go:25-79`),项以按钮呈现;勾选状态无法通过公开接口写入(`ui/base.go:56` 的 checked 为内部字段),因此写入名称。
|
||||
- 指针点击非可聚焦元素会清除焦点(`ui/input.go:238`);选择后组件把焦点还给对应标题。
|
||||
- 菜单打开期间,焦点保持在标题上,高亮项由组件管理。驱动菜单的按键注册在标题上,而 MyGo 的 active descendant 不公开,因此高亮变化时用 `c.Announce` 朗读当前项,并把它写入标题的 `Description`。
|
||||
- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。
|
||||
- 点击菜单内任意位置(包括禁用项)或用指针打开子菜单后,焦点回到所属标题,方向键、Enter、Escape 继续有效。
|
||||
@@ -64,3 +64,5 @@ if id, ok := res.Chosen(); ok {
|
||||
- MyGo `TextInputBase` 没有占位文字(`ui/editor.go:734`);为避免与输入法组合文字重叠,说明放在底部提示栏,搜索框以无障碍名称“搜索命令”标识。
|
||||
- 搜索是子串匹配,不做模糊匹配或排序打分。
|
||||
- 焦点留在搜索框以便继续输入;方向键移动高亮时用 `c.Announce` 朗读当前命令,并写入搜索框的 `Description`(MyGo 的 active descendant 不公开)。
|
||||
- 弹层打开时在 160 ms 内淡入,减少动画时直接显示。关闭时立即消失:弹层关闭后 MyGo 不再构建其元素,公开接口无法为已移除的浮层做淡出。
|
||||
- 搜索内容变化后高亮回到第一项,并滚入视野。
|
||||
@@ -71,5 +71,6 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- 关闭后焦点回到打开时的控件;若该控件已不存在,MyGo 不回退到父级(`ui/scope.go:137` restoreFocus 只恢复原控件),焦点为空,再按 Tab 从窗口开头开始。
|
||||
- 没有关闭过渡:MyGo 在 `*open` 变为 false 的下一帧即移除浮层元素,`Transition` 的 Exit 需要父元素保留,而浮层根只在有浮层时存在。
|
||||
- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。
|
||||
- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。
|
||||
- 没有关闭过渡:`*open` 变为 false 的下一帧浮层元素即被移除。
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## 用途
|
||||
|
||||
确认危险操作。默认聚焦“取消”,点击遮罩无效,Escape 等同取消;可要求先输入目标名称,未确认目标前 Enter 不会执行。
|
||||
确认危险操作。默认聚焦“取消”,点击遮罩无效,Escape 等同取消;可要求先输入目标名称,未输入前确认按钮禁用;输入框中的 Enter 从不确认。
|
||||
|
||||
## 最小示例
|
||||
|
||||
@@ -46,7 +46,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 事件
|
||||
|
||||
- `Confirmed`:点击确认,或在输入框内按 Enter 且目标已匹配。
|
||||
- `Confirmed`:点击确认,或焦点在已解锁的确认按钮上按 Enter / Space。
|
||||
- `Cancelled`:点击取消、在取消上按 Enter / Space、或按 Escape。
|
||||
- 点击遮罩不产生事件。
|
||||
|
||||
@@ -55,11 +55,13 @@ ui.Render(view, 400, 300, 1)
|
||||
| 按键 | 行为 |
|
||||
| --- | --- |
|
||||
| 打开时 | 焦点在“取消”。 |
|
||||
| Enter | 触发聚焦的按钮;在输入框中只有目标匹配时确认。 |
|
||||
| Enter | 触发聚焦的按钮;在输入框中不确认,Tab 到确认按钮后再按。 |
|
||||
| Escape | 取消并关闭。 |
|
||||
| Tab | 在对话框内循环。 |
|
||||
|
||||
## 限制
|
||||
|
||||
- 取消按钮的焦点由 `AutoFocus` 给出,打开时不显示焦点环(MyGo 只在键盘移动焦点后设置可见焦点,`ui/input.go:868`);按 Tab 后显示。
|
||||
- 不替代系统原生警示框;需要系统样式时用 MyGo 的桌面对话框。
|
||||
- 输入框中的 Enter 从不确认,必须用 Tab 移到确认按钮:MyGo 在 Enter 时先提交输入法组合再处理按键(`ui/editor.go:688`),而组合状态不公开,无法区分“提交组合”的 Enter。
|
||||
- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。
|
||||
- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。
|
||||
- 取消按钮的焦点由 `AutoFocus` 给出,打开时不显示焦点环(`ui/input.go:868`);按 Tab 后显示。
|
||||
@@ -51,5 +51,7 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- 非模态抽屉不在打开时移动焦点,关闭时也不自动恢复焦点:MyGo 记录与恢复打开者的 `openOverlay` 不公开(`ui/scope.go:123`),也没有读取当前焦点的公开接口。
|
||||
- 非模态抽屉的 Escape 是窗口级快捷键,优先级最低;同时打开多个非模态浮层时由先构建的那个响应。
|
||||
- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。
|
||||
- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。
|
||||
- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。
|
||||
- 非模态抽屉打开时不移动焦点,其内容排在窗口焦点顺序末尾。
|
||||
@@ -50,5 +50,6 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- 只支持下方与上方两个位置:位置由 MyGo `PopoverBase` 决定(`ui/widgets.go:689` keepInWindow)。
|
||||
- 打开时外部点击只用于关闭,不会传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。
|
||||
- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。
|
||||
- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。
|
||||
- 只支持下方与上方两个位置(`ui/widgets.go:689` keepInWindow);宽度不超过窗口,过高时内容滚动。
|
||||
@@ -41,5 +41,6 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- 只在键盘焦点(可见焦点)时显示,鼠标点击获得的焦点不触发,避免点击后弹出提示。
|
||||
- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。
|
||||
- 只在键盘焦点(可见焦点)时显示,鼠标点击获得的焦点不触发。
|
||||
- 不使用 MyGo 的 `Element.Tooltip`:它只跟随指针、不响应焦点(`ui/widgets.go:549`)。
|
||||
@@ -48,5 +48,6 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- 键盘打开时卡片由 `PopoverBase` 承载,以便焦点顺序紧跟锚点;此时卡片外的点击只用于关闭(`ui/base.go:373`)。指针打开时卡片不拦截窗口其余部分。
|
||||
- 指针打开时卡片元素在窗口焦点顺序的末尾。
|
||||
- 卡片由 `PopoverBase` 承载,使焦点顺序紧跟锚点,在 Dialog 内也属于对话框的焦点范围;代价是打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。
|
||||
- 指针在卡片外但焦点仍在卡片内时卡片保持打开,点击外部或按 Escape 关闭。
|
||||
- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。
|
||||
@@ -72,4 +72,5 @@ ui.Render(view, 400, 300, 1)
|
||||
## 限制
|
||||
|
||||
- MyGo 没有 menu / menuitem 无障碍角色(`ui/access.go:22` Role 列表),菜单面板报告为 popup,普通项为 button,勾选项为 checkbox,触发按钮为 menu button。
|
||||
- 菜单打开期间外部点击只用于关闭(`ui/base.go:373`)。
|
||||
- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。
|
||||
- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。
|
||||
@@ -51,5 +51,7 @@ ui.Render(view, 400, 300, 1)
|
||||
## 限制
|
||||
|
||||
- `target` 必须是容器:菜单会向它追加一个不可见的定位子元素,文字元素不能有非文字子元素(MyGo 会 panic)。
|
||||
- 菜单通过 `target.HandleInput` 接收右键按下,因此会替换调用方在同一元素上设置的 `HandleInput`(MyGo 每个元素只保存一个处理函数,`ui/handler.go:73`)。
|
||||
- 菜单通过 `target.HandleInput` 接收右键按下,因此会替换调用方在同一元素上设置的 `HandleInput`(`ui/handler.go:73`)。
|
||||
- 不使用 MyGo 的 `Element.ContextMenu`:那是系统菜单,PRD 不允许把它当作哥特样式菜单。
|
||||
- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。
|
||||
- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。
|
||||
@@ -55,5 +55,5 @@ ui.Render(view, 400, 300, 1)
|
||||
|
||||
## 限制
|
||||
|
||||
- 非模态:不屏蔽窗口,但外部点击只用于取消,不传给下方控件(`ui/base.go:373`)。
|
||||
- 位置只有下方与上方(`ui/widgets.go:689`)。
|
||||
- 打开期间外部点击只用于关闭,不传给下方控件:`PopoverBase` 用覆盖整窗的背板接收点击(`ui/base.go:373`)。
|
||||
- 位置只有下方与上方(`ui/widgets.go:689`);宽度不超过窗口,说明过长时滚动,按钮始终可见。
|
||||
@@ -46,6 +46,7 @@ Escape 与(模态时)背景点击把 `*open` 设为 false。
|
||||
|
||||
## 限制
|
||||
|
||||
- 非模态覆盖层的 Escape 是窗口级快捷键(MyGo 浮层专用快捷键 `overlayShortcut` 不公开,`ui/scope.go:109`),同时打开多个非模态浮层时由先构建的那个响应。
|
||||
- 非模态覆盖层关闭时不自动恢复焦点(`ui/scope.go:123` openOverlay 不公开)。
|
||||
- 输入法组合期间按 Escape 仍会关闭浮层:MyGo 没有公开组合状态(`ui/editor.go:73` 的 `compose` 未导出,组合事件不交给带编辑器元素的 `HandleInput`,`ui/input.go:689`),编辑器也不接收 Escape(`ui/editor.go:163`),macOS 上按键先于输入法送达应用(`internal/darwin/surface.go:704`)。
|
||||
- Escape 只关闭最上层的 MujicaUI 浮层(窗口内按打开顺序记录)。MyGo 自带的 `ui.Popover`、`ui.Select` 弹层不在这份记录中;它们打开在非模态 MujicaUI 浮层之上时,Escape 可能先关闭下面的 MujicaUI 浮层。
|
||||
- 关闭后焦点优先由 MyGo 还给打开者;打开者已消失、焦点落空时,焦点停在组件调用处的不可见占位元素上,Tab 从这里继续。不能回到打开者的父级:MyGo 不公开当前焦点或打开者(`ui/scope.go:123`、`ui/scope.go:137`)。
|
||||
- 非模态面板首帧尚未测量尺寸,该帧透明,下一帧定位后淡入。
|
||||
@@ -42,7 +42,7 @@ mujica.Meter(c, 93, mujica.MeterOptions{
|
||||
| `Levels` | `*ui.MeterLevels` | `nil` | 阈值:达到 `Warning` 为偏高、达到 `Critical` 为严重;`Critical < Warning` 时低值为坏(如电量)。须在范围内。 |
|
||||
| `Label` | `string` | `""` | 名称。 |
|
||||
| `ShowValue` | `bool` | `false` | 显示“值 / 最大值”。 |
|
||||
| `Width` | `float32` | 填满 | 宽度。 |
|
||||
| `Width` | `float32` | 填满 | 宽度;负数、NaN 或无穷 panic。 |
|
||||
|
||||
## 状态
|
||||
|
||||
@@ -60,5 +60,5 @@ mujica.Meter(c, 93, mujica.MeterOptions{
|
||||
|
||||
## 限制
|
||||
|
||||
- 环形直径小于 44 DIP 时百分比显示在旁边,而不是环内。
|
||||
- 百分比按缩放后的文字尺寸测量,放不进环内时显示在环旁边。
|
||||
- MyGo 不提供自定义无障碍数值的公开接口,因此必须基于 `ui.Progress` / `ui.Meter` 元素绘制。
|
||||
@@ -0,0 +1,38 @@
|
||||
# 上游归并与上游缺口
|
||||
|
||||
## 归并(PRD 4.11)
|
||||
|
||||
MyGo 已有能力纳入下列组件,不增加计数。
|
||||
|
||||
| MyGo 能力 | MujicaUI 组件 | 文件 |
|
||||
| --- | --- | --- |
|
||||
| Primary button、Menu button | Button 主按钮变体;DropdownMenu | `button.go`、`dropdown_menu.go` |
|
||||
| Stepper、Number input | NumberInput 仅步进模式与输入模式 | `number_input.go` |
|
||||
| Search field、Find bar | SearchInput 搜索模式与查找栏模式 | `search_input.go` |
|
||||
| Autocomplete、Combobox | Combobox 自由输入与选项选择 | `combobox.go` |
|
||||
| Token field、Editable text | TagsInput;Text 的 `EditableText` | `tags_input.go`、`text.go` |
|
||||
| List、Grid view | List 列表模式与网格模式 | `list.go` |
|
||||
| Table、Outline、Tree | DataTable;TreeTable;Tree | `data_table.go`、`tree_table.go`、`tree.go` |
|
||||
| Date input、Time input | DatePicker;TimePicker | `date_picker.go`、`time_picker.go` |
|
||||
| Back and forward buttons | NavigationMenu 历史导航 | `navigation_menu.go` |
|
||||
| Meter、Progress bar | Progress 计量模式与进度模式 | `progress.go` |
|
||||
|
||||
## 上游缺口(待提交 MyGo)
|
||||
|
||||
PRD 第 2 节要求公开接口不足时向上游提交扩展。以下缺口在 MyGo v0.2.10 中核实,组件文档的“限制”节写明影响。本仓库不访问 MyGo internal,也未修改依赖。
|
||||
|
||||
| 缺口 | 位置 | 受影响组件 |
|
||||
| --- | --- | --- |
|
||||
| 自定义元素无法设置 expanded、mixed、数值范围与值 | `ui/element.go:282-295`、`ui/base.go:128,297` | Checkbox 半选、MultiSelect、TreeSelect、RangeSlider、Rating、全部日期时间弹出按钮 |
|
||||
| 无 menu、menuitem 角色 | `ui/access.go:22-79` | Menubar、DropdownMenu、ContextMenu |
|
||||
| 无 heading 角色 | `ui/access.go` | Heading |
|
||||
| 输入法组合状态不公开 | `ui/editor.go:73`、`ui/input.go:689` | TextInput、TextArea、SearchInput、Dialog、AlertDialog |
|
||||
| 打开链接失败不回传 | `ui/context.go:270`、`ui/window.go:583` | Link |
|
||||
| 浮层焦点恢复与浮层快捷键不公开 | `ui/scope.go:109,123,137` | Dialog、Drawer、Overlay |
|
||||
| `PopoverBase` 背板吞掉外部点击;只有上下两个位置 | `ui/base.go:373`、`ui/widgets.go:689` | Popover、Popconfirm、DropdownMenu、HoverCard |
|
||||
| `activeDescendant` 不公开 | `ui/element.go:331` | Menubar、CommandPalette |
|
||||
| `SliderBase` 方向键步长固定 1%,构建时处理指针 | `ui/base.go:111-117` | Slider |
|
||||
| `SelectBase` 高亮无设置接口 | `ui/base.go:260` | Select |
|
||||
| `TextInputBase` 无只读开关与占位符 | `ui/editor.go:139,734,811` | TextInput 只读模式、CommandPalette |
|
||||
| `ui.Stepper` 禁用时仍可步进,固定 9 位取整 | `ui/indicators.go:216`、`ui/widgets.go` `snap` | NumberInput(已改为自绘) |
|
||||
| 路径遮罩缓存按四分之一像素取键 | `ui/path.go:189-210` | 全部自绘路径(以 `internal.Snap` 对齐网格) |
|
||||
@@ -22,8 +22,7 @@ const (
|
||||
type DrawerOptions struct {
|
||||
Title string
|
||||
Side DrawerSide
|
||||
// Size is the width (left, right) or height (top, bottom): 360 or 280 DIPs when zero.
|
||||
// It is kept between 160 DIPs and 90% of the window.
|
||||
// Size is the width or height (360 or 280 when zero), kept within 160 DIPs and 90% of the window.
|
||||
Size float32
|
||||
// NonModal leaves the window usable and the focus free.
|
||||
NonModal bool
|
||||
@@ -31,8 +30,7 @@ type DrawerOptions struct {
|
||||
Actions func()
|
||||
}
|
||||
|
||||
// Drawer shows a panel along a window edge while *open is true. A modal
|
||||
// drawer traps the focus and restores it on close; Escape closes either kind.
|
||||
// Drawer shows a panel along a window edge while *open is true; Escape closes it.
|
||||
func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.Element {
|
||||
if opts.Title == "" {
|
||||
panic("mujica: Drawer needs a Title")
|
||||
@@ -43,7 +41,9 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E
|
||||
if opts.Size < 0 {
|
||||
panic("mujica: Drawer Size is negative")
|
||||
}
|
||||
site, focus := overlaySite(c)
|
||||
if !*open {
|
||||
focus.closed(c, site)
|
||||
return nil
|
||||
}
|
||||
k := Tokens(c)
|
||||
@@ -69,8 +69,9 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E
|
||||
drawerPlace(c, panel, opts.Side, size*p)
|
||||
panel.Children(func() { body(panel, p) })
|
||||
})
|
||||
escaped = c.Shortcut(0, ui.KeyEscape)
|
||||
escaped = overlayEscape(c, open)
|
||||
} else {
|
||||
overlayLayer(c, open)
|
||||
shown := true
|
||||
panel = ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
p := overlayEnter(c, panel)
|
||||
@@ -80,6 +81,7 @@ func Drawer(c *ui.Context, open *bool, opts DrawerOptions, content func()) *ui.E
|
||||
})
|
||||
escaped = !shown
|
||||
}
|
||||
focus.shown(panel)
|
||||
if closed || escaped {
|
||||
slog.Debug("mujica: drawer close", "escape", escaped)
|
||||
*open = false
|
||||
|
||||
+1
-4
@@ -15,10 +15,7 @@ type DropdownMenuOptions struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// DropdownMenu is a button opening an in-window menu that build fills.
|
||||
// Click, Enter, Space or Down opens it with the focus on the first item;
|
||||
// the arrows, Home and End move, Escape or Tab closes, and the focus
|
||||
// returns to the button. It returns the button.
|
||||
// DropdownMenu is a button opening an in-window menu that build fills; it returns the button.
|
||||
func DropdownMenu(c *ui.Context, label string, opts DropdownMenuOptions, build func(m *PopupMenu)) *ui.Element {
|
||||
if build == nil {
|
||||
panic("mujica: DropdownMenu needs a build function")
|
||||
|
||||
+30
-6
@@ -145,14 +145,38 @@ func TestDropdownMenuDisabled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuTabInSubmenuClosesMenu(t *testing.T) {
|
||||
a := &menuApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
if !tt.Focused("Archive") {
|
||||
t.Fatal("submenu not entered")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if tt.HasText("Archive") || tt.HasText("Rename") {
|
||||
t.Fatal("Tab in a submenu left the menu open")
|
||||
}
|
||||
if !tt.Focused("Actions") {
|
||||
t.Fatal("focus did not return to the trigger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDropdownMenuSnapshot(t *testing.T) {
|
||||
snapshot(t, "dropdown_menu", 460, 320, func(c *ui.Context) {
|
||||
a := &menuApp{pinned: true}
|
||||
a := &menuApp{pinned: true}
|
||||
overlaySnapshot(t, "dropdown_menu", 520, 320, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(20).AlignItems(ui.Start).Children(func() {
|
||||
b := ui.ButtonBase(c)
|
||||
popupMenuTrigger(c, b, "Actions", nil, true)
|
||||
open := true
|
||||
popupMenuOpen(c, b, &open, a.build)
|
||||
DropdownMenu(c, "Actions", DropdownMenuOptions{}, a.build)
|
||||
})
|
||||
}, func(tt *ui.Tester) {
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown) // opens with the first item focused
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Key(0, ui.KeyRight) // opens Move to, focus ring on Archive
|
||||
if !tt.Focused("Archive") {
|
||||
t.Fatal("submenu did not open")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -105,10 +105,10 @@ view := func(c *ui.Context) {
|
||||
func choiceDemo(c *ui.Context, controls, samples func()) {
|
||||
k := mujica.Tokens(c)
|
||||
ui.Column(c).Gap(20).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, "Parameters").Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
|
||||
ui.Text(c, "Parameters").Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
ui.Row(c).Gap(20).Wrap().AlignItems(ui.Center).Children(controls)
|
||||
ui.Box(c).Height(1).FillWidth().Background(k.Ornament)
|
||||
ui.Text(c, "States").Font(theme.SerifFont).FontSize(theme.H3Size).TextColor(k.Text)
|
||||
ui.Text(c, "States").Font(theme.SerifFont).FontSize(mujica.FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
ui.Column(c).Gap(16).AlignItems(ui.Start).Children(samples)
|
||||
})
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func choiceFlag(c *ui.Context, on *bool, label string, log func(string)) {
|
||||
// choiceLabeled shows a caption above a sample.
|
||||
func choiceLabeled(c *ui.Context, caption string, fn func()) {
|
||||
ui.Column(c).Gap(6).Children(func() {
|
||||
ui.Text(c, caption).FontSize(theme.CaptionSize).TextColor(mujica.Tokens(c).TextMuted)
|
||||
ui.Text(c, caption).FontSize(mujica.FontSize(c, theme.CaptionSize)).TextColor(mujica.Tokens(c).TextMuted)
|
||||
fn()
|
||||
})
|
||||
}
|
||||
@@ -288,7 +288,7 @@ var choiceFruits = []mujica.SelectOption[string]{
|
||||
|
||||
func selectDemo(c *ui.Context, log func(string)) {
|
||||
type state struct {
|
||||
value string
|
||||
value, picked string
|
||||
clearable, disabled, noData bool
|
||||
}
|
||||
s := *ui.Local(c.Root(), "036", func() *state { return &state{value: "banana", clearable: true} })
|
||||
@@ -306,9 +306,10 @@ func selectDemo(c *ui.Context, log func(string)) {
|
||||
log(fmt.Sprintf("Fruit: %q", s.value))
|
||||
}
|
||||
})
|
||||
empty := ""
|
||||
choiceLabeled(c, "Placeholder", func() {
|
||||
mujica.Select(c, &empty, choiceFruits, mujica.SelectOptions{Placeholder: "Pick a fruit"})
|
||||
if mujica.Select(c, &s.picked, choiceFruits, mujica.SelectOptions{Placeholder: "Pick a fruit", Clearable: true}).Changed() {
|
||||
log(fmt.Sprintf("Picked: %q", s.picked))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
)
|
||||
|
||||
// choiceDemoTester runs the demo of page id at text scale and returns its tester and event log.
|
||||
func choiceDemoTester(t *testing.T, id string, scale float32) (*ui.Tester, *[]string) {
|
||||
t.Helper()
|
||||
i := slices.IndexFunc(choicePages, func(p page) bool { return p.ID == id })
|
||||
if i < 0 {
|
||||
t.Fatalf("no page %s", id)
|
||||
}
|
||||
events := &[]string{}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
mujica.Use(c, mujica.Settings{Mode: mujica.Light})
|
||||
ui.Scroll(c).Fill().Padding(24).Children(func() {
|
||||
choicePages[i].Demo(c, func(s string) { *events = append(*events, s) })
|
||||
})
|
||||
}, 900, 700)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: scale})
|
||||
return tt, events
|
||||
}
|
||||
|
||||
func TestChoiceSelectDemoKeepsPlaceholderChoice(t *testing.T) {
|
||||
tt, events := choiceDemoTester(t, "036", 1)
|
||||
if err := tt.Click("Pick a fruit"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := tt.Click("Fig"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Frame()
|
||||
if !tt.HasText("Fig") || !slices.Contains(*events, `Picked: "fig"`) {
|
||||
t.Fatalf("choice lost: texts %q, events %q", tt.Texts(), *events)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChoiceDemoHeadingsFollowTextScale(t *testing.T) {
|
||||
height := func(scale float32) float32 {
|
||||
tt, _ := choiceDemoTester(t, "031", scale)
|
||||
tt.Frame()
|
||||
r, ok := tt.Find("Parameters")
|
||||
if !ok {
|
||||
t.Fatal("heading missing")
|
||||
}
|
||||
return r.H
|
||||
}
|
||||
if one, two := height(1), height(2); two < one*1.5 {
|
||||
t.Fatalf("heading %v high at 2x, %v at 1x", two, one)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,6 @@ func textDemo(c *ui.Context, log func(string)) {
|
||||
log("edit canceled")
|
||||
}
|
||||
})
|
||||
displayCode(c, textCode, log)
|
||||
}
|
||||
|
||||
const textCode = `mujica.Text(c, "Order ID: 4821-AX", mujica.TextOptions{Selectable: true})
|
||||
@@ -113,7 +112,6 @@ func headingDemo(c *ui.Context, log func(string)) {
|
||||
mujica.Heading(c, fmt.Sprintf("Level %d · 宫廷", l), mujica.HeadingOptions{Level: l})
|
||||
}
|
||||
})
|
||||
displayCode(c, headingCode, log)
|
||||
}
|
||||
|
||||
const headingCode = `mujica.Heading(c, "The Grand Hall", mujica.HeadingOptions{Subtitle: "Winter ceremonies", Divider: true})
|
||||
@@ -163,7 +161,6 @@ func iconDemo(c *ui.Context, log func(string)) {
|
||||
mujica.Icon(c, "missing-name", mujica.IconOptions{Size: 20})
|
||||
})
|
||||
})
|
||||
displayCode(c, iconCode, log)
|
||||
}
|
||||
|
||||
const iconCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
@@ -203,7 +200,6 @@ func avatarGroupDemo(c *ui.Context, log func(string)) {
|
||||
}
|
||||
}
|
||||
})
|
||||
displayCode(c, avatarGroupCode, log)
|
||||
}
|
||||
|
||||
const avatarGroupCode = `mujica.AvatarGroup(c, people, mujica.AvatarGroupOptions{Max: 3})`
|
||||
@@ -250,7 +246,6 @@ func badgeDemo(c *ui.Context, log func(string)) {
|
||||
mujica.Badge(c, opts, func() { mujica.Avatar(c, "Sakiko Togawa", mujica.AvatarOptions{Size: 40}) })
|
||||
})
|
||||
})
|
||||
displayCode(c, badgeCode, log)
|
||||
}
|
||||
|
||||
const badgeCode = `mujica.Badge(c, mujica.BadgeOptions{Text: "New", Tone: mujica.BadgeNeutral}, nil)
|
||||
@@ -303,7 +298,6 @@ func tagDemo(c *ui.Context, log func(string)) {
|
||||
}
|
||||
})
|
||||
})
|
||||
displayCode(c, tagCode, log)
|
||||
}
|
||||
|
||||
const tagCode = `for i, t := range tags {
|
||||
@@ -345,7 +339,6 @@ func linkDemo(c *ui.Context, log func(string)) {
|
||||
log("in-app action")
|
||||
}
|
||||
})
|
||||
displayCode(c, linkCode, log)
|
||||
}
|
||||
|
||||
const linkCode = `mujica.Link(c, "Read the charter", mujica.LinkOptions{URL: "https://example.com/charter"})
|
||||
@@ -384,7 +377,6 @@ func kbdDemo(c *ui.Context, log func(string)) {
|
||||
})
|
||||
}
|
||||
})
|
||||
displayCode(c, kbdCode, log)
|
||||
}
|
||||
|
||||
const kbdCode = `ui.Row(c).Gap(6).Children(func() {
|
||||
|
||||
@@ -59,7 +59,6 @@ func imageDemo(c *ui.Context, log func(string)) {
|
||||
}
|
||||
})
|
||||
})
|
||||
displayCode(c, imageCode, log)
|
||||
}
|
||||
|
||||
const imageCode = `img := mujica.Image(c, photo, mujica.ImageOptions{
|
||||
@@ -111,7 +110,6 @@ func avatarDemo(c *ui.Context, log func(string)) {
|
||||
mujica.Avatar(c, "", mujica.AvatarOptions{Size: 40})
|
||||
})
|
||||
})
|
||||
displayCode(c, avatarCode, log)
|
||||
}
|
||||
|
||||
const avatarCode = `ui.Row(c).Gap(8).Children(func() {
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
mujica "mujicaui"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
// displaySection shows a titled block of a demo.
|
||||
@@ -22,17 +21,6 @@ func displayControls(c *ui.Context, fn func()) {
|
||||
ui.Row(c).Gap(16).Wrap().AlignItems(ui.Center).Children(fn)
|
||||
}
|
||||
|
||||
// displayCode shows a page's snippet with a copy button.
|
||||
func displayCode(c *ui.Context, code string, log func(string)) {
|
||||
displaySection(c, "Code", func() {
|
||||
if mujica.Button(c, "Copy code", mujica.ButtonOptions{Variant: mujica.Secondary}).AlignSelf(ui.Start).Clicked() {
|
||||
c.WriteClipboard(code)
|
||||
log("code copied")
|
||||
}
|
||||
mujica.Text(c, code, mujica.TextOptions{Selectable: true}).Font(theme.MonoFont).FontSize(12)
|
||||
})
|
||||
}
|
||||
|
||||
// displayLog logs a control's new value when it changed.
|
||||
func displayLog[T any](log func(string), e *ui.Element, name string, v *T) {
|
||||
if e.Changed() {
|
||||
|
||||
@@ -15,7 +15,7 @@ var overlayPages = []page{
|
||||
{ID: "081", Name: "Dialog", ZhName: "对话框", Category: overlayCategory, Demo: dialogDemo, Code: dialogCode,
|
||||
Keys: "Tab / Shift+Tab 在对话框内循环;Escape 关闭(可关闭此规则);关闭后焦点回到触发按钮。"},
|
||||
{ID: "082", Name: "AlertDialog", ZhName: "警示对话框", Category: overlayCategory, Demo: alertDialogDemo, Code: alertDialogCode,
|
||||
Keys: "打开时焦点在取消;Enter 只触发聚焦的按钮;Escape 等同取消;点击遮罩无效。"},
|
||||
Keys: "打开时焦点在取消;Enter 只触发聚焦的按钮,输入框中的 Enter 不确认;Escape 等同取消;点击遮罩无效。"},
|
||||
{ID: "083", Name: "Drawer", ZhName: "抽屉", Category: overlayCategory, Demo: drawerDemo, Code: drawerCode,
|
||||
Keys: "模态:焦点限制在抽屉内并在关闭后恢复;Escape 关闭。非模态:窗口仍可操作,Escape 关闭。"},
|
||||
{ID: "084", Name: "Popover", ZhName: "弹出框", Category: overlayCategory, Demo: popoverDemo, Code: popoverCode,
|
||||
@@ -25,7 +25,7 @@ var overlayPages = []page{
|
||||
{ID: "086", Name: "HoverCard", ZhName: "悬停卡片", Category: overlayCategory, Demo: hoverCardDemo, Code: hoverCardCode,
|
||||
Keys: "键盘聚焦锚点后显示,Tab 进入卡片;Escape 关闭。"},
|
||||
{ID: "087", Name: "DropdownMenu", ZhName: "下拉菜单", Category: overlayCategory, Demo: dropdownMenuDemo, Code: dropdownMenuCode,
|
||||
Keys: "Enter / Space / ↓ 打开;↑↓ Home End 移动;→ 打开子菜单,← 关闭;Enter 选择;Escape 或 Tab 关闭。"},
|
||||
Keys: "Enter / Space / ↓ 打开;↑↓ Home End 移动;→ 打开子菜单,← 关闭;Enter 选择;Escape 关闭最上层;Tab 在任一层关闭整个菜单。"},
|
||||
{ID: "088", Name: "ContextMenu", ZhName: "右键菜单", Category: overlayCategory, Demo: contextMenuDemo, Code: contextMenuCode,
|
||||
Keys: "右键在指针处打开;焦点在区域内时按 Menu 键或 Shift+F10 在区域下方打开;其余按键同下拉菜单。"},
|
||||
{ID: "089", Name: "Popconfirm", ZhName: "气泡确认", Category: overlayCategory, Demo: popconfirmDemo, Code: popconfirmCode,
|
||||
@@ -171,10 +171,7 @@ func drawerDemo(c *ui.Context, log func(string)) {
|
||||
log("side = " + s.side)
|
||||
}
|
||||
overlayCheck(c, log, &s.nonModal, "NonModal")
|
||||
ui.Text(c, fmt.Sprintf("Size %.0f", s.size))
|
||||
if ui.StepSlider(c, &s.size, 120, 900, 20).Width(160).Changed() {
|
||||
log(fmt.Sprintf("size = %.0f", s.size))
|
||||
}
|
||||
overlaySlider(c, log, &s.size, "Size", "DIP", 120, 900, 20)
|
||||
})
|
||||
if mujica.Button(c, "Open drawer", mujica.ButtonOptions{}).Clicked() {
|
||||
s.open = true
|
||||
@@ -247,10 +244,7 @@ ui.Render(view, 400, 300, 1)`
|
||||
func tooltipDemo(c *ui.Context, log func(string)) {
|
||||
delay := ui.Local(c.Root(), "085", func() float64 { return 500 })
|
||||
overlayControls(c, func() {
|
||||
ui.Text(c, fmt.Sprintf("Delay %.0f ms", *delay))
|
||||
if ui.StepSlider(c, delay, 100, 1500, 100).Width(160).Changed() {
|
||||
log(fmt.Sprintf("delay = %.0f ms", *delay))
|
||||
}
|
||||
overlaySlider(c, log, delay, "Delay", "ms", 100, 1500, 100)
|
||||
})
|
||||
opts := mujica.TooltipOptions{Delay: time.Duration(*delay) * time.Millisecond}
|
||||
ui.Row(c).Gap(12).Children(func() {
|
||||
@@ -276,19 +270,43 @@ const hoverCardCode = `view := func(c *ui.Context) {
|
||||
}
|
||||
ui.Render(view, 400, 300, 1)`
|
||||
|
||||
type hoverCardDemoState struct {
|
||||
shown bool
|
||||
openDelay, closeDelay, width float64
|
||||
}
|
||||
|
||||
func hoverCardDemo(c *ui.Context, log func(string)) {
|
||||
shown := ui.Local(c.Root(), "086", func() bool { return false })
|
||||
s := ui.Local(c.Root(), "086", func() hoverCardDemoState {
|
||||
return hoverCardDemoState{openDelay: 500, closeDelay: 300, width: 280}
|
||||
})
|
||||
overlayControls(c, func() {
|
||||
overlaySlider(c, log, &s.openDelay, "OpenDelay", "ms", 100, 1500, 100)
|
||||
overlaySlider(c, log, &s.closeDelay, "CloseDelay", "ms", 100, 1500, 100)
|
||||
overlaySlider(c, log, &s.width, "Width", "DIP", 200, 420, 20)
|
||||
})
|
||||
b := mujica.Button(c, "Lady Sakiko", mujica.ButtonOptions{Variant: mujica.Ghost})
|
||||
card := mujica.HoverCard(c, b, mujica.HoverCardOptions{}, func() {
|
||||
card := mujica.HoverCard(c, b, mujica.HoverCardOptions{
|
||||
OpenDelay: time.Duration(s.openDelay) * time.Millisecond,
|
||||
CloseDelay: time.Duration(s.closeDelay) * time.Millisecond,
|
||||
Width: float32(s.width),
|
||||
}, func() {
|
||||
ui.Text(c, "Lady Sakiko").FontWeight(600)
|
||||
ui.Text(c, "Keyboardist of the court; writes the masquerade's score.").TextColor(mujica.Tokens(c).TextMuted)
|
||||
if mujica.Button(c, "Follow", mujica.ButtonOptions{Variant: mujica.Outline}).Clicked() {
|
||||
log("follow")
|
||||
}
|
||||
})
|
||||
if (card != nil) != *shown {
|
||||
*shown = card != nil
|
||||
log(fmt.Sprintf("card shown = %v", *shown))
|
||||
if (card != nil) != s.shown {
|
||||
s.shown = card != nil
|
||||
log(fmt.Sprintf("card shown = %v", s.shown))
|
||||
}
|
||||
}
|
||||
|
||||
// overlaySlider is a labeled step slider logging its value.
|
||||
func overlaySlider(c *ui.Context, log func(string), v *float64, name, unit string, lo, hi, step float64) {
|
||||
ui.Text(c, fmt.Sprintf("%s %.0f %s", name, *v, unit))
|
||||
if ui.StepSlider(c, v, lo, hi, step).Width(140).Changed() {
|
||||
log(fmt.Sprintf("%s = %.0f %s", name, *v, unit))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,6 +60,9 @@ func TestFeedbackRejectsInvalidInput(t *testing.T) {
|
||||
"status unknown labeled": func(c *ui.Context) { StatusIndicator(c, 42, StatusIndicatorOptions{Label: "Custom"}) },
|
||||
"alert severity": func(c *ui.Context) { Alert(c, nil, "x", AlertOptions{Severity: 9}) },
|
||||
"toast severity": func(c *ui.Context) { ShowToast(c, ToastMessage{Message: "x", Severity: -1}) },
|
||||
"meter negative width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: -1}) },
|
||||
"meter NaN width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: float32(nan)}) },
|
||||
"meter infinite width": func(c *ui.Context) { Meter(c, 1, MeterOptions{Max: 2, Width: float32(inf)}) },
|
||||
} {
|
||||
func() {
|
||||
defer func() {
|
||||
@@ -82,3 +85,75 @@ func TestNotificationTitleOnlyHitHeight(t *testing.T) {
|
||||
t.Fatalf("title-only notification hit box %v %v", r, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotificationHeaderWrapsAtDoubleText(t *testing.T) {
|
||||
items := []Notification{{ID: "a", Title: "Build failed"}, {ID: "b", Title: "Backup complete", Read: true}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Width(520).Height(700).Children(func() {
|
||||
NotificationCenter(c, &items, NotificationCenterOptions{})
|
||||
})
|
||||
}, 600, 760)
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 2})
|
||||
for _, s := range []string{"Notifications", "1 unread", "Mark all read", "Clear all"} {
|
||||
if r, ok := tt.Find(s); !ok || r.W == 0 || r.H == 0 || r.X < 0 || r.X+r.W > 520 {
|
||||
t.Errorf("%q at 200%%: %v %v", s, r, ok)
|
||||
}
|
||||
}
|
||||
if err := tt.Click("Clear all"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 0 {
|
||||
t.Fatalf("Clear all at 200%% left %d items", len(items))
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressRingCaptionFitsAtDoubleText(t *testing.T) {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Row(c).Children(func() {
|
||||
Progress(c, 1, ProgressOptions{Shape: ProgressRing, ShowValue: true})
|
||||
})
|
||||
}, 400, 200)
|
||||
for _, scale := range []float32{1, 2} {
|
||||
tt.SetPreferences(ui.Preferences{TextScale: scale})
|
||||
tt.Frame()
|
||||
ring, ok := tt.Find("Loading")
|
||||
text, ok2 := tt.Find("100%")
|
||||
if !ok || !ok2 {
|
||||
t.Fatalf("scale %v: ring %v text %v", scale, ok, ok2)
|
||||
}
|
||||
stroke := max(float32(3), ring.W/10)
|
||||
inside := text.X >= ring.X+stroke && text.X+text.W <= ring.X+ring.W-stroke
|
||||
outside := text.X >= ring.X+ring.W
|
||||
if !inside && !outside {
|
||||
t.Errorf("scale %v: caption %v clipped by ring %v", scale, text, ring)
|
||||
}
|
||||
if scale == 1 && !inside {
|
||||
t.Errorf("caption left the ring at 100%%: %v", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressResumesFromDisplayedValueAfterReducedMotion(t *testing.T) {
|
||||
v := 0.0
|
||||
var bar ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Column(c).Width(300).Children(func() {
|
||||
bar = Progress(c, v, ProgressOptions{}).Bounds()
|
||||
})
|
||||
}, 400, 100)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
v = 0.5
|
||||
tt.Frame()
|
||||
tt.SetPreferences(ui.Preferences{TextScale: 1})
|
||||
if got, want := progressPixel(tt, bar.X+bar.W*0.45, bar.Y+bar.H/2), theme.Light().AccentText; got != want {
|
||||
t.Fatalf("after motion returned the fill at 45%% is %v, want %v", got, want)
|
||||
}
|
||||
v = 1
|
||||
tt.Frame()
|
||||
if got := progressPixel(tt, bar.X+bar.W*0.95, bar.Y+bar.H/2); got == theme.Light().AccentText {
|
||||
t.Fatal("restored motion did not animate the next change")
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -152,11 +152,12 @@ func FileDropZone(c *ui.Context, files *[]DroppedFile, opts FileDropZoneOptions)
|
||||
col.Children(func() {
|
||||
zone := ui.Column(c).AlignItems(ui.Center).Justify(ui.Center).Gap(d.Unit() * 2).MinHeight(140).Padding(d.Unit() * 5).
|
||||
Radius(theme.CardRadius).Disabled(opts.Disabled)
|
||||
if dropped := zone.DroppedFiles(); dropped != nil && !opts.Disabled {
|
||||
disabled := zone.IsDisabled()
|
||||
if dropped := zone.DroppedFiles(); dropped != nil && !disabled {
|
||||
slog.Debug("mujica: file drop zone dropped", "count", len(dropped))
|
||||
take(dropped)
|
||||
}
|
||||
over := zone.FileDragOver() && !opts.Disabled
|
||||
over := zone.FileDragOver() && !disabled
|
||||
bg, border := k.Surface, k.ControlBorder
|
||||
if over {
|
||||
bg, border = k.Selection, k.AccentText
|
||||
|
||||
@@ -35,6 +35,7 @@ type formFieldState struct {
|
||||
pending bool
|
||||
hasAsync bool
|
||||
check func() string
|
||||
refresh func()
|
||||
startAsync func()
|
||||
}
|
||||
|
||||
@@ -196,6 +197,12 @@ func formStartSubmit(st *FormState) {
|
||||
|
||||
// formSettle finishes a waiting submit once no check is pending; it reports success.
|
||||
func formSettle(c *ui.Context, st *FormState) bool {
|
||||
// Current values only: later edits and cross-field rules count.
|
||||
for _, name := range st.order {
|
||||
f := st.fields[name]
|
||||
f.refresh()
|
||||
f.err = f.check()
|
||||
}
|
||||
first, pending := "", false
|
||||
for _, name := range st.order {
|
||||
if st.Error(name) != "" && first == "" {
|
||||
@@ -244,11 +251,10 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
||||
panic("mujica: FormItem " + name + " registered twice")
|
||||
}
|
||||
st.order = append(st.order, name)
|
||||
snap := fmt.Sprintf("%#v", *value)
|
||||
fs := st.fields[name]
|
||||
if fs == nil {
|
||||
st.seq++
|
||||
fs = &formFieldState{snapshot: snap, version: st.seq}
|
||||
fs = &formFieldState{snapshot: fmt.Sprintf("%#v", *value), version: st.seq}
|
||||
st.fields[name] = fs
|
||||
}
|
||||
fs.hasAsync = opts.Async != nil
|
||||
@@ -268,12 +274,18 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
||||
opts.Async(*value, fs.version)
|
||||
}
|
||||
}
|
||||
if snap != fs.snapshot {
|
||||
// refresh gives an edited value a new version; it runs after the control took its input.
|
||||
fs.refresh = func() {
|
||||
snap := fmt.Sprintf("%#v", *value)
|
||||
if snap == fs.snapshot {
|
||||
return
|
||||
}
|
||||
st.seq++
|
||||
fs.snapshot, fs.version, fs.pending, fs.asyncErr = snap, st.seq, false, ""
|
||||
if fs.touched || st.tried {
|
||||
fs.err = fs.check()
|
||||
}
|
||||
c.Invalidate()
|
||||
}
|
||||
initial := opts.Initial
|
||||
st.resets = append(st.resets, func() { *value = initial })
|
||||
@@ -286,6 +298,7 @@ func FormItem[T any](f *FormScope, name string, value *T, opts FormItemOptions[T
|
||||
ctrl = control()
|
||||
return ctrl
|
||||
})
|
||||
fs.refresh()
|
||||
focused := ctrl.FocusWithin()
|
||||
if fs.focused && !focused {
|
||||
fs.touched = true
|
||||
|
||||
@@ -262,3 +262,59 @@ func TestFormSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Text typed in the frame the check passes gets its own check before submitting.
|
||||
func TestFormInputAfterAsyncPassIsRechecked(t *testing.T) {
|
||||
p := &formProbe{name: "Uika", tags: []string{"x"}, email: "good@x", useAsync: true}
|
||||
tt := ui.NewTester(p.run, 600, 500)
|
||||
_ = tt.Click("Email")
|
||||
p.st.Submit()
|
||||
tt.Frame()
|
||||
if len(p.asyncCalls) != 1 {
|
||||
t.Fatalf("calls %v", p.asyncCalls)
|
||||
}
|
||||
p.st.Resolve("email", p.asyncCalls[0], "")
|
||||
tt.Type("bad")
|
||||
if p.submits != 0 || len(p.asyncValues) != 2 || p.asyncValues[1] != "good@xbad" {
|
||||
t.Fatalf("submits %d checked %q", p.submits, p.asyncValues)
|
||||
}
|
||||
}
|
||||
|
||||
// A cross-field rule sees the other field's latest value when the submit settles.
|
||||
func TestFormCrossFieldRuleAtSettle(t *testing.T) {
|
||||
var st FormState
|
||||
user, pw, confirm := "uika", "a", "a"
|
||||
var version uint64
|
||||
submits := 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
res := Form(c, &st, FormOptions{}, func(f *FormScope) {
|
||||
FormItem(f, "user", &user, FormItemOptions[string]{Label: "User", Async: func(_ string, v uint64) { version = v }}, func() *ui.Element {
|
||||
return InputGroup(c, &user, InputGroupOptions{}).Input
|
||||
})
|
||||
FormItem(f, "pw", &pw, FormItemOptions[string]{Label: "Password"}, func() *ui.Element {
|
||||
return InputGroup(c, &pw, InputGroupOptions{}).Input
|
||||
})
|
||||
FormItem(f, "confirm", &confirm, FormItemOptions[string]{Label: "Confirm", Validate: func(v string) string {
|
||||
if v != pw {
|
||||
return "Passwords differ."
|
||||
}
|
||||
return ""
|
||||
}}, func() *ui.Element {
|
||||
return InputGroup(c, &confirm, InputGroupOptions{}).Input
|
||||
})
|
||||
})
|
||||
if res.Submitted() {
|
||||
submits++
|
||||
}
|
||||
}, 600, 500)
|
||||
_ = tt.Click("Submit")
|
||||
pw = "b"
|
||||
tt.Frame()
|
||||
st.Resolve("user", version, "")
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if submits != 0 || !tt.HasText("Passwords differ.") {
|
||||
t.Fatalf("mismatched pair submitted: %d", submits)
|
||||
}
|
||||
}
|
||||
+19
-44
@@ -20,17 +20,12 @@ type HoverCardOptions struct {
|
||||
|
||||
// hoverCardState is what a card keeps between frames while its anchor lives.
|
||||
type hoverCardState struct {
|
||||
open, dismissed bool
|
||||
// keyMode keeps the card in the focus order while the keyboard is on it.
|
||||
keyMode bool
|
||||
enterAt, leaveAt time.Time
|
||||
panel ui.Rect
|
||||
open, dismissed, focusIn bool
|
||||
enterAt, leaveAt time.Time
|
||||
panel ui.Rect
|
||||
}
|
||||
|
||||
// HoverCard previews content beside anchor while the pointer rests on it or
|
||||
// it has the keyboard focus. The pointer may move into the card; with the
|
||||
// keyboard, Tab from the anchor enters it. It closes after CloseDelay once
|
||||
// neither holds it, or at once on Escape. It returns the card, or nil.
|
||||
// HoverCard previews content by a hovered or focused anchor; nil while hidden.
|
||||
func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content func()) *ui.Element {
|
||||
if anchor == nil {
|
||||
panic("mujica: HoverCard needs an anchor")
|
||||
@@ -50,11 +45,11 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content
|
||||
keyboard := anchor.FocusVisible()
|
||||
pointer := anchor.Hovered()
|
||||
if st.open {
|
||||
// The card's layer covers the anchor, so test the pointer by geometry.
|
||||
// The popover's backdrop covers the anchor, so test the pointer by geometry.
|
||||
x, y, in := c.Root().PointerPosition()
|
||||
pointer = pointer || in && (b.Contains(x, y) || st.panel.Contains(x, y))
|
||||
}
|
||||
hot := pointer || keyboard || st.keyMode
|
||||
hot := pointer || keyboard || st.focusIn
|
||||
if !hot {
|
||||
st.dismissed = false
|
||||
}
|
||||
@@ -87,7 +82,7 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content
|
||||
c.After(wait)
|
||||
} else {
|
||||
slog.Debug("mujica: hover_card close")
|
||||
st.open, st.keyMode, st.leaveAt = false, false, time.Time{}
|
||||
st.open, st.focusIn, st.leaveAt = false, false, time.Time{}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -95,43 +90,23 @@ func HoverCard(c *ui.Context, anchor *ui.Element, opts HoverCardOptions, content
|
||||
if width == 0 {
|
||||
width = 280
|
||||
}
|
||||
style := func(panel *ui.Element, p float32) {
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Width(width).Padding(Density(c).Unit() * 4).Gap(Density(c).Unit() * 2).Label(opts.Label).
|
||||
MaxHeight(popoverRoom(c, b))
|
||||
overlayLayer(c, st)
|
||||
// A popover keeps the card in the anchor's focus order and modal scope, even inside a Dialog.
|
||||
shown := true
|
||||
panel := ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, b))
|
||||
panel.Margin(6, 0, 0, 0).Width(popoverWide(c, width)).Padding(Density(c).Unit() * 4).Gap(Density(c).Unit() * 2).
|
||||
Label(opts.Label).MaxHeight(popoverRoom(c, b))
|
||||
ui.Scroll(c).Shrink(1).Children(content)
|
||||
}
|
||||
var panel *ui.Element
|
||||
dismissed, keyMode := false, keyboard || st.keyMode
|
||||
if keyMode {
|
||||
shown := true
|
||||
panel = ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) {
|
||||
style(panel.Margin(6, 0, 0, 0), overlayAnchored(c, panel, b))
|
||||
})
|
||||
dismissed = !shown
|
||||
} else {
|
||||
ui.Overlay(c, func() {
|
||||
panel = ui.Column(c).Absolute().Role(ui.RolePopup)
|
||||
prev := panel.Bounds()
|
||||
x, y := overlayPlace(c, b, width, prev.H, 6)
|
||||
panel.Left(x).Top(y)
|
||||
p := overlayEnter(c, panel)
|
||||
if prev.H == 0 {
|
||||
p = 0 // not measured yet
|
||||
c.AnimationFrame()
|
||||
}
|
||||
panel.Children(func() { style(panel, p) })
|
||||
})
|
||||
dismissed = c.Shortcut(0, ui.KeyEscape)
|
||||
}
|
||||
if dismissed {
|
||||
})
|
||||
if !shown {
|
||||
slog.Debug("mujica: hover_card dismiss")
|
||||
st.open, st.dismissed, st.keyMode = false, true, false
|
||||
if keyMode {
|
||||
if keyboard || st.focusIn {
|
||||
anchor.Focus() // at once, so the anchor stays hot and the card stays dismissed
|
||||
}
|
||||
st.open, st.dismissed, st.focusIn = false, true, false
|
||||
return panel
|
||||
}
|
||||
st.panel, st.keyMode = panel.Bounds(), keyboard || keyMode && panel.FocusWithin()
|
||||
st.panel, st.focusIn = panel.Bounds(), panel.FocusWithin()
|
||||
return panel
|
||||
}
|
||||
+100
-42
@@ -7,30 +7,45 @@ import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
const hoverCardTestDelay = 20 * time.Millisecond
|
||||
const hoverCardTestDelay = 150 * time.Millisecond
|
||||
|
||||
type hoverCardApp struct {
|
||||
follows, next int
|
||||
inDialog bool
|
||||
}
|
||||
|
||||
func (a *hoverCardApp) card(c *ui.Context) {
|
||||
b := Button(c, "Lady Sakiko", ButtonOptions{Variant: Ghost})
|
||||
HoverCard(c, b, HoverCardOptions{OpenDelay: hoverCardTestDelay, CloseDelay: hoverCardTestDelay}, func() {
|
||||
ui.Text(c, "Keyboardist of the court")
|
||||
if Button(c, "Follow", ButtonOptions{}).Clicked() {
|
||||
a.follows++
|
||||
}
|
||||
})
|
||||
if Button(c, "Next", ButtonOptions{}).Clicked() {
|
||||
a.next++
|
||||
}
|
||||
}
|
||||
|
||||
func (a *hoverCardApp) view(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(40).Gap(80).AlignItems(ui.Start).Children(func() {
|
||||
b := Button(c, "Lady Sakiko", ButtonOptions{Variant: Ghost})
|
||||
HoverCard(c, b, HoverCardOptions{OpenDelay: hoverCardTestDelay, CloseDelay: hoverCardTestDelay}, func() {
|
||||
ui.Text(c, "Keyboardist of the court")
|
||||
if Button(c, "Follow", ButtonOptions{}).Clicked() {
|
||||
a.follows++
|
||||
}
|
||||
if a.inDialog {
|
||||
open := true
|
||||
Dialog(c, &open, DialogOptions{Title: "Court", HideClose: true}, func() {
|
||||
ui.Column(c).Gap(80).AlignItems(ui.Start).Children(func() { a.card(c) })
|
||||
})
|
||||
if Button(c, "Next", ButtonOptions{}).Clicked() {
|
||||
a.next++
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
ui.Column(c).Padding(40).Gap(80).AlignItems(ui.Start).Children(func() { a.card(c) })
|
||||
}
|
||||
|
||||
func hoverCardWait(tt *ui.Tester) {
|
||||
time.Sleep(hoverCardTestDelay + 15*time.Millisecond)
|
||||
func hoverCardShown(tt *ui.Tester) func() bool {
|
||||
return func() bool { return tt.HasText("Keyboardist of the court") }
|
||||
}
|
||||
|
||||
// hoverCardSettle waits past both delays so any pending open or close has happened.
|
||||
func hoverCardSettle(tt *ui.Tester) {
|
||||
time.Sleep(hoverCardTestDelay + 30*time.Millisecond)
|
||||
tt.Frame()
|
||||
}
|
||||
|
||||
@@ -38,34 +53,54 @@ func TestHoverCardPointerCanEnterCard(t *testing.T) {
|
||||
a := &hoverCardApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
r, _ := tt.Find("Lady Sakiko")
|
||||
start := time.Now()
|
||||
tt.Move(r.X+5, r.Y+5)
|
||||
if tt.HasText("Keyboardist of the court") {
|
||||
if time.Since(start) < hoverCardTestDelay && tt.HasText("Keyboardist of the court") {
|
||||
t.Fatal("opened before the delay")
|
||||
}
|
||||
hoverCardWait(tt)
|
||||
tt.Frame()
|
||||
f, ok := tt.Find("Follow")
|
||||
if !ok {
|
||||
if !overlayUntil(tt, hoverCardShown(tt)) {
|
||||
t.Fatalf("card did not open: %q", tt.Texts())
|
||||
}
|
||||
// Moving into the card keeps it; its button works.
|
||||
tt.Move(f.X+5, f.Y+5)
|
||||
hoverCardWait(tt)
|
||||
// The pointer inside the card keeps it open past the close delay.
|
||||
k, _ := tt.Find("Keyboardist of the court")
|
||||
tt.Move(k.X+5, k.Y+5)
|
||||
hoverCardSettle(tt)
|
||||
if !tt.HasText("Follow") {
|
||||
t.Fatal("card closed with the pointer inside it")
|
||||
}
|
||||
if err := tt.Click("Follow"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.follows != 1 {
|
||||
t.Fatalf("follows %d", a.follows)
|
||||
if a.follows != 1 || !tt.Focused("Follow") {
|
||||
t.Fatalf("follows %d, Follow focused %v", a.follows, tt.Focused("Follow"))
|
||||
}
|
||||
// Leaving both closes it after the delay, and the window stays usable.
|
||||
// With the focus inside, leaving keeps it; a click outside closes it.
|
||||
tt.Move(490, 390)
|
||||
tt.Frame()
|
||||
hoverCardSettle(tt)
|
||||
if !tt.HasText("Follow") {
|
||||
t.Fatal("card closed with the focus inside it")
|
||||
}
|
||||
tt.ClickAt(490, 390)
|
||||
if tt.HasText("Follow") {
|
||||
t.Fatal("outside click did not close the card")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverCardCloseDelay(t *testing.T) {
|
||||
a := &hoverCardApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
r, _ := tt.Find("Lady Sakiko")
|
||||
tt.Move(r.X+5, r.Y+5)
|
||||
if !overlayUntil(tt, hoverCardShown(tt)) {
|
||||
t.Fatal("card did not open")
|
||||
}
|
||||
start := time.Now()
|
||||
tt.Move(490, 390)
|
||||
if time.Since(start) < hoverCardTestDelay && !tt.HasText("Follow") {
|
||||
t.Fatal("closed without the close delay")
|
||||
}
|
||||
hoverCardWait(tt)
|
||||
if tt.HasText("Follow") {
|
||||
t.Fatal("card stayed open")
|
||||
if !overlayUntil(tt, func() bool { return !tt.HasText("Follow") }) {
|
||||
t.Fatal("card stayed open after the close delay")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,8 +108,7 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) {
|
||||
a := &hoverCardApp{}
|
||||
tt := ui.NewTester(a.view, 500, 400)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
hoverCardWait(tt)
|
||||
if !tt.HasText("Keyboardist of the court") {
|
||||
if !overlayUntil(tt, hoverCardShown(tt)) {
|
||||
t.Fatal("no card on keyboard focus")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
@@ -89,7 +123,7 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) {
|
||||
if tt.HasText("Follow") || !tt.Focused("Lady Sakiko") {
|
||||
t.Fatalf("Escape: card shown %v, anchor focused %v", tt.HasText("Follow"), tt.Focused("Lady Sakiko"))
|
||||
}
|
||||
hoverCardWait(tt)
|
||||
hoverCardSettle(tt)
|
||||
if tt.HasText("Follow") {
|
||||
t.Fatal("dismissed card came back while focused")
|
||||
}
|
||||
@@ -99,19 +133,43 @@ func TestHoverCardKeyboardEntersAndEscapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverCardInsideDialog(t *testing.T) {
|
||||
a := &hoverCardApp{inDialog: true}
|
||||
tt := ui.NewTester(a.view, 600, 500)
|
||||
r, _ := tt.Find("Lady Sakiko")
|
||||
tt.Move(r.X+5, r.Y+5)
|
||||
if !overlayUntil(tt, hoverCardShown(tt)) {
|
||||
t.Fatal("card did not open in the dialog")
|
||||
}
|
||||
if err := tt.Click("Follow"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a.follows != 1 || !tt.Focused("Follow") {
|
||||
t.Fatalf("follows %d, focus kept on Follow %v", a.follows, tt.Focused("Follow"))
|
||||
}
|
||||
tt.Key(ui.Shift, ui.KeyTab)
|
||||
if !tt.Focused("Lady Sakiko") {
|
||||
t.Fatal("Shift+Tab from the card did not reach its anchor in the dialog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHoverCardSnapshot(t *testing.T) {
|
||||
snapshot(t, "hover_card", 420, 240, func(c *ui.Context) {
|
||||
overlaySnapshot(t, "hover_card", 420, 240, func(c *ui.Context) {
|
||||
ui.Column(c).Padding(24).AlignItems(ui.Start).Children(func() {
|
||||
Button(c, "Lady Uika", ButtonOptions{Variant: Ghost})
|
||||
})
|
||||
ui.Overlay(c, func() {
|
||||
p := ui.Column(c).Absolute().Left(24).Top(66)
|
||||
overlayStyle(c, p, 1)
|
||||
p.Width(280).Padding(16).Gap(8).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, "Lady Uika").FontWeight(600)
|
||||
ui.Text(c, "Vocalist and guitarist; keeps the ledger of the masquerade.").TextColor(Tokens(c).TextMuted)
|
||||
Button(c, "Follow", ButtonOptions{Variant: Outline})
|
||||
b := Button(c, "Lady Uika", ButtonOptions{Variant: Ghost})
|
||||
HoverCard(c, b, HoverCardOptions{OpenDelay: time.Millisecond}, func() {
|
||||
ui.Column(c).Gap(8).AlignItems(ui.Start).Children(func() {
|
||||
ui.Text(c, "Lady Uika").FontWeight(600)
|
||||
ui.Text(c, "Vocalist and guitarist; keeps the ledger of the masquerade.").TextColor(Tokens(c).TextMuted)
|
||||
Button(c, "Follow", ButtonOptions{Variant: Outline})
|
||||
})
|
||||
})
|
||||
})
|
||||
}, func(tt *ui.Tester) {
|
||||
r, _ := tt.Find("Lady Uika")
|
||||
tt.Move(r.X+5, r.Y+5)
|
||||
if !overlayUntil(tt, func() bool { return tt.HasText("Follow") }) {
|
||||
t.Fatal("card did not open")
|
||||
}
|
||||
})
|
||||
}
|
||||
+18
-4
@@ -41,13 +41,14 @@ type InputGroupParts struct {
|
||||
Input *ui.Element
|
||||
// Clicked is the index in Buttons of the button clicked, or -1.
|
||||
Clicked int
|
||||
locked bool
|
||||
}
|
||||
|
||||
// Changed reports whether the user edited the value this frame.
|
||||
func (p InputGroupParts) Changed() bool { return p.Input.Changed() }
|
||||
func (p InputGroupParts) Changed() bool { return !p.locked && p.Input.Changed() }
|
||||
|
||||
// Submitted reports whether Enter was pressed in the input.
|
||||
func (p InputGroupParts) Submitted() bool { return p.Input.Submitted() }
|
||||
func (p InputGroupParts) Submitted() bool { return !p.locked && p.Input.Submitted() }
|
||||
|
||||
// InputGroup joins an input, fixed text and buttons, each its own Tab stop, in one border.
|
||||
func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroupParts {
|
||||
@@ -91,7 +92,7 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup
|
||||
}
|
||||
button := func(i int) {
|
||||
b := opts.Buttons[i]
|
||||
bb := ui.ButtonBase(c).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()).
|
||||
bb := ui.ButtonBase(c).MinHeight(theme.MinHitSize).Padding(0, d.Unit()*3).Gap(d.Unit() * 2).MinWidth(d.ControlHeight()).
|
||||
Disabled(b.Disabled).TextColor(k.AccentText).Label(b.Label)
|
||||
segment(bb)
|
||||
internal.Ring(bb, k.Focus, theme.ControlRadius)
|
||||
@@ -133,7 +134,8 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup
|
||||
// As wide as MyGo makes a text input.
|
||||
parts.Input = ui.Text(c, *value).SingleLine().Selectable().Focusable().FocusRing(false).Grow(1).Width(200).MinWidth(0)
|
||||
} else {
|
||||
parts.Input = ui.TextInputBase(c, value).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder)
|
||||
parts.locked = box.IsDisabled()
|
||||
parts.Input = inputGroupEditor(c, box, value, parts.locked).FocusRing(false).Grow(1).MinWidth(0).Placeholder(opts.Placeholder)
|
||||
}
|
||||
if opts.Label != "" {
|
||||
parts.Input.Label(opts.Label)
|
||||
@@ -169,3 +171,15 @@ func InputGroup(c *ui.Context, value *string, opts InputGroupOptions) InputGroup
|
||||
})
|
||||
return parts
|
||||
}
|
||||
|
||||
// inputGroupEditor builds the input; a disabled one edits a copy, so queued input cannot reach value.
|
||||
func inputGroupEditor(c *ui.Context, box *ui.Element, value *string, locked bool) *ui.Element {
|
||||
if !locked {
|
||||
return ui.TextInputBase(c, value)
|
||||
}
|
||||
shown := ui.Local(box, "locked", func() string { return *value })
|
||||
*shown = *value
|
||||
in := ui.TextInputBase(c, shown)
|
||||
*shown = *value
|
||||
return in
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestInputGroupTypingAndButtons(t *testing.T) {
|
||||
@@ -108,3 +109,35 @@ func TestInputGroupSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Input queued while focused must not edit a group disabled since.
|
||||
func TestInputGroupDisabledAfterFocus(t *testing.T) {
|
||||
value, disabled, changes := "abc", false, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Disabled(disabled).Children(func() {
|
||||
if InputGroup(c, &value, InputGroupOptions{Label: "Code"}).Changed() {
|
||||
changes++
|
||||
}
|
||||
})
|
||||
}, 400, 100)
|
||||
_ = tt.Click("Code")
|
||||
disabled = true
|
||||
tt.Type("zz")
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
tt.Frame()
|
||||
if value != "abc" || changes != 0 {
|
||||
t.Fatalf("value %q changes %d", value, changes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInputGroupHitAreasCompact(t *testing.T) {
|
||||
value := ""
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Density: theme.Compact})
|
||||
InputGroup(c, &value, InputGroupOptions{Label: "Query", Buttons: []InputGroupButton{{Label: "Clear", Icon: icons.Must("x"), IconOnly: true}}})
|
||||
}, 400, 100)
|
||||
if r, ok := tt.Find("Clear"); !ok || r.W < 28 || r.H < 28 {
|
||||
t.Fatalf("button %v", r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func TestInputsObeyDisabledAncestor(t *testing.T) {
|
||||
text, area, num, find, cur := "", "", 5.0, "a", 0
|
||||
off, events := false, 0
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Gap(8).Disabled(off).Children(func() {
|
||||
for _, r := range []InputResult{
|
||||
TextInput(c, &text, TextInputOptions{Label: "Text"}),
|
||||
TextArea(c, &area, TextAreaOptions{Label: "Area", SubmitKey: ui.KeyEnter}),
|
||||
NumberInput(c, &num, NumberInputOptions{Label: "Num"}),
|
||||
} {
|
||||
if r.Changed() || r.Submitted() {
|
||||
events++
|
||||
}
|
||||
}
|
||||
if SearchInput(c, &find, SearchInputOptions{Label: "Find", Find: true, Matches: 3, Current: &cur}).Moved() {
|
||||
events++
|
||||
}
|
||||
})
|
||||
}, 400, 500)
|
||||
next := func() {
|
||||
if runtime.GOOS == "darwin" {
|
||||
tt.Key(ui.Cmd, ui.KeyG)
|
||||
} else {
|
||||
tt.Key(0, ui.KeyF3)
|
||||
}
|
||||
}
|
||||
for _, name := range []string{"Text", "Area", "Num"} {
|
||||
_ = tt.Click(name)
|
||||
events = 0
|
||||
off = true
|
||||
tt.Type("x")
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
tt.Key(0, ui.KeyUp)
|
||||
next()
|
||||
if events != 0 {
|
||||
t.Errorf("%s: %d events under a disabled ancestor", name, events)
|
||||
}
|
||||
off = false
|
||||
tt.Frame()
|
||||
}
|
||||
if text != "" || area != "" || num != 5 || cur != 0 {
|
||||
t.Fatalf("values changed: %q %q %v current %d", text, area, num, cur)
|
||||
}
|
||||
}
|
||||
|
||||
// buttonLabelInk counts pixels in the label's box that differ from its first pixel.
|
||||
func buttonLabelInk(tt *ui.Tester, label string) int {
|
||||
r, _ := tt.Find(label)
|
||||
img := tt.Image()
|
||||
bg := img.At(int(r.X), int(r.Y))
|
||||
n := 0
|
||||
for y := int(r.Y); y < int(r.Y+r.H); y++ {
|
||||
for x := int(r.X); x < int(r.X+r.W); x++ {
|
||||
if !sameColor(img.At(x, y), bg) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func sameColor(a, b color.Color) bool {
|
||||
r1, g1, b1, a1 := a.RGBA()
|
||||
r2, g2, b2, a2 := b.RGBA()
|
||||
return r1 == r2 && g1 == g2 && b1 == b2 && a1 == a2
|
||||
}
|
||||
|
||||
func TestButtonLoadingKeepsLabelAndWidth(t *testing.T) {
|
||||
for _, d := range []theme.Density{theme.Compact, theme.Default, theme.Comfortable} {
|
||||
for _, icon := range []*ui.SVG{nil, icons.Must("check")} {
|
||||
loading := false
|
||||
var w float32
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Density: d})
|
||||
ui.Row(c).Children(func() {
|
||||
w = Button(c, "Save", ButtonOptions{Icon: icon, Loading: loading}).Bounds().W
|
||||
})
|
||||
}, 300, 200)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
tt.Frame()
|
||||
before := w
|
||||
loading = true
|
||||
tt.Frame()
|
||||
tt.Frame()
|
||||
if w != before {
|
||||
t.Errorf("%v icon %v: width %v -> %v", d, icon != nil, before, w)
|
||||
}
|
||||
// The spinner sits beside the label, never over it, and the label has ink.
|
||||
sp, _ := tt.Find("Loading")
|
||||
lb, _ := tt.Find("Save")
|
||||
if sp.X+sp.W > lb.X || buttonLabelInk(tt, "Save") == 0 {
|
||||
t.Errorf("%v icon %v: label hidden while loading (spinner %v, label %v)", d, icon != nil, sp, lb)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func numberInputStepTester(v *float64, opts NumberInputOptions) *ui.Tester {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
NumberInput(c, v, opts)
|
||||
}, 400, 200)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
return tt
|
||||
}
|
||||
|
||||
func TestNumberInputStepGrid(t *testing.T) {
|
||||
for _, stepper := range []bool{false, true} {
|
||||
v := 0.2
|
||||
tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 0.29999999, Step: 0.1, StepperOnly: stepper})
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != 0.2 {
|
||||
t.Errorf("stepper %v: Up past Max 0.29999999 gave %v", stepper, v)
|
||||
}
|
||||
v = 0
|
||||
tt = numberInputStepTester(&v, NumberInputOptions{Min: -1, Max: 1, Step: 1e-10, StepperOnly: stepper})
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v != 1e-10 {
|
||||
t.Errorf("stepper %v: Up by 1e-10 from 0 gave %v", stepper, v)
|
||||
}
|
||||
}
|
||||
v := 0.0
|
||||
tt := numberInputStepTester(&v, NumberInputOptions{Min: 0, Max: 10, Step: 3, StepperOnly: true})
|
||||
var got []float64
|
||||
for _, k := range []ui.Key{ui.KeyUp, ui.KeyEnd, ui.KeyDown, ui.KeyHome, ui.KeyDown} {
|
||||
tt.Key(0, k)
|
||||
got = append(got, v)
|
||||
}
|
||||
want := []float64{3, 10, 9, 0, 0}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("stepper [0,10] by 3: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// radioSameName prints the same for every value, as fmt keys would see it.
|
||||
type radioSameName struct{ id int }
|
||||
|
||||
func (radioSameName) String() string { return "same" }
|
||||
|
||||
func TestRadioGroupKeysByEquality(t *testing.T) {
|
||||
v := radioSameName{0}
|
||||
items := []RadioItem[radioSameName]{{Value: radioSameName{1}, Label: "One"}, {Value: radioSameName{2}, Label: "Two"}}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
RadioGroup(c, &v, items, RadioGroupOptions{})
|
||||
}, 300, 200)
|
||||
_ = tt.Click("Two")
|
||||
if v.id != 2 {
|
||||
t.Fatalf("click chose %v", v)
|
||||
}
|
||||
tt.Key(0, ui.KeyUp)
|
||||
if v.id != 1 {
|
||||
t.Fatalf("Up chose %v", v)
|
||||
}
|
||||
items[0], items[1] = items[1], items[0]
|
||||
tt.Frame()
|
||||
tt.Key(0, ui.KeySpace)
|
||||
if v.id != 1 || !tt.Focused("One") {
|
||||
t.Fatalf("after reorder chose %v, One focused %v", v, tt.Focused("One"))
|
||||
}
|
||||
}
|
||||
+4
-5
@@ -36,12 +36,11 @@ func CheckBox(p *ui.Painter, r ui.Rect, s CheckState, k theme.Tokens, hover bool
|
||||
if s == Mixed {
|
||||
mark = "minus"
|
||||
}
|
||||
Mark(p, r, mark, k.OnAccent)
|
||||
in := r.W * 0.12
|
||||
Mark(p, ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, mark, k.OnAccent)
|
||||
}
|
||||
|
||||
// Mark paints the Lucide icon name inset in r. Icons sit on the pixel grid,
|
||||
// so their cached masks never differ between frames or tests.
|
||||
// Mark paints the Lucide icon name in r, on the pixel grid so masks never vary.
|
||||
func Mark(p *ui.Painter, r ui.Rect, name string, c ui.Color) {
|
||||
in := r.W * 0.12
|
||||
p.Icon(icons.Must(name), ui.Rect{X: r.X + in, Y: r.Y + in, W: r.W - 2*in, H: r.H - 2*in}, c)
|
||||
p.Icon(icons.Must(name), r, c)
|
||||
}
|
||||
@@ -17,3 +17,15 @@ func Ease(e *ui.Element, d time.Duration, fg ui.Color) *ui.Element {
|
||||
}
|
||||
return e.TextColor(ui.Color{R: ch("mujica.fg.r", fg.R), G: ch("mujica.fg.g", fg.G), B: ch("mujica.fg.b", fg.B), A: ch("mujica.fg.a", fg.A)})
|
||||
}
|
||||
|
||||
// PopIn fades popup e in over d from the frame it appeared and returns the progress, 0 to 1.
|
||||
func PopIn(c *ui.Context, e *ui.Element, d time.Duration) float32 {
|
||||
born := ui.Local(e, "mujica.popin", func() time.Time { return c.Now() })
|
||||
p := float32(1)
|
||||
if t := float32(c.Now().Sub(*born)) / float32(max(d, 1)); d > 0 && t < 1 {
|
||||
p = ui.EaseOut(t)
|
||||
c.AnimationFrame()
|
||||
}
|
||||
e.Opacity(p)
|
||||
return p
|
||||
}
|
||||
@@ -285,6 +285,7 @@ func (m *menubarRun) activate(l, h int) {
|
||||
}
|
||||
if it.Items != nil {
|
||||
m.st.hl = append(m.st.hl[:l+1], menubarStep(it.Items, -1, 1))
|
||||
m.focus(m.st.open)
|
||||
return
|
||||
}
|
||||
m.chosen = it.ID
|
||||
|
||||
@@ -64,6 +64,7 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u
|
||||
panel := ui.Scroll(c).Absolute().Padding(u).MinWidth(200).MaxHeight(wh-8).AlignItems(ui.Stretch).Role(ui.RolePopup).
|
||||
Radius(theme.PopupRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Shadow(0, 6, 16, 0, ui.RGBA(0, 0, 0, 0.18))
|
||||
internal.PopIn(c, panel, Motion(c, theme.PopupDuration))
|
||||
pb := panel.Bounds()
|
||||
if x+pb.W > ww-4 {
|
||||
x = ww - 4 - pb.W
|
||||
@@ -75,6 +76,9 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u
|
||||
y = max(4, wh-4-pb.H)
|
||||
}
|
||||
panel.Left(max(4, x)).Top(y)
|
||||
if panel.Clicked() {
|
||||
m.focus(st.open)
|
||||
}
|
||||
var anchor *ui.Element
|
||||
panel.Children(func() {
|
||||
for j, it := range items {
|
||||
@@ -95,6 +99,10 @@ func (m *menubarRun) panel(l int, x, y float32, parent ui.Rect) (*ui.Element, *u
|
||||
m.activate(l, j)
|
||||
return
|
||||
}
|
||||
if row.Pressed() {
|
||||
// A press takes the focus off the title, which drives the keys.
|
||||
m.focus(st.open)
|
||||
}
|
||||
px, py, over := row.PointerPosition()
|
||||
b := row.Bounds()
|
||||
at := [2]float32{b.X + px, b.Y + py}
|
||||
|
||||
@@ -150,6 +150,34 @@ func TestMenubarKeyboard(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarKeysAfterClickInMenu(t *testing.T) {
|
||||
a := &menubarApp{}
|
||||
tt := ui.NewTester(a.view, 700, 500)
|
||||
_ = tt.Click("File")
|
||||
if err := tt.Click("Export"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !tt.Focused("File") {
|
||||
t.Fatal("click into the menu left the title without focus")
|
||||
}
|
||||
tt.Key(0, ui.KeyDown) // PDF to Vector, skipping disabled Image
|
||||
tt.Key(0, ui.KeyEnter)
|
||||
if len(a.chosen) != 1 || a.chosen[0] != "svg" {
|
||||
t.Fatalf("arrows after a click chose %q", a.chosen)
|
||||
}
|
||||
_ = tt.Click("File")
|
||||
_ = tt.Click("Export")
|
||||
_ = tt.Click("Image") // disabled: the press still keeps keys working
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Vector") || !tt.HasText("Quit") {
|
||||
t.Fatalf("Escape did not close only the submenu: %q", tt.Texts())
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if tt.HasText("Quit") {
|
||||
t.Fatal("second Escape did not close the menu")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMenubarLongMenuScrolls(t *testing.T) {
|
||||
var items []MenubarItem
|
||||
for i := range 30 {
|
||||
|
||||
@@ -13,6 +13,7 @@ func init() {
|
||||
"multi_select.limit": {"%d of %d chosen", "已选 %d / %d"},
|
||||
"multi_select.more": {"+%d", "+%d"},
|
||||
"multi_select.remove": {"Remove %s", "移除 %s"},
|
||||
"combobox.toggle": {"Show options", "显示选项"},
|
||||
"combobox.loading": {"Loading…", "加载中…"},
|
||||
"combobox.no_results": {"No matches", "无匹配项"},
|
||||
"combobox.use": {"Use “%s”", "使用“%s”"},
|
||||
@@ -24,5 +25,9 @@ func init() {
|
||||
"tree_select.no_results": {"No results", "无匹配结果"},
|
||||
"tree_select.expand": {"Expand %s", "展开 %s"},
|
||||
"tree_select.collapse": {"Collapse %s", "收起 %s"},
|
||||
"tree_select.expanded": {"expanded", "已展开"},
|
||||
"tree_select.collapsed": {"collapsed", "已收起"},
|
||||
"tree_select.mixed": {"partly selected", "部分选中"},
|
||||
"multi_select.mixed": {"partly selected", "部分选中"},
|
||||
})
|
||||
}
|
||||
+15
-10
@@ -28,9 +28,7 @@ type MultiSelectOptions struct {
|
||||
// multiSelectKey keeps this file's state apart from MyGo's own.
|
||||
type multiSelectKey struct{ name string }
|
||||
|
||||
// MultiSelect chooses several *values from options in a popup with a
|
||||
// search field, "Select all" for the matches, and an optional limit.
|
||||
// Down from the search moves into the list; Space checks; Escape closes.
|
||||
// MultiSelect chooses several *values from options in a popup with search and "Select all".
|
||||
func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOption[T], opts MultiSelectOptions) ChoiceResult {
|
||||
if opts.Max < 0 || opts.MaxTags < 0 {
|
||||
panic("mujica: MultiSelect Max and MaxTags must not be negative")
|
||||
@@ -42,6 +40,9 @@ func MultiSelect[T comparable](c *ui.Context, values *[]T, options []SelectOptio
|
||||
tr.Label(opts.Label)
|
||||
}
|
||||
open := ui.Local(tr, multiSelectKey{"open"}, func() bool { return false })
|
||||
if opts.Disabled {
|
||||
*open = false
|
||||
}
|
||||
query := ui.Local(tr, multiSelectKey{"query"}, func() string { return "" })
|
||||
if tr.Clicked() || (!*open && tr.Shortcut(0, ui.KeyDown)) {
|
||||
*open = !*open
|
||||
@@ -100,7 +101,7 @@ func multiSelectTag(c *ui.Context, label string, disabled bool) bool {
|
||||
removed := false
|
||||
ui.Row(c).Gap(2).Padding(1, 2, 1, 6).Radius(theme.ControlRadius).Background(k.Selection).
|
||||
Border(theme.BorderWidth, k.Border).Shrink(0).Children(func() {
|
||||
ui.Text(c, label).FontSize(theme.CaptionSize).TextColor(k.Text).SingleLine()
|
||||
ui.Text(c, label).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Text).SingleLine()
|
||||
if !disabled {
|
||||
removed = selectClear(c, k, fmt.Sprintf(msg(c, "multi_select.remove"), label))
|
||||
}
|
||||
@@ -170,7 +171,7 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T,
|
||||
})
|
||||
if opts.Max > 0 {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "multi_select.limit"), len(*values), opts.Max)).
|
||||
FontSize(theme.CaptionSize).TextColor(k.TextMuted).Padding(2, d.Unit()*2)
|
||||
FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(2, d.Unit()*2)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -178,12 +179,16 @@ func multiSelectPopup[T comparable](c *ui.Context, k theme.Tokens, values *[]T,
|
||||
// multiSelectCheck is a CheckboxBase row drawing its own box.
|
||||
func multiSelectCheck(c *ui.Context, k theme.Tokens, on *bool, st internal.CheckState, label string, disabled bool) *ui.Element {
|
||||
d := Density(c)
|
||||
scratch := *on
|
||||
if disabled {
|
||||
on = &scratch
|
||||
var cb *ui.Element
|
||||
// A disabled wrapper and a copy block the base, which toggles as it is built.
|
||||
ui.Row(c).Grow(1).Disabled(disabled).Children(func() {
|
||||
cb = ui.CheckboxBase(c, choiceTarget(on, disabled)).MinHeight(d.ControlHeight()).Padding(0, d.Unit()*2).
|
||||
Gap(d.Unit() * 2).AlignItems(ui.Center).Radius(theme.ControlRadius).Grow(1)
|
||||
})
|
||||
if st == internal.Mixed {
|
||||
// MyGo sets the mixed state only inside its own widgets (ui/element.go:282).
|
||||
cb.Description(msg(c, "multi_select.mixed"))
|
||||
}
|
||||
cb := ui.CheckboxBase(c, on).Disabled(disabled).MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*2).
|
||||
Gap(d.Unit() * 2).AlignItems(ui.Center).Radius(theme.ControlRadius).Grow(1)
|
||||
internal.Ring(cb, k.Focus, theme.ControlRadius)
|
||||
hover := cb.Hovered()
|
||||
if hover {
|
||||
|
||||
+38
-26
@@ -147,36 +147,48 @@ func navigationMenuTop(c *ui.Context, l NavigationLink, path string, push func(s
|
||||
*open = true
|
||||
}
|
||||
ui.PopoverBase(c, b, open, func(panel *ui.Element) {
|
||||
panel.Margin(u, 0, 0, 0).Padding(u*2).Gap(u).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
internal.PopIn(c, panel, Motion(c, theme.PopupDuration))
|
||||
// Capped to the room beside the anchor; the links scroll inside.
|
||||
_, wh := c.Size()
|
||||
r := b.Bounds()
|
||||
panel.Margin(u, 0, 0, 0).Padding(u*2).Radius(theme.PopupRadius).Background(k.Surface).MaxHeight(max(r.Y, wh-r.Y-r.H)-16).
|
||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16)).MinWidth(240).MaxWidth(360)
|
||||
panel.FocusGroup(ui.Vertical)
|
||||
first := true
|
||||
var list func(items []NavigationLink)
|
||||
list = func(items []NavigationLink) {
|
||||
for _, it := range items {
|
||||
if it.Label == "" {
|
||||
panic("mujica: NavigationLink needs a Label")
|
||||
}
|
||||
if it.Items != nil {
|
||||
ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).
|
||||
Padding(u*2, u*2, u, u*2).SingleLine()
|
||||
list(it.Items)
|
||||
continue
|
||||
}
|
||||
if it.Path == "" {
|
||||
panic("mujica: NavigationLink needs a Path or Items")
|
||||
}
|
||||
if navigationMenuItem(c, it, it.Path == path, first).Clicked() {
|
||||
*open = false
|
||||
push(it.Path)
|
||||
}
|
||||
first = false
|
||||
}
|
||||
}
|
||||
list(l.Items)
|
||||
ui.Scroll(c).Shrink(1).Gap(u).FocusGroup(ui.Vertical).Children(func() {
|
||||
navigationMenuPanel(c, l.Items, path, open, push)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// navigationMenuPanel lists a group's links, its subgroups under serif headings.
|
||||
func navigationMenuPanel(c *ui.Context, items []NavigationLink, path string, open *bool, push func(string)) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
first := true
|
||||
var list func(items []NavigationLink)
|
||||
list = func(items []NavigationLink) {
|
||||
for _, it := range items {
|
||||
if it.Label == "" {
|
||||
panic("mujica: NavigationLink needs a Label")
|
||||
}
|
||||
if it.Items != nil {
|
||||
ui.Text(c, it.Label).Font(theme.SerifFont).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.Ornament.Mix(k.Text, 0.35)).
|
||||
Padding(u*2, u*2, u, u*2).SingleLine()
|
||||
list(it.Items)
|
||||
continue
|
||||
}
|
||||
if it.Path == "" {
|
||||
panic("mujica: NavigationLink needs a Path or Items")
|
||||
}
|
||||
if navigationMenuItem(c, it, it.Path == path, first).Clicked() {
|
||||
*open = false
|
||||
push(it.Path)
|
||||
}
|
||||
first = false
|
||||
}
|
||||
}
|
||||
list(items)
|
||||
}
|
||||
|
||||
func navigationMenuItem(c *ui.Context, l NavigationLink, on, first bool) *ui.Element {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
|
||||
@@ -1,13 +1,85 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/icons"
|
||||
)
|
||||
|
||||
// TestNavigationPopupsFadeIn checks each popup fades in over PopupDuration instead of appearing at once.
|
||||
func TestNavigationPopupsFadeIn(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, open, sample string
|
||||
view func(c *ui.Context)
|
||||
}{
|
||||
{"breadcrumb", "Show hidden path", "Alpha", func(c *ui.Context) {
|
||||
Breadcrumb(c, breadcrumbPath("Root", "Alpha", "Beta", "Gamma", "Omega", "Leaf"), BreadcrumbOptions{})
|
||||
}},
|
||||
{"pagination", "10 / page", "50 / page", func(c *ui.Context) {
|
||||
page, size := 1, 10
|
||||
Pagination(c, &page, &size, PaginationOptions{Total: 100, PageSizes: []int{10, 50}})
|
||||
}},
|
||||
{"navigation menu", "Library", "Letters", func(c *ui.Context) {
|
||||
NavigationMenu(c, ui.NewRouter("/"), navigationMenuSample(), NavigationMenuOptions{})
|
||||
}},
|
||||
{"menubar", "File", "Quit", func(c *ui.Context) {
|
||||
Menubar(c, (&menubarApp{}).menus(), MenubarOptions{})
|
||||
}},
|
||||
{"command palette", "", "Esc close", func(c *ui.Context) {
|
||||
open := true
|
||||
CommandPalette(c, &open, commandPaletteSample(), CommandPaletteOptions{})
|
||||
}},
|
||||
} {
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: Light})
|
||||
ui.Column(c).Fill().Padding(16).AlignItems(ui.Start).Children(func() { tc.view(c) })
|
||||
}, 700, 500)
|
||||
if tc.open != "" {
|
||||
if err := tt.Click(tc.open); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
r, ok := tt.Find(tc.sample)
|
||||
if !ok {
|
||||
t.Fatalf("%s: no %q", tc.name, tc.sample)
|
||||
}
|
||||
x, y := int(r.X-3), int(r.Y+r.H/2)
|
||||
early := tt.Image().RGBAAt(x, y)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
tt.Frame()
|
||||
if late := tt.Image().RGBAAt(x, y); early == late {
|
||||
t.Errorf("%s: popup appeared at once (%v)", tc.name, late)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNavigationMenuLongPanelScrolls checks a tall group stays in the window and follows the focus.
|
||||
func TestNavigationMenuLongPanelScrolls(t *testing.T) {
|
||||
var links []NavigationLink
|
||||
for i := range 30 {
|
||||
links = append(links, NavigationLink{Label: fmt.Sprintf("Link %02d", i), Path: fmt.Sprintf("/l/%d", i)})
|
||||
}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
NavigationMenu(c, ui.NewRouter("/"), []NavigationLink{{Label: "Group", Items: links}}, NavigationMenuOptions{})
|
||||
}, 400, 300)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyDown)
|
||||
tt.Key(0, ui.KeyEnd)
|
||||
tt.Frame()
|
||||
if !tt.Focused("Link 29") {
|
||||
t.Fatal("End did not focus the last link")
|
||||
}
|
||||
r, _ := tt.Find("Link 29")
|
||||
if r.H == 0 || r.Y+r.H > 300 {
|
||||
t.Fatalf("focused link off screen at %+v", r)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNavigationCaptionsFollowTextScale checks small text grows with the desktop text scale.
|
||||
func TestNavigationCaptionsFollowTextScale(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
|
||||
+21
-17
@@ -67,24 +67,28 @@ func NotificationCenter(c *ui.Context, items *[]Notification, opts NotificationC
|
||||
panel := ui.Column(c).FillWidth().Shrink(1).MinHeight(0).Radius(theme.CardRadius).Background(k.Surface).Border(theme.BorderWidth, k.Border).
|
||||
Role(ui.RoleGroup).Label(title).Clip()
|
||||
panel.Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text)
|
||||
if unread > 0 {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText)
|
||||
}
|
||||
ui.Spacer(c)
|
||||
if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() {
|
||||
for i := range *items {
|
||||
(*items)[i].Read = true
|
||||
// Wraps so title and actions stay whole when text is enlarged.
|
||||
ui.Row(c).Wrap().AlignItems(ui.Center).Justify(ui.SpaceBetween).Gap(u*2).Padding(u*3, u*3, u*3, u*4).Children(func() {
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
ui.Text(c, title).Font(theme.SerifFont).FontSize(FontSize(c, theme.H3Size)).TextColor(k.Text).SingleLine()
|
||||
if unread > 0 {
|
||||
ui.Text(c, fmt.Sprintf(msg(c, "notification_center.unread"), unread)).FontSize(FontSize(c, theme.CaptionSize)).TextColor(k.AccentText).SingleLine()
|
||||
}
|
||||
res.MarkedAllRead = true
|
||||
slog.Debug("mujica: notification_center mark all read", "count", unread)
|
||||
}
|
||||
if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() {
|
||||
slog.Debug("mujica: notification_center clear all", "count", len(*items))
|
||||
*items = (*items)[:0]
|
||||
res.ClearedAll = true
|
||||
}
|
||||
})
|
||||
ui.Row(c).AlignItems(ui.Center).Gap(u * 2).Children(func() {
|
||||
if Button(c, msg(c, "notification_center.mark_all_read"), ButtonOptions{Variant: Ghost, Disabled: unread == 0}).Clicked() {
|
||||
for i := range *items {
|
||||
(*items)[i].Read = true
|
||||
}
|
||||
res.MarkedAllRead = true
|
||||
slog.Debug("mujica: notification_center mark all read", "count", unread)
|
||||
}
|
||||
if Button(c, msg(c, "notification_center.clear_all"), ButtonOptions{Variant: Ghost, Disabled: len(*items) == 0}).Clicked() {
|
||||
slog.Debug("mujica: notification_center clear all", "count", len(*items))
|
||||
*items = (*items)[:0]
|
||||
res.ClearedAll = true
|
||||
}
|
||||
})
|
||||
})
|
||||
// Ornament hairline under the panel title.
|
||||
ui.Box(c).FillWidth().Height(1).Background(k.Ornament).Role(ui.RoleNone)
|
||||
|
||||
+87
-27
@@ -50,9 +50,34 @@ func (s numberInputSpec) format(v float64) string {
|
||||
// clamp rounds v to the precision, then bounds it; the bounds sit on that grid.
|
||||
func (s numberInputSpec) clamp(v float64) float64 {
|
||||
p := math.Pow(10, float64(s.decimals))
|
||||
return math.Max(s.lo, math.Min(s.hi, math.Round(v*p)/p))
|
||||
return math.Max(s.lo, math.Min(s.hi, math.Round(v*p)/p)) + 0 // + 0 turns -0 into 0
|
||||
}
|
||||
|
||||
// stepBy moves v n steps along the grid of Step from Min, stopping at the bounds.
|
||||
func (s numberInputSpec) stepBy(v float64, n int) float64 {
|
||||
if math.IsNaN(v) {
|
||||
// From empty, the first step lands next to zero.
|
||||
v = s.clamp(0)
|
||||
}
|
||||
base := s.lo
|
||||
if math.IsInf(base, 0) {
|
||||
base = 0
|
||||
}
|
||||
k := (v - base) / s.step
|
||||
if n > 0 {
|
||||
k = numberInputFloor(k) + float64(n)
|
||||
} else {
|
||||
k = numberInputCeil(k) + float64(n)
|
||||
}
|
||||
return s.clamp(base + k*s.step)
|
||||
}
|
||||
|
||||
// numberInputFloor floors x, forgiving floating-point error just below an integer.
|
||||
func numberInputFloor(x float64) float64 { return math.Floor(x + math.Max(1e-9, 1e-12*math.Abs(x))) }
|
||||
|
||||
// numberInputCeil ceils x, forgiving floating-point error just above an integer.
|
||||
func numberInputCeil(x float64) float64 { return math.Ceil(x - math.Max(1e-9, 1e-12*math.Abs(x))) }
|
||||
|
||||
// numberInputSame reports equal values, counting NaN (empty) as equal.
|
||||
func numberInputSame(a, b float64) bool { return a == b || math.IsNaN(a) && math.IsNaN(b) }
|
||||
|
||||
@@ -80,8 +105,8 @@ func numberInputResolve(opts NumberInputOptions) numberInputSpec {
|
||||
}
|
||||
// Bounds move inward onto the precision grid, so clamped values keep both.
|
||||
p := math.Pow(10, float64(s.decimals))
|
||||
s.lo = math.Ceil(math.Round(s.lo*p*1e6)/1e6) / p
|
||||
s.hi = math.Floor(math.Round(s.hi*p*1e6)/1e6) / p
|
||||
s.lo = numberInputCeil(s.lo*p)/p + 0 // + 0 turns -0 into 0
|
||||
s.hi = numberInputFloor(s.hi*p)/p + 0
|
||||
if s.lo > s.hi {
|
||||
panic("mujica: NumberInput has no value in range at this precision")
|
||||
}
|
||||
@@ -113,13 +138,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe
|
||||
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)
|
||||
}
|
||||
stepBy := func(n int) { set(s.stepBy(*value, n)) }
|
||||
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) })
|
||||
@@ -129,13 +148,13 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe
|
||||
// Set from outside: the text follows, and a typing error goes.
|
||||
*text, *invalid = s.format(*value), ""
|
||||
}
|
||||
in := textInputEditor(c, frame, text, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 12).
|
||||
in := textInputEditor(c, frame, text, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 12).
|
||||
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||
if in.Changed() && !opts.Disabled {
|
||||
if in.Changed() && !look.disabled {
|
||||
*invalid = numberInputParse(c, s, *text, opts.AllowEmpty, set)
|
||||
}
|
||||
switch {
|
||||
case opts.Disabled:
|
||||
case look.disabled:
|
||||
case in.Shortcut(0, ui.KeyUp):
|
||||
stepBy(1)
|
||||
*text, *invalid = s.format(*value), ""
|
||||
@@ -151,7 +170,7 @@ func NumberInput(c *ui.Context, value *float64, opts NumberInputOptions) InputRe
|
||||
for _, b := range []struct {
|
||||
name string
|
||||
icon string
|
||||
delta float64
|
||||
delta int
|
||||
can bool
|
||||
}{
|
||||
{msg(c, "number_input.decrease"), "minus", -1, math.IsNaN(*value) || *value > s.lo},
|
||||
@@ -209,37 +228,78 @@ func numberInputButton(c *ui.Context, icon, name string, can bool) bool {
|
||||
return b.Clicked()
|
||||
}
|
||||
|
||||
// numberInputStepper shows the value beside MyGo's stepper, restyled.
|
||||
// numberInputStepper shows the value beside up and down arrows; it steps itself.
|
||||
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)
|
||||
disabled := r.Element.IsDisabled()
|
||||
set := func(v float64) {
|
||||
if v != *value && !disabled {
|
||||
*value = v
|
||||
r.changed = true
|
||||
}
|
||||
}
|
||||
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)
|
||||
border := k.ControlBorder
|
||||
if opts.Error != "" {
|
||||
st.Error(opts.Error).Border(theme.BorderWidth, k.Danger)
|
||||
border = k.Danger
|
||||
}
|
||||
st := ui.Column(c).Focusable().Shrink(0).Width(d.Unit()*6).Radius(theme.ControlRadius).Clip().
|
||||
Background(k.Surface).Border(theme.BorderWidth, border).Role(ui.RoleStepper).Label(opts.Label).
|
||||
Description(s.format(*value)).Error(opts.Error)
|
||||
internal.Ring(st, k.Focus, theme.ControlRadius)
|
||||
switch {
|
||||
case st.Shortcut(0, ui.KeyUp), st.Shortcut(0, ui.KeyRight):
|
||||
set(s.stepBy(*value, 1))
|
||||
case st.Shortcut(0, ui.KeyDown), st.Shortcut(0, ui.KeyLeft):
|
||||
set(s.stepBy(*value, -1))
|
||||
case st.Shortcut(0, ui.KeyHome):
|
||||
set(s.lo)
|
||||
case st.Shortcut(0, ui.KeyEnd):
|
||||
set(s.hi)
|
||||
}
|
||||
st.Children(func() {
|
||||
if numberInputArrow(c, "chevron-up", *value < s.hi, false) {
|
||||
set(s.stepBy(*value, 1))
|
||||
st.Focus()
|
||||
}
|
||||
if numberInputArrow(c, "chevron-down", *value > s.lo, true) {
|
||||
set(s.stepBy(*value, -1))
|
||||
st.Focus()
|
||||
}
|
||||
})
|
||||
r.Input = st
|
||||
r.changed = st.Changed() && !opts.Disabled
|
||||
})
|
||||
if opts.Error != "" {
|
||||
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(theme.CaptionSize)
|
||||
ui.Text(c, opts.Error).TextColor(k.Danger).FontSize(FontSize(c, theme.CaptionSize))
|
||||
}
|
||||
})
|
||||
if r.changed {
|
||||
*value = s.clamp(*value)
|
||||
slog.Debug("mujica: number_input step", "value", *value)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// numberInputArrow builds one half of the stepper and reports a click.
|
||||
func numberInputArrow(c *ui.Context, icon string, can, lower bool) bool {
|
||||
k := Tokens(c)
|
||||
h := Density(c).ControlHeight()/2 - theme.BorderWidth
|
||||
a := ui.Box(c).Height(h).Center().Role(ui.RoleNone).Cursor(ui.CursorPointer).Disabled(!can)
|
||||
if lower {
|
||||
a.BorderWidth(theme.BorderWidth, 0, 0, 0).BorderColor(k.Border)
|
||||
}
|
||||
fg := k.TextMuted
|
||||
if a.Pressed() {
|
||||
a.Background(k.Selection)
|
||||
fg = k.Text
|
||||
} else if a.Hovered() {
|
||||
a.Background(k.SurfaceHover)
|
||||
fg = k.Text
|
||||
}
|
||||
a.Children(func() { ui.Icon(c, icons.Must(icon)).Size(12, 12).TextColor(fg) })
|
||||
return a.Clicked()
|
||||
}
|
||||
+7
-3
@@ -37,8 +37,7 @@ type OverlayOptions struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// Overlay shows content in a layer above the window while *open is true.
|
||||
// Escape closes it; a modal one also closes on a backdrop click.
|
||||
// Overlay shows content in a layer above the window while *open is true; Escape closes it.
|
||||
func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui.Element {
|
||||
if opts.Dim && !opts.Modal {
|
||||
panic("mujica: Overlay Dim requires Modal")
|
||||
@@ -46,7 +45,9 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui
|
||||
if opts.Place < OverlayCenter || opts.Place > OverlayBottomRight {
|
||||
panic("mujica: Overlay Place out of range")
|
||||
}
|
||||
site, focus := overlaySite(c)
|
||||
if !*open {
|
||||
focus.closed(c, site)
|
||||
return nil
|
||||
}
|
||||
k := Tokens(c)
|
||||
@@ -56,6 +57,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui
|
||||
}
|
||||
fx, fy := overlayFractions(opts.Place)
|
||||
if opts.Modal {
|
||||
overlayLayer(c, open)
|
||||
var backClicked bool
|
||||
shown := true
|
||||
panel := ui.DialogBase(c, &shown, func(back, panel *ui.Element) {
|
||||
@@ -69,6 +71,7 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui
|
||||
panel.Label(opts.Label).Padding(Density(c).Unit() * 4)
|
||||
content()
|
||||
})
|
||||
focus.shown(panel)
|
||||
if !shown {
|
||||
slog.Debug("mujica: overlay close", "backdrop", backClicked)
|
||||
*open = false
|
||||
@@ -89,7 +92,8 @@ func Overlay(c *ui.Context, open *bool, opts OverlayOptions, content func()) *ui
|
||||
overlayStyle(c, panel, p)
|
||||
panel.Children(content)
|
||||
})
|
||||
if c.Shortcut(0, ui.KeyEscape) {
|
||||
focus.shown(panel)
|
||||
if overlayEscape(c, open) {
|
||||
slog.Debug("mujica: overlay close", "escape", true)
|
||||
*open = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// overlayLayers is the window's open MujicaUI layers, oldest first.
|
||||
type overlayLayers struct {
|
||||
frame, prev time.Time
|
||||
keys []any
|
||||
seen []time.Time
|
||||
}
|
||||
|
||||
type overlayLayersKey struct{}
|
||||
|
||||
// overlayLayer marks the layer key open in this frame and reports whether it is on top.
|
||||
func overlayLayer(c *ui.Context, key any) bool {
|
||||
s := ui.Local(c.Root(), overlayLayersKey{}, func() overlayLayers { return overlayLayers{} })
|
||||
if now := c.Now(); now != s.frame {
|
||||
s.prev, s.frame = s.frame, now
|
||||
n := 0
|
||||
for i, k := range s.keys {
|
||||
if !s.seen[i].Before(s.prev) { // drop layers the last frame did not build
|
||||
s.keys[n], s.seen[n] = k, s.seen[i]
|
||||
n++
|
||||
}
|
||||
}
|
||||
clear(s.keys[n:])
|
||||
s.keys, s.seen = s.keys[:n], s.seen[:n]
|
||||
}
|
||||
at := -1
|
||||
for i, k := range s.keys {
|
||||
if k == key {
|
||||
at = i
|
||||
}
|
||||
}
|
||||
if at < 0 {
|
||||
s.keys, s.seen = append(s.keys, key), append(s.seen, s.frame)
|
||||
at = len(s.keys) - 1
|
||||
}
|
||||
s.seen[at] = s.frame
|
||||
return at == len(s.keys)-1
|
||||
}
|
||||
|
||||
// overlayEscape reports Escape for a window-level layer, delivered only while it is on top.
|
||||
func overlayEscape(c *ui.Context, key any) bool {
|
||||
if !overlayLayer(c, key) {
|
||||
return false
|
||||
}
|
||||
// The root is around every focus, so it takes Escape before MyGo's overlays.
|
||||
byFocus := c.Root().Shortcut(0, ui.KeyEscape)
|
||||
byWindow := c.Shortcut(0, ui.KeyEscape)
|
||||
return byFocus || byWindow
|
||||
}
|
||||
|
||||
// overlaySiteState follows the focus of a layer after it closes.
|
||||
type overlaySiteState struct {
|
||||
focusIn bool // the focus was inside the layer when it last showed
|
||||
wait int // frames left to check the focus after a close
|
||||
holding bool // the site holds the fallback focus
|
||||
}
|
||||
|
||||
// overlaySite builds the invisible focus fallback at a layer's call site.
|
||||
func overlaySite(c *ui.Context) (*ui.Element, *overlaySiteState) {
|
||||
site := ui.Box(c).Size(0, 0).FocusRing(false)
|
||||
st := ui.Local(site, "mujica.overlay.site", func() overlaySiteState { return overlaySiteState{} })
|
||||
st.holding = st.holding && site.FocusWithin()
|
||||
if st.holding {
|
||||
site.Focusable() // in flow, so Tab goes on from the layer's place
|
||||
} else {
|
||||
site.Absolute()
|
||||
}
|
||||
return site, st
|
||||
}
|
||||
|
||||
// shown records whether the focus is in the open layer's panel.
|
||||
func (st *overlaySiteState) shown(panel *ui.Element) {
|
||||
st.focusIn, st.wait = panel != nil && panel.FocusWithin(), 0
|
||||
}
|
||||
|
||||
// closed moves a lost focus to the site once MyGo has had its chance to restore it.
|
||||
func (st *overlaySiteState) closed(c *ui.Context, site *ui.Element) {
|
||||
if st.focusIn {
|
||||
st.focusIn, st.wait = false, 2 // MyGo prunes the panel at the end of the next frame
|
||||
}
|
||||
if st.wait == 0 {
|
||||
return
|
||||
}
|
||||
st.wait--
|
||||
if st.wait > 0 {
|
||||
c.AnimationFrame()
|
||||
return
|
||||
}
|
||||
// The site itself counts as lost: it may inherit a vanished opener's positional ID.
|
||||
if !c.Root().FocusWithin() || site.FocusWithin() {
|
||||
slog.Debug("mujica: overlay focus fallback")
|
||||
site.Focus()
|
||||
st.holding = true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
|
||||
// overlaySnapshot is snapshot, with drive opening the real component through input before the capture.
|
||||
func overlaySnapshot(t *testing.T, name string, w, h int, view func(c *ui.Context), drive func(tt *ui.Tester)) {
|
||||
t.Helper()
|
||||
for _, m := range []Mode{Light, Dark} {
|
||||
ready := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Mode: m})
|
||||
if ready {
|
||||
view(c)
|
||||
}
|
||||
}, w, h)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
ready = true
|
||||
tt.Frame()
|
||||
drive(tt)
|
||||
got := encode(t, tt.Image())
|
||||
path := filepath.Join("testdata", "snapshots", runtime.GOOS, name+"-"+strings.ToLower(m.String())+".png")
|
||||
if *update {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
want, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("%v (run go test -update)", err)
|
||||
}
|
||||
if !bytes.Equal(got, want) {
|
||||
if err := os.WriteFile(path+".got.png", got, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Errorf("%s differs; see %s.got.png", path, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overlayUntil draws frames until cond holds, for at most 5 s of wall time.
|
||||
func overlayUntil(tt *ui.Tester, cond func() bool) bool {
|
||||
end := time.Now().Add(5 * time.Second)
|
||||
for !cond() {
|
||||
if time.Now().After(end) {
|
||||
return false
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
tt.Frame()
|
||||
}
|
||||
return true
|
||||
}
|
||||
+155
-55
@@ -3,6 +3,7 @@ package mujica
|
||||
import (
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
)
|
||||
@@ -93,6 +94,57 @@ func TestOverlayNonModal(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayEscapeClosesTopNonModalLayerFirst(t *testing.T) {
|
||||
bottom, top := false, false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
if Button(c, "Show", ButtonOptions{}).Clicked() {
|
||||
if bottom {
|
||||
top = true
|
||||
}
|
||||
bottom = true
|
||||
}
|
||||
Overlay(c, &top, OverlayOptions{Place: OverlayTopRight}, func() { ui.Text(c, "Top layer") })
|
||||
Drawer(c, &bottom, DrawerOptions{Title: "Bottom layer", NonModal: true, Side: DrawerLeft}, nil)
|
||||
}, 600, 400)
|
||||
_ = tt.Click("Show")
|
||||
_ = tt.Click("Show") // the overlay opens after the drawer, though built before it
|
||||
if !bottom || !top {
|
||||
t.Fatalf("bottom %v top %v", bottom, top)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if top || !bottom {
|
||||
t.Fatalf("first Escape: top %v bottom %v", top, bottom)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if bottom {
|
||||
t.Fatal("second Escape did not close the drawer")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayNonModalRestoresFocusToItsPlace(t *testing.T) {
|
||||
open := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Children(func() {
|
||||
Button(c, "First", ButtonOptions{})
|
||||
Overlay(c, &open, OverlayOptions{}, func() { Button(c, "Inside", ButtonOptions{}) })
|
||||
Button(c, "Next", ButtonOptions{})
|
||||
})
|
||||
}, 600, 400)
|
||||
if err := tt.Click("Inside"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if open {
|
||||
t.Fatal("Escape did not close")
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Next") {
|
||||
t.Fatal("focus lost on close instead of staying at the overlay's place")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayDimNeedsModal(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
@@ -106,82 +158,130 @@ func TestOverlayDimNeedsModal(t *testing.T) {
|
||||
}, 200, 200)
|
||||
}
|
||||
|
||||
// TestOverlayLifecycle opens and closes every overlay 100 times and checks
|
||||
// that nothing grows: goroutines, heap objects or the elements left behind.
|
||||
func TestOverlayLifecycle(t *testing.T) {
|
||||
var open bool
|
||||
views := map[string]func(c *ui.Context, b *ui.Element){
|
||||
"Dialog": func(c *ui.Context, b *ui.Element) { Dialog(c, &open, DialogOptions{Title: "T"}, func() {}) },
|
||||
"Alert": func(c *ui.Context, b *ui.Element) {
|
||||
AlertDialog(c, &open, AlertDialogOptions{Title: "T", ConfirmText: "x"})
|
||||
},
|
||||
"Drawer": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T"}, nil) },
|
||||
"Drawer2": func(c *ui.Context, b *ui.Element) { Drawer(c, &open, DrawerOptions{Title: "T", NonModal: true}, nil) },
|
||||
"Popover": func(c *ui.Context, b *ui.Element) { Popover(c, b, &open, PopoverOptions{}, func() {}) },
|
||||
"Confirm": func(c *ui.Context, b *ui.Element) { Popconfirm(c, b, &open, PopconfirmOptions{Title: "T"}) },
|
||||
"Overlay": func(c *ui.Context, b *ui.Element) { Overlay(c, &open, OverlayOptions{Modal: true}, func() {}) },
|
||||
"Menu": func(c *ui.Context, b *ui.Element) {
|
||||
popupMenuOpen(c, b, &open, (&menuApp{}).build)
|
||||
},
|
||||
"Context": nil,
|
||||
// overlayCase drives one component open and closed through real input.
|
||||
type overlayCase struct {
|
||||
name string
|
||||
view func(c *ui.Context, open *bool)
|
||||
open, close func(tt *ui.Tester)
|
||||
marker string
|
||||
}
|
||||
|
||||
func overlayCases() []overlayCase {
|
||||
clickAnchor := func(tt *ui.Tester) { _ = tt.Click("Anchor") }
|
||||
escape := func(tt *ui.Tester) { tt.Key(0, ui.KeyEscape) }
|
||||
hover := func(tt *ui.Tester) {
|
||||
r, _ := tt.Find("Anchor")
|
||||
tt.Move(r.X+4, r.Y+4)
|
||||
}
|
||||
for name, view := range views {
|
||||
leave := func(tt *ui.Tester) { tt.Move(590, 390) }
|
||||
anchor := func(c *ui.Context, open *bool) *ui.Element {
|
||||
b := Button(c, "Anchor", ButtonOptions{})
|
||||
if b.Clicked() {
|
||||
*open = true
|
||||
}
|
||||
return b
|
||||
}
|
||||
body := func(c *ui.Context) func() { return func() { ui.Text(c, "Layer body") } }
|
||||
return []overlayCase{
|
||||
{"Dialog", func(c *ui.Context, open *bool) {
|
||||
anchor(c, open)
|
||||
Dialog(c, open, DialogOptions{Title: "T"}, body(c))
|
||||
}, clickAnchor, escape, "Layer body"},
|
||||
{"AlertDialog", func(c *ui.Context, open *bool) {
|
||||
anchor(c, open)
|
||||
AlertDialog(c, open, AlertDialogOptions{Title: "Layer body", ConfirmText: "x"})
|
||||
}, clickAnchor, escape, "Layer body"},
|
||||
{"Drawer", func(c *ui.Context, open *bool) {
|
||||
anchor(c, open)
|
||||
Drawer(c, open, DrawerOptions{Title: "T"}, body(c))
|
||||
}, clickAnchor, escape, "Layer body"},
|
||||
{"DrawerNonModal", func(c *ui.Context, open *bool) {
|
||||
anchor(c, open)
|
||||
Drawer(c, open, DrawerOptions{Title: "T", NonModal: true}, body(c))
|
||||
}, clickAnchor, escape, "Layer body"},
|
||||
{"Popover", func(c *ui.Context, open *bool) {
|
||||
Popover(c, anchor(c, open), open, PopoverOptions{}, body(c))
|
||||
}, clickAnchor, escape, "Layer body"},
|
||||
{"Tooltip", func(c *ui.Context, open *bool) {
|
||||
Tooltip(c, anchor(c, open), "Layer body", TooltipOptions{Delay: time.Millisecond})
|
||||
}, hover, leave, "Layer body"},
|
||||
{"HoverCard", func(c *ui.Context, open *bool) {
|
||||
HoverCard(c, anchor(c, open), HoverCardOptions{OpenDelay: time.Millisecond, CloseDelay: time.Millisecond}, body(c))
|
||||
}, hover, leave, "Layer body"},
|
||||
{"DropdownMenu", func(c *ui.Context, open *bool) {
|
||||
DropdownMenu(c, "Anchor", DropdownMenuOptions{}, (&menuApp{}).build)
|
||||
}, clickAnchor, escape, "Rename"},
|
||||
{"ContextMenu", func(c *ui.Context, open *bool) {
|
||||
row := ui.Row(c)
|
||||
row.Children(func() { Button(c, "Anchor", ButtonOptions{}) })
|
||||
ContextMenu(c, row, ContextMenuOptions{}, (&menuApp{}).build)
|
||||
}, func(tt *ui.Tester) {
|
||||
clickAnchor(tt)
|
||||
tt.Key(ui.Shift, ui.KeyF10)
|
||||
}, escape, "Rename"},
|
||||
{"Popconfirm", func(c *ui.Context, open *bool) {
|
||||
Popconfirm(c, anchor(c, open), open, PopconfirmOptions{Title: "Layer body"})
|
||||
}, clickAnchor, escape, "Layer body"},
|
||||
{"Overlay", func(c *ui.Context, open *bool) {
|
||||
anchor(c, open)
|
||||
Overlay(c, open, OverlayOptions{Modal: true}, body(c))
|
||||
}, clickAnchor, escape, "Layer body"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestOverlayLifecycle opens and closes every overlay 100 times; goroutines, heap objects and elements must not grow.
|
||||
func TestOverlayLifecycle(t *testing.T) {
|
||||
for _, oc := range overlayCases() {
|
||||
open := false
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
row := ui.Row(c)
|
||||
var b *ui.Element
|
||||
row.Children(func() { b = Button(c, "Anchor", ButtonOptions{}) })
|
||||
if view == nil {
|
||||
ContextMenu(c, row, ContextMenuOptions{}, (&menuApp{}).build)
|
||||
} else {
|
||||
view(c, b)
|
||||
}
|
||||
oc.view(c, &open)
|
||||
}, 600, 400)
|
||||
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
|
||||
cycle := func() {
|
||||
open = true
|
||||
if view == nil {
|
||||
_ = tt.Click("Anchor")
|
||||
tt.Key(ui.Shift, ui.KeyF10)
|
||||
if !tt.HasText("Rename") {
|
||||
t.Fatalf("%s: did not open", name)
|
||||
}
|
||||
shown := func() bool { return tt.HasText(oc.marker) }
|
||||
cycle := func(i int) {
|
||||
oc.open(tt)
|
||||
if !overlayUntil(tt, shown) {
|
||||
t.Fatalf("%s: cycle %d did not open", oc.name, i)
|
||||
}
|
||||
tt.Frame()
|
||||
if name != "Drawer2" {
|
||||
tt.Key(0, ui.KeyEscape)
|
||||
if view == nil {
|
||||
open = false
|
||||
}
|
||||
} else {
|
||||
open = false
|
||||
tt.Frame()
|
||||
}
|
||||
if open || tt.HasText("Rename") {
|
||||
t.Fatalf("%s: Escape did not close", name)
|
||||
oc.close(tt)
|
||||
if !overlayUntil(tt, func() bool { return !shown() }) {
|
||||
t.Fatalf("%s: cycle %d did not close", oc.name, i)
|
||||
}
|
||||
}
|
||||
for range 20 {
|
||||
cycle()
|
||||
for i := range 20 {
|
||||
cycle(i)
|
||||
}
|
||||
texts := len(tt.Texts())
|
||||
before := heapObjects()
|
||||
goroutines := runtime.NumGoroutine()
|
||||
for range 100 {
|
||||
cycle()
|
||||
for i := range 100 {
|
||||
cycle(i)
|
||||
}
|
||||
if got := len(tt.Texts()); got != texts {
|
||||
t.Errorf("%s: texts %d -> %d", name, texts, got)
|
||||
t.Errorf("%s: texts %d -> %d", oc.name, texts, got)
|
||||
}
|
||||
if g := runtime.NumGoroutine(); g > goroutines {
|
||||
t.Errorf("%s: goroutines %d -> %d", name, goroutines, g)
|
||||
// A fired c.After timer (ui/runtime.go:596) runs briefly in its own goroutine; only lasting growth counts.
|
||||
if g := overlayGoroutines(goroutines); g > goroutines {
|
||||
t.Errorf("%s: goroutines %d -> %d", oc.name, goroutines, g)
|
||||
}
|
||||
if after := heapObjects(); after > before+2000 {
|
||||
t.Errorf("%s: heap objects %d -> %d", name, before, after)
|
||||
t.Errorf("%s: heap objects %d -> %d", oc.name, before, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// overlayGoroutines waits up to 2 s for the goroutine count to fall to want, and returns it.
|
||||
func overlayGoroutines(want int) int {
|
||||
end := time.Now().Add(2 * time.Second)
|
||||
g := runtime.NumGoroutine()
|
||||
for g > want && time.Now().Before(end) {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
g = runtime.NumGoroutine()
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
func heapObjects() uint64 {
|
||||
runtime.GC()
|
||||
var m runtime.MemStats
|
||||
|
||||
+2
-2
@@ -36,8 +36,7 @@ type PaginationResult struct {
|
||||
// Changed reports a new page or page size in this frame, from the user or a correction.
|
||||
func (r PaginationResult) Changed() bool { return r.changed }
|
||||
|
||||
// Pagination moves *page (from 1) through Total records, *pageSize per page.
|
||||
// A page past the end after Total shrinks is corrected to the last page.
|
||||
// Pagination pages *page (from 1) through Total records, clamping it when Total shrinks.
|
||||
func Pagination(c *ui.Context, page, pageSize *int, opts PaginationOptions) PaginationResult {
|
||||
if opts.Total < 0 {
|
||||
panic("mujica: Pagination Total is negative")
|
||||
@@ -185,6 +184,7 @@ func paginationSizes(c *ui.Context, page, pageSize *int, sizes []int, res *Pagin
|
||||
ui.Icon(c, icons.Must("chevron-down")).Size(14, 14).TextColor(k.TextMuted)
|
||||
})
|
||||
sel.Popup(func(panel *ui.Element) {
|
||||
internal.PopIn(c, panel, Motion(c, theme.PopupDuration))
|
||||
panel.Margin(u, 0, 0, 0).Padding(u).Radius(theme.PopupRadius).Background(k.Surface).
|
||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.16))
|
||||
for _, s := range sizes {
|
||||
|
||||
+3
-3
@@ -36,14 +36,14 @@ func PasswordInput(c *ui.Context, value *string, opts PasswordInputOptions) Inpu
|
||||
mode = "shown"
|
||||
}
|
||||
ui.Row(c).Key(mode).Grow(1).Children(func() {
|
||||
in = textInputEditor(c, frame, value, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).
|
||||
in = textInputEditor(c, frame, value, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).
|
||||
Placeholder(opts.Placeholder).Label(opts.Label)
|
||||
if !*shown {
|
||||
in.Password()
|
||||
}
|
||||
})
|
||||
r.changed = in.Changed() && !opts.Disabled
|
||||
r.submitted = in.Submitted() && !opts.Disabled
|
||||
r.changed = in.Changed() && !look.disabled
|
||||
r.submitted = in.Submitted() && !look.disabled
|
||||
if passwordInputToggle(c, *shown) {
|
||||
*shown = !*shown
|
||||
slog.Debug("mujica: password_input reveal", "shown", *shown)
|
||||
|
||||
+5
-3
@@ -86,6 +86,10 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts
|
||||
slog.Debug("mujica: pin input completed", "length", n)
|
||||
}
|
||||
}
|
||||
disabled := e.IsDisabled()
|
||||
if disabled {
|
||||
st.queue, st.compose = st.queue[:0], ""
|
||||
}
|
||||
for _, ev := range st.queue {
|
||||
switch {
|
||||
case ev.paste:
|
||||
@@ -97,9 +101,7 @@ func PinInput(c *ui.Context, value *string, opts PinInputOptions) PinInputParts
|
||||
}
|
||||
}
|
||||
st.queue = st.queue[:0]
|
||||
if opts.Disabled {
|
||||
st.compose = ""
|
||||
} else {
|
||||
if !disabled {
|
||||
e.Focusable().FocusRing(false).HandleInput(func(ev ui.InputEvent) bool {
|
||||
switch {
|
||||
case ev.Kind == ui.InputText:
|
||||
|
||||
@@ -102,3 +102,23 @@ func TestPinInputSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestPinInputDisabledAfterFocus(t *testing.T) {
|
||||
p := &pinInputProbe{value: "12"}
|
||||
opts := PinInputOptions{Label: "Code"}
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
r := PinInput(c, &p.value, opts)
|
||||
if r.Changed() {
|
||||
p.changes++
|
||||
}
|
||||
}, 480, 120)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
opts.Disabled = true
|
||||
tt.Type("3")
|
||||
tt.Key(0, ui.KeyBackspace)
|
||||
tt.Frame()
|
||||
if p.value != "12" || p.changes != 0 {
|
||||
t.Fatalf("value %q changes %d", p.value, p.changes)
|
||||
}
|
||||
}
|
||||
+25
-19
@@ -25,9 +25,7 @@ type PopconfirmResult struct {
|
||||
Cancelled bool
|
||||
}
|
||||
|
||||
// Popconfirm asks for a light confirmation beside anchor while *open is
|
||||
// true. Confirm takes the focus; Cancel, Escape and an outside click cancel.
|
||||
// Use AlertDialog for high-risk actions.
|
||||
// Popconfirm asks for a light confirmation by anchor; use AlertDialog for high-risk actions.
|
||||
func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOptions) PopconfirmResult {
|
||||
if anchor == nil {
|
||||
panic("mujica: Popconfirm needs an anchor")
|
||||
@@ -39,7 +37,7 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp
|
||||
if !*open {
|
||||
return res
|
||||
}
|
||||
k := Tokens(c)
|
||||
overlayLayer(c, open)
|
||||
u := Density(c).Unit()
|
||||
confirm, cancel := opts.ConfirmLabel, opts.CancelLabel
|
||||
if confirm == "" {
|
||||
@@ -56,22 +54,11 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp
|
||||
shown := true
|
||||
ui.PopoverBase(c, anchor, &shown, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, b))
|
||||
panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(320).Role(ui.RoleAlertDialog).
|
||||
panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 3).MaxWidth(popoverWide(c, 320)).MaxHeight(popoverRoom(c, b)).Role(ui.RoleAlertDialog).
|
||||
Label(opts.Title).Description(opts.Description)
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
|
||||
color := k.Warning
|
||||
if opts.Danger {
|
||||
color = k.Danger
|
||||
}
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(18, 18).TextColor(color).Shrink(0).Margin(1, 0, 0, 0)
|
||||
ui.Column(c).Gap(u).Shrink(1).Children(func() {
|
||||
ui.Text(c, opts.Title).FontWeight(600)
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Children(func() {
|
||||
// The text scrolls so the actions stay in the window.
|
||||
ui.Scroll(c).Shrink(1).Children(func() { popconfirmText(c, opts) })
|
||||
ui.Row(c).Gap(u * 2).Justify(ui.End).Shrink(0).Children(func() {
|
||||
if Button(c, cancel, ButtonOptions{Variant: Secondary}).Clicked() {
|
||||
res.Cancelled = true
|
||||
}
|
||||
@@ -89,3 +76,22 @@ func Popconfirm(c *ui.Context, anchor *ui.Element, open *bool, opts PopconfirmOp
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// popconfirmText is the icon, title and description of a Popconfirm.
|
||||
func popconfirmText(c *ui.Context, opts PopconfirmOptions) {
|
||||
k := Tokens(c)
|
||||
u := Density(c).Unit()
|
||||
ui.Row(c).Gap(u * 2).AlignItems(ui.Start).Children(func() {
|
||||
color := k.Warning
|
||||
if opts.Danger {
|
||||
color = k.Danger
|
||||
}
|
||||
ui.Icon(c, icons.Must("circle-alert")).Size(18, 18).TextColor(color).Shrink(0).Margin(1, 0, 0, 0)
|
||||
ui.Column(c).Gap(u).Shrink(1).Children(func() {
|
||||
ui.Text(c, opts.Title).FontWeight(600)
|
||||
if opts.Description != "" {
|
||||
ui.Text(c, opts.Description).TextColor(k.TextMuted)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -59,6 +60,25 @@ func TestPopconfirmCancelPaths(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopconfirmKeepsActionsInWindow(t *testing.T) {
|
||||
long := strings.Repeat("The east gate stays unwatched tonight. ", 60)
|
||||
open := true
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(30).Children(func() {
|
||||
b := Button(c, "Archive", ButtonOptions{})
|
||||
Popconfirm(c, b, &open, PopconfirmOptions{Title: "Archive?", Description: long})
|
||||
})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
for _, label := range []string{"Confirm", "Cancel"} {
|
||||
r, ok := tt.Find(label)
|
||||
if !ok || r.Y < 0 || r.Y+r.H > 300 || r.X+r.W > 400 {
|
||||
t.Fatalf("%s at %+v outside the 400×300 window", label, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopconfirmSnapshot(t *testing.T) {
|
||||
snapshot(t, "popconfirm", 420, 220, func(c *ui.Context) {
|
||||
open := true
|
||||
|
||||
+12
-7
@@ -18,9 +18,7 @@ type PopoverOptions struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// Popover shows content in a panel below anchor while *open is true. The
|
||||
// panel flips above where there is no room, stays in the window and scrolls
|
||||
// when taller than the room; an outside click or Escape closes it.
|
||||
// Popover shows content below anchor, or above it, inside the window while *open is true.
|
||||
func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions, content func()) *ui.Element {
|
||||
if anchor == nil {
|
||||
panic("mujica: Popover needs an anchor")
|
||||
@@ -28,6 +26,7 @@ func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions,
|
||||
if !*open {
|
||||
return nil
|
||||
}
|
||||
overlayLayer(c, open)
|
||||
u := Density(c).Unit()
|
||||
b := anchor.Bounds()
|
||||
label := opts.Label
|
||||
@@ -36,14 +35,14 @@ func Popover(c *ui.Context, anchor *ui.Element, open *bool, opts PopoverOptions,
|
||||
}
|
||||
panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, b))
|
||||
panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(b.W).MaxHeight(popoverRoom(c, b)).Label(label)
|
||||
panel.Margin(6, 0, 0, 0).Padding(u * 3).Gap(u * 2).MinWidth(popoverWide(c, b.W)).MaxHeight(popoverRoom(c, b)).Label(label)
|
||||
if opts.Width > 0 {
|
||||
panel.Width(opts.Width)
|
||||
panel.Width(popoverWide(c, opts.Width))
|
||||
} else {
|
||||
panel.MaxWidth(360)
|
||||
panel.MaxWidth(popoverWide(c, 360))
|
||||
}
|
||||
if opts.Title != "" {
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(theme.BodySize + 2).FontWeight(600).Shrink(0)
|
||||
ui.Text(c, opts.Title).Font(theme.SerifFont).FontSize(FontSize(c, theme.BodySize+2)).FontWeight(600).Shrink(0)
|
||||
}
|
||||
ui.Scroll(c).Shrink(1).Children(content)
|
||||
})
|
||||
@@ -58,3 +57,9 @@ func popoverRoom(c *ui.Context, b ui.Rect) float32 {
|
||||
_, h := c.Size()
|
||||
return max(b.Y, h-b.Y-b.H) - 16
|
||||
}
|
||||
|
||||
// popoverWide caps a panel width w to the window.
|
||||
func popoverWide(c *ui.Context, w float32) float32 {
|
||||
ww, _ := c.Size()
|
||||
return min(w, ww-8)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package mujica
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
@@ -76,6 +77,27 @@ func TestPopoverOutsideClickAndEscape(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopoverFitsWindow(t *testing.T) {
|
||||
open := true
|
||||
var panel ui.Rect
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{})
|
||||
ui.Column(c).Padding(30).Children(func() {
|
||||
b := Button(c, "Filter", ButtonOptions{})
|
||||
if p := Popover(c, b, &open, PopoverOptions{Width: 1000}, func() {
|
||||
ui.Text(c, strings.Repeat("Long row of guests. ", 200))
|
||||
Button(c, "Bottom", ButtonOptions{})
|
||||
}); p != nil {
|
||||
panel = p.Bounds()
|
||||
}
|
||||
})
|
||||
}, 400, 300)
|
||||
tt.Frame()
|
||||
if panel.W > 400 || panel.X < 0 || panel.Y+panel.H > 300 {
|
||||
t.Fatalf("panel %+v outside the 400×300 window", panel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPopoverNeedsAnchor(t *testing.T) {
|
||||
defer func() {
|
||||
if recover() == nil {
|
||||
|
||||
+15
-15
@@ -17,8 +17,7 @@ type PopupMenuItemOptions struct {
|
||||
KeepOpen bool
|
||||
}
|
||||
|
||||
// PopupMenu builds the items of a DropdownMenu or ContextMenu. Its methods
|
||||
// add items in order and report choices in the frame they happen.
|
||||
// PopupMenu builds the items of a DropdownMenu or ContextMenu, reporting choices as they happen.
|
||||
type PopupMenu struct {
|
||||
c *ui.Context
|
||||
root *popupMenuRoot
|
||||
@@ -43,8 +42,7 @@ type popupMenuSub struct {
|
||||
build func(m *PopupMenu)
|
||||
}
|
||||
|
||||
// Item adds an item showing label and reports whether it was chosen.
|
||||
// Choosing it closes the menu unless KeepOpen is set.
|
||||
// Item adds an item and reports its choice, which closes the menu unless KeepOpen.
|
||||
func (m *PopupMenu) Item(label string, opts PopupMenuItemOptions) bool {
|
||||
it := m.row(ui.ButtonBase(m.c), opts.Disabled, false)
|
||||
m.content(it, label, false, false)
|
||||
@@ -68,8 +66,7 @@ func (m *PopupMenu) Check(label string, checked *bool, opts PopupMenuItemOptions
|
||||
return true
|
||||
}
|
||||
|
||||
// Submenu adds an item opening a nested menu that build fills. Hover,
|
||||
// click, Enter, Space or Right opens it; Left or Escape closes it.
|
||||
// Submenu adds an item opening a nested menu that build fills.
|
||||
func (m *PopupMenu) Submenu(label string, opts PopupMenuItemOptions, build func(m *PopupMenu)) {
|
||||
if build == nil {
|
||||
panic("mujica: PopupMenu Submenu needs a build function")
|
||||
@@ -95,7 +92,7 @@ func (m *PopupMenu) Group(label string, fn func()) {
|
||||
m.Separator()
|
||||
}
|
||||
if label != "" {
|
||||
ui.Text(m.c, label).FontSize(theme.CaptionSize).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0)
|
||||
ui.Text(m.c, label).FontSize(FontSize(m.c, theme.CaptionSize)).TextColor(k.TextMuted).Padding(u, u*3, u/2, u*3+16+u*2).Shrink(0)
|
||||
}
|
||||
fn()
|
||||
}
|
||||
@@ -115,7 +112,8 @@ func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element {
|
||||
m.rows++
|
||||
seq := m.seq
|
||||
it.Justify(ui.Start).MinHeight(max(theme.MinHitSize, d.ControlHeight()-4)).Padding(0, d.Unit()*3).
|
||||
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).FocusRing(false).TextColor(k.Text)
|
||||
Gap(d.Unit() * 2).Radius(theme.ControlRadius).Disabled(disabled).TextColor(k.Text)
|
||||
internal.Ring(it, k.Focus, theme.ControlRadius)
|
||||
if disabled {
|
||||
return it
|
||||
}
|
||||
@@ -135,7 +133,7 @@ func (m *PopupMenu) row(it *ui.Element, disabled, sub bool) *ui.Element {
|
||||
}
|
||||
*was = hovered
|
||||
if hovered || it.FocusVisible() || m.level.open == seq {
|
||||
it.Background(k.Selection).DrawOver(func(p *ui.Painter, r ui.Rect) {
|
||||
it.Background(k.Selection).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1)
|
||||
})
|
||||
}
|
||||
@@ -163,13 +161,15 @@ func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m *
|
||||
if !*open {
|
||||
return nil
|
||||
}
|
||||
overlayLayer(c, open)
|
||||
b := anchor.Bounds()
|
||||
root := &popupMenuRoot{}
|
||||
panel := ui.PopoverBase(c, anchor, open, func(panel *ui.Element) {
|
||||
overlayStyle(c, panel, overlayAnchored(c, panel, b))
|
||||
panel.Margin(4, 0, 0, 0).MinWidth(max(b.W, 200)).MaxWidth(360).MaxHeight(popoverRoom(c, b))
|
||||
items := popupMenuLevelBuild(c, panel, root, build, true)
|
||||
if items.Shortcut(0, ui.KeyTab) || items.Shortcut(ui.Shift, ui.KeyTab) {
|
||||
panel.Margin(4, 0, 0, 0).MinWidth(popoverWide(c, max(b.W, 200))).MaxWidth(popoverWide(c, 360)).MaxHeight(popoverRoom(c, b))
|
||||
popupMenuLevelBuild(c, panel, root, build, true)
|
||||
// The panel holds every level, so Tab in a submenu closes the whole menu too.
|
||||
if panel.Shortcut(0, ui.KeyTab) || panel.Shortcut(ui.Shift, ui.KeyTab) {
|
||||
root.close = true
|
||||
}
|
||||
})
|
||||
@@ -183,9 +183,10 @@ func popupMenuOpen(c *ui.Context, anchor *ui.Element, open *bool, build func(m *
|
||||
}
|
||||
|
||||
// popupMenuLevelBuild builds one level's items in outer, then its open submenu.
|
||||
func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) *ui.Element {
|
||||
func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot, build func(m *PopupMenu), autofocus bool) {
|
||||
u := Density(c).Unit()
|
||||
items := ui.Scroll(c).Shrink(1).Padding(u).FocusGroup(ui.Vertical)
|
||||
// The padding leaves room for the items' focus rings inside the clipping scroll.
|
||||
items := ui.Scroll(c).Shrink(1).Padding(u + theme.FocusGap + theme.FocusWidth).FocusGroup(ui.Vertical)
|
||||
m := &PopupMenu{c: c, root: root, autofocus: autofocus}
|
||||
m.level = ui.Local(items, "mujica.menu.level", func() popupMenuLevel { return popupMenuLevel{} })
|
||||
outer.Role(ui.RolePopup)
|
||||
@@ -195,7 +196,6 @@ func popupMenuLevelBuild(c *ui.Context, outer *ui.Element, root *popupMenuRoot,
|
||||
popupMenuSubmenu(c, outer, root, m.level, s)
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// popupMenuSubmenu builds a submenu beside its item, flipped to stay in the window.
|
||||
|
||||
+24
-9
@@ -78,8 +78,8 @@ func Progress(c *ui.Context, value float64, opts ProgressOptions) *ui.Element {
|
||||
}
|
||||
bar := ui.Progress(c, acc).Label(name).FillWidth().Height(u * 1.5).Radius(theme.ControlRadius).Background(k.Border)
|
||||
shown := float32(value)
|
||||
if !opts.Indeterminate && !still {
|
||||
shown = bar.Animate("progress.value", shown, theme.StateDuration)
|
||||
if !opts.Indeterminate {
|
||||
shown = progressAnimate(bar, shown, still)
|
||||
}
|
||||
bar.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
switch {
|
||||
@@ -110,10 +110,13 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
|
||||
row.Children(func() {
|
||||
ring := ui.Progress(c, acc).Label(name).Size(d, d).Radius(0).Background(ui.Transparent)
|
||||
shown := float32(acc)
|
||||
if !opts.Indeterminate && !still {
|
||||
shown = ring.Animate("progress.value", shown, theme.StateDuration)
|
||||
if !opts.Indeterminate {
|
||||
shown = progressAnimate(ring, shown, still)
|
||||
}
|
||||
stroke := max(3, d/10)
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
tw, th := c.MeasureText(0, ui.Span{Text: caption, Size: size})
|
||||
inside := caption != "" && !opts.Indeterminate && tw+4 <= d-2*stroke && th <= d-2*stroke
|
||||
ring.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
cx, cy, rad := internal.Snap(p, r.X+r.W/2), internal.Snap(p, r.Y+r.H/2), internal.Snap(p, (r.W-stroke)/2)
|
||||
var track ui.Path
|
||||
@@ -132,19 +135,19 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
|
||||
p.StrokePath(progressArc(p, cx, cy, rad, start, start+0.25), stroke, k.AccentText)
|
||||
}
|
||||
})
|
||||
if caption != "" && d >= 44 && !opts.Indeterminate {
|
||||
if inside {
|
||||
ring.Children(func() {
|
||||
ui.Text(c, caption).FontSize(FontSize(c, theme.CaptionSize)).FontFeatures("tnum").TextColor(k.Text)
|
||||
ui.Text(c, caption).FontSize(size).FontFeatures("tnum").TextColor(k.Text)
|
||||
})
|
||||
ring.Center()
|
||||
}
|
||||
if opts.Label != "" || (caption != "" && (d < 44 || opts.Indeterminate)) {
|
||||
if opts.Label != "" || (caption != "" && !inside) {
|
||||
ui.Column(c).Children(func() {
|
||||
if opts.Label != "" {
|
||||
ui.Text(c, opts.Label).TextColor(k.Text)
|
||||
}
|
||||
if caption != "" && (d < 44 || opts.Indeterminate) {
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(FontSize(c, theme.CaptionSize))
|
||||
if caption != "" && !inside {
|
||||
ui.Text(c, caption).TextColor(k.TextMuted).FontSize(size).FontFeatures("tnum")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -152,6 +155,15 @@ func progressRing(c *ui.Context, k theme.Tokens, u float32, acc float64, name, c
|
||||
return row
|
||||
}
|
||||
|
||||
// progressAnimate eases toward target; under reduced motion it lands there and keeps the state in step.
|
||||
func progressAnimate(e *ui.Element, target float32, still bool) float32 {
|
||||
if still {
|
||||
// A changed target lands at once under reduced motion; the detour stops a running transition.
|
||||
e.Animate("progress.value", target+1, theme.StateDuration)
|
||||
}
|
||||
return e.Animate("progress.value", target, theme.StateDuration)
|
||||
}
|
||||
|
||||
// progressArc is the arc from turn a to turn b, clockwise from twelve o'clock.
|
||||
func progressArc(pt *ui.Painter, cx, cy, r float32, a, b float32) *ui.Path {
|
||||
var p ui.Path
|
||||
@@ -230,6 +242,9 @@ func Meter(c *ui.Context, value float64, opts MeterOptions) *ui.Element {
|
||||
}
|
||||
frac := func(v float64) float32 { return float32((v - lo) / (hi - lo)) }
|
||||
col := ui.Column(c).Gap(u * 1.5)
|
||||
if !(opts.Width >= 0) || math.IsInf(float64(opts.Width), 1) {
|
||||
panic(fmt.Sprintf("mujica: Meter Width %v invalid", opts.Width))
|
||||
}
|
||||
if opts.Width > 0 {
|
||||
col.Width(opts.Width)
|
||||
} else {
|
||||
|
||||
+13
-5
@@ -2,6 +2,7 @@ package mujica
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"slices"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
@@ -45,10 +46,17 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt
|
||||
var r InputResult
|
||||
// Disabled on a wrapper blocks the bases' clicks before they choose.
|
||||
wrap := ui.Box(c).Shrink(0).Disabled(opts.Disabled)
|
||||
// Keys by first appearance of each value: fmt keys would merge equal-looking values.
|
||||
known := ui.Local(wrap, "values", func() []T { return nil })
|
||||
wrap.Children(func() {
|
||||
r.Element = ui.RadioGroup(c, func() {
|
||||
for _, it := range items {
|
||||
if radioGroupItem(c, k, d, value, it) {
|
||||
key := slices.Index(*known, it.Value)
|
||||
if key < 0 {
|
||||
key = len(*known)
|
||||
*known = append(*known, it.Value)
|
||||
}
|
||||
if radioGroupItem(c, k, d, value, it, key) {
|
||||
r.changed = true
|
||||
slog.Debug("mujica: radio_group choose", "label", it.Label)
|
||||
}
|
||||
@@ -58,21 +66,21 @@ func RadioGroup[T comparable](c *ui.Context, value *T, items []RadioItem[T], opt
|
||||
if opts.Horizontal {
|
||||
r.Element.Row().Gap(d.Unit() * 6)
|
||||
}
|
||||
if opts.Disabled && wrap.FocusWithin() {
|
||||
if wrap.IsDisabled() && wrap.FocusWithin() {
|
||||
wrap.FocusRing(false).Focus()
|
||||
}
|
||||
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 {
|
||||
func radioGroupItem[T comparable](c *ui.Context, k theme.Tokens, d theme.Density, value *T, it RadioItem[T], key int) bool {
|
||||
// Keyed by value so focus follows the item, not its position.
|
||||
var row *ui.Element
|
||||
item := ui.Box(c).Key(it.Value).Disabled(it.Disabled)
|
||||
item := ui.Box(c).Key(key).Disabled(it.Disabled)
|
||||
item.Children(func() {
|
||||
row = ui.RadioBase(c, value, it.Value).Gap(d.Unit() * 2).MinHeight(d.ControlHeight()).FocusRing(false)
|
||||
})
|
||||
if it.Disabled && item.FocusWithin() {
|
||||
if item.IsDisabled() && item.FocusWithin() {
|
||||
item.FocusRing(false).Focus()
|
||||
}
|
||||
on := *value == it.Value
|
||||
|
||||
+20
-28
@@ -22,11 +22,11 @@ type RangeSliderOptions struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
// RangeSlider sets *low and *high, which never cross. A press moves the
|
||||
// nearer thumb; each thumb takes the focus and the arrows, Home and End.
|
||||
// RangeSlider sets *low and *high, which never cross; a press moves the nearer thumb, each takes the keys.
|
||||
func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) ChoiceResult {
|
||||
g, h := sliderSetup(opts.Min, opts.Max, opts.Step, opts.Marks, opts.ShowValue, c)
|
||||
format := sliderFormat(opts.Format, opts.Step)
|
||||
format := sliderFormat(opts.Format, g)
|
||||
size := FontSize(c, theme.CaptionSize)
|
||||
k := Tokens(c)
|
||||
*low, *high = g.snap(math.Min(*low, *high)), g.snap(math.Max(*low, *high))
|
||||
oldLow, oldHigh := *low, *high
|
||||
@@ -47,32 +47,24 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho
|
||||
from, to = *low, g.hi
|
||||
}
|
||||
x := g.x(ui.Rect{W: r.W}, *v) - sliderThumb/2
|
||||
kb := ui.Box(c).Key(i).Absolute().Left(x).Top(g.trackY-sliderThumb/2).Size(sliderThumb, sliderThumb).
|
||||
Focusable().FocusRing(false).Role(ui.RoleSlider)
|
||||
var kb *ui.Element
|
||||
var next float64
|
||||
var held bool
|
||||
// Each thumb is a SliderBase, which reports the range and value to assistive technology.
|
||||
ui.Box(c).Key(i).Absolute().Left(x).Top(g.trackY-sliderThumb/2).Size(sliderThumb, sliderThumb).Children(func() {
|
||||
kb, next, held = sliderControl(c, *v, g, from, to)
|
||||
})
|
||||
kb.Size(sliderThumb, sliderThumb).FocusRing(false)
|
||||
name := msg(c, []string{"range_slider.min", "range_slider.max"}[i])
|
||||
if opts.Label != "" {
|
||||
name = opts.Label + " " + name
|
||||
}
|
||||
kb.Label(name + ", " + format(*v))
|
||||
step := func(d float64) float64 {
|
||||
if g.step > 0 {
|
||||
return g.snap(*v + d*g.step)
|
||||
}
|
||||
return *v + d*(g.hi-g.lo)/100
|
||||
}
|
||||
switch {
|
||||
case kb.Shortcut(0, ui.KeyLeft), kb.Shortcut(0, ui.KeyDown):
|
||||
*v = max(from, step(-1))
|
||||
case kb.Shortcut(0, ui.KeyRight), kb.Shortcut(0, ui.KeyUp):
|
||||
*v = min(to, step(1))
|
||||
case kb.Shortcut(0, ui.KeyHome):
|
||||
*v = from
|
||||
case kb.Shortcut(0, ui.KeyEnd):
|
||||
*v = to
|
||||
}
|
||||
if _, _, ok := kb.Dragged(); ok {
|
||||
kb.Label(name)
|
||||
if held {
|
||||
pressed = true
|
||||
*drag = i
|
||||
} else if !opts.Disabled {
|
||||
*v = next
|
||||
}
|
||||
knobs[i] = kb
|
||||
}
|
||||
@@ -104,12 +96,12 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho
|
||||
hover := e.Hovered()
|
||||
e.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
x0, x1 := g.x(r, lo), g.x(r, hi)
|
||||
sliderTrackPaint(p, r, g, k, opts.Marks, format, x0, x1)
|
||||
sliderTrackPaint(p, r, g, k, opts.Marks, size, x0, x1)
|
||||
for i, x := range []float32{x0, x1} {
|
||||
hot := which == i || knobs[i].Hovered()
|
||||
sliderThumbPaint(p, k, x, r.Y+g.trackY, hot, knobs[i].FocusVisible())
|
||||
if opts.ShowValue && (hot || hover || knobs[i].Focused()) {
|
||||
rangeSliderBubble(p, k, x, r.Y+g.trackY, format([]float64{lo, hi}[i]), i)
|
||||
rangeSliderBubble(p, k, x, r.Y+g.trackY, format([]float64{lo, hi}[i]), i, size)
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -120,11 +112,11 @@ func RangeSlider(c *ui.Context, low, high *float64, opts RangeSliderOptions) Cho
|
||||
type rangeSliderDrag struct{}
|
||||
|
||||
// rangeSliderBubble keeps the two tips apart: the low one leans left, the high one right.
|
||||
func rangeSliderBubble(p *ui.Painter, k theme.Tokens, x, y float32, s string, i int) {
|
||||
w, _ := p.MeasureText(1000, ui.Span{Text: s, Size: theme.CaptionSize})
|
||||
func rangeSliderBubble(p *ui.Painter, k theme.Tokens, x, y float32, s string, i int, size float32) {
|
||||
w, _ := p.MeasureText(1000, ui.Span{Text: s, Size: size})
|
||||
shift := (w+12)/2 - sliderThumb/2
|
||||
if i == 0 {
|
||||
shift = -shift
|
||||
}
|
||||
sliderBubblePaint(p, k, x+shift, y, s)
|
||||
sliderBubblePaint(p, k, x+shift, y, s, size)
|
||||
}
|
||||
+2
-14
@@ -21,7 +21,7 @@ func TestRangeSliderKeysPerThumb(t *testing.T) {
|
||||
low, high, changes := 20.0, 80.0, 0
|
||||
tt := rangeTester(&low, &high, RangeSliderOptions{Step: 10, Label: "Price"}, &changes)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Price minimum, 20") {
|
||||
if !tt.Focused("Price minimum") {
|
||||
t.Fatal("first Tab did not focus the low thumb")
|
||||
}
|
||||
tt.Key(0, ui.KeyRight)
|
||||
@@ -37,7 +37,7 @@ func TestRangeSliderKeysPerThumb(t *testing.T) {
|
||||
t.Fatalf("low crossed high: %v", low)
|
||||
}
|
||||
tt.Key(0, ui.KeyTab)
|
||||
if !tt.Focused("Price maximum, 80") {
|
||||
if !tt.Focused("Price maximum") {
|
||||
t.Fatal("second Tab did not focus the high thumb")
|
||||
}
|
||||
tt.Key(0, ui.KeyLeft)
|
||||
@@ -77,18 +77,6 @@ func TestRangeSliderOrdersSwappedEnds(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeSliderDisabled(t *testing.T) {
|
||||
low, high, changes := 20.0, 40.0, 0
|
||||
tt := rangeTester(&low, &high, RangeSliderOptions{Disabled: true}, &changes)
|
||||
tt.Key(0, ui.KeyTab)
|
||||
tt.Key(0, ui.KeyRight)
|
||||
tt.Press(150, 18)
|
||||
tt.Release(150, 18)
|
||||
if low != 20 || high != 40 || changes != 0 {
|
||||
t.Fatalf("disabled moved: %v..%v", low, high)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRangeSliderSnapshot(t *testing.T) {
|
||||
snapshot(t, "range_slider", 420, 170, func(c *ui.Context) {
|
||||
a, b := 20.0, 70.0
|
||||
|
||||
@@ -48,6 +48,8 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
|
||||
}
|
||||
k := Tokens(c)
|
||||
size := Density(c).ControlHeight() * 0.6
|
||||
// Each star sits centered in a hit area of at least the minimum size.
|
||||
hit := max(size, theme.MinHitSize)
|
||||
label := opts.Label
|
||||
if label == "" {
|
||||
label = msg(c, "rating.label")
|
||||
@@ -64,10 +66,10 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
|
||||
e := ui.Row(c).AlignItems(ui.Center).Gap(Density(c).Unit() * 2).Shrink(0)
|
||||
parts.Element = e
|
||||
e.Label(label)
|
||||
interactive := !opts.ReadOnly && !opts.Disabled
|
||||
if opts.Disabled {
|
||||
e.Disabled(true)
|
||||
} else {
|
||||
e.Disabled(opts.Disabled)
|
||||
disabled := e.IsDisabled()
|
||||
interactive := !opts.ReadOnly && !disabled
|
||||
if !disabled {
|
||||
e.Focusable()
|
||||
internal.Ring(e, k.Focus, theme.ControlRadius)
|
||||
}
|
||||
@@ -85,10 +87,10 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
|
||||
}
|
||||
}
|
||||
e.Children(func() {
|
||||
stars := ui.Box(c).Size(size*float32(n), size).Shrink(0)
|
||||
stars := ui.Box(c).Size(hit*float32(n), hit).Shrink(0)
|
||||
shown, fill := *value, k.AccentText
|
||||
if interactive {
|
||||
at := func(x float32) float64 { return math.Ceil(float64(x/size)/step-1e-9) * step }
|
||||
at := func(x float32) float64 { return math.Ceil(float64(x/hit)/step-1e-9) * step }
|
||||
if _, _, ok := stars.Dragged(); ok {
|
||||
x, _, _ := stars.PointerPosition()
|
||||
set(at(x))
|
||||
@@ -99,7 +101,7 @@ func Rating(c *ui.Context, value *float64, opts RatingOptions) RatingParts {
|
||||
}
|
||||
stars.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
for i := range n {
|
||||
cell := ui.Rect{X: r.X + float32(i)*size, Y: r.Y, W: size, H: size}
|
||||
cell := ui.Rect{X: r.X + float32(i)*hit + (hit-size)/2, Y: r.Y + (hit-size)/2, W: size, H: size}
|
||||
path := ratingStar(cell)
|
||||
p.StrokePath(path, 1.25, k.ControlBorder)
|
||||
part := float32(math.Max(0, math.Min(1, shown-float64(i))))
|
||||
|
||||
+18
-1
@@ -4,6 +4,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/egoist/mygo/ui"
|
||||
|
||||
"mujicaui/theme"
|
||||
)
|
||||
|
||||
func ratingTester(v *float64, opts RatingOptions, changes *int) *ui.Tester {
|
||||
@@ -48,7 +50,7 @@ func TestRatingKeyboardSteps(t *testing.T) {
|
||||
func TestRatingPointer(t *testing.T) {
|
||||
v, n := 0.0, 0
|
||||
tt := ratingTester(&v, RatingOptions{Step: 0.5, Label: "Taste"}, &n)
|
||||
size := float32(36) * 0.6
|
||||
size := theme.MinHitSize
|
||||
// The left half of the third star.
|
||||
tt.ClickAt(10+2*size+size*0.25, 10+size/2)
|
||||
if v != 2.5 || n != 1 {
|
||||
@@ -100,3 +102,18 @@ func TestRatingSnapshot(t *testing.T) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestRatingHitAreaCompact(t *testing.T) {
|
||||
v := 2.0
|
||||
var r RatingParts
|
||||
tt := ui.NewTester(func(c *ui.Context) {
|
||||
Use(c, Settings{Density: theme.Compact})
|
||||
ui.Column(c).AlignItems(ui.Start).Children(func() {
|
||||
r = Rating(c, &v, RatingOptions{})
|
||||
})
|
||||
}, 300, 100)
|
||||
tt.Frame()
|
||||
if b := r.Element.Bounds(); b.W < 5*28 || b.H < 28 {
|
||||
t.Fatalf("stars %v", b)
|
||||
}
|
||||
}
|
||||
+10
-9
@@ -59,8 +59,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn
|
||||
name = place
|
||||
}
|
||||
var r SearchInputResult
|
||||
look := &textInputLook{disabled: opts.Disabled}
|
||||
step := func(d int) {
|
||||
if opts.Matches == 0 || opts.Disabled {
|
||||
if opts.Matches == 0 || look.disabled {
|
||||
return
|
||||
}
|
||||
*opts.Current = (*opts.Current + d + opts.Matches) % opts.Matches
|
||||
@@ -69,7 +70,10 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn
|
||||
}
|
||||
if opts.Find {
|
||||
*opts.Current = max(0, min(*opts.Current, opts.Matches-1))
|
||||
if !opts.Disabled {
|
||||
}
|
||||
r.Element, r.Input = textInputFrame(c, look, func(frame *ui.Element) *ui.Element {
|
||||
if opts.Find {
|
||||
// Built inside the frame, where step knows of disabled ancestors.
|
||||
mac := runtime.GOOS == "darwin"
|
||||
switch {
|
||||
case mac && c.Shortcut(ui.Cmd, ui.KeyG), !mac && c.Shortcut(0, ui.KeyF3):
|
||||
@@ -78,13 +82,10 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn
|
||||
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 := textInputEditor(c, frame, query, opts.Disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name)
|
||||
r.changed = in.Changed() && !opts.Disabled
|
||||
in := textInputEditor(c, frame, query, look.disabled, ui.TextInputBase).Grow(1).MinWidth(Density(c).Unit() * 10).Placeholder(place).Label(name)
|
||||
r.changed = in.Changed() && !look.disabled
|
||||
if opts.Find {
|
||||
// Shift+Enter would reach the editor as a plain submit.
|
||||
in.HandleInput(func(ev ui.InputEvent) bool {
|
||||
@@ -102,9 +103,9 @@ func SearchInput(c *ui.Context, query *string, opts SearchInputOptions) SearchIn
|
||||
step(1)
|
||||
}
|
||||
} else {
|
||||
r.submitted = in.Submitted() && !opts.Disabled
|
||||
r.submitted = in.Submitted() && !look.disabled
|
||||
}
|
||||
if *query != "" && !opts.Disabled && textInputClear(c, in) {
|
||||
if *query != "" && !look.disabled && textInputClear(c, in) {
|
||||
*query = ""
|
||||
r.changed = true
|
||||
slog.Debug("mujica: search_input clear")
|
||||
|
||||
@@ -41,19 +41,15 @@ type selectTypeahead struct {
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// Select chooses *value from options in a popup. Enter, Space and the
|
||||
// arrows open it; the arrows, Home and End move; Enter chooses; Escape
|
||||
// closes. Typing on the closed select chooses the option starting with it.
|
||||
// Select chooses *value from options in a popup; typing on the closed select jumps to a match.
|
||||
func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], opts SelectOptions) ChoiceResult {
|
||||
k := Tokens(c)
|
||||
old := *value
|
||||
if opts.Disabled {
|
||||
// SelectBase takes clicks as it is built: a disabled one works on a copy.
|
||||
scratch := old
|
||||
value = &scratch
|
||||
}
|
||||
s := ui.SelectBase(c, value)
|
||||
tr := s.Trigger.Disabled(opts.Disabled)
|
||||
value = choiceTarget(value, opts.Disabled)
|
||||
var s *ui.SelectParts[T]
|
||||
// SelectBase takes clicks as it is built: a disabled wrapper and a copy block them.
|
||||
ui.Row(c).Shrink(0).Disabled(opts.Disabled).Children(func() { s = ui.SelectBase(c, value) })
|
||||
tr := s.Trigger
|
||||
if opts.Label != "" {
|
||||
tr.Label(opts.Label)
|
||||
}
|
||||
@@ -93,6 +89,10 @@ func Select[T comparable](c *ui.Context, value *T, options []SelectOption[T], op
|
||||
}
|
||||
})
|
||||
lastHi := ui.Local(tr, selectKey{"shown"}, func() int { return -1 })
|
||||
if opts.Disabled {
|
||||
// SelectParts has no way to close it (ui/base.go:310): a disabled select builds no popup.
|
||||
return ChoiceResult{tr, false}
|
||||
}
|
||||
s.Popup(func(panel *ui.Element) {
|
||||
selectPanel(c, panel)
|
||||
ui.Scroll(c).MaxHeight(280).Children(func() {
|
||||
@@ -191,17 +191,33 @@ func selectChevron(open bool) string {
|
||||
return "chevron-down"
|
||||
}
|
||||
|
||||
// selectClear draws a small clear button that takes no Tab stop; it reports a click.
|
||||
// selectClear draws a clear cross outside the Tab order, hit area 28 DIP laid out as 18; it reports a click.
|
||||
func selectClear(c *ui.Context, k theme.Tokens, name string) bool {
|
||||
x := ui.Box(c).Size(18, 18).Shrink(0).Radius(theme.ControlRadius).Role(ui.RoleButton).Label(name)
|
||||
x := ui.Box(c).Size(theme.MinHitSize, theme.MinHitSize).Margin(-5).Shrink(0).Radius(theme.ControlRadius).
|
||||
Role(ui.RoleButton).Label(name).Cursor(ui.CursorPointer)
|
||||
clicked := x.Clicked()
|
||||
if x.Hovered() {
|
||||
x.Background(k.SurfaceHover)
|
||||
}
|
||||
x.Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, r, "x", k.TextMuted) })
|
||||
x.Draw(func(p *ui.Painter, r ui.Rect) { internal.Mark(p, selectCenter(r, 14), "x", k.TextMuted) })
|
||||
return clicked
|
||||
}
|
||||
|
||||
// selectCenter returns the size×size square centered in r.
|
||||
func selectCenter(r ui.Rect, size float32) ui.Rect {
|
||||
return ui.Rect{X: r.X + (r.W-size)/2, Y: r.Y + (r.H-size)/2, W: size, H: size}
|
||||
}
|
||||
|
||||
// selectCheck reserves room for the chosen mark and draws it when on.
|
||||
func selectCheck(c *ui.Context, on bool) {
|
||||
k := Tokens(c)
|
||||
ui.Box(c).Size(16, 16).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if on {
|
||||
internal.Mark(p, r, "check", k.AccentText)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// selectPanel styles a popup panel.
|
||||
func selectPanel(c *ui.Context, panel *ui.Element) {
|
||||
k := Tokens(c)
|
||||
@@ -209,24 +225,25 @@ func selectPanel(c *ui.Context, panel *ui.Element) {
|
||||
Border(theme.BorderWidth, k.Border).Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14))
|
||||
}
|
||||
|
||||
// selectRow styles an option row: highlight background, check mark when chosen.
|
||||
// selectRow styles an option row: chosen has the selection fill and a check, highlighted a side bar.
|
||||
func selectRow(c *ui.Context, row *ui.Element, highlighted, chosen bool, label string) {
|
||||
k := Tokens(c)
|
||||
d := Density(c)
|
||||
row.MinHeight(d.ControlHeight()-4).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2).
|
||||
AlignItems(ui.Center).Radius(theme.ControlRadius)
|
||||
if highlighted {
|
||||
row.MinHeight(d.ControlHeight()).Padding(0, d.Unit()*3, 0, d.Unit()*2).Gap(d.Unit() * 2).
|
||||
AlignItems(ui.Center).Radius(theme.ControlRadius).Role(ui.RoleListItem)
|
||||
switch {
|
||||
case chosen:
|
||||
row.Background(k.Selection).TextColor(k.AccentText)
|
||||
case highlighted:
|
||||
row.Background(k.SurfaceHover)
|
||||
}
|
||||
if chosen {
|
||||
row.TextColor(k.AccentText)
|
||||
if highlighted {
|
||||
row.Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
p.Fill(ui.Rect{X: r.X, Y: r.Y + 4, W: 2, H: r.H - 8}, k.AccentText, 1)
|
||||
})
|
||||
}
|
||||
row.Children(func() {
|
||||
ui.Box(c).Size(14, 14).Shrink(0).Draw(func(p *ui.Painter, r ui.Rect) {
|
||||
if chosen {
|
||||
internal.Mark(p, r, "check", k.AccentText)
|
||||
}
|
||||
})
|
||||
selectCheck(c, chosen)
|
||||
ui.Text(c, label).SingleLine()
|
||||
})
|
||||
}
|
||||
|
||||
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user