mode_frame.go RawHistoryFolder

1package ui
2
3import (
4	"strings"
5
6	"github.com/bnema/dumber/internal/domain/entity"
7	"github.com/bnema/dumber/internal/ui/input"
8	"github.com/bnema/dumber/internal/ui/layout"
9	"github.com/bnema/puregotk/v4/gdk"
10	"github.com/bnema/puregotk/v4/glib"
11	"github.com/bnema/puregotk/v4/gobject"
12	"github.com/bnema/puregotk/v4/graphene"
13	"github.com/bnema/puregotk/v4/gtk"
14)
15
16const (
17	legendColumnSpacing  = 8
18	legendMaxWidth       = 700
19	legendCompactWidth   = 460
20	legendTwoColumnWidth = 650
21	legendInset          = 16
22	legendHeaderSpace    = 80
23	legendRefreshMs      = 100
24)
25
26// modeFrame owns the per-window mode border and its non-interactive legend.
27// GTK access is restricted to the main thread, as with the rest of the UI.
28type modeFrame struct {
29	mode               input.Mode
30	tabID              entity.TabID
31	target             layout.Widget
32	frameClass         string
33	root               *gtk.Box
34	border             *gtk.Box
35	borderStyle        cssClassTarget // border's CSS classes; a seam for tests
36	borderRect         func() (x, y, width, height int, ok bool)
37	panel              *gtk.Box
38	content            *gtk.FlowBox
39	scroller           *gtk.ScrolledWindow
40	heading            *gtk.Label
41	rows               map[string][]*gtk.Widget
42	keycaps            map[string][]*gtk.Widget
43	pending            string
44	visible            bool
45	style              string
46	modeClass          string
47	config             entity.WorkspaceStylingConfig
48	showTimer          uint
49	flashTimer         uint
50	generation         uint64
51	placement          func() (x, y, width, height int, ok bool)
52	refreshSource      uint
53	lastColumns        uint
54	lastPanelWidth     int
55	lastMaxHeight      int
56	overlay            *gtk.Overlay
57	window             *gtk.ApplicationWindow
58	pressInFlight      bool
59	pressIdle          uint
60	positionHandlerID  uint
61	pulseCycle         bool
62	lingering          bool
63	pointerX, pointerY float64
64	pointerInside      bool
65	motionController   *gtk.EventControllerMotion
66	keyController      *gtk.EventControllerKey
67	clickController    *gtk.GestureClick
68	motionCb           func(gtk.EventControllerMotion, float64, float64)
69	enterCb            func(gtk.EventControllerMotion, float64, float64)
70	leaveCb            func(gtk.EventControllerMotion)
71	keyCb              func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
72	clickCb            func(gtk.GestureClick, int, float64, float64)
73}
74
75func newModeFrame(overlay *gtk.Overlay, window *gtk.ApplicationWindow) *modeFrame {
76	if overlay == nil || window == nil {
77		return nil
78	}
79	f := &modeFrame{mode: input.ModeNormal, overlay: overlay, window: window}
80	f.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
81	f.root.SetHalign(gtk.AlignStartValue)
82	f.root.SetValign(gtk.AlignStartValue)
83	f.root.SetCanFocus(false)
84	f.root.SetCanTarget(false)
85	f.root.SetVisible(false)
86	f.root.AddCssClass("mode-legend-anchor")
87	f.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
88	f.panel.SetCanFocus(false)
89	f.panel.SetCanTarget(false)
90	f.panel.AddCssClass("omnibox-container")
91	f.panel.AddCssClass("mode-legend-panel")
92	f.root.Append(&f.panel.Widget)
93	title := ""
94	f.heading = gtk.NewLabel(&title)
95	f.heading.SetCanTarget(false)
96	f.heading.SetCanFocus(false)
97	f.heading.SetHalign(gtk.AlignStartValue)
98	f.heading.AddCssClass("mode-legend-title")
99	f.panel.Append(&f.heading.Widget)
100	f.initLegendContent()
101	// The border is its own overlay: inset shadows on pane containers are
102	// painted below web content and would be invisible.
103	f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
104	f.border.SetCanFocus(false)
105	f.border.SetCanTarget(false)
106	f.border.SetVisible(false)
107	f.border.AddCssClass("mode-frame-border")
108	f.borderStyle = f.border
109	overlay.AddOverlay(&f.border.Widget)
110	overlay.SetClipOverlay(&f.border.Widget, false)
111	overlay.SetMeasureOverlay(&f.border.Widget, false)
112	overlay.AddOverlay(&f.root.Widget)
113	overlay.SetClipOverlay(&f.root.Widget, false)
114	overlay.SetMeasureOverlay(&f.root.Widget, false)
115	// A single positioned overlay tracks the active pane without reparenting it.
116	cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
117		placement := f.placement
118		if f.border != nil && widgetPtr == f.border.GoPointer() {
119			placement = f.borderRect
120		} else if widgetPtr != f.root.GoPointer() {
121			return false
122		}
123		if placement == nil {
124			return false
125		}
126		x, y, w, h, ok := placement()
127		if !ok {
128			return false
129		}
130		return writeOverlayAllocation(allocation, x, y, w, h)
131	}
132	f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
133	f.installDismissControllers()
134	return f
135}
136
137func (f *modeFrame) installDismissControllers() {
138	overlay := f.overlay
139	window := f.window
140	f.motionController = gtk.NewEventControllerMotion()
141	f.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
142		f.pointerX, f.pointerY, f.pointerInside = x, y, true
143	}
144	f.leaveCb = func(_ gtk.EventControllerMotion) { f.pointerInside = false }
145	f.enterCb = func(_ gtk.EventControllerMotion, x, y float64) {
146		f.pointerX, f.pointerY, f.pointerInside = x, y, true
147	}
148	f.motionController.ConnectEnter(&f.enterCb)
149	f.motionController.ConnectMotion(&f.motionCb)
150	f.motionController.ConnectLeave(&f.leaveCb)
151	overlay.AddController(&f.motionController.EventController)
152	f.keyController = gtk.NewEventControllerKey()
153	f.keyController.SetPropagationPhase(gtk.PhaseCaptureValue)
154	f.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
155		f.dismissLinger()
156		return false
157	}
158	f.keyController.ConnectKeyPressed(&f.keyCb)
159	window.AddController(&f.keyController.EventController)
160	f.clickController = gtk.NewGestureClick()
161	f.clickController.SetButton(0)
162	f.clickController.SetPropagationPhase(gtk.PhaseCaptureValue)
163	f.clickCb = func(_ gtk.GestureClick, _ int, _, _ float64) {
164		f.dismissLinger()
165		f.pressInFlight = true
166		if f.pressIdle != 0 {
167			glib.SourceRemove(f.pressIdle)
168		}
169		cb := glib.SourceFunc(func(_ uintptr) bool {
170			f.pressInFlight = false
171			f.pressIdle = 0
172			return false
173		})
174		f.pressIdle = glib.IdleAdd(&cb, 0)
175	}
176	f.clickController.ConnectPressed(&f.clickCb)
177	window.AddController(&f.clickController.EventController)
178}
179
180func (f *modeFrame) initLegendContent() {
181	f.content = gtk.NewFlowBox()
182	f.content.SetCanFocus(false)
183	f.content.SetCanTarget(false)
184	f.content.SetOrientation(gtk.OrientationHorizontalValue)
185	f.content.SetSelectionMode(gtk.SelectionNoneValue)
186	f.content.SetMinChildrenPerLine(1)
187	f.content.SetMaxChildrenPerLine(3)
188	f.content.SetColumnSpacing(legendColumnSpacing)
189	f.content.SetRowSpacing(4)
190	f.content.AddCssClass("mode-legend-content")
191	f.scroller = gtk.NewScrolledWindow()
192	f.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
193	f.scroller.SetPropagateNaturalHeight(true)
194	f.scroller.SetHasFrame(false)
195	f.scroller.SetCanFocus(false)
196	f.scroller.SetCanTarget(false)
197	f.scroller.SetChild(&f.content.Widget)
198	f.panel.Append(&f.scroller.Widget)
199}
200
201func (f *modeFrame) destroy() {
202	if f == nil {
203		return
204	}
205	f.cancelTimers()
206	f.dismissLinger()
207	f.stopGeometryRefresh()
208	if f.pressIdle != 0 {
209		glib.SourceRemove(f.pressIdle)
210		f.pressIdle = 0
211	}
212	if f.overlay != nil {
213		f.overlay.RemoveController(&f.motionController.EventController)
214	}
215	if f.window != nil {
216		f.window.RemoveController(&f.keyController.EventController)
217		f.window.RemoveController(&f.clickController.EventController)
218	}
219	f.setTarget(nil, "")
220	if f.overlay != nil && f.positionHandlerID != 0 {
221		gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
222		f.positionHandlerID = 0
223	}
224	f.placement = nil
225	f.borderRect = nil
226	if f.overlay != nil && f.root != nil {
227		f.overlay.RemoveOverlay(&f.root.Widget)
228	}
229	if f.overlay != nil && f.border != nil {
230		f.overlay.RemoveOverlay(&f.border.Widget)
231	}
232	f.overlay = nil
233	f.window = nil
234	f.root = nil
235	f.border = nil
236	f.borderStyle = nil
237}
238
239type cssClassTarget interface {
240	AddCssClass(string)
241	RemoveCssClass(string)
242}
243
244func (f *modeFrame) setTarget(target layout.Widget, class string) {
245	if f == nil {
246		return
247	}
248	if target == f.target && class == f.frameClass {
249		return
250	}
251	if f.borderStyle != nil && f.frameClass != "" {
252		f.borderStyle.RemoveCssClass(f.frameClass)
253		for _, pulseClass := range vimPulseClasses {
254			f.borderStyle.RemoveCssClass(pulseClass)
255		}
256	}
257	f.target, f.frameClass = target, class
258	if f.borderStyle != nil && target != nil && class != "" {
259		f.borderStyle.AddCssClass(class)
260	}
261	if f.border == nil {
262		return
263	}
264	if target != nil && class != "" {
265		f.startGeometryRefresh()
266		return
267	}
268	f.stopGeometryRefresh()
269	f.borderRect = nil
270	f.border.SetVisible(false)
271}
272
273var vimPulseClasses = []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"}
274
275func (f *modeFrame) pulse(fast bool) {
276	if f == nil || f.mode != input.ModeVim || f.target == nil || f.borderStyle == nil {
277		return
278	}
279	for _, class := range vimPulseClasses {
280		f.borderStyle.RemoveCssClass(class)
281	}
282	class := "vim-mode-pulse"
283	if fast {
284		class = "vim-mode-pulse-fast"
285	}
286	f.borderStyle.AddCssClass(class)
287	cycle := "vim-mode-pulse-cycle-a"
288	if f.pulseCycle {
289		cycle = "vim-mode-pulse-cycle-b"
290	}
291	f.pulseCycle = !f.pulseCycle
292	f.borderStyle.AddCssClass(cycle)
293}
294
295func modeFrameClass(mode input.Mode) string {
296	switch mode {
297	case input.ModePane:
298		return "pane-mode-active"
299	case input.ModeResize:
300		return "resize-mode-active"
301	case input.ModeVim:
302		return "vim-mode-active"
303	case input.ModeTab:
304		return "tab-mode-active"
305	case input.ModeSession:
306		return "session-mode-active"
307	default:
308		return ""
309	}
310}
311
312func (f *modeFrame) setMode(
313	mode input.Mode, target layout.Widget, cfg entity.WorkspaceStylingConfig,
314	style string, actions map[string]entity.ActionBinding,
315) {
316	if f == nil {
317		return
318	}
319	f.cancelTimers()
320	// Normal-mode refreshes must preserve an already lingering legend.
321	keepLinger := mode == input.ModeNormal && f.mode == input.ModeNormal && f.lingering
322	linger := shouldStartLinger(mode == input.ModeNormal && f.visible, f.pressInFlight,
323		cfg.ModeLegendLinger, f.pointerInside, f.pointerX, f.pointerY, f.placement)
324	if !keepLinger {
325		f.dismissLinger()
326	}
327	f.generation++
328	f.mode, f.config = mode, cfg
329	f.pending = ""
330	f.pulseCycle = false
331	if linger {
332		f.lingering = true
333		f.visible = false
334		f.clearLegendFeedback()
335		f.panel.AddCssClass("mode-legend-lingering")
336	} else if !keepLinger {
337		f.hide()
338	}
339	f.setTarget(target, modeFrameClass(mode))
340	f.setLegendStyle(style, mode, linger || keepLinger)
341	if mode == input.ModeNormal || cfg.ModeLegend == "off" || target == nil {
342		if !linger && !keepLinger {
343			f.content.RemoveAll()
344		}
345		f.rows = nil
346		f.keycaps = nil
347		return
348	}
349	f.build(actions)
350	if cfg.ModeLegendAnimations {
351		f.panel.RemoveCssClass("mode-legend-no-motion")
352	} else {
353		f.panel.AddCssClass("mode-legend-no-motion")
354	}
355	if cfg.ModeLegend == "delay" && cfg.ModeLegendDelayMs > 0 {
356		generation := f.generation
357		cb := glib.SourceFunc(func(_ uintptr) bool {
358			f.showTimer = 0
359			if generation == f.generation {
360				f.show()
361			}
362			return false
363		})
364		f.showTimer = glib.TimeoutAdd(uint(cfg.ModeLegendDelayMs), &cb, 0)
365		return
366	}
367	f.show()
368}
369
370func (f *modeFrame) setLegendStyle(style string, mode input.Mode, preserveModeClass bool) {
371	if f.style != style {
372		if f.style != "" {
373			f.panel.RemoveCssClass("omnibox-style-" + f.style)
374		}
375		f.style = style
376		if style != "" {
377			f.panel.AddCssClass("omnibox-style-" + style)
378		}
379	}
380	if f.modeClass != "" && !preserveModeClass {
381		f.panel.RemoveCssClass(f.modeClass)
382	}
383	if !preserveModeClass {
384		f.modeClass = "mode-legend-" + mode.String()
385		f.panel.AddCssClass(f.modeClass)
386	}
387}
388
389func (f *modeFrame) clearLegendFeedback() {
390	for _, rows := range f.rows {
391		for _, row := range rows {
392			row.RemoveCssClass("mode-legend-dim")
393		}
394	}
395	for _, caps := range f.keycaps {
396		for _, cap := range caps {
397			cap.RemoveCssClass("mode-legend-flash")
398		}
399	}
400}
401
402func (f *modeFrame) cancelTimers() {
403	if f == nil {
404		return
405	}
406	if f.showTimer != 0 {
407		glib.SourceRemove(f.showTimer)
408		f.showTimer = 0
409	}
410	if f.flashTimer != 0 {
411		glib.SourceRemove(f.flashTimer)
412		f.flashTimer = 0
413	}
414}
415
416func pointInRect(x, y float64, rx, ry, width, height int) bool {
417	return x >= float64(rx) && y >= float64(ry) && x < float64(rx+width) && y < float64(ry+height)
418}
419
420func shouldLinger(enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
421	if !enabled || !inside || placement == nil {
422		return false
423	}
424	rx, ry, w, h, ok := placement()
425	return ok && pointInRect(x, y, rx, ry, w, h)
426}
427
428func shouldStartLinger(exiting, pressInFlight, enabled, inside bool, x, y float64, placement func() (int, int, int, int, bool)) bool {
429	return exiting && !pressInFlight && shouldLinger(enabled, inside, x, y, placement)
430}
431
432func (f *modeFrame) dismissLinger() {
433	if f == nil || !f.lingering {
434		return
435	}
436	f.lingering = false
437	f.panel.RemoveCssClass("mode-legend-lingering")
438	f.hide()
439}
440
441func (f *modeFrame) hide() {
442	if f == nil || f.root == nil {
443		return
444	}
445	f.visible = false
446	f.placement = nil
447	f.resetLegendHeight()
448	f.root.SetVisible(false)
449}
450
451func (f *modeFrame) stopGeometryRefresh() {
452	if f == nil || f.refreshSource == 0 {
453		return
454	}
455	glib.SourceRemove(f.refreshSource)
456	f.refreshSource = 0
457}
458
459func (f *modeFrame) show() {
460	if f == nil || f.mode == input.ModeNormal || f.target == nil {
461		return
462	}
463	f.visible = true
464	f.startGeometryRefresh()
465}
466
467// Measure outside GTK allocation while the legend is active, including window
468// resizes and splitter drags after the initial layout has settled.
469func (f *modeFrame) startGeometryRefresh() {
470	f.stopGeometryRefresh()
471	f.refreshGeometry()
472	cb := glib.SourceFunc(func(_ uintptr) bool {
473		if f.target == nil || f.root == nil || f.lingering {
474			f.refreshSource = 0
475			return false
476		}
477		f.refreshGeometry()
478		return true
479	})
480	f.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
481}
482
483func (f *modeFrame) resetLegendHeight() {
484	if f.lastMaxHeight != 0 {
485		f.scroller.SetSizeRequest(-1, -1)
486		f.scroller.SetPropagateNaturalHeight(true)
487		f.lastMaxHeight = 0
488	}
489}
490
491// clearPlacement hides both the border and the legend: the target is gone.
492func (f *modeFrame) clearPlacement() {
493	if f.borderRect != nil {
494		f.borderRect = nil
495		f.border.SetVisible(false)
496	}
497	f.clearLegend()
498}
499
500// clearLegend hides only the legend; the border keeps tracking its target.
501func (f *modeFrame) clearLegend() {
502	if f.placement == nil {
503		return
504	}
505	f.placement = nil
506	f.root.SetVisible(false)
507	f.root.QueueAllocate()
508}
509
510func (f *modeFrame) measureLegendHeight(panelWidth int) int {
511	maxHeight := f.overlay.GetAllocatedHeight() - legendInset
512	if f.lastMaxHeight != 0 && maxHeight > f.lastMaxHeight {
513		f.resetLegendHeight()
514	}
515	// Measure the panel: GTK reports 0 for the root while it is still hidden.
516	_, naturalHeight := 0, 0
517	f.panel.Measure(gtk.OrientationVerticalValue, panelWidth, nil, &naturalHeight, nil, nil)
518	if naturalHeight > maxHeight && maxHeight > 0 {
519		if f.lastMaxHeight != maxHeight {
520			f.scroller.SetPropagateNaturalHeight(false)
521			f.scroller.SetSizeRequest(-1, max(maxHeight-legendHeaderSpace, 1))
522			f.lastMaxHeight = maxHeight
523		}
524		return maxHeight
525	}
526	return naturalHeight
527}
528
529// refreshGeometry measures the legend outside GTK's positioning callback.
530// A narrow target falls back to the window width, keeping the legend reachable.
531func (f *modeFrame) refreshGeometry() {
532	if f.lingering {
533		return
534	}
535	previous := f.placement
536	if f.target == nil || f.overlay == nil {
537		f.clearPlacement()
538		return
539	}
540	overlayWidget := &f.overlay.Widget
541	point := &graphene.Point{X: 0, Y: 0}
542	out := &graphene.Point{}
543	target := f.target.GtkWidget()
544	if target == nil || !target.ComputePoint(overlayWidget, point, out) {
545		f.clearPlacement()
546		return
547	}
548	width, height := target.GetAllocatedWidth(), target.GetAllocatedHeight()
549	if width <= 0 || height <= 0 {
550		f.clearPlacement()
551		return
552	}
553	x, bottom := int(out.X), int(out.Y)+height
554	f.placeBorder(x, int(out.Y), width, height)
555	if !f.visible {
556		f.clearLegend()
557		return
558	}
559	// Keep every keycap visible even in a narrow split. No webview resize.
560	panelWidth := min(width-legendInset, legendMaxWidth)
561	columns := uint(3)
562	if panelWidth < legendCompactWidth {
563		panelWidth = min(f.overlay.GetAllocatedWidth()-legendInset, legendCompactWidth)
564		x = (f.overlay.GetAllocatedWidth() - panelWidth) / 2
565		columns = 1
566	} else {
567		if panelWidth < legendTwoColumnWidth {
568			columns = 2
569		}
570		x += (width - panelWidth) / 2
571	}
572	if panelWidth <= 0 {
573		f.clearLegend()
574		return
575	}
576	f.sizeLegendColumns(columns, panelWidth)
577	naturalHeight := f.measureLegendHeight(panelWidth)
578	if naturalHeight <= 0 {
579		f.clearLegend()
580		return
581	}
582	y := bottom - naturalHeight - 4
583	if y < 0 {
584		y = 0
585	}
586	f.placement = func() (int, int, int, int, bool) { return x, y, panelWidth, naturalHeight, true }
587	f.root.SetVisible(true)
588	if previous != nil {
589		oldX, oldY, oldWidth, oldHeight, _ := previous()
590		if oldX == x && oldY == y && oldWidth == panelWidth && oldHeight == naturalHeight {
591			return
592		}
593	}
594	f.root.QueueAllocate()
595}
596
597func (f *modeFrame) placeBorder(x, y, width, height int) {
598	if f.borderRect != nil {
599		if ox, oy, ow, oh, _ := f.borderRect(); ox == x && oy == y && ow == width && oh == height {
600			return
601		}
602	}
603	f.borderRect = func() (int, int, int, int, bool) { return x, y, width, height, true }
604	f.border.SetVisible(true)
605	f.border.QueueAllocate()
606}
607
608func (f *modeFrame) sizeLegendColumns(columns uint, width int) {
609	if columns != f.lastColumns {
610		f.content.SetMaxChildrenPerLine(columns)
611		f.lastColumns = columns
612	}
613	if width != f.lastPanelWidth {
614		f.panel.SetSizeRequest(width, -1)
615		f.lastPanelWidth = width
616	}
617}
618
619func modeLegendGroup(mode input.Mode, name string) string {
620	switch {
621	case name == "confirm" || name == "cancel":
622		return "EXIT"
623	case strings.HasPrefix(name, "split-"):
624		return "SPLIT"
625	case strings.HasPrefix(name, "focus-"):
626		return "FOCUS"
627	case strings.HasPrefix(name, "resize-"):
628		return "RESIZE"
629	case strings.HasPrefix(name, "vim-scroll-") || strings.HasPrefix(name, "half-page-"):
630		return "SCROLL"
631	case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"):
632		return "JUMP"
633	case mode == input.ModeTab && (name == "next-tab" || name == "previous-tab"):
634		return "SWITCH"
635	default:
636		return "MANAGE"
637	}
638}

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