v0.26.0 ae3a77a

main_window.go RawHistoryFolder

1// Package window provides GTK window implementations.
2package window
3
4import (
5	"context"
6
7	"github.com/bnema/dumber/internal/infrastructure/config"
8	"github.com/bnema/dumber/internal/logging"
9	"github.com/bnema/dumber/internal/ui/component"
10	"github.com/jwijenbergh/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	cfg    *config.Config
30	logger zerolog.Logger
31}
32
33// New creates a new main browser window.
34func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWindow, error) {
35	log := logging.FromContext(ctx)
36
37	mw := &MainWindow{
38		cfg:    cfg,
39		logger: log.With().Str("component", "main-window").Logger(),
40	}
41
42	// Create the application window
43	mw.window = gtk.NewApplicationWindow(app)
44	if mw.window == nil {
45		return nil, ErrWindowCreationFailed
46	}
47
48	// Configure window
49	title := windowTitle
50	mw.window.SetTitle(&title)
51	mw.window.SetDefaultSize(defaultWidth, defaultHeight)
52
53	// Create root container (vertical box)
54	mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
55	if mw.rootBox == nil {
56		mw.window.Unref()
57		return nil, ErrWidgetCreationFailed("rootBox")
58	}
59	mw.rootBox.SetHexpand(true)
60	mw.rootBox.SetVexpand(true)
61	mw.rootBox.SetVisible(true)
62
63	// Create tab bar
64	mw.tabBar = component.NewTabBar()
65	if mw.tabBar == nil {
66		mw.rootBox.Unref()
67		mw.window.Unref()
68		return nil, ErrWidgetCreationFailed("tabBar")
69	}
70
71	// Create content overlay (for omnibox and other overlays)
72	mw.contentOverlay = gtk.NewOverlay()
73	if mw.contentOverlay == nil {
74		mw.tabBar.Destroy()
75		mw.rootBox.Unref()
76		mw.window.Unref()
77		return nil, ErrWidgetCreationFailed("contentOverlay")
78	}
79	mw.contentOverlay.SetHexpand(true)
80	mw.contentOverlay.SetVexpand(true)
81	mw.contentOverlay.SetVisible(true)
82
83	// Create content area
84	mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)
85	if mw.contentArea == nil {
86		mw.contentOverlay.Unref()
87		mw.tabBar.Destroy()
88		mw.rootBox.Unref()
89		mw.window.Unref()
90		return nil, ErrWidgetCreationFailed("contentArea")
91	}
92	mw.contentArea.SetHexpand(true)
93	mw.contentArea.SetVexpand(true)
94	mw.contentArea.SetVisible(true)
95	mw.contentArea.AddCssClass("content-area")
96
97	// Set content area as the main child of the overlay
98	mw.contentOverlay.SetChild(&mw.contentArea.Widget)
99
100	// Assemble layout based on tab bar position
101	mw.assembleLayout()
102
103	// Set root box as window content
104	mw.window.SetChild(&mw.rootBox.Widget)
105
106	return mw, nil
107}
108
109// assembleLayout arranges widgets based on configuration.
110func (mw *MainWindow) assembleLayout() {
111	tabBarPos := "top" // default
112	if mw.cfg != nil {
113		tabBarPos = mw.cfg.Workspace.TabBarPosition
114	}
115
116	if tabBarPos == "bottom" {
117		// Content first, then tab bar
118		mw.rootBox.Append(&mw.contentOverlay.Widget)
119		mw.rootBox.Append(mw.tabBar.Widget())
120	} else {
121		// Tab bar first, then content (default: top)
122		mw.rootBox.Append(mw.tabBar.Widget())
123		mw.rootBox.Append(&mw.contentOverlay.Widget)
124	}
125
126	mw.logger.Debug().
127		Str("tab_bar_position", tabBarPos).
128		Msg("window layout assembled")
129}
130
131// Show makes the window visible.
132func (mw *MainWindow) Show() {
133	mw.window.Present()
134}
135
136// Close closes the window.
137func (mw *MainWindow) Close() {
138	mw.window.Close()
139}
140
141// TabBar returns the tab bar component.
142func (mw *MainWindow) TabBar() *component.TabBar {
143	return mw.tabBar
144}
145
146// ContentArea returns the content area box.
147func (mw *MainWindow) ContentArea() *gtk.Box {
148	return mw.contentArea
149}
150
151// SetContent sets the content of the content area.
152// This removes any existing content first to properly swap workspace views on tab switch.
153func (mw *MainWindow) SetContent(widget *gtk.Widget) {
154	// Remove existing content first
155	if mw.currentContent != nil {
156		mw.contentArea.Remove(mw.currentContent)
157		mw.currentContent = nil
158	}
159
160	// Add new content
161	if widget != nil {
162		widget.SetVisible(true)
163		mw.contentArea.Append(widget)
164		mw.currentContent = widget
165	}
166}
167
168// Window returns the underlying GTK window.
169func (mw *MainWindow) Window() *gtk.ApplicationWindow {
170	return mw.window
171}
172
173// SetTitle updates the window title with proper formatting.
174// The title is capped at 255 characters for display.
175func (mw *MainWindow) SetTitle(title string) {
176	if mw.window == nil {
177		return
178	}
179	// Truncate title to 255 characters max (by runes, to avoid breaking UTF-8)
180	const maxTitleLen = 255
181	runes := []rune(title)
182	if len(runes) > maxTitleLen {
183		title = string(runes[:maxTitleLen-3]) + "..."
184	}
185	mw.window.SetTitle(&title)
186}
187
188// AddOverlay adds a widget to the content overlay.
189// The widget will be displayed on top of the workspace content.
190func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
191	if mw.contentOverlay != nil && widget != nil {
192		mw.contentOverlay.AddOverlay(widget)
193	}
194}
195
196// Destroy cleans up window resources.
197func (mw *MainWindow) Destroy() {
198	if mw.tabBar != nil {
199		mw.tabBar.Destroy()
200		mw.tabBar = nil
201	}
202	if mw.contentArea != nil {
203		mw.contentArea.Unref()
204		mw.contentArea = nil
205	}
206	if mw.rootBox != nil {
207		mw.rootBox.Unref()
208		mw.rootBox = nil
209	}
210	if mw.window != nil {
211		mw.window.Destroy()
212		mw.window = nil
213	}
214}
215
216// WindowError represents a window-related error.
217type WindowError struct {
218	Message string
219}
220
221func (e WindowError) Error() string {
222	return e.Message
223}
224
225// Error constants.
226var (
227	ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}
228)
229
230// ErrWidgetCreationFailed creates an error for widget creation failure.
231func ErrWidgetCreationFailed(name string) error {
232	return WindowError{Message: "failed to create widget: " + name}
233}

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