feat(page-mode): show pending vim sequences in mode toaster
- commit
- eca30c16d927221d5f3ab900b91faa2beac18a18
- author
- brice · · 2026-07-29 06:46 UTC
- committer
- bnema · · 2026-08-14 02:42 UTC
- parent
- adf5a45
- signature
- unsigned
2 files · +68 -10
M internal/ui/app.go +46 -0
| @@ -1100,6 +1100,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1100 | 1100 | a.activateBrowserWindow(bw) | |
| 1101 | 1101 | a.handleModeChange(ctx, bw, from, to) | |
| 1102 | 1102 | }) | |
| 1103 | + | a.bindPageModeSequenceToaster(ctx, bw) | |
| 1103 | 1104 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | |
| 1104 | 1105 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | |
| 1105 | 1106 | return input.RoutePassToWidget | |
| @@ -4054,6 +4055,51 @@ func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, | |||
| 4054 | 4055 | ) | |
| 4055 | 4056 | } | |
| 4056 | 4057 | ||
| 4058 | + | // bindPageModeSequenceToaster wires pending Vim-sequence text onto this | |
| 4059 | + | // window's modeToaster only (never appToaster / pane toasters). | |
| 4060 | + | func (a *App) bindPageModeSequenceToaster(ctx context.Context, bw *browserWindow) { | |
| 4061 | + | if a == nil || bw == nil || bw.keyboardHandler == nil { | |
| 4062 | + | return | |
| 4063 | + | } | |
| 4064 | + | bw.keyboardHandler.SetOnPendingSequenceChange(func(pending string) { | |
| 4065 | + | a.showPendingSequence(ctx, bw, pending) | |
| 4066 | + | }) | |
| 4067 | + | logging.FromContext(ctx).Debug(). | |
| 4068 | + | Str("window_id", bw.id). | |
| 4069 | + | Msg("page mode pending sequence toaster bound") | |
| 4070 | + | } | |
| 4071 | + | ||
| 4072 | + | // showPendingSequence shows "PAGE MODE · pending" on the per-window modeToaster | |
| 4073 | + | // while a sequence is incomplete, and restores stable "PAGE MODE" when pending | |
| 4074 | + | // clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster; | |
| 4075 | + | // silent reset under ModalState avoids stale pending callbacks. | |
| 4076 | + | func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) { | |
| 4077 | + | if a == nil || bw == nil || bw.modeToaster == nil { | |
| 4078 | + | return | |
| 4079 | + | } | |
| 4080 | + | log := logging.FromContext(ctx) | |
| 4081 | + | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled { | |
| 4082 | + | bw.modeToaster.Hide() | |
| 4083 | + | log.Debug().Str("window_id", bw.id).Msg("page mode pending toaster hidden (disabled)") | |
| 4084 | + | return | |
| 4085 | + | } | |
| 4086 | + | ||
| 4087 | + | text := input.ModePage.DisplayName() | |
| 4088 | + | if pending != "" { | |
| 4089 | + | text = text + " · " + pending | |
| 4090 | + | } | |
| 4091 | + | bw.modeToaster.Show(ctx, text, component.ToastInfo, | |
| 4092 | + | component.WithDuration(0), | |
| 4093 | + | component.WithPosition(component.ToastPositionBottomLeft), | |
| 4094 | + | component.WithModeClass(getModeToastClass(input.ModePage)), | |
| 4095 | + | ) | |
| 4096 | + | log.Debug(). | |
| 4097 | + | Str("window_id", bw.id). | |
| 4098 | + | Str("pending", pending). | |
| 4099 | + | Str("toast_text", text). | |
| 4100 | + | Msg("page mode pending toaster updated") | |
| 4101 | + | } | |
| 4102 | + | ||
| 4057 | 4103 | // getModeToastClass returns the CSS class for the given mode's toast styling. | |
| 4058 | 4104 | func getModeToastClass(mode input.Mode) string { | |
| 4059 | 4105 | switch mode { | |
| @@ -1100,6 +1100,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||||
| 1100 | a.activateBrowserWindow(bw) | 1100 | a.activateBrowserWindow(bw) | ||
| 1101 | a.handleModeChange(ctx, bw, from, to) | 1101 | a.handleModeChange(ctx, bw, from, to) | ||
| 1102 | }) | 1102 | }) | ||
| 1103 | + | a.bindPageModeSequenceToaster(ctx, bw) | |||
| 1103 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | 1104 | bw.keyboardHandler.SetRouteKey(func(kc input.KeyContext) input.KeyRoute { | ||
| 1104 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | 1105 | if bw.sessionManager != nil && bw.sessionManager.IsVisible() { | ||
| 1105 | return input.RoutePassToWidget | 1106 | return input.RoutePassToWidget | ||
| @@ -4054,6 +4055,51 @@ func (a *App) updateModeIndicatorToaster(ctx context.Context, bw *browserWindow, | |||||
| 4054 | ) | 4055 | ) | ||
| 4055 | } | 4056 | } | ||
| 4056 | 4057 | ||||
| 4058 | + | // bindPageModeSequenceToaster wires pending Vim-sequence text onto this | |||
| 4059 | + | // window's modeToaster only (never appToaster / pane toasters). | |||
| 4060 | + | func (a *App) bindPageModeSequenceToaster(ctx context.Context, bw *browserWindow) { | |||
| 4061 | + | if a == nil || bw == nil || bw.keyboardHandler == nil { | |||
| 4062 | + | return | |||
| 4063 | + | } | |||
| 4064 | + | bw.keyboardHandler.SetOnPendingSequenceChange(func(pending string) { | |||
| 4065 | + | a.showPendingSequence(ctx, bw, pending) | |||
| 4066 | + | }) | |||
| 4067 | + | logging.FromContext(ctx).Debug(). | |||
| 4068 | + | Str("window_id", bw.id). | |||
| 4069 | + | Msg("page mode pending sequence toaster bound") | |||
| 4070 | + | } | |||
| 4071 | + | ||||
| 4072 | + | // showPendingSequence shows "PAGE MODE · pending" on the per-window modeToaster | |||
| 4073 | + | // while a sequence is incomplete, and restores stable "PAGE MODE" when pending | |||
| 4074 | + | // clears. Mode exit/hide is handled by handleModeChange/updateModeIndicatorToaster; | |||
| 4075 | + | // silent reset under ModalState avoids stale pending callbacks. | |||
| 4076 | + | func (a *App) showPendingSequence(ctx context.Context, bw *browserWindow, pending string) { | |||
| 4077 | + | if a == nil || bw == nil || bw.modeToaster == nil { | |||
| 4078 | + | return | |||
| 4079 | + | } | |||
| 4080 | + | log := logging.FromContext(ctx) | |||
| 4081 | + | if !a.runtimeConfigSnapshot().UI.Workspace.Styling.ModeIndicatorToasterEnabled { | |||
| 4082 | + | bw.modeToaster.Hide() | |||
| 4083 | + | log.Debug().Str("window_id", bw.id).Msg("page mode pending toaster hidden (disabled)") | |||
| 4084 | + | return | |||
| 4085 | + | } | |||
| 4086 | + | ||||
| 4087 | + | text := input.ModePage.DisplayName() | |||
| 4088 | + | if pending != "" { | |||
| 4089 | + | text = text + " · " + pending | |||
| 4090 | + | } | |||
| 4091 | + | bw.modeToaster.Show(ctx, text, component.ToastInfo, | |||
| 4092 | + | component.WithDuration(0), | |||
| 4093 | + | component.WithPosition(component.ToastPositionBottomLeft), | |||
| 4094 | + | component.WithModeClass(getModeToastClass(input.ModePage)), | |||
| 4095 | + | ) | |||
| 4096 | + | log.Debug(). | |||
| 4097 | + | Str("window_id", bw.id). | |||
| 4098 | + | Str("pending", pending). | |||
| 4099 | + | Str("toast_text", text). | |||
| 4100 | + | Msg("page mode pending toaster updated") | |||
| 4101 | + | } | |||
| 4102 | + | ||||
| 4057 | // getModeToastClass returns the CSS class for the given mode's toast styling. | 4103 | // getModeToastClass returns the CSS class for the given mode's toast styling. | ||
| 4058 | func getModeToastClass(mode input.Mode) string { | 4104 | func getModeToastClass(mode input.Mode) string { | ||
| 4059 | switch mode { | 4105 | switch mode { | ||
M internal/ui/input/keyboard.go +22 -10
| @@ -287,16 +287,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 287 | 287 | // so app shortcuts still work. | |
| 288 | 288 | h.controller.SetPropagationPhase(gtk.PhaseBubbleValue) | |
| 289 | 289 | ||
| 290 | - | // Wire GTK main thread scheduler for modal timeouts. Timer goroutines | |
| 291 | - | // must dispatch ExitMode to the GTK thread because onModeChange may | |
| 292 | - | // call setControllerPhase (a GTK operation). | |
| 293 | - | h.modal.SetMainThreadScheduler(func(fn func()) { | |
| 294 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 295 | - | fn() | |
| 296 | - | return false | |
| 297 | - | }) | |
| 298 | - | glib.IdleAdd(&cb, 0) | |
| 299 | - | }) | |
| 290 | + | // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts. | |
| 291 | + | // Timer goroutines must dispatch back to the GTK thread because callbacks | |
| 292 | + | // may touch GTK widgets / EventController phase. | |
| 293 | + | h.installGTKMainThreadSchedulers() | |
| 300 | 294 | ||
| 301 | 295 | // Connect key pressed handler (retain callback to prevent GC). | |
| 302 | 296 | // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers) | |
| @@ -318,6 +312,24 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||
| 318 | 312 | log.Debug().Msg("keyboard handler attached to window") | |
| 319 | 313 | } | |
| 320 | 314 | ||
| 315 | + | // installGTKMainThreadSchedulers routes modal and sequence timer callbacks | |
| 316 | + | // through glib.IdleAdd so they run on the GTK main thread. | |
| 317 | + | func (h *KeyboardHandler) installGTKMainThreadSchedulers() { | |
| 318 | + | if h == nil { | |
| 319 | + | return | |
| 320 | + | } | |
| 321 | + | schedule := func(fn func()) { | |
| 322 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 323 | + | fn() | |
| 324 | + | return false | |
| 325 | + | }) | |
| 326 | + | glib.IdleAdd(&cb, 0) | |
| 327 | + | } | |
| 328 | + | h.modal.SetMainThreadScheduler(schedule) | |
| 329 | + | h.SetSequenceMainThreadScheduler(schedule) | |
| 330 | + | logging.FromContext(h.ctx).Debug().Msg("gtk main-thread schedulers installed for modal and page-mode sequences") | |
| 331 | + | } | |
| 332 | + | ||
| 321 | 333 | // Detach removes the keyboard handler from a live GTK window and releases its | |
| 322 | 334 | // retained GTK signal callbacks. | |
| 323 | 335 | func (h *KeyboardHandler) Detach() { | |
| @@ -287,16 +287,10 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||||
| 287 | // so app shortcuts still work. | 287 | // so app shortcuts still work. | ||
| 288 | h.controller.SetPropagationPhase(gtk.PhaseBubbleValue) | 288 | h.controller.SetPropagationPhase(gtk.PhaseBubbleValue) | ||
| 289 | 289 | ||||
| 290 | - | // Wire GTK main thread scheduler for modal timeouts. Timer goroutines | 290 | + | // Wire GTK main thread schedulers for modal and sequence ambiguity timeouts. |
| 291 | - | // must dispatch ExitMode to the GTK thread because onModeChange may | 291 | + | // Timer goroutines must dispatch back to the GTK thread because callbacks |
| 292 | - | // call setControllerPhase (a GTK operation). | 292 | + | // may touch GTK widgets / EventController phase. |
| 293 | - | h.modal.SetMainThreadScheduler(func(fn func()) { | 293 | + | h.installGTKMainThreadSchedulers() |
| 294 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 295 | - | fn() | |||
| 296 | - | return false | |||
| 297 | - | }) | |||
| 298 | - | glib.IdleAdd(&cb, 0) | |||
| 299 | - | }) | |||
| 300 | 294 | ||||
| 301 | // Connect key pressed handler (retain callback to prevent GC). | 295 | // Connect key pressed handler (retain callback to prevent GC). | ||
| 302 | // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers) | 296 | // The callback receives: keyval (translated key), keycode (hardware key position), state (modifiers) | ||
| @@ -318,6 +312,24 @@ func (h *KeyboardHandler) AttachTo(window *gtk.ApplicationWindow) { | |||||
| 318 | log.Debug().Msg("keyboard handler attached to window") | 312 | log.Debug().Msg("keyboard handler attached to window") | ||
| 319 | } | 313 | } | ||
| 320 | 314 | ||||
| 315 | + | // installGTKMainThreadSchedulers routes modal and sequence timer callbacks | |||
| 316 | + | // through glib.IdleAdd so they run on the GTK main thread. | |||
| 317 | + | func (h *KeyboardHandler) installGTKMainThreadSchedulers() { | |||
| 318 | + | if h == nil { | |||
| 319 | + | return | |||
| 320 | + | } | |||
| 321 | + | schedule := func(fn func()) { | |||
| 322 | + | cb := glib.SourceFunc(func(_ uintptr) bool { | |||
| 323 | + | fn() | |||
| 324 | + | return false | |||
| 325 | + | }) | |||
| 326 | + | glib.IdleAdd(&cb, 0) | |||
| 327 | + | } | |||
| 328 | + | h.modal.SetMainThreadScheduler(schedule) | |||
| 329 | + | h.SetSequenceMainThreadScheduler(schedule) | |||
| 330 | + | logging.FromContext(h.ctx).Debug().Msg("gtk main-thread schedulers installed for modal and page-mode sequences") | |||
| 331 | + | } | |||
| 332 | + | ||||
| 321 | // Detach removes the keyboard handler from a live GTK window and releases its | 333 | // Detach removes the keyboard handler from a live GTK window and releases its | ||
| 322 | // retained GTK signal callbacks. | 334 | // retained GTK signal callbacks. | ||
| 323 | func (h *KeyboardHandler) Detach() { | 335 | func (h *KeyboardHandler) Detach() { | ||