From cd2b4f3fcad740fcf1bb2e96033d89bbe3946118 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 5 Oct 2026 14:25:28 -0400 Subject: [PATCH] WIP essentials 10 --- messages_essentials.go | 2 + testdata/snapshots/darwin/undo_toast-dark.png | Bin 0 -> 7684 bytes .../snapshots/darwin/undo_toast-light.png | Bin 0 -> 8528 bytes undo_toast.go | 136 ++++++++++++++++++ undo_toast_test.go | 91 ++++++++++++ 5 files changed, 229 insertions(+) create mode 100644 testdata/snapshots/darwin/undo_toast-dark.png create mode 100644 testdata/snapshots/darwin/undo_toast-light.png create mode 100644 undo_toast.go create mode 100644 undo_toast_test.go diff --git a/messages_essentials.go b/messages_essentials.go index ad95e04..7955b6e 100644 --- a/messages_essentials.go +++ b/messages_essentials.go @@ -26,6 +26,8 @@ func init() { "signature_pad.empty": {"Sign here", "请在此签名"}, "signature_pad.clear": {"Clear", "清除"}, + "undo_toast.undo": {"Undo", "撤销"}, + "qr_code.label": {"QR code", "二维码"}, "qr_code.too_long": {"Text too long for a QR code", "文字过长,无法生成二维码"}, }) diff --git a/testdata/snapshots/darwin/undo_toast-dark.png b/testdata/snapshots/darwin/undo_toast-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..61e51a2ce5d338e785fabe56df8e80cdb1317245 GIT binary patch literal 7684 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 O2&gD%%Gb(TNB#%KEe}!v literal 0 HcmV?d00001 diff --git a/testdata/snapshots/darwin/undo_toast-light.png b/testdata/snapshots/darwin/undo_toast-light.png new file mode 100644 index 0000000000000000000000000000000000000000..26b855e06925d30e2eb68ba64488719e19604539 GIT binary patch literal 8528 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 literal 0 HcmV?d00001 diff --git a/undo_toast.go b/undo_toast.go new file mode 100644 index 0000000..bbbe138 --- /dev/null +++ b/undo_toast.go @@ -0,0 +1,136 @@ +package mujica + +import ( + "cmp" + "fmt" + "log/slog" + "math" + "time" + + "github.com/egoist/mygo/ui" + + "mujicaui/icons" + "mujicaui/theme" +) + +// UndoToastOptions configure an UndoToast. +type UndoToastOptions struct { + // OnUndo is required; it runs when the user undoes. + OnUndo func() + // Duration is the countdown; zero is ToastDuration. + Duration time.Duration + // Action labels the button; empty uses "Undo". + Action string +} + +// UndoToastResult reports how an UndoToast ended this frame, if it did. +type UndoToastResult struct { + Element *ui.Element + Undone bool + // Expired: the countdown ran out, or the user closed it; the action stands. + Expired bool +} + +// undoToastClock counts the time shown, stopped while hovered or focused. +type undoToastClock struct { + message string + spent time.Duration + runFrom time.Time +} + +// UndoToast shows message at the bottom of the window with a countdown while *open; it never takes the focus. +func UndoToast(c *ui.Context, open *bool, message string, opts UndoToastOptions) UndoToastResult { + if open == nil || message == "" || opts.OnUndo == nil { + panic("mujica: UndoToast needs open, a message and OnUndo") + } + if opts.Duration < 0 { + panic("mujica: negative UndoToast Duration") + } + clock := ui.Local(c.Root(), "undo_toast", func() *undoToastClock { return &undoToastClock{} }) + s := *clock + var res UndoToastResult + if !*open { + *s = undoToastClock{} + return res + } + now := c.Now() + life := cmp.Or(opts.Duration, ToastDuration) + if s.message != message { + *s = undoToastClock{message: message, runFrom: now} + c.Announce(message) + } + spent := s.spent + if !s.runFrom.IsZero() { + spent += now.Sub(s.runFrom) + } + if spent >= life { + *open, res.Expired = false, true + *s = undoToastClock{} + slog.Debug("mujica: undo_toast expired") + return res + } + k := Tokens(c) + u := Density(c).Unit() + ww, _ := c.Size() + left := life - spent + ui.Overlay(c, func() { + dock := ui.Row(c).Absolute().Left(0).Right(0).Bottom(u * 4).Justify(ui.Center).PassThrough() + dock.Children(func() { + card := ui.Column(c).Width(min(420, ww-8*u)).Radius(theme.PopupRadius).Background(k.Surface). + Border(theme.BorderWidth, k.ControlBorder).Role(ui.RoleStatus).Label(message).Clip() + card.Shadow(0, 4, 12, 0, ui.RGBA(0, 0, 0, 0.14)) + card.Transition(ui.ElementTransition{Duration: theme.PopupDuration, Enter: &ui.Motion{Y: 8}, Exit: &ui.Motion{}}) + res.Element = card + card.Children(func() { + ui.Row(c).AlignItems(ui.Center).Gap(u*3).Padding(u*2, u*2, u*2, u*4).Children(func() { + ui.Text(c, message).TextColor(k.Text).Grow(1).Shrink(1) + secs := int(math.Ceil(left.Seconds())) + label := fmt.Sprintf("%s (%d)", cmp.Or(opts.Action, msg(c, "undo_toast.undo")), secs) + if Button(c, label, ButtonOptions{Variant: Ghost, Icon: icons.Must("refresh-cw")}).Clicked() { + res.Undone = true + } + if iconAction(c, icons.Must("x"), msg(c, "toast.close")).Clicked() { + res.Expired = true + } + }) + still := c.Preferences().ReduceMotion + ui.Box(c).FillWidth().Height(2).Role(ui.RoleNone).Draw(func(p *ui.Painter, r ui.Rect) { + if still { + return + } + rest := left + if !s.runFrom.IsZero() { + rest = life - s.spent - p.Now().Sub(s.runFrom) + p.AnimationFrame() + } + p.Fill(ui.Rect{X: r.X, Y: r.Y, W: r.W * max(0, float32(rest)/float32(life)), H: r.H}, k.AccentText, 0) + }) + }) + if card.Shortcut(0, ui.KeyEscape) { + res.Expired = true + } + // Hover or focus holds the countdown. + switch held := card.Hovered() || card.FocusWithin(); { + case held && !s.runFrom.IsZero(): + s.spent, s.runFrom = spent, time.Time{} + case !held && s.runFrom.IsZero(): + s.runFrom = now + } + }) + }) + switch { + case res.Undone: + *open, res.Expired = false, false + *s = undoToastClock{} + slog.Debug("mujica: undo_toast undo") + opts.OnUndo() + case res.Expired: + *open = false + *s = undoToastClock{} + slog.Debug("mujica: undo_toast closed") + case !s.runFrom.IsZero(): + // Wake for the next whole second of the count, and for the end. + c.After(min(left, left-left.Truncate(time.Second)+time.Millisecond)) + } + return res +} diff --git a/undo_toast_test.go b/undo_toast_test.go new file mode 100644 index 0000000..4c687d7 --- /dev/null +++ b/undo_toast_test.go @@ -0,0 +1,91 @@ +package mujica + +import ( + "slices" + "testing" + "time" + + "github.com/egoist/mygo/ui" +) + +func undoTester(open *bool, life time.Duration, undos *int, last *UndoToastResult) *ui.Tester { + return ui.NewTester(func(c *ui.Context) { + Use(c, Settings{}) + if Button(c, "Delete", ButtonOptions{}).Clicked() { + *open = true + } + if r := UndoToast(c, open, "File deleted", UndoToastOptions{Duration: life, OnUndo: func() { *undos++ }}); r.Undone || r.Expired { + *last = r + } + }, 600, 400) +} + +func TestUndoToastUndoAndFocus(t *testing.T) { + open, undos := false, 0 + var last UndoToastResult + tt := undoTester(&open, 0, &undos, &last) + tt.Key(0, ui.KeyTab) + tt.Key(0, ui.KeyEnter) + if !tt.HasText("File deleted") || !tt.HasText("Undo (5)") || !tt.Focused("Delete") { + t.Fatalf("shown %q, focus kept %v", tt.Texts(), tt.Focused("Delete")) + } + if !slices.Contains(tt.Announcements(), "File deleted") { + t.Fatal("not announced") + } + if err := tt.Click("Undo (5)"); err != nil { + t.Fatal(err) + } + if undos != 1 || open || !last.Undone || tt.HasText("File deleted") { + t.Fatalf("undo: %d %v %+v", undos, open, last) + } +} + +func TestUndoToastCountdownPauseAndClose(t *testing.T) { + open, undos := false, 0 + var last UndoToastResult + tt := undoTester(&open, 60*time.Millisecond, &undos, &last) + _ = tt.Click("Delete") + r, _ := tt.Find("File deleted") + tt.Move(r.X+r.W/2, r.Y+r.H/2) + time.Sleep(90 * time.Millisecond) + tt.Frame() + if !open { + t.Fatal("hovered countdown ran out") + } + tt.Move(5, 5) + time.Sleep(90 * time.Millisecond) + tt.Frame() + if open || !last.Expired || undos != 0 { + t.Fatalf("expiry: %v %+v %d", open, last, undos) + } + last = UndoToastResult{} + _ = tt.Click("Delete") + _ = tt.Click("Dismiss notification") + if open || !last.Expired || undos != 0 { + t.Fatalf("close: %v %+v", open, last) + } +} + +func TestUndoToastRejects(t *testing.T) { + open := true + for name, f := range map[string]func(c *ui.Context){ + "no undo": func(c *ui.Context) { UndoToast(c, &open, "x", UndoToastOptions{}) }, + "negative": func(c *ui.Context) { UndoToast(c, &open, "x", UndoToastOptions{OnUndo: func() {}, Duration: -1}) }, + } { + func() { + defer func() { + if recover() == nil { + t.Errorf("%s: no panic", name) + } + }() + ui.NewTester(func(c *ui.Context) { Use(c, Settings{}); f(c) }, 300, 200) + }() + } +} + +func TestUndoToastSnapshot(t *testing.T) { + open := true + snapshot(t, "undo_toast", 520, 160, func(c *ui.Context) { + UndoToast(c, &open, "3 messages archived", UndoToastOptions{OnUndo: func() {}, Duration: time.Hour}) + }) +}