feat(tabs): implement inline tab rename functionality
Add inline editing capability for tab names with full keyboard support: Changes: - tab_bar.go: Add startInlineRename(), finishInlineRename(), cancelRename() - Replace tab button label with Entry widget for editing - Handle Enter (save), Escape (cancel), and focus-out events - Preserve and restore button focus behavior during rename - Add trimWhitespace() and isWhitespace() helpers - tab_manager.go: Add rename state tracking - Add renameInProgress, renamingTab fields - Add renamePrevFocusOnClick, renamePrevCanFocus fields - Add IsRenameInProgress() method for external checks - tab_mode.go: Convert handleRenameTab() to inline editing - Replace dialog approach with startInlineRename() - Guard against rename when only 1 tab (tab bar hidden) - Tab mode exits automatically on rename completion - window_shortcuts.go: Add rename detection to prevent key interception - Add isRenameInProgress() check in keycode handler - Let ALL keys propagate to Entry widget during rename - Prevents tab mode action keys from interfering with text input This enables users to rename tabs with Alt+T → r, providing a seamless inline editing experience similar to modern IDEs.
- commit
- 630065ade68318a3bc54739e299ef9efd06218ba
- author
- bnema · · 2025-11-18 18:06 UTC
- parent
- e68eda9
- signature
- GPG Unverified
2B82207D370127A0The key is unknown
4 files · +250 -19
M internal/app/browser/tab_bar.go +205 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 8 | 8 | "github.com/bnema/dumber/pkg/webkit" | |
| 9 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 9 | 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -238,3 +239,207 @@ func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) { | |||
| 238 | 239 | } | |
| 239 | 240 | }) | |
| 240 | 241 | } | |
| 242 | + | ||
| 243 | + | // startInlineRename replaces the tab button's label with an entry widget for inline editing. | |
| 244 | + | func (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 | |
| 356 | + | func (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. | |
| 367 | + | func (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. | |
| 425 | + | func 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. | |
| 443 | + | func isWhitespace(b byte) bool { | |
| 444 | + | return b == ' ' || b == '\t' || b == '\n' || b == '\r' | |
| 445 | + | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | 6 | ||||
| 7 | "github.com/bnema/dumber/internal/logging" | 7 | "github.com/bnema/dumber/internal/logging" | ||
| 8 | "github.com/bnema/dumber/pkg/webkit" | 8 | "github.com/bnema/dumber/pkg/webkit" | ||
| 9 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |||
| 9 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | 10 | "github.com/diamondburned/gotk4/pkg/gtk/v4" | ||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| @@ -238,3 +239,207 @@ func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) { | |||||
| 238 | } | 239 | } | ||
| 239 | }) | 240 | }) | ||
| 240 | } | 241 | } | ||
| 242 | + | ||||
| 243 | + | // startInlineRename replaces the tab button's label with an entry widget for inline editing. | |||
| 244 | + | func (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 | |||
| 356 | + | func (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. | |||
| 367 | + | func (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. | |||
| 425 | + | func 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. | |||
| 443 | + | func isWhitespace(b byte) bool { | |||
| 444 | + | return b == ' ' || b == '\t' || b == '\n' || b == '\r' | |||
| 445 | + | } | |||
M internal/app/browser/tab_manager.go +15 -0
| @@ -29,6 +29,13 @@ type TabManager struct { | |||
| 29 | 29 | tabModeActive bool | |
| 30 | 30 | tabModeTimer *time.Timer | |
| 31 | 31 | ||
| 32 | + | // Rename state | |
| 33 | + | renameInProgress bool | |
| 34 | + | renamingTab *Tab | |
| 35 | + | // Stores original focus behaviour while inline rename is active | |
| 36 | + | renamePrevFocusOnClick bool | |
| 37 | + | renamePrevCanFocus bool | |
| 38 | + | ||
| 32 | 39 | // Tab button click handling (pattern from StackedPaneManager) | |
| 33 | 40 | buttonToTab map[uint64]*Tab // Maps button ID to tab for click handling | |
| 34 | 41 | nextButtonID uint64 // Atomic counter for generating unique button IDs | |
| @@ -453,6 +460,14 @@ func (tm *TabManager) IsTabModeActive() bool { | |||
| 453 | 460 | return tm.tabModeActive | |
| 454 | 461 | } | |
| 455 | 462 | ||
| 463 | + | // IsRenameInProgress returns whether a tab rename is currently in progress. | |
| 464 | + | func (tm *TabManager) IsRenameInProgress() bool { | |
| 465 | + | tm.mu.RLock() | |
| 466 | + | defer tm.mu.RUnlock() | |
| 467 | + | ||
| 468 | + | return tm.renameInProgress | |
| 469 | + | } | |
| 470 | + | ||
| 456 | 471 | // NextTab switches to the next tab (wraps around). | |
| 457 | 472 | func (tm *TabManager) NextTab() error { | |
| 458 | 473 | tm.mu.Lock() | |
| @@ -29,6 +29,13 @@ type TabManager struct { | |||||
| 29 | tabModeActive bool | 29 | tabModeActive bool | ||
| 30 | tabModeTimer *time.Timer | 30 | tabModeTimer *time.Timer | ||
| 31 | 31 | ||||
| 32 | + | // Rename state | |||
| 33 | + | renameInProgress bool | |||
| 34 | + | renamingTab *Tab | |||
| 35 | + | // Stores original focus behaviour while inline rename is active | |||
| 36 | + | renamePrevFocusOnClick bool | |||
| 37 | + | renamePrevCanFocus bool | |||
| 38 | + | ||||
| 32 | // Tab button click handling (pattern from StackedPaneManager) | 39 | // Tab button click handling (pattern from StackedPaneManager) | ||
| 33 | buttonToTab map[uint64]*Tab // Maps button ID to tab for click handling | 40 | buttonToTab map[uint64]*Tab // Maps button ID to tab for click handling | ||
| 34 | nextButtonID uint64 // Atomic counter for generating unique button IDs | 41 | nextButtonID uint64 // Atomic counter for generating unique button IDs | ||
| @@ -453,6 +460,14 @@ func (tm *TabManager) IsTabModeActive() bool { | |||||
| 453 | return tm.tabModeActive | 460 | return tm.tabModeActive | ||
| 454 | } | 461 | } | ||
| 455 | 462 | ||||
| 463 | + | // IsRenameInProgress returns whether a tab rename is currently in progress. | |||
| 464 | + | func (tm *TabManager) IsRenameInProgress() bool { | |||
| 465 | + | tm.mu.RLock() | |||
| 466 | + | defer tm.mu.RUnlock() | |||
| 467 | + | ||||
| 468 | + | return tm.renameInProgress | |||
| 469 | + | } | |||
| 470 | + | ||||
| 456 | // NextTab switches to the next tab (wraps around). | 471 | // NextTab switches to the next tab (wraps around). | ||
| 457 | func (tm *TabManager) NextTab() error { | 472 | func (tm *TabManager) NextTab() error { | ||
| 458 | tm.mu.Lock() | 473 | tm.mu.Lock() | ||
M internal/app/browser/tab_mode.go +15 -19
| @@ -172,37 +172,33 @@ func (tm *TabManager) handlePreviousTab() { | |||
| 172 | 172 | } | |
| 173 | 173 | } | |
| 174 | 174 | ||
| 175 | - | // handleRenameTab shows a dialog to rename the active tab. | |
| 175 | + | // handleRenameTab starts inline editing of the active tab's name. | |
| 176 | 176 | func (tm *TabManager) handleRenameTab() { | |
| 177 | - | logging.Debug("[tab-mode] Opening rename dialog") | |
| 178 | - | ||
| 179 | - | // Exit tab mode first so the dialog can be interacted with | |
| 180 | - | tm.ExitTabMode("rename-dialog") | |
| 177 | + | logging.Debug("[tab-mode] Starting inline tab rename") | |
| 181 | 178 | ||
| 182 | 179 | tm.mu.RLock() | |
| 183 | 180 | activeIndex := tm.activeIndex | |
| 184 | - | var currentTitle string | |
| 181 | + | tabCount := len(tm.tabs) | |
| 182 | + | var activeTab *Tab | |
| 185 | 183 | if activeIndex >= 0 && activeIndex < len(tm.tabs) { | |
| 186 | - | tab := tm.tabs[activeIndex] | |
| 187 | - | if tab.customTitle != "" { | |
| 188 | - | currentTitle = tab.customTitle | |
| 189 | - | } else { | |
| 190 | - | currentTitle = tab.title | |
| 191 | - | } | |
| 184 | + | activeTab = tm.tabs[activeIndex] | |
| 192 | 185 | } | |
| 193 | 186 | tm.mu.RUnlock() | |
| 194 | 187 | ||
| 195 | - | if activeIndex < 0 { | |
| 196 | - | logging.Error("[tab-mode] No active tab to rename") | |
| 188 | + | // Guard: Don't allow rename if only 1 tab (tab bar is hidden) | |
| 189 | + | if tabCount <= 1 { | |
| 190 | + | logging.Warn("[tab-mode] Cannot rename: only one tab (tab bar hidden)") | |
| 197 | 191 | return | |
| 198 | 192 | } | |
| 199 | 193 | ||
| 200 | - | // Show rename dialog (will be implemented in Step 10) | |
| 201 | - | // For now, just log | |
| 202 | - | logging.Info(fmt.Sprintf("[tab-mode] Would show rename dialog for tab %d (current: %s)", activeIndex, currentTitle)) | |
| 194 | + | if activeTab == nil { | |
| 195 | + | logging.Error("[tab-mode] No active tab to rename") | |
| 196 | + | return | |
| 197 | + | } | |
| 203 | 198 | ||
| 204 | - | // TODO: Implement GTK dialog in Step 10 | |
| 205 | - | // tm.showRenameDialog(activeIndex, currentTitle) | |
| 199 | + | // Start inline rename (tab mode will be exited by finishInlineRename) | |
| 200 | + | logging.Info(fmt.Sprintf("[tab-mode] Starting inline rename for tab %s", activeTab.id)) | |
| 201 | + | tm.startInlineRename(activeTab) | |
| 206 | 202 | } | |
| 207 | 203 | ||
| 208 | 204 | // applyTabModeBorder applies visual indicator when tab mode is active. | |
| @@ -172,37 +172,33 @@ func (tm *TabManager) handlePreviousTab() { | |||||
| 172 | } | 172 | } | ||
| 173 | } | 173 | } | ||
| 174 | 174 | ||||
| 175 | - | // handleRenameTab shows a dialog to rename the active tab. | 175 | + | // handleRenameTab starts inline editing of the active tab's name. |
| 176 | func (tm *TabManager) handleRenameTab() { | 176 | func (tm *TabManager) handleRenameTab() { | ||
| 177 | - | logging.Debug("[tab-mode] Opening rename dialog") | 177 | + | logging.Debug("[tab-mode] Starting inline tab rename") |
| 178 | - | ||||
| 179 | - | // Exit tab mode first so the dialog can be interacted with | |||
| 180 | - | tm.ExitTabMode("rename-dialog") | |||
| 181 | 178 | ||||
| 182 | tm.mu.RLock() | 179 | tm.mu.RLock() | ||
| 183 | activeIndex := tm.activeIndex | 180 | activeIndex := tm.activeIndex | ||
| 184 | - | var currentTitle string | 181 | + | tabCount := len(tm.tabs) |
| 182 | + | var activeTab *Tab | |||
| 185 | if activeIndex >= 0 && activeIndex < len(tm.tabs) { | 183 | if activeIndex >= 0 && activeIndex < len(tm.tabs) { | ||
| 186 | - | tab := tm.tabs[activeIndex] | 184 | + | activeTab = tm.tabs[activeIndex] |
| 187 | - | if tab.customTitle != "" { | |||
| 188 | - | currentTitle = tab.customTitle | |||
| 189 | - | } else { | |||
| 190 | - | currentTitle = tab.title | |||
| 191 | - | } | |||
| 192 | } | 185 | } | ||
| 193 | tm.mu.RUnlock() | 186 | tm.mu.RUnlock() | ||
| 194 | 187 | ||||
| 195 | - | if activeIndex < 0 { | 188 | + | // Guard: Don't allow rename if only 1 tab (tab bar is hidden) |
| 196 | - | logging.Error("[tab-mode] No active tab to rename") | 189 | + | if tabCount <= 1 { |
| 190 | + | logging.Warn("[tab-mode] Cannot rename: only one tab (tab bar hidden)") | |||
| 197 | return | 191 | return | ||
| 198 | } | 192 | } | ||
| 199 | 193 | ||||
| 200 | - | // Show rename dialog (will be implemented in Step 10) | 194 | + | if activeTab == nil { |
| 201 | - | // For now, just log | 195 | + | logging.Error("[tab-mode] No active tab to rename") |
| 202 | - | logging.Info(fmt.Sprintf("[tab-mode] Would show rename dialog for tab %d (current: %s)", activeIndex, currentTitle)) | 196 | + | return |
| 197 | + | } | |||
| 203 | 198 | ||||
| 204 | - | // TODO: Implement GTK dialog in Step 10 | 199 | + | // Start inline rename (tab mode will be exited by finishInlineRename) |
| 205 | - | // tm.showRenameDialog(activeIndex, currentTitle) | 200 | + | logging.Info(fmt.Sprintf("[tab-mode] Starting inline rename for tab %s", activeTab.id)) |
| 201 | + | tm.startInlineRename(activeTab) | |||
| 206 | } | 202 | } | ||
| 207 | 203 | ||||
| 208 | // applyTabModeBorder applies visual indicator when tab mode is active. | 204 | // applyTabModeBorder applies visual indicator when tab mode is active. | ||
M internal/app/browser/window_shortcuts.go +15 -0
| @@ -187,8 +187,15 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |||
| 187 | 187 | } | |
| 188 | 188 | ||
| 189 | 189 | // Handle tab mode action keys (only when tab mode is active) | |
| 190 | + | // IMPORTANT: If rename is in progress, let ALL keys through to the Entry widget | |
| 190 | 191 | // These keys need to be checked here so they can propagate when tab mode is not active | |
| 191 | 192 | if h.isTabModeActive() { | |
| 193 | + | // Check if rename is in progress - if so, don't intercept ANY keys | |
| 194 | + | // This allows the Entry widget to receive all keyboard input | |
| 195 | + | if h.isRenameInProgress() { | |
| 196 | + | return false // Let all keys propagate to Entry widget | |
| 197 | + | } | |
| 198 | + | ||
| 192 | 199 | handled := false | |
| 193 | 200 | action := "" | |
| 194 | 201 | ||
| @@ -630,6 +637,14 @@ func (h *WindowShortcutHandler) isTabModeActive() bool { | |||
| 630 | 637 | return h.app.tabManager.IsTabModeActive() | |
| 631 | 638 | } | |
| 632 | 639 | ||
| 640 | + | // isRenameInProgress checks if a tab rename is currently in progress | |
| 641 | + | func (h *WindowShortcutHandler) isRenameInProgress() bool { | |
| 642 | + | if h.app == nil || h.app.tabManager == nil { | |
| 643 | + | return false | |
| 644 | + | } | |
| 645 | + | return h.app.tabManager.IsRenameInProgress() | |
| 646 | + | } | |
| 647 | + | ||
| 633 | 648 | // handleTabModeAction handles tab mode action keys (n, x, l, h, etc.) | |
| 634 | 649 | // Only processes actions when tab mode is active | |
| 635 | 650 | func (h *WindowShortcutHandler) handleTabModeAction(action string) { | |
| @@ -187,8 +187,15 @@ func (h *WindowShortcutHandler) registerKeycodeShortcuts() error { | |||||
| 187 | } | 187 | } | ||
| 188 | 188 | ||||
| 189 | // Handle tab mode action keys (only when tab mode is active) | 189 | // Handle tab mode action keys (only when tab mode is active) | ||
| 190 | + | // IMPORTANT: If rename is in progress, let ALL keys through to the Entry widget | |||
| 190 | // These keys need to be checked here so they can propagate when tab mode is not active | 191 | // These keys need to be checked here so they can propagate when tab mode is not active | ||
| 191 | if h.isTabModeActive() { | 192 | if h.isTabModeActive() { | ||
| 193 | + | // Check if rename is in progress - if so, don't intercept ANY keys | |||
| 194 | + | // This allows the Entry widget to receive all keyboard input | |||
| 195 | + | if h.isRenameInProgress() { | |||
| 196 | + | return false // Let all keys propagate to Entry widget | |||
| 197 | + | } | |||
| 198 | + | ||||
| 192 | handled := false | 199 | handled := false | ||
| 193 | action := "" | 200 | action := "" | ||
| 194 | 201 | ||||
| @@ -630,6 +637,14 @@ func (h *WindowShortcutHandler) isTabModeActive() bool { | |||||
| 630 | return h.app.tabManager.IsTabModeActive() | 637 | return h.app.tabManager.IsTabModeActive() | ||
| 631 | } | 638 | } | ||
| 632 | 639 | ||||
| 640 | + | // isRenameInProgress checks if a tab rename is currently in progress | |||
| 641 | + | func (h *WindowShortcutHandler) isRenameInProgress() bool { | |||
| 642 | + | if h.app == nil || h.app.tabManager == nil { | |||
| 643 | + | return false | |||
| 644 | + | } | |||
| 645 | + | return h.app.tabManager.IsRenameInProgress() | |||
| 646 | + | } | |||
| 647 | + | ||||
| 633 | // handleTabModeAction handles tab mode action keys (n, x, l, h, etc.) | 648 | // handleTabModeAction handles tab mode action keys (n, x, l, h, etc.) | ||
| 634 | // Only processes actions when tab mode is active | 649 | // Only processes actions when tab mode is active | ||
| 635 | func (h *WindowShortcutHandler) handleTabModeAction(action string) { | 650 | func (h *WindowShortcutHandler) handleTabModeAction(action string) { | ||