1package ui
2
3import (
4 "context"
5 "sync"
6
7 "github.com/bnema/dumber/internal/logging"
8 "github.com/bnema/dumber/internal/ui/input"
9 "github.com/bnema/dumber/internal/ui/layout"
10)
11
12func (a *App) modeFrameTarget(bw *browserWindow, mode input.Mode) layout.Widget {
13 wsView := a.activeWorkspaceViewForBrowserWindow(bw)
14 if wsView == nil {
15 return nil
16 }
17 if mode == input.ModeTab || mode == input.ModeSession || mode == input.ModePrefix {
18 return wsView.Widget()
19 }
20 ws := a.activeWorkspaceForBrowserWindow(bw)
21 if ws == nil {
22 return nil
23 }
24 if mode == input.ModePane || mode == input.ModeResize {
25 if stack := wsView.GetStackContainerWidget(ws.ActivePaneID); stack != nil {
26 return stack
27 }
28 }
29 if mode == input.ModeVim || mode == input.ModePane || mode == input.ModeResize {
30 return wsView.GetPaneWidget(ws.ActivePaneID)
31 }
32 return nil
33}
34
35func (a *App) retargetModeFrame(bw *browserWindow, mode input.Mode) {
36 if bw == nil || bw.modeFrame == nil {
37 return
38 }
39 a.setModeFrameTab(bw)
40 // Rebuilds can temporarily lack a pane widget. Drop the old border while
41 // the next focus/rebuild event retries the target.
42 bw.modeFrame.setTarget(a.modeFrameTarget(bw, mode), modeFrameClass(mode))
43 if bw.modeFrame.target != nil {
44 bw.modeFrame.refreshGeometry()
45 }
46}
47
48// updateModeFrame reconciles the window's mode border and mode toaster with
49// mode and the live configuration.
50func (a *App) updateModeFrame(bw *browserWindow, mode input.Mode) {
51 if bw == nil {
52 return
53 }
54 a.setModeFrameTab(bw)
55 cfg := a.runtimeConfigSnapshot().UI
56 if bw.modeFrame != nil {
57 bw.modeFrame.setMode(mode, a.modeFrameTarget(bw, mode))
58 }
59 if bw.modeToaster != nil {
60 styling := cfg.Workspace.Styling
61 styling.ModeLegendCollapsed = a.modeLegendCollapsed()
62 bw.modeToaster.apply(mode, styling, cfg.Omnibox.Style, legendActions(mode, cfg.Workspace, cfg.Session))
63 }
64}
65
66func (a *App) setModeFrameTab(bw *browserWindow) {
67 if bw.modeFrame == nil {
68 return
69 }
70 tabID := bw.modeFrame.tabID
71 bw.modeFrame.tabID = ""
72 if tab := a.activeTabForBrowserWindow(bw); tab != nil {
73 bw.modeFrame.tabID = tab.ID
74 }
75 if tabID != bw.modeFrame.tabID {
76 bw.modeToaster.dismissLinger()
77 }
78}
79
80func (a *App) modeFrameAction(bw *browserWindow, action input.Action) {
81 if bw != nil && bw.modeToaster != nil {
82 bw.modeToaster.flash(action)
83 }
84}
85
86// modeLegendPreference is the app-wide "legend collapsed" state, shared by
87// every mode and window. A click is applied at once and saved in the
88// background. It keeps priority over the configured value, which may still be
89// the previous one while saves are in flight, until a configuration reload
90// received after the writer drained settles it (see configReloaded).
91type modeLegendPreference struct {
92 mu sync.Mutex
93 collapsed bool // last click; meaningful while clicked is set
94 clicked bool
95 // pending is the next value to save; saving reports a running writer. The
96 // writer drains pending until it is empty, so saves go out in click order
97 // and the file ends on the last click.
98 pending *bool
99 saving bool
100}
101
102// current returns the state to show, given the configured one.
103func (p *modeLegendPreference) current(configured bool) bool {
104 p.mu.Lock()
105 defer p.mu.Unlock()
106 if p.clicked {
107 return p.collapsed
108 }
109 return configured
110}
111
112// click records a click and returns whether a writer must be started.
113func (p *modeLegendPreference) click(collapsed bool) (startWriter bool) {
114 p.mu.Lock()
115 defer p.mu.Unlock()
116 p.clicked, p.collapsed = true, collapsed
117 p.pending = &collapsed
118 if p.saving {
119 return false
120 }
121 p.saving = true
122 return true
123}
124
125// next hands the writer its next value, or ends it when none is left.
126func (p *modeLegendPreference) next() (collapsed, ok bool) {
127 p.mu.Lock()
128 defer p.mu.Unlock()
129 if p.pending == nil {
130 p.saving = false
131 return false, false
132 }
133 collapsed, p.pending = *p.pending, nil
134 return collapsed, true
135}
136
137// configReloaded hands the state back to the configuration on a reload
138// received once the writer has drained: that reload reflects the saved clicks
139// (or an external edit, or a failed save). Reloads received while the writer
140// runs are ignored: they may come from an earlier save and would undo the
141// latest click.
142func (p *modeLegendPreference) configReloaded(_ bool) {
143 p.mu.Lock()
144 defer p.mu.Unlock()
145 if !p.saving {
146 p.clicked = false
147 }
148}
149
150// modeLegendCollapsed returns the state new windows and mode changes start from.
151func (a *App) modeLegendCollapsed() bool {
152 return a.modeLegend.current(a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeLegendCollapsed)
153}
154
155// setModeLegendCollapsed applies a click on a mode toaster: it updates every
156// open window at once, then saves the choice off the GTK thread. A failed save
157// is logged; the state stays applied until the next configuration reload.
158func (a *App) setModeLegendCollapsed(ctx context.Context, collapsed bool) {
159 for _, bw := range a.browserWindows {
160 if bw != nil && bw.modeToaster != nil {
161 bw.modeToaster.setCollapsed(collapsed)
162 }
163 }
164 start := a.modeLegend.click(collapsed)
165 if !start {
166 return
167 }
168 var save func(context.Context, bool) error
169 if a.deps != nil {
170 save = a.deps.HandlerDeps.SaveModeLegendCollapsed
171 }
172 go func() {
173 for {
174 next, ok := a.modeLegend.next()
175 if !ok {
176 return
177 }
178 if save == nil {
179 continue
180 }
181 if err := save(ctx, next); err != nil {
182 logging.FromContext(ctx).Warn().Err(err).Bool("collapsed", next).
183 Msg("failed to save mode legend collapsed preference")
184 }
185 }
186 }()
187}