v0.31.0 bf0756b

standalone_omnibox.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"fmt"
6	"os"
7	"strings"
8
9	"github.com/bnema/dumber/internal/bootstrap"
10	"github.com/bnema/dumber/internal/logging"
11	"github.com/bnema/dumber/internal/ui/component"
12	"github.com/bnema/dumber/internal/ui/layout"
13	"github.com/bnema/puregotk/v4/gdk"
14	"github.com/bnema/puregotk/v4/gio"
15	"github.com/bnema/puregotk/v4/glib"
16	"github.com/bnema/puregotk/v4/gtk"
17	"github.com/bnema/puregotk/v4/layershell"
18)
19
20var layerShellLibraryAvailable = func() bool {
21	if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) {
22		return true
23	}
24
25	return bootstrap.LayerShellLibraryPath() != ""
26}
27
28const (
29	standaloneOmniboxNamespace = AppID + ".omnibox"
30	standaloneOmniboxTitle     = "Dumber Omnibox"
31	standaloneOmniboxWidth     = 1200
32	standaloneOmniboxHeight    = 200
33	standaloneOmniboxWindowCSS = "standalone-omnibox-window"
34)
35
36type StandaloneOmniboxWindowConfig struct {
37	Namespace string
38	Title     string
39	Width     int
40	Height    int
41}
42
43type StandaloneOmniboxRuntime struct {
44	OmniboxCfg component.OmniboxConfig
45	ApplyTheme func(display *gdk.Display)
46}
47
48type standaloneOmniboxHostLayoutConfig struct {
49	UseDefaultSize bool
50	OmniboxSize    component.ModalSizeConfig
51}
52
53var (
54	layerShellIsSupported   = layershell.IsSupported
55	layerShellInitForWindow = func(window *gtk.ApplicationWindow) {
56		layershell.InitForWindow(&window.Window)
57	}
58	layerShellSetLayer = func(window *gtk.ApplicationWindow, layer layershell.Layer) {
59		layershell.SetLayer(&window.Window, layer)
60	}
61	layerShellSetExclusiveZone = func(window *gtk.ApplicationWindow, zone int) {
62		layershell.SetExclusiveZone(&window.Window, zone)
63	}
64	layerShellSetNamespace = func(window *gtk.ApplicationWindow, namespace *string) {
65		layershell.SetNamespace(&window.Window, namespace)
66	}
67	layerShellSetKeyboardMode = func(window *gtk.ApplicationWindow, mode layershell.KeyboardMode) {
68		layershell.SetKeyboardMode(&window.Window, mode)
69	}
70	buildStandaloneOmniboxHostFn       = buildStandaloneOmniboxHost
71	newStandaloneOmniboxWindow         = gtk.NewApplicationWindow
72	configureStandaloneOmniboxWindowFn = configureStandaloneOmniboxWindow
73	presentStandaloneOmniboxWindow     = func(window *gtk.ApplicationWindow) {
74		window.Present()
75	}
76	logStandaloneOmniboxHostAllocationFn = logStandaloneOmniboxHostAllocation
77	showStandaloneOmniboxFn              = func(ctx context.Context, omnibox *component.Omnibox) {
78		omnibox.Show(ctx, "")
79	}
80)
81
82type standaloneOmniboxActivationRetention struct {
83	window  *gtk.ApplicationWindow
84	host    *gtk.Widget
85	omnibox *component.Omnibox
86}
87
88func (r *standaloneOmniboxActivationRetention) retain(
89	window *gtk.ApplicationWindow,
90	host *gtk.Widget,
91	omnibox *component.Omnibox,
92) {
93	if r == nil {
94		return
95	}
96	r.window = window
97	r.host = host
98	r.omnibox = omnibox
99}
100
101func (r *standaloneOmniboxActivationRetention) release() {
102	if r == nil {
103		return
104	}
105	r.window = nil
106	r.host = nil
107	r.omnibox = nil
108}
109
110func DefaultStandaloneOmniboxWindowConfig() StandaloneOmniboxWindowConfig {
111	return StandaloneOmniboxWindowConfig{
112		Namespace: standaloneOmniboxNamespace,
113		Title:     standaloneOmniboxTitle,
114		Width:     standaloneOmniboxWidth,
115		Height:    standaloneOmniboxHeight,
116	}
117}
118
119func standaloneOmniboxArgv(args []string) []string {
120	if len(args) == 0 {
121		return []string{"dumber-omnibox"}
122	}
123
124	filtered := []string{args[0]}
125	subcommandSkipped := false
126	for i := 1; i < len(args); i++ {
127		arg := args[i]
128		if arg == "omnibox" && !subcommandSkipped {
129			subcommandSkipped = true
130			continue
131		}
132		if strings.HasPrefix(arg, "-test.") {
133			if !strings.Contains(arg, "=") && goTestFlagConsumesNextArg(arg) && i+1 < len(args) {
134				i++
135			}
136			continue
137		}
138		filtered = append(filtered, arg)
139	}
140
141	return filtered
142}
143
144func goTestFlagConsumesNextArg(arg string) bool {
145	switch arg {
146	case "-test.bench", "-test.blockprofile", "-test.count", "-test.coverprofile",
147		"-test.cpu", "-test.cpuprofile", "-test.fuzz", "-test.fuzzcachedir",
148		"-test.fuzzminimizetime", "-test.fuzztime", "-test.list", "-test.memprofile",
149		"-test.memprofilerate", "-test.mutexprofile", "-test.mutexprofilefraction",
150		"-test.outputdir", "-test.parallel", "-test.run", "-test.shuffle",
151		"-test.skip", "-test.timeout", "-test.trace":
152		return true
153	default:
154		return false
155	}
156}
157
158func wrapStandaloneOmniboxNavigate(original func(context.Context, string) error, quit func()) func(context.Context, string) error {
159	return func(ctx context.Context, url string) error {
160		if original == nil {
161			return fmt.Errorf("standalone omnibox navigate handler is not configured")
162		}
163		if err := original(ctx, url); err != nil {
164			return err
165		}
166		if quit != nil {
167			quit()
168		}
169		return nil
170	}
171}
172
173func standaloneOmniboxCloseHandler(quit func()) func() {
174	return func() {
175		if quit != nil {
176			quit()
177		}
178	}
179}
180
181func standaloneOmniboxWindowDecorated(layerShellApplied bool) bool {
182	return !layerShellApplied
183}
184
185func standaloneOmniboxWindowResizable(layerShellApplied bool) bool {
186	return !layerShellApplied
187}
188
189func standaloneOmniboxHostLayout(layerShellApplied bool) standaloneOmniboxHostLayoutConfig {
190	return standaloneOmniboxHostLayoutConfig{
191		UseDefaultSize: !layerShellApplied,
192		OmniboxSize:    standaloneOmniboxSizeConfig(),
193	}
194}
195
196func attachStandaloneOmniboxHostWidget(host layout.OverlayWidget, omniboxWidget layout.Widget) {
197	if host == nil {
198		return
199	}
200
201	host.SetChild(omniboxWidget)
202}
203
204func standaloneOmniboxSizeConfig() component.ModalSizeConfig {
205	return component.ResolveModalSizeConfig(component.ModalSizeConfig{
206		FixedWidth:        component.OmniboxSizeDefaults.MaxWidth,
207		FixedTopMargin:    0,
208		UseFixedTopMargin: true,
209	}, component.OmniboxSizeDefaults)
210}
211
212func tryInitLayerShell(window *gtk.ApplicationWindow, cfg StandaloneOmniboxWindowConfig) bool {
213	if window == nil {
214		return false
215	}
216	if !safeLayerShellSupported() {
217		return false
218	}
219
220	layerShellInitForWindow(window)
221	layerShellSetLayer(window, layershell.LayerOverlayValue)
222	layerShellSetExclusiveZone(window, 0)
223	layerShellSetNamespace(window, &cfg.Namespace)
224	layerShellSetKeyboardMode(window, layershell.KeyboardModeExclusiveValue)
225	return true
226}
227
228func safeLayerShellSupported() (supported bool) {
229	defer func() {
230		if recover() != nil {
231			supported = false
232		}
233	}()
234
235	if bootstrap.LayerShellPreloadPresent(bootstrap.CurrentEnvMap()) || layerShellLibraryAvailable() {
236		return layerShellIsSupported()
237	}
238
239	return false
240}
241
242func configureStandaloneOmniboxWindow(window *gtk.ApplicationWindow, windowCfg StandaloneOmniboxWindowConfig, child *gtk.Widget) {
243	if window == nil {
244		return
245	}
246
247	title := windowCfg.Title
248	window.SetTitle(&title)
249	layerShellApplied := tryInitLayerShell(window, windowCfg)
250	hostLayout := standaloneOmniboxHostLayout(layerShellApplied)
251	if hostLayout.UseDefaultSize {
252		window.SetDefaultSize(windowCfg.Width, windowCfg.Height)
253	}
254	if layerShellApplied {
255		window.AddCssClass(standaloneOmniboxWindowCSS)
256	}
257	window.SetDecorated(standaloneOmniboxWindowDecorated(layerShellApplied))
258	window.SetResizable(standaloneOmniboxWindowResizable(layerShellApplied))
259	window.SetChild(child)
260}
261
262func buildStandaloneOmniboxHost(
263	ctx context.Context,
264	runtimeCfg *StandaloneOmniboxRuntime,
265	gtkApp *gtk.Application,
266) (*gtk.Widget, *component.Omnibox) {
267	factory := layout.NewGtkWidgetFactory()
268	overlay := factory.NewOverlay()
269	if overlay == nil {
270		logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox host widgets")
271		return nil, nil
272	}
273
274	cfg := runtimeCfg.OmniboxCfg
275	cfg.OnNavigate = wrapStandaloneOmniboxNavigate(cfg.OnNavigate, gtkApp.Quit)
276	cfg.SizeConfig = standaloneOmniboxSizeConfig()
277
278	omnibox := component.NewOmnibox(ctx, cfg)
279	if omnibox == nil {
280		logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox")
281		return nil, nil
282	}
283
284	overlay.SetVisible(true)
285	omnibox.SetParentOverlay(overlay)
286	omnibox.SetOnClose(standaloneOmniboxCloseHandler(gtkApp.Quit))
287
288	omniboxWidget := omnibox.WidgetAsLayout(factory)
289	if omniboxWidget == nil {
290		logging.FromContext(ctx).Error().Msg("failed to wrap standalone omnibox widget")
291		return nil, nil
292	}
293
294	attachStandaloneOmniboxHostWidget(overlay, omniboxWidget)
295	logging.FromContext(ctx).Debug().Msg("standalone omnibox host configured with primary child")
296	return overlay.GtkWidget(), omnibox
297}
298
299func logStandaloneOmniboxHostAllocation(ctx context.Context, host *gtk.Widget) {
300	if host == nil {
301		return
302	}
303
304	var cb glib.SourceFunc = func(uintptr) bool {
305		logging.FromContext(ctx).Debug().
306			Int("width", host.GetAllocatedWidth()).
307			Int("height", host.GetAllocatedHeight()).
308			Msg("standalone omnibox host allocation")
309		return false
310	}
311	glib.IdleAdd(&cb, 0)
312}
313
314func activateStandaloneOmnibox(
315	ctx context.Context,
316	runtimeCfg *StandaloneOmniboxRuntime,
317	gtkApp *gtk.Application,
318	windowCfg StandaloneOmniboxWindowConfig,
319	retention *standaloneOmniboxActivationRetention,
320) {
321	child, omnibox := buildStandaloneOmniboxHostFn(ctx, runtimeCfg, gtkApp)
322	if child == nil || omnibox == nil {
323		gtkApp.Quit()
324		return
325	}
326
327	window := newStandaloneOmniboxWindow(gtkApp)
328	if window == nil {
329		logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox window")
330		gtkApp.Quit()
331		return
332	}
333
334	retention.retain(window, child, omnibox)
335	configureStandaloneOmniboxWindowFn(window, windowCfg, child)
336	if runtimeCfg.ApplyTheme != nil {
337		if display := window.GetDisplay(); display != nil {
338			runtimeCfg.ApplyTheme(display)
339		}
340	}
341	presentStandaloneOmniboxWindow(window)
342	logStandaloneOmniboxHostAllocationFn(ctx, child)
343	showStandaloneOmniboxFn(ctx, omnibox)
344}
345
346func RunStandaloneOmnibox(ctx context.Context, runtimeCfg *StandaloneOmniboxRuntime) int {
347	if runtimeCfg == nil {
348		logging.FromContext(ctx).Error().Msg("standalone omnibox runtime not configured")
349		return 1
350	}
351
352	EnsureAdwaitaInitialized()
353	windowCfg := DefaultStandaloneOmniboxWindowConfig()
354	appID := windowCfg.Namespace
355	gtkApp := gtk.NewApplication(&appID, gtkApplicationFlags())
356	if gtkApp == nil {
357		logging.FromContext(ctx).Error().Msg("failed to create standalone omnibox application")
358		return 1
359	}
360	defer gtkApp.Unref()
361	retention := &standaloneOmniboxActivationRetention{}
362
363	activateCb := func(_ gio.Application) {
364		activateStandaloneOmnibox(ctx, runtimeCfg, gtkApp, windowCfg, retention)
365	}
366	gtkApp.ConnectActivate(&activateCb)
367
368	argv := standaloneOmniboxArgv(os.Args)
369	code := gtkApp.Run(len(argv), argv)
370	retention.release()
371	return code
372}

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