1package browser
2
3import (
4 "fmt"
5 "sync/atomic"
6
7 "github.com/bnema/dumber/internal/logging"
8 "github.com/bnema/dumber/pkg/webkit"
9 "github.com/diamondburned/gotk4/pkg/gdk/v4"
10 "github.com/diamondburned/gotk4/pkg/gtk/v4"
11)
12
13// createTabBar creates the horizontal tab bar container.
14// Returns the tab bar widget.
15func (tm *TabManager) createTabBar() gtk.Widgetter {
16 logging.Debug("[tabs] Creating tab bar container")
17
18 tabBar := gtk.NewBox(gtk.OrientationHorizontal, 0)
19 if tabBar == nil {
20 logging.Error("[tabs] Failed to create tab bar box")
21 return nil
22 }
23
24 tabBar.AddCSSClass("tab-bar")
25 tabBar.SetHExpand(true)
26 tabBar.SetVExpand(false)
27
28 tm.tabBar = tabBar
29 logging.Debug("[tabs] Tab bar container created")
30 return tabBar
31}
32
33// createTabButton creates a simple GTK button widget for a tab with just a text label.
34func (tm *TabManager) createTabButton(tab *Tab) gtk.Widgetter {
35 logging.Debug(fmt.Sprintf("[tabs] Creating button for tab %s", tab.id))
36
37 // Create button
38 button := gtk.NewButton()
39 if button == nil {
40 logging.Error("[tabs] Failed to create tab button")
41 return nil
42 }
43
44 // CRITICAL: Disable focus-on-click to prevent GTK focus system from interfering
45 // Tab buttons should be clickable without grabbing focus from the WebView
46 button.SetFocusOnClick(false)
47 button.SetCanFocus(false)
48
49 // Apply CSS classes
50 button.AddCSSClass("tab-button")
51
52 // Get display title (custom name or default name)
53 displayTitle := tab.title
54 if tab.customTitle != "" {
55 displayTitle = tab.customTitle
56 }
57
58 // Create title label
59 titleLabel := gtk.NewLabel(displayTitle)
60 if titleLabel != nil {
61 titleLabel.AddCSSClass("tab-title")
62 titleLabel.SetEllipsize(2) // Pango ellipsize mode: middle
63 titleLabel.SetMaxWidthChars(20)
64 webkit.WidgetSetVisible(titleLabel, true)
65 }
66
67 // Set button child
68 button.SetChild(titleLabel)
69
70 // Store button reference for later updates
71 tab.titleButton = button
72
73 // Note: Click handler will be attached in createTabInternal after tab is added to slice
74
75 // Set active class if this is the active tab
76 if tab.isActive {
77 webkit.WidgetAddCSSClass(button, "tab-button-active")
78 }
79
80 webkit.WidgetSetVisible(button, true)
81 logging.Debug(fmt.Sprintf("[tabs] Button created for tab %s", tab.id))
82 return button
83}
84
85// attachTabClickHandler attaches a click handler to switch to a tab when clicked.
86// Uses the same pattern as StackedPaneManager: maps unique button ID -> tab,
87// then looks up current index at click time to handle tab reordering correctly.
88func (tm *TabManager) attachTabClickHandler(button gtk.Widgetter, tab *Tab) {
89 if button == nil || tab == nil {
90 logging.Error("[tabs] Cannot attach click handler: nil button or tab")
91 return
92 }
93
94 gtkButton, ok := button.(*gtk.Button)
95 if !ok {
96 logging.Error(fmt.Sprintf("[tabs] Cannot attach click handler: widget is not *gtk.Button for tab %s", tab.id))
97 return
98 }
99
100 // Generate unique button ID and store mapping (pattern from stacked panes)
101 buttonID := atomic.AddUint64(&tm.nextButtonID, 1)
102 tm.buttonToTab[buttonID] = tab
103
104 gtkButton.ConnectClicked(func() {
105 tm.handleTabButtonClick(buttonID)
106 })
107
108 logging.Debug(fmt.Sprintf("[tabs] Click handler attached to tab %s (buttonID=%d)", tab.id, buttonID))
109}
110
111// handleTabButtonClick handles clicks on tab buttons to switch tabs.
112// Looks up the current index dynamically to handle tab reordering (pattern from stacked panes).
113func (tm *TabManager) handleTabButtonClick(buttonID uint64) {
114 tm.mu.Lock()
115 defer tm.mu.Unlock()
116
117 // Look up tab from button ID
118 tab, exists := tm.buttonToTab[buttonID]
119 if !exists || tab == nil {
120 logging.Warn(fmt.Sprintf("[tabs] Tab button click for unknown ID: %d", buttonID))
121 return
122 }
123
124 // Find current index of this tab
125 targetIndex := -1
126 for i, t := range tm.tabs {
127 if t == tab {
128 targetIndex = i
129 break
130 }
131 }
132
133 if targetIndex < 0 {
134 logging.Error(fmt.Sprintf("[tabs] Tab %s not found in tabs slice", tab.id))
135 return
136 }
137
138 // Only switch if not already active
139 if targetIndex == tm.activeIndex {
140 logging.Debug(fmt.Sprintf("[tabs] Tab %s already active (index %d)", tab.id, targetIndex))
141 return
142 }
143
144 logging.Info(fmt.Sprintf("[tabs] Tab button clicked: switching from tab %d to %d (%s)", tm.activeIndex, targetIndex, tab.id))
145
146 // Switch to this tab
147 if err := tm.switchToTabInternal(targetIndex); err != nil {
148 logging.Error(fmt.Sprintf("[tabs] Failed to switch to tab %d: %v", targetIndex, err))
149 }
150}
151
152// updateTabButton updates the tab button's title label.
153func (tm *TabManager) updateTabButton(tab *Tab) {
154 if tab.titleButton == nil {
155 logging.Warn(fmt.Sprintf("[tabs] Cannot update button for tab %s: button is nil", tab.id))
156 return
157 }
158
159 webkit.RunOnMainThread(func() {
160 button, ok := tab.titleButton.(*gtk.Button)
161 if !ok {
162 logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
163 return
164 }
165
166 // Get button child (the label)
167 child := button.Child()
168 if child == nil {
169 logging.Warn(fmt.Sprintf("[tabs] Tab button has no child: %s", tab.id))
170 return
171 }
172
173 label, ok := child.(*gtk.Label)
174 if !ok {
175 logging.Error(fmt.Sprintf("[tabs] Tab button child is not a *gtk.Label: %s", tab.id))
176 return
177 }
178
179 // Update label text
180 displayTitle := tab.title
181 if tab.customTitle != "" {
182 displayTitle = tab.customTitle
183 }
184 label.SetLabel(displayTitle)
185 logging.Debug(fmt.Sprintf("[tabs] Updated tab button label: %s -> %s", tab.id, displayTitle))
186 })
187}
188
189// addTabToBar adds a tab button to the tab bar.
190func (tm *TabManager) addTabToBar(tab *Tab) {
191 if tm.tabBar == nil {
192 logging.Error("[tabs] Cannot add tab to bar: tab bar is nil")
193 return
194 }
195
196 if tab.titleButton == nil {
197 logging.Error(fmt.Sprintf("[tabs] Cannot add tab %s to bar: button is nil", tab.id))
198 return
199 }
200
201 webkit.RunOnMainThread(func() {
202 if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
203 tabBarBox.Append(tab.titleButton)
204 logging.Debug(fmt.Sprintf("[tabs] Added tab %s to tab bar", tab.id))
205 } else {
206 logging.Error("[tabs] Tab bar is not a *gtk.Box")
207 }
208 })
209}
210
211// removeTabFromBar removes a tab button from the tab bar.
212func (tm *TabManager) removeTabFromBar(tab *Tab) {
213 if tm.tabBar == nil || tab.titleButton == nil {
214 return
215 }
216
217 webkit.RunOnMainThread(func() {
218 if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
219 tabBarBox.Remove(tab.titleButton)
220 logging.Debug(fmt.Sprintf("[tabs] Removed tab %s from tab bar", tab.id))
221 }
222 })
223}
224
225// setTabActiveStyle applies or removes the active CSS class on a tab button.
226func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {
227 if tab.titleButton == nil {
228 logging.Warn(fmt.Sprintf("[tabs] Cannot set active style: button is nil for tab %s", tab.id))
229 return
230 }
231
232 webkit.RunOnMainThread(func() {
233 if active {
234 webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")
235 logging.Debug(fmt.Sprintf("[tabs] Added active class to tab %s", tab.id))
236 } else {
237 webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")
238 logging.Debug(fmt.Sprintf("[tabs] Removed active class from tab %s", tab.id))
239 }
240 })
241}
242
243// startInlineRename replaces the tab button's label with an entry widget for inline editing.
244func (tm *TabManager) startInlineRename(tab *Tab) {
245 if tab.titleButton == nil {
246 logging.Error(fmt.Sprintf("[tabs] Cannot start rename: button is nil for tab %s", tab.id))
247 return
248 }
249
250 // Check if rename already in progress
251 tm.mu.Lock()
252 if tm.renameInProgress {
253 logging.Debug("[tabs] Rename already in progress, ignoring")
254 tm.mu.Unlock()
255 return
256 }
257 tm.renameInProgress = true
258 tm.renamingTab = tab
259
260 // Stop tab mode timer during rename
261 if tm.tabModeTimer != nil {
262 tm.tabModeTimer.Stop()
263 tm.tabModeTimer = nil
264 }
265 tm.mu.Unlock()
266
267 webkit.RunOnMainThread(func() {
268 button, ok := tab.titleButton.(*gtk.Button)
269 if !ok {
270 logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
271 tm.cancelRename()
272 return
273 }
274
275 // Get current text
276 currentText := tab.title
277 if tab.customTitle != "" {
278 currentText = tab.customTitle
279 }
280
281 // Store original label for restoration
282 originalLabel := button.Child()
283
284 // Create entry widget
285 entry := gtk.NewEntry()
286 if entry == nil {
287 logging.Error("[tabs] Failed to create entry widget for rename")
288 tm.cancelRename()
289 return
290 }
291
292 // Store current focus behavior so we can restore it later
293 prevFocusOnClick := button.FocusOnClick()
294 prevCanFocus := button.CanFocus()
295 tm.mu.Lock()
296 tm.renamePrevFocusOnClick = prevFocusOnClick
297 tm.renamePrevCanFocus = prevCanFocus
298 tm.mu.Unlock()
299
300 // Allow the entry to receive focus while rename is active
301 button.SetFocusOnClick(true)
302 button.SetCanFocus(true)
303
304 // Configure entry BEFORE adding to widget tree
305 entry.SetText(currentText)
306 entry.AddCSSClass("tab-rename-entry")
307 entry.SetCanFocus(true) // Explicitly allow focus
308 entry.SetFocusable(true) // GTK4 focusability
309
310 // Handle Enter key - save
311 entry.ConnectActivate(func() {
312 newName := entry.Text()
313 logging.Debug(fmt.Sprintf("[tabs] Rename activate (Enter): '%s' -> '%s'", currentText, newName))
314 tm.finishInlineRename(tab, newName, originalLabel)
315 })
316
317 // Handle Escape key - cancel
318 keyController := gtk.NewEventControllerKey()
319 keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
320 const gdkKeyEscape = 0xff1b
321 if keyval == gdkKeyEscape {
322 logging.Debug("[tabs] Rename cancelled (Escape)")
323 tm.finishInlineRename(tab, "", originalLabel) // Empty = cancel
324 return true
325 }
326 return false
327 })
328 entry.AddController(keyController)
329
330 // Handle focus-out - cancel (safety)
331 focusController := gtk.NewEventControllerFocus()
332 focusController.ConnectLeave(func() {
333 logging.Debug("[tabs] Rename cancelled (focus-out)")
334 tm.finishInlineRename(tab, "", originalLabel)
335 })
336 entry.AddController(focusController)
337
338 // Replace label with entry
339 button.SetChild(entry)
340
341 // Make entry visible first
342 webkit.WidgetSetVisible(entry, true)
343
344 // Grab focus after widget is visible and realized
345 // Use idle callback to ensure widget is fully realized
346 entry.GrabFocus()
347
348 // Select all text after focus
349 entry.SelectRegion(0, -1)
350
351 logging.Debug(fmt.Sprintf("[tabs] Started inline rename for tab %s", tab.id))
352 })
353}
354
355// cancelRename clears the rename state without changing anything
356func (tm *TabManager) cancelRename() {
357 tm.mu.Lock()
358 defer tm.mu.Unlock()
359 tm.renameInProgress = false
360 tm.renamingTab = nil
361 tm.renamePrevFocusOnClick = false
362 tm.renamePrevCanFocus = false
363 logging.Debug("[tabs] Rename state cleared")
364}
365
366// finishInlineRename restores the label and saves the new name if provided.
367func (tm *TabManager) finishInlineRename(tab *Tab, newName string, originalLabel gtk.Widgetter) {
368 // Guard against multiple calls
369 tm.mu.Lock()
370 if !tm.renameInProgress {
371 logging.Debug("[tabs] finishInlineRename called but no rename in progress, ignoring")
372 tm.mu.Unlock()
373 return
374 }
375 if tm.renamingTab != tab {
376 logging.Debug(fmt.Sprintf("[tabs] finishInlineRename called for wrong tab (expected %s, got %s)",
377 tm.renamingTab.id, tab.id))
378 tm.mu.Unlock()
379 return
380 }
381
382 // Clear rename state immediately to prevent re-entry
383 tm.renameInProgress = false
384 tm.renamingTab = nil
385 tm.mu.Unlock()
386
387 webkit.RunOnMainThread(func() {
388 button, ok := tab.titleButton.(*gtk.Button)
389 if !ok {
390 logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button during finish: %s", tab.id))
391 return
392 }
393
394 // Restore original label
395 button.SetChild(originalLabel)
396
397 // Restore original focus behavior now that rename is finished
398 tm.mu.Lock()
399 prevFocusOnClick := tm.renamePrevFocusOnClick
400 prevCanFocus := tm.renamePrevCanFocus
401 tm.renamePrevFocusOnClick = false
402 tm.renamePrevCanFocus = false
403 tm.mu.Unlock()
404 button.SetFocusOnClick(prevFocusOnClick)
405 button.SetCanFocus(prevCanFocus)
406
407 // Save new name if provided and not just whitespace
408 newName = trimWhitespace(newName)
409 if newName != "" {
410 tab.customTitle = newName
411 logging.Info(fmt.Sprintf("[tabs] Tab %s renamed to '%s'", tab.id, newName))
412 } else {
413 logging.Debug(fmt.Sprintf("[tabs] Tab %s rename cancelled", tab.id))
414 }
415
416 // Update label text
417 tm.updateTabButton(tab)
418
419 // Exit tab mode
420 tm.ExitTabMode("rename-complete")
421 })
422}
423
424// trimWhitespace removes leading and trailing whitespace from a string.
425func trimWhitespace(s string) string {
426 start := 0
427 end := len(s)
428
429 // Trim leading whitespace
430 for start < end && isWhitespace(s[start]) {
431 start++
432 }
433
434 // Trim trailing whitespace
435 for end > start && isWhitespace(s[end-1]) {
436 end--
437 }
438
439 return s[start:end]
440}
441
442// isWhitespace checks if a byte is a whitespace character.
443func isWhitespace(b byte) bool {
444 return b == ' ' || b == '\t' || b == '\n' || b == '\r'
445}