detached 6b46846

fix(tabs): ctrl+tab navigation and international keyboard support

- Add Ctrl+Tab/Ctrl+Shift+Tab for tab switching via EventControllerKey
- Fix active tab styling with distinct gray shades and GTK CSS compliance
- Add hardware keycode support for Alt+number on AZERTY/QWERTZ layouts
- Fix keyboard layout detection prioritizing actual layout over LANG
commit
6b46846c2cc2b0e8392c43f6e2fc66e0d62696d2
author
bnema · · 2025-11-17 20:26 UTC
parent
7bbc91a
signature
unsigned
6 files · +171 -42
M CHANGELOG.md +6 -1
@@ -5,7 +5,8 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8-- **Zellij-style tab system**: Each tab contains an independent workspace with panes, splits, and stacks. `Ctrl+T` enters tab mode with modal keyboard control (`n`=new, `x`=close, `l/h`=navigate). `Alt+1-9/0` for direct tab switching. Tab bar auto-hides with single tab. Orange border indicator (distinct from blue pane mode).
8+- **Zellij-style tab system**: Each tab contains an independent workspace with panes, splits, and stacks. `Ctrl+T` enters tab mode with modal keyboard control (`n`=new, `x`=close, `l/h`=navigate). `Alt+1-9/0` for direct tab switching. `Ctrl+Tab`/`Ctrl+Shift+Tab` for next/previous tab. Tab bar auto-hides with single tab. Orange border indicator (distinct from blue pane mode).
9+- **International keyboard layout support**: Hardware keycode-based shortcuts for `Alt+number` keys work across all keyboard layouts (QWERTY, AZERTY, QWERTZ, etc.). Physical key position used instead of character mapping.
910 - **Click-to-activate stacked panes**: Click on any collapsed title bar to instantly activate that pane. Uses GTK4 gesture clicks for native interaction feel.
1011 - **Title bar visual separators**: Subtle 1px borders between stacked title bars. Theme-aware colors automatically adjust for light/dark mode.
1112 - **UI scaling for title bars**: New `workspace.styling.ui_scale` config option (default: 1.0). Scales font size, padding, height, and favicon size. Range: 0.5 to 3.0 (e.g., 1.2 = 120% size).
@@ -24,6 +25,10 @@ All notable changes to this project will be documented in this file.
2425 - **Smart Escape key**: First press clears input text, second press closes omnibox. Empty input closes immediately.
2526
2627 ### Fixed
28+- **Ctrl+Tab shortcuts**: Fixed focus cycling behavior, now properly switches between tabs
29+- **Active tab visibility**: Fixed identical styling - active tab now clearly visible with distinct gray shade
30+- **AZERTY keyboard support**: Alt+number shortcuts now work on all keyboard layouts using hardware keycodes
31+- **Keyboard layout detection**: Fixed always detecting "en" - now correctly detects fr/de/etc layouts
2732 - **Zoom visual glitch**: Fixed zoom level for next page being applied before navigation completed, causing visible zoom change on current page. Zoom now applies on load-committed event instead of URI change.
2833 - **Toast notifications**: Fixed toast notifications not appearing for copy URL (Ctrl+Shift+C) and clipboard operations. Event name was `dumber:showToast` but ToastContainer listens for `dumber:toast`.
2934 - **Page reload shortcuts**: Restored `Ctrl+R`, `Ctrl+Shift+R`, `F5`, and `Ctrl+F5` shortcuts. They were registered but not actually wired to the window-level handler.
@@ -5,7 +5,8 @@ All notable changes to this project will be documented in this file.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8-- **Zellij-style tab system**: Each tab contains an independent workspace with panes, splits, and stacks. `Ctrl+T` enters tab mode with modal keyboard control (`n`=new, `x`=close, `l/h`=navigate). `Alt+1-9/0` for direct tab switching. Tab bar auto-hides with single tab. Orange border indicator (distinct from blue pane mode).8+- **Zellij-style tab system**: Each tab contains an independent workspace with panes, splits, and stacks. `Ctrl+T` enters tab mode with modal keyboard control (`n`=new, `x`=close, `l/h`=navigate). `Alt+1-9/0` for direct tab switching. `Ctrl+Tab`/`Ctrl+Shift+Tab` for next/previous tab. Tab bar auto-hides with single tab. Orange border indicator (distinct from blue pane mode).
9+- **International keyboard layout support**: Hardware keycode-based shortcuts for `Alt+number` keys work across all keyboard layouts (QWERTY, AZERTY, QWERTZ, etc.). Physical key position used instead of character mapping.
9 - **Click-to-activate stacked panes**: Click on any collapsed title bar to instantly activate that pane. Uses GTK4 gesture clicks for native interaction feel.10 - **Click-to-activate stacked panes**: Click on any collapsed title bar to instantly activate that pane. Uses GTK4 gesture clicks for native interaction feel.
10 - **Title bar visual separators**: Subtle 1px borders between stacked title bars. Theme-aware colors automatically adjust for light/dark mode.11 - **Title bar visual separators**: Subtle 1px borders between stacked title bars. Theme-aware colors automatically adjust for light/dark mode.
11 - **UI scaling for title bars**: New `workspace.styling.ui_scale` config option (default: 1.0). Scales font size, padding, height, and favicon size. Range: 0.5 to 3.0 (e.g., 1.2 = 120% size).12 - **UI scaling for title bars**: New `workspace.styling.ui_scale` config option (default: 1.0). Scales font size, padding, height, and favicon size. Range: 0.5 to 3.0 (e.g., 1.2 = 120% size).
@@ -24,6 +25,10 @@ All notable changes to this project will be documented in this file.
24 - **Smart Escape key**: First press clears input text, second press closes omnibox. Empty input closes immediately.25 - **Smart Escape key**: First press clears input text, second press closes omnibox. Empty input closes immediately.
25 26
26 ### Fixed27 ### Fixed
28+- **Ctrl+Tab shortcuts**: Fixed focus cycling behavior, now properly switches between tabs
29+- **Active tab visibility**: Fixed identical styling - active tab now clearly visible with distinct gray shade
30+- **AZERTY keyboard support**: Alt+number shortcuts now work on all keyboard layouts using hardware keycodes
31+- **Keyboard layout detection**: Fixed always detecting "en" - now correctly detects fr/de/etc layouts
27 - **Zoom visual glitch**: Fixed zoom level for next page being applied before navigation completed, causing visible zoom change on current page. Zoom now applies on load-committed event instead of URI change.32 - **Zoom visual glitch**: Fixed zoom level for next page being applied before navigation completed, causing visible zoom change on current page. Zoom now applies on load-committed event instead of URI change.
28 - **Toast notifications**: Fixed toast notifications not appearing for copy URL (Ctrl+Shift+C) and clipboard operations. Event name was `dumber:showToast` but ToastContainer listens for `dumber:toast`.33 - **Toast notifications**: Fixed toast notifications not appearing for copy URL (Ctrl+Shift+C) and clipboard operations. Event name was `dumber:showToast` but ToastContainer listens for `dumber:toast`.
29 - **Page reload shortcuts**: Restored `Ctrl+R`, `Ctrl+Shift+R`, `F5`, and `Ctrl+F5` shortcuts. They were registered but not actually wired to the window-level handler.34 - **Page reload shortcuts**: Restored `Ctrl+R`, `Ctrl+Shift+R`, `F5`, and `Ctrl+F5` shortcuts. They were registered but not actually wired to the window-level handler.
M internal/app/browser/tab_bar.go +3 -0
@@ -193,14 +193,17 @@ func (tm *TabManager) removeTabFromBar(tab *Tab) {
193193 // setTabActiveStyle applies or removes the active CSS class on a tab button.
194194 func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {
195195 if tab.titleButton == nil {
196+ logging.Warn(fmt.Sprintf("[tabs] Cannot set active style: button is nil for tab %s", tab.id))
196197 return
197198 }
198199
199200 webkit.RunOnMainThread(func() {
200201 if active {
201202 webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")
203+ logging.Debug(fmt.Sprintf("[tabs] Added active class to tab %s", tab.id))
202204 } else {
203205 webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")
206+ logging.Debug(fmt.Sprintf("[tabs] Removed active class from tab %s", tab.id))
204207 }
205208 })
206209 }
@@ -193,14 +193,17 @@ func (tm *TabManager) removeTabFromBar(tab *Tab) {
193 // setTabActiveStyle applies or removes the active CSS class on a tab button.193 // setTabActiveStyle applies or removes the active CSS class on a tab button.
194 func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {194 func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {
195 if tab.titleButton == nil {195 if tab.titleButton == nil {
196+ logging.Warn(fmt.Sprintf("[tabs] Cannot set active style: button is nil for tab %s", tab.id))
196 return197 return
197 }198 }
198 199
199 webkit.RunOnMainThread(func() {200 webkit.RunOnMainThread(func() {
200 if active {201 if active {
201 webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")202 webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")
203+ logging.Debug(fmt.Sprintf("[tabs] Added active class to tab %s", tab.id))
202 } else {204 } else {
203 webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")205 webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")
206+ logging.Debug(fmt.Sprintf("[tabs] Removed active class from tab %s", tab.id))
204 }207 }
205 })208 })
206 }209 }
M internal/app/browser/window_shortcuts.go +107 -11
@@ -8,6 +8,8 @@ import (
88
99 "github.com/bnema/dumber/internal/app/messaging"
1010 "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
12+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
1113 )
1214
1315 // WindowShortcutHandler manages global shortcuts at the window level
@@ -47,6 +49,11 @@ func (h *WindowShortcutHandler) initialize() error {
4749 return ErrFailedToInitialize
4850 }
4951
52+ // Register hardware keycode-based shortcuts (for AZERTY/international keyboards)
53+ if err := h.registerKeycodeShortcuts(); err != nil {
54+ log.Printf("[window-shortcuts] Failed to register keycode shortcuts: %v", err)
55+ }
56+
5057 return h.registerGlobalShortcuts()
5158 }
5259
@@ -65,6 +72,8 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
6572 {"ctrl+p", h.handlePaneMode, "Enter pane mode"},
6673 // Tab management
6774 {"ctrl+t", h.handleTabMode, "Enter tab mode"},
75+ // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts
76+ // This prevents GTK's default focus cycling behavior
6877 // Tab mode actions (only active when tab mode is enabled)
6978 {"n", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},
7079 {"c", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},
@@ -76,17 +85,8 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
7685 {"shift+Tab", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"},
7786 {"Return", func() { h.handleTabModeAction("confirm") }, "Confirm (tab mode)"},
7887 {"Escape", func() { h.handleTabModeAction("cancel") }, "Cancel (tab mode)"},
79- // Direct tab switching
80- {"alt+1", func() { h.handleDirectTabSwitch(0) }, "Switch to tab 1"},
81- {"alt+2", func() { h.handleDirectTabSwitch(1) }, "Switch to tab 2"},
82- {"alt+3", func() { h.handleDirectTabSwitch(2) }, "Switch to tab 3"},
83- {"alt+4", func() { h.handleDirectTabSwitch(3) }, "Switch to tab 4"},
84- {"alt+5", func() { h.handleDirectTabSwitch(4) }, "Switch to tab 5"},
85- {"alt+6", func() { h.handleDirectTabSwitch(5) }, "Switch to tab 6"},
86- {"alt+7", func() { h.handleDirectTabSwitch(6) }, "Switch to tab 7"},
87- {"alt+8", func() { h.handleDirectTabSwitch(7) }, "Switch to tab 8"},
88- {"alt+9", func() { h.handleDirectTabSwitch(8) }, "Switch to tab 9"},
89- {"alt+0", func() { h.handleDirectTabSwitch(9) }, "Switch to tab 10"},
88+ // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts)
89+ // This supports all keyboard layouts (QWERTY, AZERTY, etc.)
9090 // Page reload shortcuts
9191 {"ctrl+r", h.handleReload, "Reload page"},
9292 {"ctrl+shift+r", h.handleHardReload, "Hard reload (bypass cache)"},
@@ -118,6 +118,76 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
118118 return nil
119119 }
120120
121+// registerKeycodeShortcuts registers keyboard shortcuts based on hardware keycodes
122+// This allows Alt+number shortcuts to work on AZERTY and other keyboard layouts
123+func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
124+ if h.window == nil {
125+ return fmt.Errorf("window is nil")
126+ }
127+
128+ gtkWindow := h.window.AsWindow()
129+ if gtkWindow == nil {
130+ return fmt.Errorf("gtk window is nil")
131+ }
132+
133+ // Create event controller for keyboard events
134+ keyController := gtk.NewEventControllerKey()
135+ keyController.SetPropagationPhase(gtk.PhaseCapture)
136+
137+ // GDK key constants
138+ const (
139+ gdkKeyTab = 0xff09
140+ )
141+
142+ // Map hardware keycodes to tab indices
143+ // These keycodes are the same across all keyboard layouts
144+ keycodeToTab := map[uint]int{
145+ 10: 0, // 1/& key
146+ 11: 1, // 2/é key
147+ 12: 2, // 3/" key
148+ 13: 3, // 4/' key
149+ 14: 4, // 5/( key
150+ 15: 5, // 6/- key
151+ 16: 6, // 7/è key
152+ 17: 7, // 8/_ key
153+ 18: 8, // 9/ç key
154+ 19: 9, // 0/à key
155+ }
156+
157+ keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
158+ // Handle Ctrl+Tab for next tab
159+ if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
160+ log.Printf("[window-shortcuts] Ctrl+Tab -> next tab")
161+ h.handleNextTab()
162+ return true // Consume event to prevent focus cycling
163+ }
164+
165+ // Handle Ctrl+Shift+Tab for previous tab
166+ if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && state.Has(gdk.ShiftMask) {
167+ log.Printf("[window-shortcuts] Ctrl+Shift+Tab -> previous tab")
168+ h.handlePrevTab()
169+ return true // Consume event to prevent focus cycling
170+ }
171+
172+ // Check for Alt+number (but not with Ctrl or Shift)
173+ if state.Has(gdk.AltMask) && !state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
174+ // Check if this is a number key
175+ if tabIndex, ok := keycodeToTab[keycode]; ok {
176+ log.Printf("[window-shortcuts] Hardware keycode shortcut: Alt+keycode(%d) -> tab %d", keycode, tabIndex)
177+ h.handleDirectTabSwitch(tabIndex)
178+ return true // Handled
179+ }
180+ }
181+
182+ return false
183+ })
184+
185+ gtkWindow.AddController(keyController)
186+ log.Printf("[window-shortcuts] Registered hardware keycode shortcuts for Alt+number (AZERTY support)")
187+
188+ return nil
189+}
190+
121191 func (h *WindowShortcutHandler) handleOmniboxToggle() {
122192 h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
123193 }
@@ -455,6 +525,32 @@ func (h *WindowShortcutHandler) handleTabMode() {
455525 h.app.tabManager.EnterTabMode()
456526 }
457527
528+// handleNextTab switches to the next tab.
529+func (h *WindowShortcutHandler) handleNextTab() {
530+ if h.app == nil || h.app.tabManager == nil {
531+ log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
532+ return
533+ }
534+
535+ log.Printf("[window-shortcuts] Switching to next tab")
536+ if err := h.app.tabManager.NextTab(); err != nil {
537+ log.Printf("[window-shortcuts] Failed to switch to next tab: %v", err)
538+ }
539+}
540+
541+// handlePrevTab switches to the previous tab.
542+func (h *WindowShortcutHandler) handlePrevTab() {
543+ if h.app == nil || h.app.tabManager == nil {
544+ log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
545+ return
546+ }
547+
548+ log.Printf("[window-shortcuts] Switching to previous tab")
549+ if err := h.app.tabManager.PreviousTab(); err != nil {
550+ log.Printf("[window-shortcuts] Failed to switch to previous tab: %v", err)
551+ }
552+}
553+
458554 // handleDirectTabSwitch switches to a specific tab by index (0-based).
459555 func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {
460556 if h.app == nil || h.app.tabManager == nil {
@@ -8,6 +8,8 @@ import (
8 8
9 "github.com/bnema/dumber/internal/app/messaging"9 "github.com/bnema/dumber/internal/app/messaging"
10 "github.com/bnema/dumber/pkg/webkit"10 "github.com/bnema/dumber/pkg/webkit"
11+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
12+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
11 )13 )
12 14
13 // WindowShortcutHandler manages global shortcuts at the window level15 // WindowShortcutHandler manages global shortcuts at the window level
@@ -47,6 +49,11 @@ func (h *WindowShortcutHandler) initialize() error {
47 return ErrFailedToInitialize49 return ErrFailedToInitialize
48 }50 }
49 51
52+ // Register hardware keycode-based shortcuts (for AZERTY/international keyboards)
53+ if err := h.registerKeycodeShortcuts(); err != nil {
54+ log.Printf("[window-shortcuts] Failed to register keycode shortcuts: %v", err)
55+ }
56+
50 return h.registerGlobalShortcuts()57 return h.registerGlobalShortcuts()
51 }58 }
52 59
@@ -65,6 +72,8 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
65 {"ctrl+p", h.handlePaneMode, "Enter pane mode"},72 {"ctrl+p", h.handlePaneMode, "Enter pane mode"},
66 // Tab management73 // Tab management
67 {"ctrl+t", h.handleTabMode, "Enter tab mode"},74 {"ctrl+t", h.handleTabMode, "Enter tab mode"},
75+ // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts
76+ // This prevents GTK's default focus cycling behavior
68 // Tab mode actions (only active when tab mode is enabled)77 // Tab mode actions (only active when tab mode is enabled)
69 {"n", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},78 {"n", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},
70 {"c", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},79 {"c", func() { h.handleTabModeAction("new-tab") }, "New tab (tab mode)"},
@@ -76,17 +85,8 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
76 {"shift+Tab", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"},85 {"shift+Tab", func() { h.handleTabModeAction("previous-tab") }, "Previous tab (tab mode)"},
77 {"Return", func() { h.handleTabModeAction("confirm") }, "Confirm (tab mode)"},86 {"Return", func() { h.handleTabModeAction("confirm") }, "Confirm (tab mode)"},
78 {"Escape", func() { h.handleTabModeAction("cancel") }, "Cancel (tab mode)"},87 {"Escape", func() { h.handleTabModeAction("cancel") }, "Cancel (tab mode)"},
79- // Direct tab switching88+ // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts)
80- {"alt+1", func() { h.handleDirectTabSwitch(0) }, "Switch to tab 1"},89+ // This supports all keyboard layouts (QWERTY, AZERTY, etc.)
81- {"alt+2", func() { h.handleDirectTabSwitch(1) }, "Switch to tab 2"},
82- {"alt+3", func() { h.handleDirectTabSwitch(2) }, "Switch to tab 3"},
83- {"alt+4", func() { h.handleDirectTabSwitch(3) }, "Switch to tab 4"},
84- {"alt+5", func() { h.handleDirectTabSwitch(4) }, "Switch to tab 5"},
85- {"alt+6", func() { h.handleDirectTabSwitch(5) }, "Switch to tab 6"},
86- {"alt+7", func() { h.handleDirectTabSwitch(6) }, "Switch to tab 7"},
87- {"alt+8", func() { h.handleDirectTabSwitch(7) }, "Switch to tab 8"},
88- {"alt+9", func() { h.handleDirectTabSwitch(8) }, "Switch to tab 9"},
89- {"alt+0", func() { h.handleDirectTabSwitch(9) }, "Switch to tab 10"},
90 // Page reload shortcuts90 // Page reload shortcuts
91 {"ctrl+r", h.handleReload, "Reload page"},91 {"ctrl+r", h.handleReload, "Reload page"},
92 {"ctrl+shift+r", h.handleHardReload, "Hard reload (bypass cache)"},92 {"ctrl+shift+r", h.handleHardReload, "Hard reload (bypass cache)"},
@@ -118,6 +118,76 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
118 return nil118 return nil
119 }119 }
120 120
121+// registerKeycodeShortcuts registers keyboard shortcuts based on hardware keycodes
122+// This allows Alt+number shortcuts to work on AZERTY and other keyboard layouts
123+func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
124+ if h.window == nil {
125+ return fmt.Errorf("window is nil")
126+ }
127+
128+ gtkWindow := h.window.AsWindow()
129+ if gtkWindow == nil {
130+ return fmt.Errorf("gtk window is nil")
131+ }
132+
133+ // Create event controller for keyboard events
134+ keyController := gtk.NewEventControllerKey()
135+ keyController.SetPropagationPhase(gtk.PhaseCapture)
136+
137+ // GDK key constants
138+ const (
139+ gdkKeyTab = 0xff09
140+ )
141+
142+ // Map hardware keycodes to tab indices
143+ // These keycodes are the same across all keyboard layouts
144+ keycodeToTab := map[uint]int{
145+ 10: 0, // 1/& key
146+ 11: 1, // 2/é key
147+ 12: 2, // 3/" key
148+ 13: 3, // 4/' key
149+ 14: 4, // 5/( key
150+ 15: 5, // 6/- key
151+ 16: 6, // 7/è key
152+ 17: 7, // 8/_ key
153+ 18: 8, // 9/ç key
154+ 19: 9, // 0/à key
155+ }
156+
157+ keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
158+ // Handle Ctrl+Tab for next tab
159+ if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
160+ log.Printf("[window-shortcuts] Ctrl+Tab -> next tab")
161+ h.handleNextTab()
162+ return true // Consume event to prevent focus cycling
163+ }
164+
165+ // Handle Ctrl+Shift+Tab for previous tab
166+ if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && state.Has(gdk.ShiftMask) {
167+ log.Printf("[window-shortcuts] Ctrl+Shift+Tab -> previous tab")
168+ h.handlePrevTab()
169+ return true // Consume event to prevent focus cycling
170+ }
171+
172+ // Check for Alt+number (but not with Ctrl or Shift)
173+ if state.Has(gdk.AltMask) && !state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
174+ // Check if this is a number key
175+ if tabIndex, ok := keycodeToTab[keycode]; ok {
176+ log.Printf("[window-shortcuts] Hardware keycode shortcut: Alt+keycode(%d) -> tab %d", keycode, tabIndex)
177+ h.handleDirectTabSwitch(tabIndex)
178+ return true // Handled
179+ }
180+ }
181+
182+ return false
183+ })
184+
185+ gtkWindow.AddController(keyController)
186+ log.Printf("[window-shortcuts] Registered hardware keycode shortcuts for Alt+number (AZERTY support)")
187+
188+ return nil
189+}
190+
121 func (h *WindowShortcutHandler) handleOmniboxToggle() {191 func (h *WindowShortcutHandler) handleOmniboxToggle() {
122 h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")192 h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
123 }193 }
@@ -455,6 +525,32 @@ func (h *WindowShortcutHandler) handleTabMode() {
455 h.app.tabManager.EnterTabMode()525 h.app.tabManager.EnterTabMode()
456 }526 }
457 527
528+// handleNextTab switches to the next tab.
529+func (h *WindowShortcutHandler) handleNextTab() {
530+ if h.app == nil || h.app.tabManager == nil {
531+ log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
532+ return
533+ }
534+
535+ log.Printf("[window-shortcuts] Switching to next tab")
536+ if err := h.app.tabManager.NextTab(); err != nil {
537+ log.Printf("[window-shortcuts] Failed to switch to next tab: %v", err)
538+ }
539+}
540+
541+// handlePrevTab switches to the previous tab.
542+func (h *WindowShortcutHandler) handlePrevTab() {
543+ if h.app == nil || h.app.tabManager == nil {
544+ log.Printf("[window-shortcuts] Cannot switch tab: tab manager not available")
545+ return
546+ }
547+
548+ log.Printf("[window-shortcuts] Switching to previous tab")
549+ if err := h.app.tabManager.PreviousTab(); err != nil {
550+ log.Printf("[window-shortcuts] Failed to switch to previous tab: %v", err)
551+ }
552+}
553+
458 // handleDirectTabSwitch switches to a specific tab by index (0-based).554 // handleDirectTabSwitch switches to a specific tab by index (0-based).
459 func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {555 func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {
460 if h.app == nil || h.app.tabManager == nil {556 if h.app == nil || h.app.tabManager == nil {
M internal/app/browser/workspace_css.go +15 -6
@@ -44,6 +44,14 @@ func getStackTitleBorderColor(isDark bool) string {
4444 return "#e5e5e5" // Slightly darker than #f0f0f0
4545 }
4646
47+// getActiveTabBg returns a distinct background color for the active tab
48+func getActiveTabBg(isDark bool) string {
49+ if isDark {
50+ return "#707070" // Significantly lighter gray than inactive (#404040) - similar to stacked panes
51+ }
52+ return "#c0c0c0" // Significantly darker gray than inactive (#f0f0f0) - similar to stacked panes
53+}
54+
4755 // getActivePaneBorderColor returns the border color for active panes based on config and theme
4856 func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string {
4957 // Use configured border color if set
@@ -215,23 +223,24 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
215223 }
216224
217225 /* Tab button styling - matches stacked pane titles */
218- .tab-button {
226+ button.tab-button {
219227 background-color: %s;
228+ background-image: none;
220229 border: none;
221230 border-right: 1px solid %s;
222231 border-radius: 0;
223232 padding: %dpx %dpx;
224- min-width: 100px;
225- max-width: 200px;
226233 transition: background-color %dms ease-in-out;
227234 }
228235
229- .tab-button:hover {
236+ button.tab-button:hover {
230237 background-color: %s;
238+ background-image: none;
231239 }
232240
233- .tab-button-active {
241+ button.tab-button.tab-button-active {
234242 background-color: %s;
243+ background-image: none;
235244 border: none;
236245 border-right: 1px solid %s;
237246 border-radius: 0;
@@ -293,7 +302,7 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
293302 titlePaddingHorizontal, // tab-button padding horizontal
294303 styling.TransitionDuration, // tab-button transition
295304 getStackTitleHoverBg(isDark), // tab-button:hover background
296- getStackTitleHoverBg(isDark), // tab-button-active background (lighter/brighter)
305+ getActiveTabBg(isDark), // tab-button-active background (much more visible)
297306 getStackTitleBorderColor(isDark), // tab-button-active border-right
298307 titleFontSize, // tab-title font-size
299308 getStackTitleTextColor(isDark), // tab-title color
@@ -44,6 +44,14 @@ func getStackTitleBorderColor(isDark bool) string {
44 return "#e5e5e5" // Slightly darker than #f0f0f044 return "#e5e5e5" // Slightly darker than #f0f0f0
45 }45 }
46 46
47+// getActiveTabBg returns a distinct background color for the active tab
48+func getActiveTabBg(isDark bool) string {
49+ if isDark {
50+ return "#707070" // Significantly lighter gray than inactive (#404040) - similar to stacked panes
51+ }
52+ return "#c0c0c0" // Significantly darker gray than inactive (#f0f0f0) - similar to stacked panes
53+}
54+
47 // getActivePaneBorderColor returns the border color for active panes based on config and theme55 // getActivePaneBorderColor returns the border color for active panes based on config and theme
48 func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string {56 func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string {
49 // Use configured border color if set57 // Use configured border color if set
@@ -215,23 +223,24 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
215 }223 }
216 224
217 /* Tab button styling - matches stacked pane titles */225 /* Tab button styling - matches stacked pane titles */
218- .tab-button {226+ button.tab-button {
219 background-color: %s;227 background-color: %s;
228+ background-image: none;
220 border: none;229 border: none;
221 border-right: 1px solid %s;230 border-right: 1px solid %s;
222 border-radius: 0;231 border-radius: 0;
223 padding: %dpx %dpx;232 padding: %dpx %dpx;
224- min-width: 100px;
225- max-width: 200px;
226 transition: background-color %dms ease-in-out;233 transition: background-color %dms ease-in-out;
227 }234 }
228 235
229- .tab-button:hover {236+ button.tab-button:hover {
230 background-color: %s;237 background-color: %s;
238+ background-image: none;
231 }239 }
232 240
233- .tab-button-active {241+ button.tab-button.tab-button-active {
234 background-color: %s;242 background-color: %s;
243+ background-image: none;
235 border: none;244 border: none;
236 border-right: 1px solid %s;245 border-right: 1px solid %s;
237 border-radius: 0;246 border-radius: 0;
@@ -293,7 +302,7 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
293 titlePaddingHorizontal, // tab-button padding horizontal302 titlePaddingHorizontal, // tab-button padding horizontal
294 styling.TransitionDuration, // tab-button transition303 styling.TransitionDuration, // tab-button transition
295 getStackTitleHoverBg(isDark), // tab-button:hover background304 getStackTitleHoverBg(isDark), // tab-button:hover background
296- getStackTitleHoverBg(isDark), // tab-button-active background (lighter/brighter)305+ getActiveTabBg(isDark), // tab-button-active background (much more visible)
297 getStackTitleBorderColor(isDark), // tab-button-active border-right306 getStackTitleBorderColor(isDark), // tab-button-active border-right
298 titleFontSize, // tab-title font-size307 titleFontSize, // tab-title font-size
299 getStackTitleTextColor(isDark), // tab-title color308 getStackTitleTextColor(isDark), // tab-title color
M internal/app/environment/locale.go +34 -24
@@ -12,38 +12,21 @@ import (
1212 func DetectAndSetKeyboardLocale() {
1313 const colonSplitParts = 2 // Number of parts when splitting on colon
1414
15+ var locale string
16+
1517 // 0) Explicit override
16- locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")
18+ locale = os.Getenv("DUMBER_KEYBOARD_LOCALE")
1719 if locale == "" {
1820 locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
1921 }
20- // 1) Environment
21- if locale == "" {
22- locale = os.Getenv("LC_ALL")
23- }
24- if locale == "" {
25- locale = os.Getenv("LANG")
26- }
27- if locale == "" {
28- locale = os.Getenv("LC_CTYPE")
29- }
30- // Trim variants
31- if i := strings.IndexByte(locale, '.'); i > 0 {
32- locale = locale[:i]
33- }
34- if i := strings.IndexByte(locale, '@'); i > 0 {
35- locale = locale[:i]
36- }
37- if i := strings.IndexByte(locale, '_'); i > 0 {
38- locale = locale[:i]
39- }
4022
41- // 2) Try XKB env override
23+ // 1) XKB env override (actual keyboard layout)
4224 if locale == "" {
4325 locale = os.Getenv("XKB_DEFAULT_LAYOUT")
4426 }
4527
46- // 3) Best-effort probe of setxkbmap/localectl (non-fatal)
28+ // 2) Best-effort probe of localectl/setxkbmap (actual keyboard layout)
29+ // Check these BEFORE LANG since LANG is system language, not keyboard layout
4730 if locale == "" {
4831 if out, err := exec.Command("localectl", "status").Output(); err == nil {
4932 s := string(out)
@@ -55,6 +38,7 @@ func DetectAndSetKeyboardLocale() {
5538 cand := strings.TrimSpace(parts[1])
5639 if cand != "" {
5740 locale = cand
41+ log.Printf("[locale] keyboard layout from localectl: %s", locale)
5842 }
5943 }
6044 break
@@ -73,6 +57,7 @@ func DetectAndSetKeyboardLocale() {
7357 cand := strings.TrimSpace(parts[1])
7458 if cand != "" {
7559 locale = cand
60+ log.Printf("[locale] keyboard layout from setxkbmap: %s", locale)
7661 }
7762 }
7863 break
@@ -81,9 +66,34 @@ func DetectAndSetKeyboardLocale() {
8166 }
8267 }
8368
69+ // 3) Fallback to system locale (language, not keyboard layout)
70+ if locale == "" {
71+ locale = os.Getenv("LC_ALL")
72+ }
73+ if locale == "" {
74+ locale = os.Getenv("LANG")
75+ }
76+ if locale == "" {
77+ locale = os.Getenv("LC_CTYPE")
78+ }
79+ // Trim variants from locale
80+ if locale != "" {
81+ if i := strings.IndexByte(locale, '.'); i > 0 {
82+ locale = locale[:i]
83+ }
84+ if i := strings.IndexByte(locale, '@'); i > 0 {
85+ locale = locale[:i]
86+ }
87+ if i := strings.IndexByte(locale, '_'); i > 0 {
88+ locale = locale[:i]
89+ }
90+ log.Printf("[locale] keyboard layout from system locale: %s", locale)
91+ }
92+
8493 if locale == "" {
8594 locale = "en"
95+ log.Printf("[locale] keyboard layout defaulted to: %s", locale)
8696 }
87- // No layout-specific remaps; log for diagnostics only
97+
8898 log.Printf("[locale] keyboard locale detected: %s", locale)
8999 }
@@ -12,38 +12,21 @@ import (
12 func DetectAndSetKeyboardLocale() {12 func DetectAndSetKeyboardLocale() {
13 const colonSplitParts = 2 // Number of parts when splitting on colon13 const colonSplitParts = 2 // Number of parts when splitting on colon
14 14
15+ var locale string
16+
15 // 0) Explicit override17 // 0) Explicit override
16- locale := os.Getenv("DUMBER_KEYBOARD_LOCALE")18+ locale = os.Getenv("DUMBER_KEYBOARD_LOCALE")
17 if locale == "" {19 if locale == "" {
18 locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix20 locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
19 }21 }
20- // 1) Environment
21- if locale == "" {
22- locale = os.Getenv("LC_ALL")
23- }
24- if locale == "" {
25- locale = os.Getenv("LANG")
26- }
27- if locale == "" {
28- locale = os.Getenv("LC_CTYPE")
29- }
30- // Trim variants
31- if i := strings.IndexByte(locale, '.'); i > 0 {
32- locale = locale[:i]
33- }
34- if i := strings.IndexByte(locale, '@'); i > 0 {
35- locale = locale[:i]
36- }
37- if i := strings.IndexByte(locale, '_'); i > 0 {
38- locale = locale[:i]
39- }
40 22
41- // 2) Try XKB env override23+ // 1) XKB env override (actual keyboard layout)
42 if locale == "" {24 if locale == "" {
43 locale = os.Getenv("XKB_DEFAULT_LAYOUT")25 locale = os.Getenv("XKB_DEFAULT_LAYOUT")
44 }26 }
45 27
46- // 3) Best-effort probe of setxkbmap/localectl (non-fatal)28+ // 2) Best-effort probe of localectl/setxkbmap (actual keyboard layout)
29+ // Check these BEFORE LANG since LANG is system language, not keyboard layout
47 if locale == "" {30 if locale == "" {
48 if out, err := exec.Command("localectl", "status").Output(); err == nil {31 if out, err := exec.Command("localectl", "status").Output(); err == nil {
49 s := string(out)32 s := string(out)
@@ -55,6 +38,7 @@ func DetectAndSetKeyboardLocale() {
55 cand := strings.TrimSpace(parts[1])38 cand := strings.TrimSpace(parts[1])
56 if cand != "" {39 if cand != "" {
57 locale = cand40 locale = cand
41+ log.Printf("[locale] keyboard layout from localectl: %s", locale)
58 }42 }
59 }43 }
60 break44 break
@@ -73,6 +57,7 @@ func DetectAndSetKeyboardLocale() {
73 cand := strings.TrimSpace(parts[1])57 cand := strings.TrimSpace(parts[1])
74 if cand != "" {58 if cand != "" {
75 locale = cand59 locale = cand
60+ log.Printf("[locale] keyboard layout from setxkbmap: %s", locale)
76 }61 }
77 }62 }
78 break63 break
@@ -81,9 +66,34 @@ func DetectAndSetKeyboardLocale() {
81 }66 }
82 }67 }
83 68
69+ // 3) Fallback to system locale (language, not keyboard layout)
70+ if locale == "" {
71+ locale = os.Getenv("LC_ALL")
72+ }
73+ if locale == "" {
74+ locale = os.Getenv("LANG")
75+ }
76+ if locale == "" {
77+ locale = os.Getenv("LC_CTYPE")
78+ }
79+ // Trim variants from locale
80+ if locale != "" {
81+ if i := strings.IndexByte(locale, '.'); i > 0 {
82+ locale = locale[:i]
83+ }
84+ if i := strings.IndexByte(locale, '@'); i > 0 {
85+ locale = locale[:i]
86+ }
87+ if i := strings.IndexByte(locale, '_'); i > 0 {
88+ locale = locale[:i]
89+ }
90+ log.Printf("[locale] keyboard layout from system locale: %s", locale)
91+ }
92+
84 if locale == "" {93 if locale == "" {
85 locale = "en"94 locale = "en"
95+ log.Printf("[locale] keyboard layout defaulted to: %s", locale)
86 }96 }
87- // No layout-specific remaps; log for diagnostics only97+
88 log.Printf("[locale] keyboard locale detected: %s", locale)98 log.Printf("[locale] keyboard locale detected: %s", locale)
89 }99 }
M pkg/webkit/shortcuts.go +6 -0
@@ -138,6 +138,12 @@ func convertToGtkFormat(key string) string {
138138 parts = append(parts, "Up")
139139 case "down", "arrowdown":
140140 parts = append(parts, "Down")
141+ case "tab":
142+ parts = append(parts, "Tab")
143+ case "escape":
144+ parts = append(parts, "Escape")
145+ case "return", "enter":
146+ parts = append(parts, "Return")
141147 default:
142148 // Single letter keys like l, f, c, w, etc.
143149 parts = append(parts, part)
@@ -138,6 +138,12 @@ func convertToGtkFormat(key string) string {
138 parts = append(parts, "Up")138 parts = append(parts, "Up")
139 case "down", "arrowdown":139 case "down", "arrowdown":
140 parts = append(parts, "Down")140 parts = append(parts, "Down")
141+ case "tab":
142+ parts = append(parts, "Tab")
143+ case "escape":
144+ parts = append(parts, "Escape")
145+ case "return", "enter":
146+ parts = append(parts, "Return")
141 default:147 default:
142 // Single letter keys like l, f, c, w, etc.148 // Single letter keys like l, f, c, w, etc.
143 parts = append(parts, part)149 parts = append(parts, part)

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