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}