main 5041353

mode_toaster.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close