v0.29.0 21b3150

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.
21type MainWindow struct {
22	window         *gtk.ApplicationWindow
23	rootBox        *gtk.Box // Vertical: tab bar + content
24	tabBar         *component.TabBar
25	contentOverlay *gtk.Overlay // Overlay for content + omnibox
26	contentArea    *gtk.Box     // Container for workspace content
27	currentContent *gtk.Widget  // Track current content for removal on tab switch
28
29	tabBarPosition string // "top" or "bottom"
30	logger         zerolog.Logger
31}
32
33// New creates a new main browser window.
34// tabBarPosition controls whether the tab bar is at the "top" or "bottom" (default: "top").
35func New(ctx context.Context, app *gtk.Application, tabBarPosition string) (*MainWindow, error) {
36	log := logging.FromContext(ctx)
37
38	mw := &MainWindow{
39		tabBarPosition: tabBarPosition,
40		logger:         log.With().Str("component", "main-window").Logger(),
41	}
42
43	mw.window = gtk.NewApplicationWindow(app)
44	if mw.window == nil {
45		return nil, ErrWindowCreationFailed
46	}
47
48	title := windowTitle
49	mw.window.SetTitle(&title)
50	mw.window.SetDefaultSize(defaultWidth, defaultHeight)
51
52	mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
53	if mw.rootBox == nil {
54		mw.window.Unref()
55		return nil, ErrWidgetCreationFailed("rootBox")
56	}
57	mw.rootBox.SetHexpand(true)
58	mw.rootBox.SetVexpand(true)
59	mw.rootBox.SetVisible(true)
60
61	mw.tabBar = component.NewTabBar()
62	if mw.tabBar == nil {
63		mw.rootBox.Unref()
64		mw.window.Unref()
65		return nil, ErrWidgetCreationFailed("tabBar")
66	}
67
68	mw.contentOverlay = gtk.NewOverlay()
69	if mw.contentOverlay == nil {
70		mw.tabBar.Destroy()
71		mw.rootBox.Unref()
72		mw.window.Unref()
73		return nil, ErrWidgetCreationFailed("contentOverlay")
74	}
75	mw.contentOverlay.SetHexpand(true)
76	mw.contentOverlay.SetVexpand(true)
77	mw.contentOverlay.SetVisible(true)
78
79	mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)
80	if mw.contentArea == nil {
81		mw.contentOverlay.Unref()
82		mw.tabBar.Destroy()
83		mw.rootBox.Unref()
84		mw.window.Unref()
85		return nil, ErrWidgetCreationFailed("contentArea")
86	}
87	mw.contentArea.SetHexpand(true)
88	mw.contentArea.SetVexpand(true)
89	mw.contentArea.SetVisible(true)
90	mw.contentArea.AddCssClass("content-area")
91
92	mw.contentOverlay.SetChild(&mw.contentArea.Widget)
93
94	mw.assembleLayout()
95
96	mw.window.SetChild(&mw.rootBox.Widget)
97
98	return mw, nil
99}
100
101func (mw *MainWindow) assembleLayout() {
102	tabBarPos := "top" // default
103	if mw.tabBarPosition != "" {
104		tabBarPos = mw.tabBarPosition
105	}
106
107	mw.rootBox.Append(&mw.contentOverlay.Widget)
108
109	tabBarWidget := mw.tabBar.Widget()
110	tabBarWidget.SetHalign(gtk.AlignFillValue)
111	tabBarWidget.SetValign(gtk.AlignStartValue)
112	if tabBarPos == "bottom" {
113		tabBarWidget.SetValign(gtk.AlignEndValue)
114		// Inset is NOT applied unconditionally. See SetTabBarContentInsetVisible
115		// which is called when the bottom tab bar is actually visible.
116	}
117	mw.contentOverlay.AddOverlay(tabBarWidget)
118	mw.contentOverlay.SetClipOverlay(tabBarWidget, false)
119	// Keep the tab bar out of measurement so showing/hiding it never changes the
120	// WebView content allocation; the content area reserves a stable inset while
121	// the overlay remains non-measured to avoid WebView allocation changes.
122	mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)
123
124	mw.logger.Debug().
125		Str("tab_bar_position", tabBarPos).
126		Msg("window layout assembled")
127}
128
129// Show presents the window.
130func (mw *MainWindow) Show() {
131	if mw == nil || mw.window == nil {
132		return
133	}
134	mw.window.Present()
135}
136
137// Close closes the window.
138func (mw *MainWindow) Close() {
139	if mw == nil || mw.window == nil {
140		return
141	}
142	mw.window.Close()
143}
144
145// TabBar returns the window's tab bar component.
146func (mw *MainWindow) TabBar() *component.TabBar {
147	return mw.tabBar
148}
149
150// ContentArea returns the main content container widget.
151func (mw *MainWindow) ContentArea() *gtk.Box {
152	return mw.contentArea
153}
154
155// SetContent replaces the current content widget with the given widget.
156func (mw *MainWindow) SetContent(widget *gtk.Widget) {
157	if mw.currentContent != nil {
158		mw.contentArea.Remove(mw.currentContent)
159		mw.currentContent = nil
160	}
161
162	if widget != nil {
163		widget.SetVisible(true)
164		mw.contentArea.Append(widget)
165		mw.currentContent = widget
166	}
167}
168
169// Window returns the underlying GTK application window.
170func (mw *MainWindow) Window() *gtk.ApplicationWindow {
171	return mw.window
172}
173
174// ConnectActiveNotify wires activation state changes for the top-level window.
175func (mw *MainWindow) ConnectActiveNotify(callback func(active bool)) uint {
176	if mw == nil || mw.window == nil || callback == nil {
177		return 0
178	}
179	cb := func(_ gobject.Object, _ *gobject.ParamSpec) {
180		callback(mw.window.IsActive())
181	}
182	return mw.window.ConnectNotifyWithDetail("is-active", &cb)
183}
184
185// SetTitle sets the window title.
186func (mw *MainWindow) SetTitle(title string) {
187	if mw.window == nil {
188		return
189	}
190	const maxTitleLen = 255
191	runes := []rune(title)
192	if len(runes) > maxTitleLen {
193		title = string(runes[:maxTitleLen-3]) + "..."
194	}
195	mw.window.SetTitle(&title)
196}
197
198// ContentOverlay returns the overlay container for the content area.
199func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
200	return mw.contentOverlay
201}
202
203// SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class
204// on the content area. Avoids duplicate add/remove if already in the desired state.
205func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {
206	if mw.contentArea == nil {
207		return
208	}
209	class := mw.tabBarContentInsetClass()
210	if visible {
211		if !mw.contentArea.HasCssClass(class) {
212			mw.contentArea.AddCssClass(class)
213		}
214	} else {
215		if mw.contentArea.HasCssClass(class) {
216			mw.contentArea.RemoveCssClass(class)
217		}
218	}
219}
220
221// HasTabBarContentInset returns whether the tab bar inset CSS class is currently
222// applied to the content area.
223func (mw *MainWindow) HasTabBarContentInset() bool {
224	if mw.contentArea == nil {
225		return false
226	}
227	return mw.contentArea.HasCssClass(mw.tabBarContentInsetClass())
228}
229
230func (mw *MainWindow) tabBarContentInsetClass() string {
231	if mw.tabBarPosition == "bottom" {
232		return "content-area-tabbar-inset-bottom"
233	}
234	return "content-area-tabbar-inset-top"
235}
236
237// AddOverlay adds a widget as an overlay above the content area.
238func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
239	if mw.contentOverlay != nil && widget != nil {
240		mw.contentOverlay.AddOverlay(widget)
241	}
242}
243
244// Destroy releases all GTK resources held by the window.
245func (mw *MainWindow) Destroy() {
246	if mw.tabBar != nil {
247		mw.tabBar.Destroy()
248		mw.tabBar = nil
249	}
250	if mw.contentArea != nil {
251		mw.contentArea.Unref()
252		mw.contentArea = nil
253	}
254	if mw.rootBox != nil {
255		mw.rootBox.Unref()
256		mw.rootBox = nil
257	}
258	if mw.window != nil {
259		mw.window.Destroy()
260		mw.window = nil
261	}
262}
263
264// WindowError is a window-related error.
265type WindowError struct {
266	Message string
267}
268
269// Error implements the error interface.
270func (e WindowError) Error() string {
271	return e.Message
272}
273
274var (
275	// ErrWindowCreationFailed is returned when the GTK application window cannot be created.
276	ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}
277)
278
279// ErrWidgetCreationFailed returns an error for a failed widget creation.
280func ErrWidgetCreationFailed(name string) error {
281	return WindowError{Message: "failed to create widget: " + name}
282}

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