From 38db3bfca7afbfb6d004773733050f054f89ca38 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 16:53:39 -0300 Subject: [PATCH 1/2] perf(native): stop building what a shut frame never shows A session card built its whole right-click menu every frame it was closed, and every dialog in the window built its title, description, buttons and widget closures every frame while shut. A busy session's spinner also asked for an immediate redraw, so the whole window repainted at the display's rate for a 22px ring. ContextMenuLazy builds its entries only while the menu is open, the two cards that carry a large menu use it, and each dialog layout leaves the frame when it is shut and done fading (DialogState.Animating keeps the close transition whole). The spinner asks for its next frame 33ms out. Measured on the sidebar card menu: 105 allocations and 12KB per card per frame become none while the menu is closed. --- CHANGELOG.md | 4 +++ native/cmd/lich-native/close_worktree.go | 3 ++ native/cmd/lich-native/dock_comment_edit.go | 3 ++ native/cmd/lich-native/dock_review.go | 3 ++ native/cmd/lich-native/dock_review_file.go | 2 +- native/cmd/lich-native/git_missing.go | 3 ++ native/cmd/lich-native/layouts_window.go | 3 ++ native/cmd/lich-native/notify_prompt.go | 3 ++ native/cmd/lich-native/palette_window.go | 3 ++ native/cmd/lich-native/pulls_actions.go | 3 ++ native/cmd/lich-native/pulls_comment_edit.go | 3 ++ native/cmd/lich-native/pulls_merge_dialog.go | 3 ++ native/cmd/lich-native/pulls_submit.go | 3 ++ native/cmd/lich-native/resume_dialog.go | 3 ++ .../lich-native/settings_font_replicate.go | 3 ++ native/cmd/lich-native/settings_language.go | 3 ++ .../lich-native/settings_profiles_dialogs.go | 6 +++- .../lich-native/settings_providers_view.go | 3 ++ .../cmd/lich-native/settings_sandbox_view.go | 3 ++ .../cmd/lich-native/settings_theme_dialogs.go | 6 ++++ .../cmd/lich-native/settings_updates_notes.go | 7 +++- native/cmd/lich-native/shortcuts_overlay.go | 3 ++ native/cmd/lich-native/sidebar_card.go | 20 ++++++++---- .../cmd/lich-native/sidebar_folder_dialog.go | 3 ++ native/cmd/lich-native/topbar_apply.go | 3 ++ native/cmd/lich-native/window_close.go | 3 ++ .../cmd/lich-native/worktree_dialog_view.go | 3 ++ native/ui/context_menu.go | 17 ++++++++-- native/ui/context_menu_test.go | 32 +++++++++++++++++++ native/ui/dialog.go | 7 ++++ native/ui/dialog_test.go | 30 +++++++++++++++-- native/ui/popup.go | 10 ++++-- 32 files changed, 187 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 432af6c7c..004638d7d 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. A window that is only spinning costs about half the redraws it did, and cards stop building menus the window never shows. + ### 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/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..c7635feff 100644 --- a/native/cmd/lich-native/settings_updates_notes.go +++ b/native/cmd/lich-native/settings_updates_notes.go @@ -239,16 +239,21 @@ func (s *settingsScreen) layoutPatchNotesDialog(gtx C, th *ui.Theme) { if notes == nil { return } + open := v.open && (s.open || v.startup) + if !open && !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 { From a6422c570130c8f83429f30b580d8395f092c749 Mon Sep 17 00:00:00 2001 From: omartelo Date: Fri, 23 Oct 2026 17:08:07 -0300 Subject: [PATCH 2/2] perf(native): skip the shut plugin gate too The plugin install gate was the one dialog still built every frame while shut. The patch notes dialog reads its open state once, after its buttons, and the CHANGELOG entry drops a redraw estimate that held only at 60Hz. --- CHANGELOG.md | 2 +- native/cmd/lich-native/plugin_gate.go | 3 +++ native/cmd/lich-native/settings_updates_notes.go | 5 ++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 004638d7d..50d71028d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. A window that is only spinning costs about half the redraws it did, and cards stop building menus the window never shows. +- 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 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/settings_updates_notes.go b/native/cmd/lich-native/settings_updates_notes.go index c7635feff..6f737c853 100644 --- a/native/cmd/lich-native/settings_updates_notes.go +++ b/native/cmd/lich-native/settings_updates_notes.go @@ -239,8 +239,7 @@ func (s *settingsScreen) layoutPatchNotesDialog(gtx C, th *ui.Theme) { if notes == nil { return } - open := v.open && (s.open || v.startup) - if !open && !v.dialog.Animating(gtx) { + if !(v.open && (s.open || v.startup)) && !v.dialog.Animating(gtx) { return } if v.gotIt.Clicked(gtx) { @@ -249,7 +248,7 @@ func (s *settingsScreen) layoutPatchNotesDialog(gtx C, th *ui.Theme) { if v.changelog.Clicked(gtx) { s.openExternal(helpRepoURL + updatesReleaseTagAt + notes.Version) } - open = v.open && (s.open || v.startup) + 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