main 5041353

mode_frame.go RawHistoryFolder

1package ui
2
3import (
4	"github.com/bnema/dumber/internal/domain/entity"
5	"github.com/bnema/dumber/internal/ui/input"
6	"github.com/bnema/dumber/internal/ui/layout"
7	"github.com/bnema/puregotk/v4/glib"
8	"github.com/bnema/puregotk/v4/gobject"
9	"github.com/bnema/puregotk/v4/graphene"
10	"github.com/bnema/puregotk/v4/gtk"
11)
12
13const borderRefreshMs = 100
14
15// modeFrame owns the per-window mode border drawn around its target pane or
16// workspace, and the Vim pulse on it. The mode indicator and legend belong to
17// modeToaster. GTK access is restricted to the main thread, as with the rest of
18// the UI.
19type modeFrame struct {
20	mode        input.Mode
21	tabID       entity.TabID
22	target      layout.Widget
23	frameClass  string
24	border      *gtk.Box
25	borderStyle cssClassTarget // border's CSS classes; a seam for tests
26	borderRect  func() (x, y, width, height int, ok bool)
27	overlay     *gtk.Overlay
28
29	refreshSource     uint
30	positionHandlerID uint
31	pulseCycle        bool
32}
33
34func newModeFrame(overlay *gtk.Overlay) *modeFrame {
35	if overlay == nil {
36		return nil
37	}
38	f := &modeFrame{mode: input.ModeNormal, overlay: overlay}
39	// The border is its own overlay: inset shadows on pane containers are
40	// painted below web content and would be invisible.
41	f.border = gtk.NewBox(gtk.OrientationVerticalValue, 0)
42	f.border.SetCanFocus(false)
43	f.border.SetCanTarget(false)
44	f.border.SetVisible(false)
45	f.border.AddCssClass("mode-frame-border")
46	f.borderStyle = f.border
47	overlay.AddOverlay(&f.border.Widget)
48	overlay.SetClipOverlay(&f.border.Widget, false)
49	overlay.SetMeasureOverlay(&f.border.Widget, false)
50	// A single positioned overlay tracks the active pane without reparenting it.
51	cb := func(_ gtk.Overlay, widgetPtr uintptr, allocation *uintptr) bool {
52		if f.border == nil || widgetPtr != f.border.GoPointer() || f.borderRect == nil {
53			return false
54		}
55		x, y, w, h, ok := f.borderRect()
56		if !ok {
57			return false
58		}
59		return writeOverlayAllocation(allocation, x, y, w, h)
60	}
61	f.positionHandlerID = overlay.ConnectGetChildPosition(&cb)
62	return f
63}
64
65func (f *modeFrame) destroy() {
66	if f == nil {
67		return
68	}
69	f.stopGeometryRefresh()
70	f.setTarget(nil, "")
71	if f.overlay != nil && f.positionHandlerID != 0 {
72		gobject.SignalHandlerDisconnect(gobject.ObjectNewFromInternalPtr(f.overlay.GoPointer()), f.positionHandlerID)
73		f.positionHandlerID = 0
74	}
75	f.borderRect = nil
76	if f.overlay != nil && f.border != nil {
77		f.overlay.RemoveOverlay(&f.border.Widget)
78		// NewBox's reference is not released by the removal.
79		f.border.Unref()
80	}
81	f.overlay = nil
82	f.border = nil
83	f.borderStyle = nil
84}
85
86type cssClassTarget interface {
87	AddCssClass(string)
88	RemoveCssClass(string)
89}
90
91// setMode retargets the border for mode; it clears the border for Normal mode,
92// which has no target.
93func (f *modeFrame) setMode(mode input.Mode, target layout.Widget) {
94	if f == nil {
95		return
96	}
97	f.mode = mode
98	f.pulseCycle = false
99	f.setTarget(target, modeFrameClass(mode))
100}
101
102func (f *modeFrame) setTarget(target layout.Widget, class string) {
103	if f == nil {
104		return
105	}
106	if target == f.target && class == f.frameClass {
107		return
108	}
109	if f.borderStyle != nil && f.frameClass != "" {
110		f.borderStyle.RemoveCssClass(f.frameClass)
111		for _, pulseClass := range vimPulseClasses {
112			f.borderStyle.RemoveCssClass(pulseClass)
113		}
114	}
115	f.target, f.frameClass = target, class
116	if f.borderStyle != nil && target != nil && class != "" {
117		f.borderStyle.AddCssClass(class)
118	}
119	if f.border == nil {
120		return
121	}
122	if target != nil && class != "" {
123		f.startGeometryRefresh()
124		return
125	}
126	f.stopGeometryRefresh()
127	f.borderRect = nil
128	f.border.SetVisible(false)
129}
130
131var vimPulseClasses = []string{"vim-mode-pulse", "vim-mode-pulse-fast", "vim-mode-pulse-cycle-a", "vim-mode-pulse-cycle-b"}
132
133func (f *modeFrame) pulse(fast bool) {
134	if f == nil || f.mode != input.ModeVim || f.target == nil || f.borderStyle == nil {
135		return
136	}
137	for _, class := range vimPulseClasses {
138		f.borderStyle.RemoveCssClass(class)
139	}
140	class := "vim-mode-pulse"
141	if fast {
142		class = "vim-mode-pulse-fast"
143	}
144	f.borderStyle.AddCssClass(class)
145	cycle := "vim-mode-pulse-cycle-a"
146	if f.pulseCycle {
147		cycle = "vim-mode-pulse-cycle-b"
148	}
149	f.pulseCycle = !f.pulseCycle
150	f.borderStyle.AddCssClass(cycle)
151}
152
153func modeFrameClass(mode input.Mode) string {
154	switch mode {
155	case input.ModePane:
156		return "pane-mode-active"
157	case input.ModeResize:
158		return "resize-mode-active"
159	case input.ModeVim:
160		return "vim-mode-active"
161	case input.ModeTab:
162		return "tab-mode-active"
163	case input.ModeSession:
164		return "session-mode-active"
165	case input.ModePrefix:
166		return "prefix-mode-active"
167	default:
168		return ""
169	}
170}
171
172func (f *modeFrame) stopGeometryRefresh() {
173	if f == nil || f.refreshSource == 0 {
174		return
175	}
176	glib.SourceRemove(f.refreshSource)
177	f.refreshSource = 0
178}
179
180// Measure outside GTK allocation while the border is active, including window
181// resizes and splitter drags after the initial layout has settled.
182func (f *modeFrame) startGeometryRefresh() {
183	f.stopGeometryRefresh()
184	f.refreshGeometry()
185	cb := glib.SourceFunc(func(_ uintptr) bool {
186		if f.target == nil || f.border == nil {
187			f.refreshSource = 0
188			return false
189		}
190		f.refreshGeometry()
191		return true
192	})
193	f.refreshSource = glib.TimeoutAdd(borderRefreshMs, &cb, 0)
194}
195
196// clearPlacement hides the border: the target is gone.
197func (f *modeFrame) clearPlacement() {
198	if f.borderRect != nil {
199		f.borderRect = nil
200		f.border.SetVisible(false)
201	}
202}
203
204// refreshGeometry measures the target outside GTK's positioning callback.
205func (f *modeFrame) refreshGeometry() {
206	if f.target == nil || f.overlay == nil {
207		f.clearPlacement()
208		return
209	}
210	point := &graphene.Point{X: 0, Y: 0}
211	out := &graphene.Point{}
212	target := f.target.GtkWidget()
213	if target == nil || !target.ComputePoint(&f.overlay.Widget, point, out) {
214		f.clearPlacement()
215		return
216	}
217	width, height := target.GetAllocatedWidth(), target.GetAllocatedHeight()
218	if width <= 0 || height <= 0 {
219		f.clearPlacement()
220		return
221	}
222	f.placeBorder(int(out.X), int(out.Y), width, height)
223}
224
225func (f *modeFrame) placeBorder(x, y, width, height int) {
226	if f.borderRect != nil {
227		if ox, oy, ow, oh, _ := f.borderRect(); ox == x && oy == y && ow == width && oh == height {
228			return
229		}
230	}
231	f.borderRect = func() (int, int, int, int, bool) { return x, y, width, height, true }
232	f.border.SetVisible(true)
233	f.border.QueueAllocate()
234}

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