v0.33.2 57f21d6

main_window.go RawHistoryFolder

1// Package window provides GTK window implementations.
2package window
3
4import (
5	"context"
6
7	"github.com/bnema/dumber/internal/logging"
8	"github.com/bnema/dumber/internal/ui/component"
9	"github.com/bnema/puregotk/v4/gobject"
10	"github.com/bnema/puregotk/v4/gtk"
11	"github.com/rs/zerolog"
12)
13
14const (
15	defaultWidth  = 1280
16	defaultHeight = 800
17	windowTitle   = "Dumber"
18)
19
20// MainWindow represents the main browser window.
21//
22// Layout hierarchy:
23//
24//	window
25//	  rootBox (vertical)
26//	    contentOverlay
27//	      contentAreaHBox (horizontal)
28//	        mainContentBox (vertical, expand) ← workspace tab content
29//	        sidebarBox (vertical, fixed width) ← optional sidebar
30//	      tabBar (overlay, not measured)
31type MainWindow struct {
32	window         *gtk.ApplicationWindow
33	rootBox        *gtk.Box // Vertical: tab bar + content
34	tabBar         *component.TabBar
35	contentOverlay *gtk.Overlay // Overlay for content + omnibox
36	contentAreaBox *gtk.Box     // Horizontal: main content + sidebar
37	mainContentBox *gtk.Box     // Vertical container for workspace content
38	sidebarBox     *gtk.Box     // Vertical container for sidebar (hidden by default)
39	currentContent *gtk.Widget  // Track current content for removal on tab switch
40
41	tabBarPosition string // "top" or "bottom"
42	logger         zerolog.Logger
43}
44
45// New creates a new main browser window.
46// tabBarPosition controls whether the tab bar is at the "top" or "bottom" (default: "top").
47func New(ctx context.Context, app *gtk.Application, tabBarPosition string) (*MainWindow, error) {
48	log := logging.FromContext(ctx)
49
50	mw := &MainWindow{
51		tabBarPosition: tabBarPosition,
52		logger:         log.With().Str("component", "main-window").Logger(),
53	}
54
55	mw.window = gtk.NewApplicationWindow(app)
56	if mw.window == nil {
57		return nil, ErrWindowCreationFailed
58	}
59
60	title := windowTitle
61	mw.window.SetTitle(&title)
62	mw.window.SetDefaultSize(defaultWidth, defaultHeight)
63
64	if err := mw.initLayoutWidgets(); err != nil {
65		return nil, err
66	}
67
68	mw.contentAreaBox.Append(&mw.mainContentBox.Widget)
69	mw.contentAreaBox.Append(&mw.sidebarBox.Widget)
70	mw.contentOverlay.SetChild(&mw.contentAreaBox.Widget)
71
72	mw.assembleLayout()
73
74	mw.window.SetChild(&mw.rootBox.Widget)
75
76	return mw, nil
77}
78
79func (mw *MainWindow) initLayoutWidgets() error {
80	mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
81	if mw.rootBox == nil {
82		mw.window.Unref()
83		return ErrWidgetCreationFailed("rootBox")
84	}
85	mw.rootBox.SetHexpand(true)
86	mw.rootBox.SetVexpand(true)
87	mw.rootBox.SetVisible(true)
88
89	mw.tabBar = component.NewTabBar()
90	if mw.tabBar == nil {
91		mw.rootBox.Unref()
92		mw.window.Unref()
93		return ErrWidgetCreationFailed("tabBar")
94	}
95
96	if err := mw.initContentWidgets(); err != nil {
97		return err
98	}
99	return nil
100}
101
102func (mw *MainWindow) initContentWidgets() error {
103	mw.contentOverlay = gtk.NewOverlay()
104	if mw.contentOverlay == nil {
105		mw.tabBar.Destroy()
106		mw.rootBox.Unref()
107		mw.window.Unref()
108		return ErrWidgetCreationFailed("contentOverlay")
109	}
110	mw.contentOverlay.SetHexpand(true)
111	mw.contentOverlay.SetVexpand(true)
112	mw.contentOverlay.SetVisible(true)
113
114	// Horizontal box to hold both main content and sidebar side by side.
115	mw.contentAreaBox = gtk.NewBox(gtk.OrientationHorizontalValue, 0)
116	if mw.contentAreaBox == nil {
117		mw.contentOverlay.Unref()
118		mw.tabBar.Destroy()
119		mw.rootBox.Unref()
120		mw.window.Unref()
121		return ErrWidgetCreationFailed("contentAreaBox")
122	}
123	mw.contentAreaBox.SetHexpand(true)
124	mw.contentAreaBox.SetVexpand(true)
125	mw.contentAreaBox.SetVisible(true)
126
127	if err := mw.initContentBoxes(); err != nil {
128		return err
129	}
130	return nil
131}
132
133func (mw *MainWindow) initContentBoxes() error {
134	// Main content box (vertical) for workspace content.
135	mw.mainContentBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
136	if mw.mainContentBox == nil {
137		mw.contentAreaBox.Unref()
138		mw.contentOverlay.Unref()
139		mw.tabBar.Destroy()
140		mw.rootBox.Unref()
141		mw.window.Unref()
142		return ErrWidgetCreationFailed("mainContentBox")
143	}
144	mw.mainContentBox.SetHexpand(true)
145	mw.mainContentBox.SetVexpand(true)
146	mw.mainContentBox.SetVisible(true)
147
148	// Sidebar box (hidden by default).
149	mw.sidebarBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
150	if mw.sidebarBox == nil {
151		mw.mainContentBox.Unref()
152		mw.contentAreaBox.Unref()
153		mw.contentOverlay.Unref()
154		mw.tabBar.Destroy()
155		mw.rootBox.Unref()
156		mw.window.Unref()
157		return ErrWidgetCreationFailed("sidebarBox")
158	}
159	mw.sidebarBox.SetHexpand(false)
160	mw.sidebarBox.SetVexpand(true)
161	mw.sidebarBox.SetVisible(false)
162
163	return nil
164}
165
166func (mw *MainWindow) assembleLayout() {
167	tabBarPos := "top" // default
168	if mw.tabBarPosition != "" {
169		tabBarPos = mw.tabBarPosition
170	}
171
172	mw.rootBox.Append(&mw.contentOverlay.Widget)
173
174	tabBarWidget := mw.tabBar.Widget()
175	tabBarWidget.SetHalign(gtk.AlignFillValue)
176	tabBarWidget.SetValign(gtk.AlignStartValue)
177	if tabBarPos == "bottom" {
178		tabBarWidget.SetValign(gtk.AlignEndValue)
179		// Inset is NOT applied unconditionally. See SetTabBarContentInsetVisible
180		// which is called when the bottom tab bar is actually visible.
181	}
182	mw.contentOverlay.AddOverlay(tabBarWidget)
183	mw.contentOverlay.SetClipOverlay(tabBarWidget, false)
184	// Keep the tab bar out of measurement so showing/hiding it never changes the
185	// WebView content allocation; the content area reserves a stable inset while
186	// the overlay remains non-measured to avoid WebView allocation changes.
187	mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)
188
189	mw.logger.Debug().
190		Str("tab_bar_position", tabBarPos).
191		Msg("window layout assembled")
192}
193
194// Show presents the window.
195func (mw *MainWindow) Show() {
196	if mw == nil || mw.window == nil {
197		return
198	}
199	mw.window.Present()
200}
201
202// Close closes the window.
203func (mw *MainWindow) Close() {
204	if mw == nil || mw.window == nil {
205		return
206	}
207	mw.window.Close()
208}
209
210// TabBar returns the window's tab bar component.
211func (mw *MainWindow) TabBar() *component.TabBar {
212	return mw.tabBar
213}
214
215// ContentArea returns the main content container widget (the vertical
216// box where workspace content is placed).
217func (mw *MainWindow) ContentArea() *gtk.Box {
218	return mw.mainContentBox
219}
220
221// SetContent replaces the current content widget in the main content area
222// (the vertical box that holds workspace tab content).
223func (mw *MainWindow) SetContent(widget *gtk.Widget) {
224	if mw == nil || mw.mainContentBox == nil {
225		return
226	}
227	if mw.currentContent != nil {
228		mw.mainContentBox.Remove(mw.currentContent)
229		mw.currentContent = nil
230	}
231
232	if widget != nil {
233		widget.SetVisible(true)
234		mw.mainContentBox.Append(widget)
235		mw.currentContent = widget
236	}
237}
238
239// Window returns the underlying GTK application window.
240func (mw *MainWindow) Window() *gtk.ApplicationWindow {
241	return mw.window
242}
243
244// ConnectActiveNotify wires activation state changes for the top-level window.
245func (mw *MainWindow) ConnectActiveNotify(callback func(active bool)) uint {
246	if mw == nil || mw.window == nil || callback == nil {
247		return 0
248	}
249	cb := func(_ gobject.Object, _ *gobject.ParamSpec) {
250		callback(mw.window.IsActive())
251	}
252	return mw.window.ConnectNotifyWithDetail("is-active", &cb)
253}
254
255// SetTitle sets the window title.
256func (mw *MainWindow) SetTitle(title string) {
257	if mw.window == nil {
258		return
259	}
260	const maxTitleLen = 255
261	runes := []rune(title)
262	if len(runes) > maxTitleLen {
263		title = string(runes[:maxTitleLen-3]) + "..."
264	}
265	mw.window.SetTitle(&title)
266}
267
268// ContentOverlay returns the overlay container for the content area.
269func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
270	return mw.contentOverlay
271}
272
273// SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class
274// on the main content area. Avoids duplicate add/remove if already in the desired state.
275func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {
276	if mw.mainContentBox == nil {
277		return
278	}
279	class := mw.tabBarContentInsetClass()
280	if visible {
281		if !mw.mainContentBox.HasCssClass(class) {
282			mw.mainContentBox.AddCssClass(class)
283		}
284	} else {
285		if mw.mainContentBox.HasCssClass(class) {
286			mw.mainContentBox.RemoveCssClass(class)
287		}
288	}
289}
290
291// HasTabBarContentInset returns whether the tab bar inset CSS class is currently
292// applied to the main content area.
293func (mw *MainWindow) HasTabBarContentInset() bool {
294	if mw.mainContentBox == nil {
295		return false
296	}
297	return mw.mainContentBox.HasCssClass(mw.tabBarContentInsetClass())
298}
299
300func (mw *MainWindow) tabBarContentInsetClass() string {
301	if mw.tabBarPosition == "bottom" {
302		return "content-area-tabbar-inset-bottom"
303	}
304	return "content-area-tabbar-inset-top"
305}
306
307// AddOverlay adds a widget as an overlay above the content area.
308func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
309	if mw.contentOverlay != nil && widget != nil {
310		mw.contentOverlay.AddOverlay(widget)
311	}
312}
313
314// AddNonMeasuringOverlay adds a widget as a visual overlay that does not affect
315// content measurement or get clipped to the main child allocation. Use this for
316// transient indicators that should float above the WebView.
317func (mw *MainWindow) AddNonMeasuringOverlay(widget *gtk.Widget) {
318	if mw.contentOverlay == nil || widget == nil {
319		return
320	}
321	mw.contentOverlay.AddOverlay(widget)
322	mw.contentOverlay.SetClipOverlay(widget, false)
323	mw.contentOverlay.SetMeasureOverlay(widget, false)
324}
325
326// Destroy releases all GTK resources held by the window.
327func (mw *MainWindow) Destroy() {
328	if mw.tabBar != nil {
329		mw.tabBar.Destroy()
330		mw.tabBar = nil
331	}
332	if mw.sidebarBox != nil {
333		mw.sidebarBox.Unref()
334		mw.sidebarBox = nil
335	}
336	if mw.mainContentBox != nil {
337		mw.mainContentBox.Unref()
338		mw.mainContentBox = nil
339	}
340	if mw.contentAreaBox != nil {
341		mw.contentAreaBox.Unref()
342		mw.contentAreaBox = nil
343	}
344	if mw.rootBox != nil {
345		mw.rootBox.Unref()
346		mw.rootBox = nil
347	}
348	if mw.window != nil {
349		mw.window.Destroy()
350		mw.window = nil
351	}
352}
353
354// WindowError is a window-related error.
355type WindowError struct {
356	Message string
357}
358
359// Error implements the error interface.
360func (e WindowError) Error() string {
361	return e.Message
362}
363
364var (
365	// ErrWindowCreationFailed is returned when the GTK application window cannot be created.
366	ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}
367)
368
369// ErrWidgetCreationFailed returns an error for a failed widget creation.
370func ErrWidgetCreationFailed(name string) error {
371	return WindowError{Message: "failed to create widget: " + name}
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