diff --git a/bulk_action_bar.go b/bulk_action_bar.go new file mode 100644 index 0000000..ad4e0d7 --- /dev/null +++ b/bulk_action_bar.go @@ -0,0 +1,53 @@ +package mujica + +import ( + "fmt" + "log/slog" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// BulkActionBarOptions configure a BulkActionBar. +type BulkActionBarOptions struct { + Disabled bool +} + +// BulkActionBar shows while selected > 0 with the count, actions and a clear button; Changed reports a clear. +func BulkActionBar(c *ui.Context, selected int, opts BulkActionBarOptions, actions func()) ChoiceResult { + if selected < 0 { + panic(fmt.Sprintf("mujica: BulkActionBar selected %d is negative", selected)) + } + if selected == 0 { + return ChoiceResult{} + } + k := Tokens(c) + u := Density(c).Unit() + count := fmt.Sprintf(msg(c, "bulk_action_bar.selected"), selected) + bar := ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).MinHeight(Density(c).ControlHeight()+4*u). + Role(ui.RoleToolbar).Label(count).Disabled(opts.Disabled) + overlayStyle(c, bar, overlayEnter(c, bar)) + said := ui.Local(bar, "bulk_action_bar.said", func() int { return 0 }) + if *said != selected { + *said = selected + c.Announce(count) + } + var clear bool + bar.Children(func() { + ui.Text(c, count).Bold().TextColor(k.Text).SingleLine() + ui.Box(c).Size(theme.BorderWidth, u*5).Background(k.Border).Role(ui.RoleNone) + ui.Row(c).Gap(u * 2).AlignItems(ui.Center).Grow(1).Children(func() { + if actions != nil { + actions() + } + }) + b := Button(c, msg(c, "bulk_action_bar.clear"), ButtonOptions{Variant: Ghost, Icon: icons.Must("x")}) + clear = b.Clicked() || bar.Shortcut(0, ui.KeyEscape) && !bar.IsDisabled() + }) + if clear { + slog.Debug("mujica: bulk_action_bar clear", "selected", selected) + } + return ChoiceResult{bar, clear} +} diff --git a/bulk_action_bar_test.go b/bulk_action_bar_test.go new file mode 100644 index 0000000..b1c14c2 --- /dev/null +++ b/bulk_action_bar_test.go @@ -0,0 +1,75 @@ +package mujica + +import ( + "slices" + "testing" + + "github.com/egoist/mygo/ui" +) + +func TestBulkActionBarCountActionsClear(t *testing.T) { + selected, deletes := 0, 0 + dis := false + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Select", ButtonOptions{}).Clicked() { + selected += 3 + } + r := BulkActionBar(c, selected, BulkActionBarOptions{Disabled: dis}, func() { + if Button(c, "Delete", ButtonOptions{Variant: Danger}).Clicked() { + deletes++ + } + }) + if r.Changed() { + selected = 0 + } + }, 600, 300) + if tt.HasText("selected") { + t.Fatal("bar shown with nothing selected") + } + _ = tt.Click("Select") + if !tt.HasText("3 selected") || !slices.Contains(tt.Announcements(), "3 selected") { + t.Fatalf("texts %q", tt.Texts()) + } + _ = tt.Click("Delete") + if deletes != 1 { + t.Fatal("action slot did not run") + } + dis = true + tt.Frame() + _ = tt.Click("Clear selection") + if selected != 3 { + t.Fatal("disabled bar cleared") + } + dis = false + tt.Frame() + _ = tt.Click("Delete") // focus inside the bar + tt.Key(0, ui.KeyEscape) + if selected != 0 || tt.HasText("selected") { + t.Fatalf("Escape left %d selected", selected) + } + _ = tt.Click("Select") + if err := tt.Click("Clear selection"); err != nil || selected != 0 { + t.Fatalf("clear button: %v %d", err, selected) + } +} + +func TestBulkActionBarRejectsNegative(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("no panic") + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); BulkActionBar(c, -1, BulkActionBarOptions{}, nil) }, 200, 100) +} + +func TestBulkActionBarSnapshot(t *testing.T) { + snapshot(t, "bulk_action_bar", 600, 90, func(c *ui.Context) { + ui.Column(c).Padding(16).Children(func() { + BulkActionBar(c, 12, BulkActionBarOptions{}, func() { + Button(c, "Archive", ButtonOptions{Variant: Secondary}) + Button(c, "Delete", ButtonOptions{Variant: Danger}) + }) + }) + }) +} diff --git a/hold_to_confirm.go b/hold_to_confirm.go new file mode 100644 index 0000000..76f1440 --- /dev/null +++ b/hold_to_confirm.go @@ -0,0 +1,113 @@ +package mujica + +import ( + "fmt" + "log/slog" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/internal" + "mujicaui/theme" +) + +// HoldToConfirmOptions configure a HoldToConfirm. +type HoldToConfirmOptions struct { + // Duration is how long to hold; zero is 1.5 s. + Duration time.Duration + Disabled bool +} + +// holdToConfirmState tracks one hold: by key or pointer, since start. +type holdToConfirmState struct { + key, done bool + start time.Time +} + +// step advances the hold to now; it reports completion once per hold. +func (s *holdToConfirmState) step(held bool, now time.Time, d time.Duration) (progress float64, confirmed bool) { + switch { + case !held: + s.start, s.done = time.Time{}, false + return 0, false + case s.done: + return 1, false + case s.start.IsZero(): + s.start = now + } + progress = min(1, float64(now.Sub(s.start))/float64(d)) + if progress >= 1 { + s.done = true + return 1, true + } + return progress, false +} + +// HoldToConfirm runs a dangerous action only after the pointer or Space is held for Duration. +func HoldToConfirm(c *ui.Context, label string, opts HoldToConfirmOptions) ChoiceResult { + if label == "" { + panic("mujica: HoldToConfirm needs a label") + } + if opts.Duration < 0 { + panic("mujica: negative HoldToConfirm Duration") + } + d := opts.Duration + if d == 0 { + d = 1500 * time.Millisecond + } + k := Tokens(c) + den := Density(c) + b := ui.ButtonBase(c).MinHeight(den.ControlHeight()).MinWidth(theme.MinHitSize).Padding(0, den.Unit()*4). + Radius(theme.ControlRadius).Disabled(opts.Disabled).Description(msg(c, "hold_to_confirm.hint")).Clip() + internal.Ring(b, k.Focus, theme.ControlRadius) + st := ui.Local(b, "hold_to_confirm", func() *holdToConfirmState { return &holdToConfirmState{} }) + s := *st + // Space holds; Enter and Space never click, so no single key press confirms. + b.HandleInput(func(ev ui.InputEvent) bool { + if ev.Key != ui.KeySpace && ev.Key != ui.KeyEnter || b.IsDisabled() { + return false + } + if ev.Key == ui.KeySpace && (ev.Kind == ui.InputKeyDown || ev.Kind == ui.InputKeyUp) { + s.key = ev.Kind == ui.InputKeyDown + } + return true + }) + _ = b.Clicked() // a click alone does nothing; it keeps the base pressable + disabled := b.IsDisabled() + held := !disabled && (s.key || b.Pressed()) + if !b.Focused() { + s.key = false + held = !disabled && b.Pressed() + } + progress, confirmed := s.step(held, c.Now(), d) + still := c.Preferences().ReduceMotion + if held && !confirmed && progress < 1 { + if still { + c.After(100 * time.Millisecond) + } else { + c.AnimationFrame() + } + } + bg, fg, _ := buttonColors(k, Danger, b.Hovered() && !disabled, false) + b.Background(bg).TextColor(fg) + text := label + if held && still && progress < 1 { + left := d - time.Duration(progress*float64(d)) + text = fmt.Sprintf(msg(c, "hold_to_confirm.left"), left.Seconds()) + } + b.Draw(func(p *ui.Painter, r ui.Rect) { + if progress > 0 && !still { + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * float32(progress), H: r.H}, k.OnDanger.Alpha(0.28), 0) + } + }) + // Room for the countdown, so the width holds. + w1, _ := c.MeasureText(0, ui.Span{Text: label}) + w2, _ := c.MeasureText(0, ui.Span{Text: fmt.Sprintf(msg(c, "hold_to_confirm.left"), d.Seconds())}) + b.MinWidth(max(w1, w2) + den.Unit()*8) + b.Children(func() { ui.Text(c, text).SingleLine() }) + if confirmed { + c.Announce(msg(c, "hold_to_confirm.done")) + slog.Debug("mujica: hold_to_confirm confirmed", "label", label) + } + return ChoiceResult{b, confirmed} +} diff --git a/hold_to_confirm_test.go b/hold_to_confirm_test.go new file mode 100644 index 0000000..062449e --- /dev/null +++ b/hold_to_confirm_test.go @@ -0,0 +1,124 @@ +package mujica + +import ( + "strings" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func TestHoldToConfirmStep(t *testing.T) { + var s holdToConfirmState + t0 := time.Unix(0, 0) + d := time.Second + if p, ok := s.step(true, t0, d); p != 0 || ok { + t.Fatalf("start: %v %v", p, ok) + } + if p, ok := s.step(true, t0.Add(d/2), d); p != 0.5 || ok { + t.Fatalf("half: %v %v", p, ok) + } + if _, ok := s.step(true, t0.Add(d), d); !ok { + t.Fatal("full hold did not confirm") + } + if _, ok := s.step(true, t0.Add(2*d), d); ok { + t.Fatal("one hold confirmed twice") + } + s.step(false, t0.Add(2*d), d) + if p, _ := s.step(true, t0.Add(3*d), d); p != 0 { + t.Fatalf("release did not restart: %v", p) + } +} + +func holdTester(dis *bool, n *int) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + ui.Row(c).Padding(20).Children(func() { + if HoldToConfirm(c, "Delete repo", HoldToConfirmOptions{Duration: 60 * time.Millisecond, Disabled: *dis}).Changed() { + *n++ + } + }) + }, 400, 200) +} + +func TestHoldToConfirmPointerHold(t *testing.T) { + dis, n := false, 0 + tt := holdTester(&dis, &n) + r, _ := tt.Find("Delete repo") + cx, cy := r.X+r.W/2, r.Y+r.H/2 + _ = tt.Click("Delete repo") + tt.Key(0, ui.KeyEnter) + tt.Key(0, ui.KeySpace) + if n != 0 { + t.Fatalf("a click or key tap confirmed: %d", n) + } + tt.Press(cx, cy) + time.Sleep(20 * time.Millisecond) + tt.Release(cx, cy) + time.Sleep(60 * time.Millisecond) + tt.Frame() + if n != 0 { + t.Fatal("an early release confirmed") + } + tt.Press(cx, cy) + time.Sleep(80 * time.Millisecond) + tt.Frame() + tt.Frame() + tt.Release(cx, cy) + if n != 1 { + t.Fatalf("full hold confirmed %d times", n) + } + dis = true + tt.Frame() + tt.Press(cx, cy) + time.Sleep(80 * time.Millisecond) + tt.Frame() + tt.Release(cx, cy) + if n != 1 { + t.Fatal("disabled hold confirmed") + } +} + +func TestHoldToConfirmSpaceHold(t *testing.T) { + n := 0 + tt := ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + // A nanosecond hold completes in the frames between Space down and up. + if HoldToConfirm(c, "Drop table", HoldToConfirmOptions{Duration: time.Nanosecond}).Changed() { + n++ + } + }, 300, 100) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if n != 0 { + t.Fatal("Enter confirmed") + } + tt.Key(0, ui.KeySpace) + if n != 1 { + t.Fatalf("Space hold confirmed %d times", n) + } +} + +func TestHoldToConfirmReducedMotionCountdown(t *testing.T) { + dis, n := false, 0 + tt := holdTester(&dis, &n) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + r, _ := tt.Find("Delete repo") + tt.Press(r.X+r.W/2, r.Y+r.H/2) + found := false + for _, s := range tt.Texts() { + found = found || strings.HasPrefix(s, "Keep holding… 0.") + } + if !found { + t.Fatalf("no countdown text: %q", tt.Texts()) + } +} + +func TestHoldToConfirmSnapshot(t *testing.T) { + snapshot(t, "hold_to_confirm", 400, 80, func(c *ui.Context) { + ui.Row(c).Padding(16).Gap(12).Children(func() { + HoldToConfirm(c, "Delete project", HoldToConfirmOptions{}) + HoldToConfirm(c, "Wipe", HoldToConfirmOptions{Disabled: true}) + }) + }) +} diff --git a/messages_essentials.go b/messages_essentials.go index ca8dd7e..3943040 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -7,6 +7,13 @@ func init() { "split_button.more": {"More options", "更多选项"}, + "hold_to_confirm.hint": {"Press and hold, or hold Space, to confirm", "按住鼠标或空格键以确认"}, + "hold_to_confirm.left": {"Keep holding… %.1f s", "继续按住… %.1f 秒"}, + "hold_to_confirm.done": {"Confirmed", "已确认"}, + + "bulk_action_bar.selected": {"%d selected", "已选 %d 项"}, + "bulk_action_bar.clear": {"Clear selection", "清除选择"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/bulk_action_bar-dark.png b/testdata/snapshots/darwin/bulk_action_bar-dark.png new file mode 100644 index 0000000..adb3146 Binary files /dev/null and b/testdata/snapshots/darwin/bulk_action_bar-dark.png differ diff --git a/testdata/snapshots/darwin/bulk_action_bar-light.png b/testdata/snapshots/darwin/bulk_action_bar-light.png new file mode 100644 index 0000000..0a8f21b Binary files /dev/null and b/testdata/snapshots/darwin/bulk_action_bar-light.png differ diff --git a/testdata/snapshots/darwin/hold_to_confirm-dark.png b/testdata/snapshots/darwin/hold_to_confirm-dark.png new file mode 100644 index 0000000..519d86f Binary files /dev/null and b/testdata/snapshots/darwin/hold_to_confirm-dark.png differ diff --git a/testdata/snapshots/darwin/hold_to_confirm-light.png b/testdata/snapshots/darwin/hold_to_confirm-light.png new file mode 100644 index 0000000..30625a2 Binary files /dev/null and b/testdata/snapshots/darwin/hold_to_confirm-light.png differ