mode_toaster_view.go RawHistoryFolder

1package ui
2
3import (
4	"github.com/bnema/dumber/internal/ui/input"
5	"github.com/bnema/puregotk/v4/gdk"
6	"github.com/bnema/puregotk/v4/glib"
7	"github.com/bnema/puregotk/v4/gobject"
8	"github.com/bnema/puregotk/v4/graphene"
9	"github.com/bnema/puregotk/v4/gtk"
10)
11
12const (
13	legendRefreshMs = 100
14	// legendGap separates the legend band from the pill under it.
15	legendGap = 6
16	// legendScrollStep is how far one wheel notch over the pill scrolls a
17	// legend taller than its height budget.
18	legendScrollStep = 40
19)
20
21// modeToasterEvents carries window events to the toaster. The view only
22// forwards them; modeToaster decides what they mean.
23type modeToasterEvents struct {
24	pillClicked  func()
25	pointerMoved func(x, y float64)
26	pointerLeft  func()
27	keyPressed   func()
28	// pressedOutside reports a pointer press anywhere but on the pill.
29	pressedOutside func()
30}
31
32// gtkModeToasterView paints the mode toaster into the window's content
33// overlay as two overlay children:
34//
35//   - the pill, the only targetable widget of the component. It is a sibling
36//     of the legend rather than its parent because GTK skips the descendants of
37//     a widget that cannot be targeted.
38//   - the legend body, which never receives the pointer. Its rectangle is
39//     measured outside GTK's allocation pass and handed to the overlay's
40//     get-child-position callback, like the mode border's.
41//
42// Main thread only.
43type gtkModeToasterView struct {
44	overlay *gtk.Overlay
45	window  *gtk.ApplicationWindow
46	events  modeToasterEvents
47
48	pill      *gtk.Box
49	pillLabel *gtk.Label
50	chevron   *gtk.Label
51	root      *gtk.Box // overlay child of the body; its panel is what is measured
52	panel     *gtk.Box
53	scroller  *gtk.ScrolledWindow
54	columnBox *gtk.Box
55	hint      *gtk.Label
56
57	pillClick   *gtk.GestureClick
58	pillScroll  *gtk.EventControllerScroll
59	motion      *gtk.EventControllerMotion
60	keys        *gtk.EventControllerKey
61	windowClick *gtk.GestureClick
62
63	// Callbacks are retained here: GTK holds only a pointer to them.
64	pillClickCb  func(gtk.GestureClick, int, float64, float64)
65	pillScrollCb func(gtk.EventControllerScroll, float64, float64) bool
66	motionCb     func(gtk.EventControllerMotion, float64, float64)
67	enterCb      func(gtk.EventControllerMotion, float64, float64)
68	leaveCb      func(gtk.EventControllerMotion)
69	keyCb        func(gtk.EventControllerKey, uint, uint, gdk.ModifierType) bool
70	windowClickC func(gtk.GestureClick, int, float64, float64)
71
72	positionHandlerID uint
73	refreshSource     uint
74
75	// Painted state, kept to skip redundant GTK calls.
76	headerText   string
77	modeClass    string // CSS class of the pill color
78	legendClass  string // CSS class of the panel color
79	styleClass   string
80	chevronState chevronState
81	pillShown    bool
82	interactive  bool
83	animations   bool
84
85	// legendWidgets are the widgets built for the current legend. puregotk
86	// constructors hand back a reference we own, which GTK does not drop when
87	// the widget is removed from its parent, so they are unreferenced once the
88	// legend is replaced or the view destroyed.
89	legendWidgets []*gtk.Widget
90
91	groups      []legendGroup
92	groupBoxes  []*gtk.Box
93	columns     [][]*gtk.Box // group boxes per column box, as placed
94	columnBoxes []*gtk.Box
95	rows        map[string][]*gtk.Widget
96	keycaps     map[string][]*gtk.Widget
97
98	bodyWanted bool
99	lingering  bool
100	// placement is the legend rectangle in overlay coordinates; it is only
101	// meaningful while placed is set, which otherwise hides the legend.
102	placement                 overlayRect
103	placed                    bool
104	lastColumns               int
105	lastWidth, lastMaxContent int
106}
107
108// overlayRect is a rectangle in overlay coordinates.
109type overlayRect struct{ x, y, width, height int }
110
111func newGTKModeToasterView(overlay *gtk.Overlay, window *gtk.ApplicationWindow, events modeToasterEvents) *gtkModeToasterView {
112	if overlay == nil || window == nil {
113		return nil
114	}
115	v := &gtkModeToasterView{overlay: overlay, window: window, events: events, animations: true}
116	v.buildPill()
117	v.buildBody()
118	overlay.AddOverlay(&v.pill.Widget)
119	overlay.SetClipOverlay(&v.pill.Widget, false)
120	overlay.SetMeasureOverlay(&v.pill.Widget, false)
121	overlay.AddOverlay(&v.root.Widget)
122	overlay.SetClipOverlay(&v.root.Widget, false)
123	overlay.SetMeasureOverlay(&v.root.Widget, false)
124	cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
125		if v.root == nil || widgetPtr != v.root.GoPointer() || !v.placed {
126			return false
127		}
128		r := v.placement
129		return writeOverlayAllocation(allocation, r.x, r.y, r.width, r.height)
130	}
131	v.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
132	v.installWindowControllers()
133	return v
134}
135
136// buildPill creates the header. It starts out inert: pointer input reaches the
137// pill only while it can toggle a legend (setPillInteractive).
138func (v *gtkModeToasterView) buildPill() {
139	v.pill = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
140	v.pill.SetHalign(gtk.AlignStartValue)
141	v.pill.SetValign(gtk.AlignEndValue)
142	v.pill.SetHexpand(false)
143	v.pill.SetVexpand(false)
144	// Widget margins keep the allocation equal to the visible pill, which the
145	// click filter and the legend anchor rely on.
146	v.pill.SetMarginStart(modeToasterMargin)
147	v.pill.SetMarginBottom(modeToasterMargin)
148	v.pill.SetVisible(false)
149	// A click must never move keyboard focus off the pane's WebView: nothing in
150	// the pill can take focus, and a press is claimed by the pill's own gesture.
151	v.pill.SetCanFocus(false)
152	v.pill.SetFocusable(false)
153	v.pill.SetFocusOnClick(false)
154	v.pill.SetCanTarget(false)
155	v.pill.AddCssClass("mode-toaster-pill")
156
157	empty := ""
158	v.pillLabel = gtk.NewLabel(&empty)
159	v.pillLabel.SetCanTarget(false)
160	v.pillLabel.SetCanFocus(false)
161	v.pillLabel.SetFocusable(false)
162	v.pillLabel.AddCssClass("mode-toaster-label")
163	v.pill.Append(&v.pillLabel.Widget)
164
165	v.chevron = gtk.NewLabel(&empty)
166	v.chevron.SetCanTarget(false)
167	v.chevron.SetCanFocus(false)
168	v.chevron.SetFocusable(false)
169	v.chevron.SetVisible(false)
170	v.chevron.AddCssClass("mode-toaster-chevron")
171	v.pill.Append(&v.chevron.Widget)
172
173	v.pillClick = gtk.NewGestureClick()
174	v.pillClick.SetButton(1) // GDK_BUTTON_PRIMARY
175	v.pillClickCb = func(_ gtk.GestureClick, nPress int, _, _ float64) {
176		// Claim the sequence so no ancestor controller sees this press.
177		v.pillClick.SetState(gtk.EventSequenceClaimedValue)
178		if nPress == 1 && v.events.pillClicked != nil {
179			v.events.pillClicked()
180		}
181	}
182	v.pillClick.ConnectPressed(&v.pillClickCb)
183	v.pill.AddController(&v.pillClick.EventController)
184
185	// The legend itself is out of reach of the pointer, so the wheel over the
186	// pill scrolls a legend taller than its height budget.
187	v.pillScroll = gtk.NewEventControllerScroll(gtk.EventControllerScrollVerticalValue)
188	v.pillScrollCb = func(_ gtk.EventControllerScroll, _, dy float64) bool {
189		return v.scrollLegend(dy * legendScrollStep)
190	}
191	v.pillScroll.ConnectScroll(&v.pillScrollCb)
192	v.pill.AddController(&v.pillScroll.EventController)
193}
194
195func (v *gtkModeToasterView) buildBody() {
196	v.root = gtk.NewBox(gtk.OrientationVerticalValue, 0)
197	v.root.SetHalign(gtk.AlignStartValue)
198	v.root.SetValign(gtk.AlignStartValue)
199	v.root.SetCanFocus(false)
200	v.root.SetCanTarget(false)
201	v.root.SetVisible(false)
202	v.root.AddCssClass("mode-legend-anchor")
203
204	v.panel = gtk.NewBox(gtk.OrientationVerticalValue, 0)
205	v.panel.SetCanFocus(false)
206	v.panel.SetCanTarget(false)
207	v.panel.AddCssClass("omnibox-container")
208	v.panel.AddCssClass("mode-legend-panel")
209	v.root.Append(&v.panel.Widget)
210
211	v.columnBox = gtk.NewBox(gtk.OrientationHorizontalValue, legendColumnGap)
212	v.columnBox.SetHomogeneous(true)
213	v.columnBox.SetCanFocus(false)
214	v.columnBox.SetCanTarget(false)
215	v.columnBox.AddCssClass("mode-legend-columns")
216
217	v.scroller = gtk.NewScrolledWindow()
218	v.scroller.SetPolicy(gtk.PolicyNeverValue, gtk.PolicyAutomaticValue)
219	v.scroller.SetPropagateNaturalHeight(true)
220	v.scroller.SetHasFrame(false)
221	v.scroller.SetCanFocus(false)
222	v.scroller.SetCanTarget(false)
223	v.scroller.SetChild(&v.columnBox.Widget)
224	v.panel.Append(&v.scroller.Widget)
225
226	hint := "esc to exit"
227	v.hint = gtk.NewLabel(&hint)
228	v.hint.SetCanFocus(false)
229	v.hint.SetCanTarget(false)
230	v.hint.SetHalign(gtk.AlignEndValue)
231	v.hint.AddCssClass("mode-legend-hint")
232	v.panel.Append(&v.hint.Widget)
233}
234
235// installWindowControllers watches the whole window, because a lingering
236// legend ends on any key press or click, wherever it lands.
237func (v *gtkModeToasterView) installWindowControllers() {
238	v.motion = gtk.NewEventControllerMotion()
239	v.motionCb = func(_ gtk.EventControllerMotion, x, y float64) {
240		if v.events.pointerMoved != nil {
241			v.events.pointerMoved(x, y)
242		}
243	}
244	v.enterCb = v.motionCb
245	v.leaveCb = func(_ gtk.EventControllerMotion) {
246		if v.events.pointerLeft != nil {
247			v.events.pointerLeft()
248		}
249	}
250	v.motion.ConnectEnter(&v.enterCb)
251	v.motion.ConnectMotion(&v.motionCb)
252	v.motion.ConnectLeave(&v.leaveCb)
253	v.overlay.AddController(&v.motion.EventController)
254
255	v.keys = gtk.NewEventControllerKey()
256	v.keys.SetPropagationPhase(gtk.PhaseCaptureValue)
257	v.keyCb = func(_ gtk.EventControllerKey, _, _ uint, _ gdk.ModifierType) bool {
258		if v.events.keyPressed != nil {
259			v.events.keyPressed()
260		}
261		return false
262	}
263	v.keys.ConnectKeyPressed(&v.keyCb)
264	v.window.AddController(&v.keys.EventController)
265
266	v.windowClick = gtk.NewGestureClick()
267	v.windowClick.SetButton(0)
268	v.windowClick.SetPropagationPhase(gtk.PhaseCaptureValue)
269	v.windowClickC = func(_ gtk.GestureClick, _ int, x, y float64) {
270		// A press on the pill is the user driving the toaster, not leaving it:
271		// it must not end a lingering legend or count as a click elsewhere.
272		if v.pillContains(x, y) {
273			return
274		}
275		if v.events.pressedOutside != nil {
276			v.events.pressedOutside()
277		}
278	}
279	v.windowClick.ConnectPressed(&v.windowClickC)
280	v.window.AddController(&v.windowClick.EventController)
281}
282
283// pillContains reports whether window point (x, y) lies on the interactive pill.
284func (v *gtkModeToasterView) pillContains(x, y float64) bool {
285	if !v.pillShown || !v.interactive {
286		return false
287	}
288	px, py, w, h, ok := v.pillBounds()
289	if !ok {
290		return false
291	}
292	// Window coordinates: the overlay sits below the window's own origin.
293	origin, out := &graphene.Point{}, &graphene.Point{}
294	if !v.overlay.ComputePoint(&v.window.Widget, origin, out) {
295		return false
296	}
297	return pointInRect(x, y, px+int(out.X), py+int(out.Y), w, h)
298}
299
300// pillBounds returns the pill's rectangle in overlay coordinates. The pill is
301// pinned to the bottom-left by alignment and a fixed margin, so the rectangle
302// follows from the overlay size. gtk_widget_compute_point is not used: for a
303// pill with CSS padding it reports the origin shifted by the padding, which
304// disagrees with where GTK draws and picks it.
305func (v *gtkModeToasterView) pillBounds() (x, y, width, height int, ok bool) {
306	if v.pill == nil || v.overlay == nil {
307		return 0, 0, 0, 0, false
308	}
309	width, height = v.pill.GetAllocatedWidth(), v.pill.GetAllocatedHeight()
310	if width <= 0 || height <= 0 {
311		return 0, 0, 0, 0, false
312	}
313	overlayHeight := v.overlay.GetAllocatedHeight()
314	return modeToasterMargin, overlayHeight - modeToasterMargin - height, width, height, true
315}
316
317func (v *gtkModeToasterView) setHeader(text string) {
318	if v.headerText == text {
319		return
320	}
321	v.headerText = text
322	v.pillLabel.SetText(text)
323}
324
325func (v *gtkModeToasterView) setMode(mode input.Mode) {
326	pillClass, panelClass := "", ""
327	if mode != input.ModeNormal {
328		pillClass = "mode-toaster-" + mode.String()
329		panelClass = "mode-legend-" + mode.String()
330	}
331	if pillClass != v.modeClass {
332		if v.modeClass != "" {
333			v.pill.RemoveCssClass(v.modeClass)
334		}
335		if pillClass != "" {
336			v.pill.AddCssClass(pillClass)
337		}
338		v.modeClass = pillClass
339	}
340	if panelClass != v.legendClass {
341		if v.legendClass != "" {
342			v.panel.RemoveCssClass(v.legendClass)
343		}
344		if panelClass != "" {
345			v.panel.AddCssClass(panelClass)
346		}
347		v.legendClass = panelClass
348	}
349}
350
351func (v *gtkModeToasterView) setChevron(state chevronState) {
352	if v.chevronState == state {
353		return
354	}
355	v.chevronState = state
356	switch state {
357	case chevronExpanded:
358		v.chevron.SetText("▾")
359		v.chevron.SetVisible(true)
360		v.setTooltip("Collapse the key legend")
361	case chevronCollapsed:
362		v.chevron.SetText("▴")
363		v.chevron.SetVisible(true)
364		v.setTooltip("Expand the key legend")
365	default:
366		v.chevron.SetVisible(false)
367		v.setTooltip("")
368	}
369}
370
371func (v *gtkModeToasterView) setTooltip(text string) {
372	if text == "" {
373		v.pill.SetTooltipText(nil)
374		return
375	}
376	v.pill.SetTooltipText(&text)
377}
378
379func (v *gtkModeToasterView) setPillVisible(visible bool) {
380	if v.pillShown == visible {
381		return
382	}
383	v.pillShown = visible
384	v.pill.SetVisible(visible)
385}
386
387func (v *gtkModeToasterView) setPillInteractive(interactive bool) {
388	if v.interactive == interactive {
389		return
390	}
391	v.interactive = interactive
392	v.pill.SetCanTarget(interactive)
393	if interactive {
394		v.pill.AddCssClass("mode-toaster-interactive")
395		cursor := "pointer"
396		v.pill.SetCursorFromName(&cursor)
397		return
398	}
399	v.pill.RemoveCssClass("mode-toaster-interactive")
400	v.pill.SetCursorFromName(nil)
401}
402
403func (v *gtkModeToasterView) setStyle(style string) {
404	if v.styleClass == "" && style == "" {
405		return
406	}
407	class := ""
408	if style != "" {
409		class = "omnibox-style-" + style
410	}
411	if class == v.styleClass {
412		return
413	}
414	if v.styleClass != "" {
415		v.panel.RemoveCssClass(v.styleClass)
416	}
417	if class != "" {
418		v.panel.AddCssClass(class)
419	}
420	v.styleClass = class
421}
422
423func (v *gtkModeToasterView) setAnimations(enabled bool) {
424	if v.animations == enabled {
425		return
426	}
427	v.animations = enabled
428	if enabled {
429		v.panel.RemoveCssClass("mode-legend-no-motion")
430	} else {
431		v.panel.AddCssClass("mode-legend-no-motion")
432	}
433}
434
435func (v *gtkModeToasterView) setBodyLingering(lingering bool) {
436	if v.lingering == lingering {
437		return
438	}
439	v.lingering = lingering
440	if lingering {
441		v.panel.AddCssClass("mode-legend-lingering")
442		v.stopRefresh()
443		return
444	}
445	v.panel.RemoveCssClass("mode-legend-lingering")
446	if v.bodyWanted {
447		// The frozen placement is stale; track the window again.
448		v.startRefresh()
449	}
450}
451
452// setLegend replaces the legend content; nil empties it.
453func (v *gtkModeToasterView) setLegend(groups []legendGroup) {
454	v.clearColumns()
455	v.releaseLegendWidgets()
456	v.groupBoxes = nil
457	v.rows = make(map[string][]*gtk.Widget)
458	v.keycaps = make(map[string][]*gtk.Widget)
459	v.groups = groups
460	for _, group := range groups {
461		v.groupBoxes = append(v.groupBoxes, v.buildGroup(group))
462	}
463	// Force the next geometry pass to place the groups.
464	v.lastColumns, v.lastWidth = 0, 0
465	if v.bodyWanted && !v.lingering {
466		v.refreshGeometry()
467	}
468}
469
470func (v *gtkModeToasterView) buildGroup(group legendGroup) *gtk.Box {
471	box := gtk.NewBox(gtk.OrientationVerticalValue, 0)
472	v.legendWidgets = append(v.legendWidgets, &box.Widget)
473	box.SetCanFocus(false)
474	box.SetCanTarget(false)
475	box.AddCssClass("mode-legend-group")
476	name := group.Name
477	title := gtk.NewLabel(&name)
478	v.legendWidgets = append(v.legendWidgets, &title.Widget)
479	title.SetCanFocus(false)
480	title.SetCanTarget(false)
481	title.SetHalign(gtk.AlignStartValue)
482	title.AddCssClass("mode-legend-group-title")
483	box.Append(&title.Widget)
484	for _, row := range group.Rows {
485		box.Append(&v.buildRow(row).Widget)
486	}
487	return box
488}
489
490func (v *gtkModeToasterView) buildRow(row legendRow) *gtk.Box {
491	box := gtk.NewBox(gtk.OrientationHorizontalValue, 0)
492	v.legendWidgets = append(v.legendWidgets, &box.Widget)
493	box.SetCanFocus(false)
494	box.SetCanTarget(false)
495	box.AddCssClass("mode-legend-row")
496	v.rows[row.Action] = append(v.rows[row.Action], &box.Widget)
497	for _, key := range row.Keys {
498		display := displayLegendKey(key)
499		keycap := gtk.NewLabel(&display)
500		v.legendWidgets = append(v.legendWidgets, &keycap.Widget)
501		keycap.SetCanFocus(false)
502		keycap.SetCanTarget(false)
503		keycap.AddCssClass("mode-legend-keycap")
504		box.Append(&keycap.Widget)
505		v.keycaps[row.Action] = append(v.keycaps[row.Action], &keycap.Widget)
506	}
507	desc := row.Desc
508	text := gtk.NewLabel(&desc)
509	v.legendWidgets = append(v.legendWidgets, &text.Widget)
510	text.SetCanFocus(false)
511	text.SetCanTarget(false)
512	text.SetHalign(gtk.AlignStartValue)
513	text.SetEllipsize(3) // PangoEllipsizeEnd
514	text.AddCssClass("mode-legend-description")
515	box.Append(&text.Widget)
516	return box
517}
518
519// clearColumns detaches the group boxes and drops the column boxes, keeping
520// the group boxes for the next layout.
521func (v *gtkModeToasterView) clearColumns() {
522	for i, col := range v.columnBoxes {
523		for _, group := range v.columns[i] {
524			col.Remove(&group.Widget)
525		}
526		v.columnBox.Remove(&col.Widget)
527		col.Unref() // drop the reference NewBox gave us
528	}
529	v.columnBoxes, v.columns = nil, nil
530}
531
532// releaseLegendWidgets drops our reference to every widget of the current
533// legend. The group boxes must already be detached from the columns.
534func (v *gtkModeToasterView) releaseLegendWidgets() {
535	for _, widget := range v.legendWidgets {
536		widget.Unref()
537	}
538	v.legendWidgets = nil
539	v.groupBoxes, v.groups = nil, nil
540	v.rows, v.keycaps = make(map[string][]*gtk.Widget), make(map[string][]*gtk.Widget)
541}
542
543// layoutColumns distributes the groups over count columns.
544func (v *gtkModeToasterView) layoutColumns(count int) {
545	v.clearColumns()
546	for _, indices := range distributeLegendGroups(legendGroupHeights(v.groups), count) {
547		col := gtk.NewBox(gtk.OrientationVerticalValue, 0)
548		col.SetCanFocus(false)
549		col.SetCanTarget(false)
550		col.SetHexpand(true)
551		col.SetValign(gtk.AlignStartValue)
552		col.AddCssClass("mode-legend-column")
553		placed := make([]*gtk.Box, 0, len(indices))
554		for _, i := range indices {
555			col.Append(&v.groupBoxes[i].Widget)
556			placed = append(placed, v.groupBoxes[i])
557		}
558		v.columnBox.Append(&col.Widget)
559		v.columnBoxes = append(v.columnBoxes, col)
560		v.columns = append(v.columns, placed)
561	}
562	v.lastColumns = count
563}
564
565func (v *gtkModeToasterView) setActionDimmed(action string, dimmed bool) {
566	for _, row := range v.rows[action] {
567		if dimmed {
568			row.AddCssClass("mode-legend-dim")
569		} else {
570			row.RemoveCssClass("mode-legend-dim")
571		}
572	}
573}
574
575func (v *gtkModeToasterView) flashAction(action string) bool {
576	caps := v.keycaps[action]
577	if len(caps) == 0 {
578		return false
579	}
580	v.clearFlash()
581	for _, keycap := range caps {
582		keycap.AddCssClass("mode-legend-flash")
583	}
584	return true
585}
586
587func (v *gtkModeToasterView) clearFlash() {
588	for _, caps := range v.keycaps {
589		for _, keycap := range caps {
590			keycap.RemoveCssClass("mode-legend-flash")
591		}
592	}
593}
594
595// setBodyVisible shows or hides the legend body. Showing waits for the first
596// successful measurement.
597func (v *gtkModeToasterView) setBodyVisible(visible bool) {
598	if visible == v.bodyWanted {
599		return
600	}
601	v.bodyWanted = visible
602	if !visible {
603		v.hideBody()
604		return
605	}
606	if !v.lingering {
607		v.startRefresh()
608	}
609}
610
611func (v *gtkModeToasterView) hideBody() {
612	v.stopRefresh()
613	v.placed = false
614	v.root.SetVisible(false)
615}
616
617func (v *gtkModeToasterView) lingerRect() (x, y, width, height int, ok bool) {
618	if !v.placed {
619		return 0, 0, 0, 0, false
620	}
621	r := v.placement
622	return r.x, r.y, r.width, r.height, true
623}
624
625func (v *gtkModeToasterView) scrollLegend(delta float64) bool {
626	adj := v.scroller.GetVadjustment()
627	if adj == nil || adj.GetUpper() <= adj.GetPageSize() {
628		return false
629	}
630	adj.SetValue(adj.GetValue() + delta)
631	return true
632}
633
634func (v *gtkModeToasterView) stopRefresh() {
635	if v.refreshSource != 0 {
636		glib.SourceRemove(v.refreshSource)
637		v.refreshSource = 0
638	}
639}
640
641// startRefresh measures outside GTK's allocation pass while the legend is
642// shown, which also follows window resizes after the initial layout settles.
643func (v *gtkModeToasterView) startRefresh() {
644	v.stopRefresh()
645	v.refreshGeometry()
646	cb := glib.SourceFunc(func(_ uintptr) bool {
647		if v.root == nil || !v.bodyWanted || v.lingering {
648			v.refreshSource = 0
649			return false
650		}
651		v.refreshGeometry()
652		return true
653	})
654	v.refreshSource = glib.TimeoutAdd(legendRefreshMs, &cb, 0)
655}
656
657// refreshGeometry places the legend above the pill, from the pill's left edge,
658// as wide as the window allows up to legendMaxWidth.
659func (v *gtkModeToasterView) refreshGeometry() {
660	if v.root == nil || v.overlay == nil || !v.bodyWanted || v.lingering {
661		return
662	}
663	width, height := v.overlay.GetAllocatedWidth(), v.overlay.GetAllocatedHeight()
664	if width <= 0 || height <= 0 || len(v.groups) == 0 {
665		v.clearPlacement()
666		return
667	}
668	left, bottom := modeToasterMargin, height-modeToasterMargin
669	if v.pillShown {
670		px, py, _, _, ok := v.pillBounds()
671		if !ok {
672			// The pill has not been allocated yet; the next tick retries.
673			v.clearPlacement()
674			return
675		}
676		left, bottom = px, py-legendGap
677	}
678	bandWidth, columns := legendBandWidth(width-left-modeToasterMargin, len(v.groups))
679	if bandWidth <= 0 {
680		v.clearPlacement()
681		return
682	}
683	if columns != v.lastColumns {
684		v.layoutColumns(columns)
685	}
686	if bandWidth != v.lastWidth {
687		v.panel.SetSizeRequest(bandWidth, -1)
688		v.lastWidth = bandWidth
689	}
690	if maxContent := legendBodyMaxHeight(height); maxContent != v.lastMaxContent {
691		v.scroller.SetMaxContentHeight(maxContent)
692		v.lastMaxContent = maxContent
693	}
694	// Measure the panel: GTK reports 0 for the hidden root.
695	naturalHeight := 0
696	v.panel.Measure(gtk.OrientationVerticalValue, bandWidth, nil, &naturalHeight, nil, nil)
697	if naturalHeight <= 0 {
698		v.clearPlacement()
699		return
700	}
701	y := max(bottom-naturalHeight, 0)
702	next := overlayRect{x: left, y: y, width: bandWidth, height: naturalHeight}
703	if v.placed && v.placement == next {
704		return
705	}
706	v.placement, v.placed = next, true
707	v.root.SetVisible(true)
708	v.root.QueueAllocate()
709}
710
711func (v *gtkModeToasterView) clearPlacement() {
712	if !v.placed {
713		return
714	}
715	v.placed = false
716	v.root.SetVisible(false)
717	v.root.QueueAllocate()
718}
719
720func (v *gtkModeToasterView) destroy() {
721	if v == nil || v.overlay == nil {
722		return
723	}
724	v.stopRefresh()
725	v.overlay.RemoveController(&v.motion.EventController)
726	v.window.RemoveController(&v.keys.EventController)
727	v.window.RemoveController(&v.windowClick.EventController)
728	if v.positionHandlerID != 0 {
729		gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(v.overlay.GoPointer()), v.positionHandlerID)
730		v.positionHandlerID = 0
731	}
732	v.placed = false
733	v.clearColumns()
734	v.releaseLegendWidgets()
735	v.overlay.RemoveOverlay(&v.pill.Widget)
736	v.overlay.RemoveOverlay(&v.root.Widget)
737
738	// Widgets are handed to us with a reference that GTK does not release on
739	// removal: drop it. (Controllers are owned by the widget they were added to,
740	// which released them on removal.) The callbacks go too, now that nothing
741	// can invoke them.
742	for _, widget := range []*gtk.Widget{
743		&v.pillLabel.Widget, &v.chevron.Widget, &v.hint.Widget, &v.columnBox.Widget,
744		&v.scroller.Widget, &v.panel.Widget, &v.pill.Widget, &v.root.Widget,
745	} {
746		widget.Unref()
747	}
748	v.pillClickCb, v.pillScrollCb, v.motionCb, v.enterCb, v.leaveCb, v.keyCb, v.windowClickC = nil, nil, nil, nil, nil, nil, nil
749	v.overlay, v.window = nil, nil
750	v.pill, v.pillLabel, v.chevron = nil, nil, nil
751	v.root, v.panel, v.scroller, v.columnBox, v.hint = nil, nil, nil, nil, nil
752	v.motion, v.keys, v.windowClick, v.pillClick, v.pillScroll = nil, nil, nil, nil, nil
753}
754
755// newModeToaster builds the mode toaster of a window inside its content
756// overlay. collapsed is the configured initial state of the legend; onToggle
757// receives the state a click on the pill asks for.
758func newModeToaster(overlay *gtk.Overlay, window *gtk.ApplicationWindow, collapsed bool, onToggle func(bool)) *modeToaster {
759	t := newModeToasterWithView(nil, onToggle)
760	t.collapsed = collapsed
761	t.cfg.ModeLegendCollapsed = collapsed
762	view := newGTKModeToasterView(overlay, window, modeToasterEvents{
763		pillClicked:    t.pillClicked,
764		pointerMoved:   t.pointerMoved,
765		pointerLeft:    t.pointerLeft,
766		keyPressed:     t.keyPressed,
767		pressedOutside: t.pressStarted,
768	})
769	if view == nil {
770		return nil
771	}
772	t.view = view
773	return t
774}

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