diff --git a/CHANGELOG.md b/CHANGELOG.md index 432af6c7c..50d71028d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- While a session works, the window redraws its busy spinner at 30 frames a second rather than at the display's rate, a card builds its right-click menu only while that menu is open, and a dialog that is shut builds nothing at all. + ### Fixed - Branch, changes and pull request on the cards and the footer catch up within a second of a commit, checkout or edit made outside a turn, where they could lag up to three seconds, and selecting a card reads its checkout at once instead of showing what the last read found. diff --git a/native/cmd/lich-native/close_worktree.go b/native/cmd/lich-native/close_worktree.go index bb7a19cdb..2c0ed193c 100644 --- a/native/cmd/lich-native/close_worktree.go +++ b/native/cmd/lich-native/close_worktree.go @@ -90,6 +90,9 @@ func (u *window) layoutCloseWorktreeConfirm(gtx C, th *ui.Theme) { c := &u.closeWorktree c.mu.Lock() defer c.mu.Unlock() + if !c.open && !c.dialog.Animating(gtx) { + return + } if c.confirm.Clicked(gtx) && c.open { c.open = false project, path, force := c.project, c.path, c.dirty diff --git a/native/cmd/lich-native/dock_comment_edit.go b/native/cmd/lich-native/dock_comment_edit.go index e066c72cb..2988172a0 100644 --- a/native/cmd/lich-native/dock_comment_edit.go +++ b/native/cmd/lich-native/dock_comment_edit.go @@ -30,6 +30,9 @@ type commentEditDialog struct { // layout is laid out by the window before its content, as every dialog is. func (d *commentEditDialog) layout(gtx C, th *ui.Theme, store *commentStore) { c, open := store.edited() + if !open && !d.dialog.Animating(gtx) { + return + } if open && d.shown != c.id { d.fill(gtx, c) } diff --git a/native/cmd/lich-native/dock_review.go b/native/cmd/lich-native/dock_review.go index 8ec8cd4c0..9bcf5cc6b 100644 --- a/native/cmd/lich-native/dock_review.go +++ b/native/cmd/lich-native/dock_review.go @@ -344,6 +344,9 @@ func revertStat(lines []backend.RevertLine) (added, deleted int) { // layoutDialogs is the discard confirmation, laid out by the window before // its content so it takes Escape first. func (p *reviewPanel) layoutDialogs(gtx C, th *ui.Theme) { + if p.pendingDiscard == nil && !p.dialog.Animating(gtx) { + return + } if p.pendingDiscard != nil { p.discardShown = *p.pendingDiscard } diff --git a/native/cmd/lich-native/dock_review_file.go b/native/cmd/lich-native/dock_review_file.go index bb5bb9e13..b7a85b9e4 100644 --- a/native/cmd/lich-native/dock_review_file.go +++ b/native/cmd/lich-native/dock_review_file.go @@ -246,7 +246,7 @@ func (c *fileCard) body(gtx C, p *reviewPanel) D { return bodyLine(gtx, th, i18n.T("diff.fileDiff.readFailed", i18n.Args{"path": c.git.path})) } return layout.Inset{Top: ui.Space(1), Bottom: ui.Space(1)}.Layout(gtx, func(gtx C) D { - return th.ContextMenu(p.root, &c.menu, c.menuEntries(p)).Layout(gtx, func(gtx C) D { + return th.ContextMenuLazy(p.root, &c.menu, func() []ui.MenuEntry { return c.menuEntries(p) }).Layout(gtx, func(gtx C) D { return c.diff(gtx, p, view) }) }) diff --git a/native/cmd/lich-native/git_missing.go b/native/cmd/lich-native/git_missing.go index bc04c0b1b..8d2a74520 100644 --- a/native/cmd/lich-native/git_missing.go +++ b/native/cmd/lich-native/git_missing.go @@ -48,6 +48,9 @@ func (u *window) checkGit(refresh bool) { func (u *window) layoutGitMissing(gtx C, th *ui.Theme) { g := &u.gitMissing + if !g.missing.Load() && !g.dialog.Animating(gtx) { + return + } if g.notNow.Clicked(gtx) { g.missing.Store(false) } diff --git a/native/cmd/lich-native/layouts_window.go b/native/cmd/lich-native/layouts_window.go index fe929c46e..1f39e7b0d 100644 --- a/native/cmd/lich-native/layouts_window.go +++ b/native/cmd/lich-native/layouts_window.go @@ -102,6 +102,9 @@ func (u *window) askMoveDelegates(orchestrator string, ids []string, away int) { // with. func (u *window) layoutMoveConfirm(gtx C, th *ui.Theme) { c := &u.layoutStage.move + if !c.open && !c.dialog.Animating(gtx) { + return + } if c.confirm.Clicked(gtx) && c.open { c.open = false if c.delegates != nil { diff --git a/native/cmd/lich-native/notify_prompt.go b/native/cmd/lich-native/notify_prompt.go index da6d6fd18..7a45bd97f 100644 --- a/native/cmd/lich-native/notify_prompt.go +++ b/native/cmd/lich-native/notify_prompt.go @@ -27,6 +27,9 @@ type notifyOptIn struct { // button is an answer, stored; Escape or the backdrop closes it unanswered. func (u *window) layoutNotifyOptIn(gtx C, th *ui.Theme) { o := &u.optIn + if _, asking := u.notifier.snapshot(); !asking && !o.dialog.Animating(gtx) { + return + } if o.no.Clicked(gtx) { u.notifier.setAttention(false) } diff --git a/native/cmd/lich-native/palette_window.go b/native/cmd/lich-native/palette_window.go index a5da7e984..17c134974 100644 --- a/native/cmd/lich-native/palette_window.go +++ b/native/cmd/lich-native/palette_window.go @@ -79,6 +79,9 @@ func (u *window) paletteAvailable(id hotkeyID) bool { // there. func (u *window) layoutPalette(gtx C, th *ui.Theme) { p := u.palette + if !p.open && !p.dialog.Animating(gtx) { + return + } on := th.On(th.Popover) // The list fades out as the frame it closed on, rather than as a query // that found nothing. diff --git a/native/cmd/lich-native/plugin_gate.go b/native/cmd/lich-native/plugin_gate.go index 7a6c98dfa..b74cfcfd7 100644 --- a/native/cmd/lich-native/plugin_gate.go +++ b/native/cmd/lich-native/plugin_gate.go @@ -205,6 +205,9 @@ func (u *window) layoutPluginGate(gtx C, th *ui.Theme) { g.mu.Lock() offer, installing := g.offer, g.installing g.mu.Unlock() + if len(offer) == 0 && !g.dialog.Animating(gtx) { + return + } closeOffer := func() { g.mu.Lock() g.offer = nil diff --git a/native/cmd/lich-native/pulls_actions.go b/native/cmd/lich-native/pulls_actions.go index 42c7055e3..7969d94fd 100644 --- a/native/cmd/lich-native/pulls_actions.go +++ b/native/cmd/lich-native/pulls_actions.go @@ -355,6 +355,9 @@ func (u *window) layoutRemoveConfirm(gtx C, th *ui.Theme) { c := &u.pulls.confirm c.mu.Lock() defer c.mu.Unlock() + if !c.open && !c.dialog.Animating(gtx) { + return + } if c.confirm.Clicked(gtx) && c.open { c.open = false project, path := c.project, c.path diff --git a/native/cmd/lich-native/pulls_comment_edit.go b/native/cmd/lich-native/pulls_comment_edit.go index 2dbbdab56..138ec553e 100644 --- a/native/cmd/lich-native/pulls_comment_edit.go +++ b/native/cmd/lich-native/pulls_comment_edit.go @@ -210,6 +210,9 @@ func (u *window) deleteComment(path string, number int, t commentTarget) { func (u *window) layoutDeleteCommentDialog(gtx C, th *ui.Theme) { p := u.pulls s := &p.view.talk + if s.deleting == nil && !s.deleteDialog.Animating(gtx) { + return + } if s.deleting != nil { s.deleteShown = *s.deleting } diff --git a/native/cmd/lich-native/pulls_merge_dialog.go b/native/cmd/lich-native/pulls_merge_dialog.go index 9d0fcab19..3986029b5 100644 --- a/native/cmd/lich-native/pulls_merge_dialog.go +++ b/native/cmd/lich-native/pulls_merge_dialog.go @@ -91,6 +91,9 @@ func (u *window) layoutMergeDialog(gtx C, th *ui.Theme) { v := &u.pulls.view m := &v.mergeEdit p := u.pulls + if !m.open && !m.dialog.Animating(gtx) { + return + } if m.cancel.Clicked(gtx) { m.open = false } diff --git a/native/cmd/lich-native/pulls_submit.go b/native/cmd/lich-native/pulls_submit.go index 842ebf904..ae2d39498 100644 --- a/native/cmd/lich-native/pulls_submit.go +++ b/native/cmd/lich-native/pulls_submit.go @@ -88,6 +88,9 @@ func (u *window) layoutSubmitDialog(gtx C, th *ui.Theme) { v := &u.pulls.view s := &v.submit p := u.pulls + if s.verdict == "" && !s.dialog.Animating(gtx) { + return + } if s.cancel.Clicked(gtx) { s.verdict = "" } diff --git a/native/cmd/lich-native/resume_dialog.go b/native/cmd/lich-native/resume_dialog.go index 2c16b2ae5..34ff4e69d 100644 --- a/native/cmd/lich-native/resume_dialog.go +++ b/native/cmd/lich-native/resume_dialog.go @@ -34,6 +34,9 @@ type resumeDialog struct { func (r *resumeDialog) layout(ctx context.Context, gtx C, th *ui.Theme, g *spawnGate, m *model) { s := g.prompt() open := s != nil + if !open && !r.dialog.Animating(gtx) { + return + } if open && r.shown.ID != s.ID { r.shown, r.provider, r.remember.Value = *s, m.providerName(s.Kind), false } diff --git a/native/cmd/lich-native/settings_font_replicate.go b/native/cmd/lich-native/settings_font_replicate.go index 863f28fa2..a71d036e2 100644 --- a/native/cmd/lich-native/settings_font_replicate.go +++ b/native/cmd/lich-native/settings_font_replicate.go @@ -57,6 +57,9 @@ func (s *settingsScreen) layoutFontReplication(gtx C, th *ui.Theme) { family := r.family s.mu.Unlock() open := family != "" && family == s.look.prefs.uiFont + if !open && !r.state.Animating(gtx) { + return + } closed := r.keep.Clicked(gtx) if r.apply.Clicked(gtx) && open { p := s.look.prefs diff --git a/native/cmd/lich-native/settings_language.go b/native/cmd/lich-native/settings_language.go index 47e16f736..2387d4eb3 100644 --- a/native/cmd/lich-native/settings_language.go +++ b/native/cmd/lich-native/settings_language.go @@ -288,6 +288,9 @@ func wholeMegabytes(n int64) int64 { return (n + bytesPerMB/2) / bytesPerMB } // at launch it asks before Settings ever opens. func (s *settingsScreen) layoutHanFontDialog(gtx C, th *ui.Theme) { o := &s.languages.offer + if !o.open && !o.dialog.Animating(gtx) { + return + } if r := o.outcome(); r != nil { s.finishHanFontDownload(r) } diff --git a/native/cmd/lich-native/settings_profiles_dialogs.go b/native/cmd/lich-native/settings_profiles_dialogs.go index 2faceea92..9593bcc7e 100644 --- a/native/cmd/lich-native/settings_profiles_dialogs.go +++ b/native/cmd/lich-native/settings_profiles_dialogs.go @@ -84,11 +84,15 @@ func (s *settingsScreen) layoutProfileDialogs(gtx C, th *ui.Theme) { // s.invalidate. func (s *settingsScreen) layoutProfileDialog(gtx C, th *ui.Theme, c *profileSettings) { f := &c.flow + if !(f.view().open && s.open) && !f.dialog.Animating(gtx) { + return + } s.flowInput(gtx, f, f.view()) d := f.view() + open := d.open && s.open on := th.On(th.Popover) title, desc := flowHeading(c.provider, d) - dlg := th.Dialog(&f.dialog, d.open && s.open, title, desc) + dlg := th.Dialog(&f.dialog, open, title, desc) dlg.MaxWidth, dlg.Root = ui.DialogWidthXL, s.root dlg.Body = func(gtx C) D { return s.flowBody(gtx, on, f, d) } dlg.Actions = s.flowActions(on, f, d) diff --git a/native/cmd/lich-native/settings_providers_view.go b/native/cmd/lich-native/settings_providers_view.go index 9756db068..3b696ec65 100644 --- a/native/cmd/lich-native/settings_providers_view.go +++ b/native/cmd/lich-native/settings_providers_view.go @@ -626,6 +626,9 @@ func (s *settingsScreen) writeSkip(kind, level string) { // with its own constraints, as every dialog. func (s *settingsScreen) layoutProviderDialog(gtx C, th *ui.Theme) { pr := &s.providers + if !(pr.asking && s.open) && !pr.dialog.Animating(gtx) { + return + } if pr.confirm.Clicked(gtx) && pr.asking { s.writeSkip(pr.change.provider.id, pr.change.level) pr.asking = false diff --git a/native/cmd/lich-native/settings_sandbox_view.go b/native/cmd/lich-native/settings_sandbox_view.go index 12b0b3ab6..f0e9a61f2 100644 --- a/native/cmd/lich-native/settings_sandbox_view.go +++ b/native/cmd/lich-native/settings_sandbox_view.go @@ -179,6 +179,9 @@ func proseLine(th *ui.Theme, text string) layout.Widget { // with its own constraints, as every dialog. func (s *settingsScreen) layoutSandboxDialog(gtx C, th *ui.Theme) { sb := &s.sandbox + if !(sb.asking && s.open) && !sb.dialog.Animating(gtx) { + return + } if sb.confirm.Clicked(gtx) && sb.asking { s.writeSandbox(sandboxKey(sb.change.providerID), sb.change.level) sb.asking = false diff --git a/native/cmd/lich-native/settings_theme_dialogs.go b/native/cmd/lich-native/settings_theme_dialogs.go index 0bece9fc7..70bae3abe 100644 --- a/native/cmd/lich-native/settings_theme_dialogs.go +++ b/native/cmd/lich-native/settings_theme_dialogs.go @@ -83,6 +83,9 @@ type confirmSpec struct { // confirmDialog is ConfirmDialog: Cancel and one destructive action at // sm:max-w-xl. It reports whether the action was pressed. func (p *themePicker) confirmDialog(gtx C, th *ui.Theme, c confirmSpec) bool { + if !c.open && !c.state.Animating(gtx) { + return false + } d := &p.dialogs pressed := d.confirm[c.slot].Clicked(gtx) && c.open && !c.busy if d.cancel[c.slot].Clicked(gtx) { @@ -106,6 +109,9 @@ func (p *themePicker) confirmDialog(gtx C, th *ui.Theme, c confirmSpec) bool { // file through the backend's picker, and the template to start one from. func (p *themePicker) importDialog(gtx C, th *ui.Theme, t themeTasks) { d := &p.dialogs + if !t.importOpen && !d.imp.Animating(gtx) { + return + } url := strings.TrimSpace(d.url.Text()) submitted := false for { diff --git a/native/cmd/lich-native/settings_updates_notes.go b/native/cmd/lich-native/settings_updates_notes.go index 450cd1fe9..6f737c853 100644 --- a/native/cmd/lich-native/settings_updates_notes.go +++ b/native/cmd/lich-native/settings_updates_notes.go @@ -239,16 +239,20 @@ func (s *settingsScreen) layoutPatchNotesDialog(gtx C, th *ui.Theme) { if notes == nil { return } + if !(v.open && (s.open || v.startup)) && !v.dialog.Animating(gtx) { + return + } if v.gotIt.Clicked(gtx) { s.closePatchNotes(notes.Version) } if v.changelog.Clicked(gtx) { s.openExternal(helpRepoURL + updatesReleaseTagAt + notes.Version) } + open := v.open && (s.open || v.startup) on := th.On(th.Popover) got := on.Button(&v.gotIt, i18n.T("shell.patchNotesDialog.gotIt")) got.Size = ui.SizeSM - dlg := th.Dialog(&v.dialog, v.open && (s.open || v.startup), i18n.T("shell.patchNotesDialog.title", i18n.Args{"version": "v" + notes.Version}), notesCounts(notes.Groups)) + dlg := th.Dialog(&v.dialog, open, i18n.T("shell.patchNotesDialog.title", i18n.Args{"version": "v" + notes.Version}), notesCounts(notes.Groups)) dlg.MaxWidth, dlg.HideClose = ui.DialogWidthXL, true dlg.Body = func(gtx C) D { return v.body(gtx, on, notes) } dlg.Actions = []layout.Widget{func(gtx C) D { diff --git a/native/cmd/lich-native/shortcuts_overlay.go b/native/cmd/lich-native/shortcuts_overlay.go index 5278440b2..9862b7103 100644 --- a/native/cmd/lich-native/shortcuts_overlay.go +++ b/native/cmd/lich-native/shortcuts_overlay.go @@ -57,6 +57,9 @@ type shortcutsOverlay struct { // layout draws the overlay over the window. Call it before the content under // it, so it takes Escape ahead of any key filter there. func (o *shortcutsOverlay) layout(gtx C, th *ui.Theme, bound hotkeyBindings) { + if !o.open && !o.dialog.Animating(gtx) { + return + } on := th.On(th.Popover) dlg := th.Dialog(&o.dialog, o.open, i18n.T("shell.shortcutsOverlay.title"), i18n.T("shell.shortcutsOverlay.rebind")) dlg.MaxWidth, dlg.HideClose, dlg.Scrim = shortcutsWidth, true, shortcutsScrim diff --git a/native/cmd/lich-native/sidebar_card.go b/native/cmd/lich-native/sidebar_card.go index c0112e915..85c4f2405 100644 --- a/native/cmd/lich-native/sidebar_card.go +++ b/native/cmd/lich-native/sidebar_card.go @@ -64,12 +64,13 @@ func (s *sidebar) card(gtx C, th *ui.Theme, sv *sessionView, active bool, folder rename = new(ui.InlineEdit) s.renames[sv.ID] = rename } - var extra []ui.MenuEntry - if len(sv.continueIn) > 0 { - extra = append(extra, continueInItem(th, sv, &act)) - } - entries := cardMenu(sv, folders, &act, func() { rename.Start(sv.Label) }, func() { s.askFolderName([]string{sv.ID}) }, extra...) - d := th.ContextMenu(s.root, menu, entries).Layout(gtx, func(gtx C) D { + d := th.ContextMenuLazy(s.root, menu, func() []ui.MenuEntry { + var extra []ui.MenuEntry + if len(sv.continueIn) > 0 { + extra = append(extra, continueInItem(th, sv, &act)) + } + return cardMenu(sv, folders, &act, func() { rename.Start(sv.Label) }, func() { s.askFolderName([]string{sv.ID}) }, extra...) + }).Layout(gtx, func(gtx C) D { return tooltip.Layout(gtx, func(gtx C) D { return s.cardFace(gtx, th, sv, click, pinClick, closeClick, prChip, fill) }) }) if s.renamed.id == sv.ID { @@ -327,6 +328,11 @@ func (s *sidebar) statusRing(gtx C, th *ui.Theme, sv *sessionView) D { return s.morph.glyph(gtx, glyphSession+sv.ID, func(gtx C) D { return s.statusGlyph(gtx, th, sv) }) } +// spinnerFrame is how often a busy session's ring redraws. The ring shares +// the window's frame, so its rate is the whole window's repaint rate: 30fps +// reads as smooth on a 22px ring and costs half of a display's. +const spinnerFrame = 33 * time.Millisecond + func (s *sidebar) statusGlyph(gtx C, th *ui.Theme, sv *sessionView) D { size := gtx.Dp(22) stroke := float32(gtx.Dp(1.25)) @@ -337,7 +343,7 @@ func (s *sidebar) statusGlyph(gtx C, th *ui.Theme, sv *sessionView) D { ui.StrokeArc(gtx, center, radius, stroke, 0, 2*math.Pi, th.Over(ui.Alpha(th.MutedForeground, 0.25))) turn := float32(time.Since(s.started).Seconds()) * 2 * math.Pi ui.StrokeArc(gtx, center, radius, stroke, turn, math.Pi/2, th.MutedForeground) - gtx.Execute(op.InvalidateCmd{}) + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(spinnerFrame)}) case "waiting": ui.StrokeArc(gtx, center, radius, stroke, 0, 2*math.Pi, th.ToneWait) case "done": diff --git a/native/cmd/lich-native/sidebar_folder_dialog.go b/native/cmd/lich-native/sidebar_folder_dialog.go index 3502199e6..e3ea6bb4d 100644 --- a/native/cmd/lich-native/sidebar_folder_dialog.go +++ b/native/cmd/lich-native/sidebar_folder_dialog.go @@ -45,6 +45,9 @@ func folderCommit(typed string, existing []string) (name string, ok, known bool) // claims Escape ahead of anything under it; it reports the filing it commits. func (s *sidebar) layoutFolderDialog(gtx C, th *ui.Theme, existing []string) fileRequest { n := &s.naming + if !n.open && !n.dialog.Animating(gtx) { + return fileRequest{} + } name, ok, known := folderCommit(n.name.Text(), existing) submitted := false for { diff --git a/native/cmd/lich-native/topbar_apply.go b/native/cmd/lich-native/topbar_apply.go index 97440d27c..356199d53 100644 --- a/native/cmd/lich-native/topbar_apply.go +++ b/native/cmd/lich-native/topbar_apply.go @@ -27,6 +27,9 @@ type closeConfirm struct { // "" for none. func (t *topbar) layoutConfirm(gtx C, th *ui.Theme) string { c := &t.confirm + if !c.open && !c.dialog.Animating(gtx) { + return "" + } closed := "" if c.confirm.Clicked(gtx) && c.open { closed, c.open = c.id, false diff --git a/native/cmd/lich-native/window_close.go b/native/cmd/lich-native/window_close.go index 4d4eb35ee..8a440613d 100644 --- a/native/cmd/lich-native/window_close.go +++ b/native/cmd/lich-native/window_close.go @@ -100,6 +100,9 @@ func (u *window) layoutClosePrompt(gtx C, th *ui.Theme) { c.mu.Lock() running := c.running c.mu.Unlock() + if running == 0 && !c.dialog.Animating(gtx) { + return + } answer := func(choice string, act func()) { if c.remember.Value { p := u.settings.look.prefs diff --git a/native/cmd/lich-native/worktree_dialog_view.go b/native/cmd/lich-native/worktree_dialog_view.go index 5eaee8532..4572ad634 100644 --- a/native/cmd/lich-native/worktree_dialog_view.go +++ b/native/cmd/lich-native/worktree_dialog_view.go @@ -105,6 +105,9 @@ func baseLabel(base string) string { // it. func (u *window) layoutWorktreeDialog(gtx C, th *ui.Theme) { d := u.worktree + if !d.open && !d.dialog.Animating(gtx) { + return + } if d.view().done { d.open = false } diff --git a/native/ui/context_menu.go b/native/ui/context_menu.go index 23b28391a..829aabe97 100644 --- a/native/ui/context_menu.go +++ b/native/ui/context_menu.go @@ -24,13 +24,22 @@ type ContextMenuStyle struct { Root *Root State *MenuState Entries []MenuEntry - theme *Theme + // build is Entries made on demand, and only while the menu is open: a + // closed menu's entries cost every frame they are built and never show. + build func() []MenuEntry + theme *Theme } func (th *Theme) ContextMenu(root *Root, state *MenuState, entries []MenuEntry) ContextMenuStyle { return ContextMenuStyle{Root: root, State: state, Entries: entries, theme: th} } +// ContextMenuLazy is ContextMenu whose entries are built only while the menu +// is open, for a trigger that draws every frame and carries a large menu. +func (th *Theme) ContextMenuLazy(root *Root, state *MenuState, entries func() []MenuEntry) ContextMenuStyle { + return ContextMenuStyle{Root: root, State: state, build: entries, theme: th} +} + // Layout lays out trigger, the area that answers a right press, and the // menu when open. func (c ContextMenuStyle) Layout(gtx C, trigger layout.Widget) D { @@ -40,6 +49,10 @@ func (c ContextMenuStyle) Layout(gtx C, trigger layout.Widget) D { pointerAt := c.Root.pointer.Round() s.menuOpen(image.Rectangle{Min: pointerAt, Max: pointerAt}, c.Root.origin(at)) } - menuCore{th: c.theme, root: c.Root, s: s, entries: c.Entries, geo: &contextGeometry}.layout(gtx) + entries := c.Entries + if c.build != nil && s.open { + entries = c.build() + } + menuCore{th: c.theme, root: c.Root, s: s, entries: entries, geo: &contextGeometry}.layout(gtx) return dims } diff --git a/native/ui/context_menu_test.go b/native/ui/context_menu_test.go index 1e59b51f3..d6ed881cf 100644 --- a/native/ui/context_menu_test.go +++ b/native/ui/context_menu_test.go @@ -51,3 +51,35 @@ func TestContextMenuFlipsAndClampsInsideTheWindow(t *testing.T) { frame() c.only(t, "Pin") } + +// TestContextMenuLazyBuildsOnlyWhileOpen pins the point of the lazy +// constructor: a trigger drawn every frame builds its entries only on the +// frames the menu shows, and the press that opens it gets them that frame. +func TestContextMenuLazyBuildsOnlyWhileOpen(t *testing.T) { + var c chosen + entries := []MenuEntry{c.item("Rename"), c.item("Pin")} + builds := 0 + g, s, th := &menuRig{size: image.Pt(400, 300)}, new(MenuState), testTheme() + frame := func() { + g.frame(func(gtx C, trigger layout.Widget) D { + return th.ContextMenuLazy(&g.root, s, func() []MenuEntry { + builds++ + return entries + }).Layout(gtx, trigger) + }) + } + frame() + frame() + if builds != 0 { + t.Fatalf("closed menu built its entries %d times", builds) + } + at := triggerAt.Add(image.Pt(30, 10)) + g.click(pointer.ButtonSecondary, at) + frame() + if !s.Opened() || builds != 1 { + t.Fatalf("opening press: open %v, builds %d; want true, 1", s.Opened(), builds) + } + g.click(pointer.ButtonPrimary, image.Pt(at.X+10, at.Y+4+4+contextRow+contextRow/2)) + frame() + c.only(t, "Pin") +} diff --git a/native/ui/dialog.go b/native/ui/dialog.go index c204caba4..30d8ee47f 100644 --- a/native/ui/dialog.go +++ b/native/ui/dialog.go @@ -114,6 +114,13 @@ func (d DialogStyle) Layout(gtx C) (dismissed bool) { return dismissed } +// Animating reports whether the dialog is on screen or still fading out: a +// caller that skips a shut dialog must keep laying it out while this is true, +// or its close transition is cut. It is false for one that never opened. +func (s *DialogState) Animating(gtx C) bool { + return s.shown.drawing(gtx, dialogTransition) +} + func (s *DialogState) dismissed(gtx C) bool { dismissed := s.close.Clicked(gtx) // The press only: the release of an Escape that closed a popup inside diff --git a/native/ui/dialog_test.go b/native/ui/dialog_test.go index 5d9b50dcd..122f94a9d 100644 --- a/native/ui/dialog_test.go +++ b/native/ui/dialog_test.go @@ -33,15 +33,19 @@ type dialogRig struct { // frame lays the window out once, dialog first as Layout asks, and reports // whether the dialog was dismissed and the content under it clicked. -func (r *dialogRig) frame() (dismissed, underClicked bool) { - r.ops.Reset() - gtx := layout.Context{ +func (r *dialogRig) gtx() layout.Context { + return layout.Context{ Ops: &r.ops, Now: r.now, Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}, Constraints: layout.Exact(dialogTestWindow), Source: r.router.Source(), } +} + +func (r *dialogRig) frame() (dismissed, underClicked bool) { + r.ops.Reset() + gtx := r.gtx() d := r.th.Dialog(&r.state, r.open, "Discard changes?", "The worktree keeps uncommitted work.") d.Actions = []layout.Widget{r.th.Button(&r.confirm, "Discard").Layout} dismissed = d.Layout(gtx) @@ -250,3 +254,23 @@ func TestDialogRawPanelHangsFromTopOverItsOwnScrim(t *testing.T) { t.Errorf("content under the scrim: blue %d, want %d", got, want) } } + +func TestDialogAnimatingCoversTheCloseTransition(t *testing.T) { + r := newDialogRig() + if r.state.Animating(r.gtx()) { + t.Fatal("a dialog that never opened is animating") + } + r.settle() + if !r.state.Animating(r.gtx()) { + t.Fatal("an open dialog is not animating") + } + r.open = false + r.frame() + if !r.state.Animating(r.gtx()) { + t.Fatal("a dialog still fading out is not animating") + } + r.now = r.now.Add(dialogTransition) + if r.state.Animating(r.gtx()) { + t.Fatal("a dialog done fading out is animating") + } +} diff --git a/native/ui/popup.go b/native/ui/popup.go index 31c562daf..3e276a151 100644 --- a/native/ui/popup.go +++ b/native/ui/popup.go @@ -192,10 +192,10 @@ func (t *transition) set(gtx C, open bool) bool { // progress is how far in the popup is, 0..1, after running for duration, and // whether to draw it at all. func (t *transition) progress(gtx C, duration time.Duration) (float32, bool) { - elapsed := gtx.Now.Sub(t.at) - if t.at.IsZero() || (!t.open && elapsed >= duration) { + if !t.drawing(gtx, duration) { return 0, false } + elapsed := gtx.Now.Sub(t.at) p := float32(1) if elapsed < duration { gtx.Execute(op.InvalidateCmd{}) @@ -207,6 +207,12 @@ func (t *transition) progress(gtx C, duration time.Duration) (float32, bool) { return p, true } +// drawing is progress' "whether to draw it at all", without the invalidation +// it schedules. +func (t *transition) drawing(gtx C, duration time.Duration) bool { + return !t.at.IsZero() && (t.open || gtx.Now.Sub(t.at) < duration) +} + // ease is CSS's "ease", cubic-bezier(0.25, 0.1, 0.25, 1), solved for x by // bisection. func ease(x float32) float32 {