1package ui
2
3import (
4 "reflect"
5 "strings"
6
7 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/ui/input"
9 "github.com/bnema/puregotk/v4/glib"
10)
11
12const legendFlashDurationMs = 120
13
14// chevronState is the expand/collapse affordance in the toaster header.
15type chevronState int
16
17const (
18 // chevronNone: nothing to expand (legend off, or no bindings).
19 chevronNone chevronState = iota
20 chevronExpanded
21 chevronCollapsed
22)
23
24// modeToasterView is the toolkit side of the mode toaster: a header pill and
25// the legend body that expands from it. Implementations ignore calls that
26// change nothing, since the toaster reconciles on every state change.
27type modeToasterView interface {
28 setHeader(text string)
29 // setMode colors the pill and the legend for mode.
30 setMode(mode input.Mode)
31 setChevron(chevronState)
32 setPillVisible(bool)
33 // setPillInteractive makes the pill, and only the pill, receive pointer
34 // input.
35 setPillInteractive(bool)
36 setLegend(groups []legendGroup)
37 setStyle(style string)
38 setAnimations(bool)
39 setBodyVisible(bool)
40 setBodyLingering(bool)
41 setActionDimmed(action string, dimmed bool)
42 // flashAction lights the keycaps of action and reports whether it has any.
43 flashAction(action string) bool
44 clearFlash()
45 // lingerRect is the screen area of the legend body, in overlay coordinates,
46 // while it is on screen.
47 lingerRect() (x, y, width, height int, ok bool)
48 destroy()
49}
50
51// modeToaster is the per-window mode indicator: a pill anchored bottom-left
52// that names the active mode and any pending key sequence or Vim sub-mode,
53// plus the key legend that expands from it. The mode border around the target
54// pane is modeFrame's.
55//
56// It holds all state and decisions; its view only paints. Main thread only.
57type modeToaster struct {
58 view modeToasterView
59
60 mode input.Mode
61 detail string // pending sequence or Vim sub-mode, appended to the header
62 pending string // pending sequence the legend rows are dimmed for
63 cfg entity.WorkspaceStylingConfig
64 style string
65 actions map[string]entity.ActionBinding
66 groups []legendGroup
67
68 // collapsed hides the legend body; the pill stays. It is shared by every
69 // mode and window: the app owns it (modeLegendPreference), pushes it with
70 // each configuration and broadcasts it on a click.
71 collapsed bool
72 // ready: the legend's show delay has elapsed.
73 ready bool
74 // suspended hides the legend while an in-page interaction (link hints,
75 // visual selection) owns the keys.
76 suspended bool
77 // lingering keeps a read-only legend on screen after its mode ended while
78 // the pointer was over it, until the next key press or click.
79 lingering bool
80 generation uint64
81 showTimer uint
82 flashTimer uint
83 // onToggle receives the collapsed state a click on the pill asks for. The
84 // app applies it to every window. Without it the toaster applies it alone.
85 onToggle func(collapsed bool)
86
87 pressInFlight bool
88 pressIdle uint
89 pointerInside bool
90 pointerX, pointerY float64
91}
92
93func newModeToasterWithView(view modeToasterView, onToggle func(bool)) *modeToaster {
94 return &modeToaster{view: view, mode: input.ModeNormal, onToggle: onToggle}
95}
96
97// modeToasterHeader is the pill text: the mode name, then " · detail".
98func modeToasterHeader(mode input.Mode, detail string) string {
99 if detail == "" {
100 return mode.DisplayName()
101 }
102 return mode.DisplayName() + " · " + detail
103}
104
105func (t *modeToaster) live() bool { return t != nil && t.view != nil }
106
107// hasLegend reports whether a legend can expand from the pill.
108func (t *modeToaster) hasLegend() bool {
109 return t.mode != input.ModeNormal && modeLegendEnabled(t.cfg) && len(t.groups) > 0
110}
111
112func (t *modeToaster) bodyShown() bool {
113 return t.lingering || (t.hasLegend() && legendBodyShown(t.ready, t.collapsed, t.suspended))
114}
115
116func (t *modeToaster) chevron() chevronState {
117 switch {
118 case !t.hasLegend():
119 return chevronNone
120 case t.collapsed:
121 return chevronCollapsed
122 default:
123 return chevronExpanded
124 }
125}
126
127// reconcile pushes the current state to the view.
128func (t *modeToaster) reconcile() {
129 if !t.live() {
130 return
131 }
132 pill := modeToasterVisible(t.mode, t.cfg)
133 t.view.setPillVisible(pill)
134 if pill {
135 // A lingering legend has no pill (its mode ended) and keeps the colors
136 // of the mode it was shown for.
137 t.view.setMode(t.mode)
138 t.view.setHeader(modeToasterHeader(t.mode, t.detail))
139 t.view.setChevron(t.chevron())
140 t.view.setPillInteractive(t.hasLegend())
141 }
142 t.view.setBodyVisible(t.bodyShown())
143}
144
145// sameLegendInputs reports whether cfg, style and actions leave the built
146// legend as it is. The indicator preference and the collapsed state are not
147// part of it.
148func (t *modeToaster) sameLegendInputs(cfg entity.WorkspaceStylingConfig, style string, actions map[string]entity.ActionBinding) bool {
149 return cfg.ModeLegend == t.cfg.ModeLegend &&
150 cfg.ModeLegendDelayMs == t.cfg.ModeLegendDelayMs &&
151 cfg.ModeLegendAnimations == t.cfg.ModeLegendAnimations &&
152 cfg.ModeLegendLinger == t.cfg.ModeLegendLinger &&
153 style == t.style && reflect.DeepEqual(actions, t.actions)
154}
155
156// apply reconciles the toaster with mode and the live configuration. A mode
157// change, or a change to what the legend lists or how it shows, rebuilds it.
158// Anything else (a config reload caused by saving the collapsed state, say)
159// only updates the preferences, so the legend neither flickers nor restarts
160// its show delay.
161func (t *modeToaster) apply(mode input.Mode, cfg entity.WorkspaceStylingConfig, style string, actions map[string]entity.ActionBinding) {
162 if !t.live() {
163 return
164 }
165 t.collapsed = cfg.ModeLegendCollapsed
166 if mode == t.mode && (mode == input.ModeNormal || t.sameLegendInputs(cfg, style, actions)) {
167 t.cfg = cfg
168 t.reconcile()
169 return
170 }
171 t.setMode(mode, cfg, style, actions)
172}
173
174func (t *modeToaster) setMode(mode input.Mode, cfg entity.WorkspaceStylingConfig, style string, actions map[string]entity.ActionBinding) {
175 t.cancelTimers()
176 // Normal-mode refreshes must preserve an already lingering legend.
177 keepLinger := mode == input.ModeNormal && t.mode == input.ModeNormal && t.lingering
178 linger := shouldStartLinger(mode == input.ModeNormal && !t.lingering && t.bodyShown(), t.pressInFlight,
179 cfg.ModeLegendLinger, t.pointerInside, t.pointerX, t.pointerY, t.view.lingerRect)
180 if !keepLinger {
181 t.clearLinger()
182 }
183 t.generation++
184 // A rebuild within the same mode (a config hot reload during a Vim page
185 // capture, say) keeps the interaction state: the sub-mode label, the pending
186 // sequence and the suspension belong to what the user is doing, not to the
187 // legend being rebuilt.
188 if mode != t.mode {
189 t.detail, t.pending = "", ""
190 t.suspended = false
191 }
192 t.mode, t.cfg, t.style, t.actions = mode, cfg, style, actions
193 t.ready = false
194 t.view.setStyle(style)
195 if linger {
196 t.lingering = true
197 t.clearFeedback()
198 t.view.setBodyLingering(true)
199 }
200 if mode == input.ModeNormal || !modeLegendEnabled(cfg) {
201 if !linger && !keepLinger {
202 t.groups = nil
203 t.view.setLegend(nil)
204 }
205 t.reconcile()
206 return
207 }
208 t.groups = buildLegendGroups(mode, actions)
209 t.view.setLegend(t.groups)
210 if t.pending != "" {
211 t.dimRulesOutPending()
212 }
213 t.view.setAnimations(cfg.ModeLegendAnimations)
214 if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
215 generation := t.generation
216 cb := glib.SourceFunc(func(_ uintptr) bool {
217 t.showTimer = 0
218 if generation == t.generation && t.live() {
219 t.ready = true
220 t.reconcile()
221 }
222 return false
223 })
224 t.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
225 } else {
226 t.ready = true
227 }
228 t.reconcile()
229}
230
231// setDetail shows detail after the Vim Mode name in the header: the sub-mode of
232// an in-page interaction, or "" for the plain name. Other modes have none.
233func (t *modeToaster) setDetail(detail string) {
234 if !t.live() || t.mode != input.ModeVim || t.detail == detail {
235 return
236 }
237 t.detail = detail
238 t.reconcile()
239}
240
241// setPending shows the pending key sequence in the header and dims the legend
242// rows it rules out.
243func (t *modeToaster) setPending(pending string) {
244 if !t.live() || t.mode != input.ModeVim {
245 return
246 }
247 t.detail = pending
248 t.pending = pending
249 t.dimRulesOutPending()
250 t.reconcile()
251}
252
253// dimRulesOutPending dims the legend rows the pending sequence rules out. A
254// rebuilt legend starts undimmed, so it is applied again after each rebuild.
255func (t *modeToaster) dimRulesOutPending() {
256 for _, group := range t.groups {
257 for _, row := range group.Rows {
258 t.view.setActionDimmed(row.Action, !legendSequenceMatches(t.pending, row.Keys))
259 }
260 }
261}
262
263// setSuspended hides the legend while suspended is true and brings it back
264// otherwise, without touching the mode or a pending show delay. Used while an
265// in-page interaction owns the keys, when the legend would list bindings that
266// do not apply. The header keeps showing the sub-mode.
267func (t *modeToaster) setSuspended(suspended bool) {
268 if !t.live() || t.suspended == suspended {
269 return
270 }
271 t.suspended = suspended
272 t.reconcile()
273}
274
275// setCollapsed collapses or expands the legend.
276func (t *modeToaster) setCollapsed(collapsed bool) {
277 if !t.live() || t.collapsed == collapsed {
278 return
279 }
280 t.collapsed = collapsed
281 if !collapsed && t.showTimer != 0 {
282 // Expanding is a request to see the legend now, not after its delay.
283 glib.SourceRemove(t.showTimer)
284 t.showTimer = 0
285 t.ready = true
286 }
287 t.reconcile()
288}
289
290// pillClicked handles a click on the header: it toggles the legend.
291func (t *modeToaster) pillClicked() {
292 if !t.live() || t.lingering || !t.hasLegend() {
293 return
294 }
295 next := nextModeLegendCollapsed(t.collapsed)
296 if t.onToggle != nil {
297 t.onToggle(next)
298 return
299 }
300 t.setCollapsed(next)
301}
302
303// release drops the legend of a tab whose workspace is going away. The header
304// stays for the active mode.
305func (t *modeToaster) release() {
306 if !t.live() {
307 return
308 }
309 t.cancelTimers()
310 t.clearLinger()
311 t.generation++
312 t.ready = false
313 t.reconcile()
314}
315
316func (t *modeToaster) clearLinger() {
317 if !t.lingering {
318 return
319 }
320 t.lingering = false
321 t.view.setBodyLingering(false)
322}
323
324func (t *modeToaster) dismissLinger() {
325 if !t.live() || !t.lingering {
326 return
327 }
328 t.clearLinger()
329 t.reconcile()
330}
331
332func (t *modeToaster) clearFeedback() {
333 for _, group := range t.groups {
334 for _, row := range group.Rows {
335 t.view.setActionDimmed(row.Action, false)
336 }
337 }
338 t.view.clearFlash()
339}
340
341func (t *modeToaster) cancelTimers() {
342 if t.showTimer != 0 {
343 glib.SourceRemove(t.showTimer)
344 t.showTimer = 0
345 }
346 if t.flashTimer != 0 {
347 glib.SourceRemove(t.flashTimer)
348 t.flashTimer = 0
349 }
350}
351
352// flash lights the keycaps of a dispatched action.
353func (t *modeToaster) flash(action input.Action) {
354 if !t.live() || t.lingering || !t.bodyShown() || !t.cfg.ModeLegendAnimations {
355 return
356 }
357 name := string(action)
358 if !t.view.flashAction(name) {
359 name = strings.ReplaceAll(name, "_", "-")
360 if !t.view.flashAction(name) {
361 return
362 }
363 }
364 if t.flashTimer != 0 {
365 glib.SourceRemove(t.flashTimer)
366 }
367 cb := glib.SourceFunc(func(_ uintptr) bool {
368 t.flashTimer = 0
369 if t.live() {
370 t.view.clearFlash()
371 }
372 return false
373 })
374 t.flashTimer = glib.TimeoutAdd(legendFlashDurationMs, &cb, 0)
375}
376
377func (t *modeToaster) pointerMoved(x, y float64) {
378 t.pointerX, t.pointerY, t.pointerInside = x, y, true
379}
380
381func (t *modeToaster) pointerLeft() { t.pointerInside = false }
382
383// keyPressed ends a lingering legend: the next key press means the user moved on.
384func (t *modeToaster) keyPressed() { t.dismissLinger() }
385
386// pressStarted records a click outside the pill: it ends a lingering legend,
387// and a mode that exits because of it must not start one.
388func (t *modeToaster) pressStarted() {
389 if !t.live() {
390 return
391 }
392 t.dismissLinger()
393 t.pressInFlight = true
394 if t.pressIdle != 0 {
395 glib.SourceRemove(t.pressIdle)
396 }
397 cb := glib.SourceFunc(func(_ uintptr) bool {
398 t.pressInFlight = false
399 t.pressIdle = 0
400 return false
401 })
402 t.pressIdle = glib.IdleAdd(&cb, 0)
403}
404
405func (t *modeToaster) destroy() {
406 if t == nil {
407 return
408 }
409 t.cancelTimers()
410 if t.pressIdle != 0 {
411 glib.SourceRemove(t.pressIdle)
412 t.pressIdle = 0
413 }
414 if t.view != nil {
415 t.view.destroy()
416 t.view = nil
417 }
418}
419
420func pointInRect(x, y float64, rx, ry, width, height int) bool {
421 return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
422}
423
424func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
425 if !enabled || !inside || placement == nil {
426 return false
427 }
428 rx, ry, w, h, ok := placement()
429 return ok && pointInRect(x, y, rx, ry, w, h)
430}
431
432func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
433 return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
434}