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}