fix(ui): apply mode class atomically to toaster and support custom colors via CSS
- commit
- 5fe539f2fe97585a637d98aabd69f25f2524df0d
- author
- brice · · 2025-12-28 07:41 UTC
- parent
- f0af95e
- signature
- unsigned
2 files · +58 -22
M internal/ui/app.go +3 -5
| @@ -1307,15 +1307,13 @@ func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | |||
| 1307 | 1307 | } | |
| 1308 | 1308 | ||
| 1309 | 1309 | // Show persistent toaster at bottom-left with mode display name. | |
| 1310 | - | // Note: Call Show() first, then ApplyModeClass() because Show() clears custom styles. | |
| 1310 | + | // Mode class is applied atomically with Show() to avoid visual flicker. | |
| 1311 | + | modeClass := getModeToastClass(mode) | |
| 1311 | 1312 | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | |
| 1312 | 1313 | component.WithDuration(0), // Persistent until mode exits. | |
| 1313 | 1314 | component.WithPosition(component.ToastPositionBottomLeft), | |
| 1315 | + | component.WithModeClass(modeClass), | |
| 1314 | 1316 | ) | |
| 1315 | - | ||
| 1316 | - | // Apply mode-specific CSS class after Show() to override the default level styling. | |
| 1317 | - | modeClass := getModeToastClass(mode) | |
| 1318 | - | a.modeToaster.ApplyModeClass(modeClass) | |
| 1319 | 1317 | } | |
| 1320 | 1318 | ||
| 1321 | 1319 | // getModeToastClass returns the CSS class for the given mode's toast styling. | |
| @@ -1307,15 +1307,13 @@ func (a *App) updateModeIndicatorToaster(ctx context.Context, mode input.Mode) { | |||||
| 1307 | } | 1307 | } | ||
| 1308 | 1308 | ||||
| 1309 | // Show persistent toaster at bottom-left with mode display name. | 1309 | // Show persistent toaster at bottom-left with mode display name. | ||
| 1310 | - | // Note: Call Show() first, then ApplyModeClass() because Show() clears custom styles. | 1310 | + | // Mode class is applied atomically with Show() to avoid visual flicker. |
| 1311 | + | modeClass := getModeToastClass(mode) | |||
| 1311 | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | 1312 | a.modeToaster.Show(ctx, mode.DisplayName(), component.ToastInfo, | ||
| 1312 | component.WithDuration(0), // Persistent until mode exits. | 1313 | component.WithDuration(0), // Persistent until mode exits. | ||
| 1313 | component.WithPosition(component.ToastPositionBottomLeft), | 1314 | component.WithPosition(component.ToastPositionBottomLeft), | ||
| 1315 | + | component.WithModeClass(modeClass), | |||
| 1314 | ) | 1316 | ) | ||
| 1315 | - | ||||
| 1316 | - | // Apply mode-specific CSS class after Show() to override the default level styling. | |||
| 1317 | - | modeClass := getModeToastClass(mode) | |||
| 1318 | - | a.modeToaster.ApplyModeClass(modeClass) | |||
| 1319 | } | 1317 | } | ||
| 1320 | 1318 | ||||
| 1321 | // getModeToastClass returns the CSS class for the given mode's toast styling. | 1319 | // getModeToastClass returns the CSS class for the given mode's toast styling. | ||
M internal/ui/component/toaster.go +55 -17
| @@ -78,6 +78,9 @@ type ToastOptions struct { | |||
| 78 | 78 | TextColor string | |
| 79 | 79 | // Position determines where the toast appears on screen. | |
| 80 | 80 | Position ToastPosition | |
| 81 | + | // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode"). | |
| 82 | + | // When set, this class is applied atomically with Show() to avoid visual flicker. | |
| 83 | + | ModeClass string | |
| 81 | 84 | } | |
| 82 | 85 | ||
| 83 | 86 | // ToastOption is a functional option for configuring toast display. | |
| @@ -112,6 +115,14 @@ func WithPosition(pos ToastPosition) ToastOption { | |||
| 112 | 115 | } | |
| 113 | 116 | } | |
| 114 | 117 | ||
| 118 | + | // WithModeClass sets a mode-specific CSS class for styling. | |
| 119 | + | // This is applied atomically within Show() to avoid visual flicker. | |
| 120 | + | func WithModeClass(class string) ToastOption { | |
| 121 | + | return func(o *ToastOptions) { | |
| 122 | + | o.ModeClass = class | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | ||
| 115 | 126 | // defaultToastOptions returns the default toast options. | |
| 116 | 127 | func defaultToastOptions() ToastOptions { | |
| 117 | 128 | return ToastOptions{ | |
| @@ -133,11 +144,12 @@ type Toaster struct { | |||
| 133 | 144 | dismissTimer uint // GLib timer source ID | |
| 134 | 145 | ||
| 135 | 146 | // Track current options for cleanup | |
| 136 | - | currentOpts ToastOptions | |
| 137 | - | hasCustomStyle bool | |
| 138 | - | customBgColor string // Current custom background color | |
| 139 | - | customTextColor string // Current custom text color | |
| 140 | - | customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode") | |
| 147 | + | currentOpts ToastOptions | |
| 148 | + | hasCustomStyle bool | |
| 149 | + | customBgColor string // Current custom background color | |
| 150 | + | customTextColor string // Current custom text color | |
| 151 | + | customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode") | |
| 152 | + | customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors | |
| 141 | 153 | ||
| 142 | 154 | mu sync.Mutex | |
| 143 | 155 | } | |
| @@ -209,6 +221,11 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, op | |||
| 209 | 221 | // Apply custom colors if specified | |
| 210 | 222 | t.applyCustomStyle(options) | |
| 211 | 223 | ||
| 224 | + | // Apply mode class if specified (atomically to avoid flicker) | |
| 225 | + | if options.ModeClass != "" { | |
| 226 | + | t.applyModeClassInternal(options.ModeClass) | |
| 227 | + | } | |
| 228 | + | ||
| 212 | 229 | // Apply position | |
| 213 | 230 | t.applyPosition(options.Position) | |
| 214 | 231 | ||
| @@ -308,8 +325,7 @@ func (t *Toaster) applyPosition(pos ToastPosition) { | |||
| 308 | 325 | t.container.SetValign(valign) | |
| 309 | 326 | } | |
| 310 | 327 | ||
| 311 | - | // applyCustomStyle applies custom background and text colors. | |
| 312 | - | // Uses a CSS class that is styled dynamically by the theme manager. | |
| 328 | + | // applyCustomStyle applies custom background and text colors via inline CSS. | |
| 313 | 329 | func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |
| 314 | 330 | if opts.BackgroundColor == "" && opts.TextColor == "" { | |
| 315 | 331 | return | |
| @@ -319,7 +335,7 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |||
| 319 | 335 | t.container.AddCssClass("toast-custom") | |
| 320 | 336 | t.hasCustomStyle = true | |
| 321 | 337 | ||
| 322 | - | // Store the colors for access by theme/CSS generation | |
| 338 | + | // Store the colors | |
| 323 | 339 | t.customBgColor = opts.BackgroundColor | |
| 324 | 340 | t.customTextColor = opts.TextColor | |
| 325 | 341 | ||
| @@ -327,6 +343,28 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |||
| 327 | 343 | if opts.BackgroundColor != "" { | |
| 328 | 344 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| 329 | 345 | } | |
| 346 | + | ||
| 347 | + | // Generate inline CSS for custom colors | |
| 348 | + | css := ".toast-custom {" | |
| 349 | + | if opts.BackgroundColor != "" { | |
| 350 | + | css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor) | |
| 351 | + | } | |
| 352 | + | if opts.TextColor != "" { | |
| 353 | + | css += fmt.Sprintf(" color: %s;", opts.TextColor) | |
| 354 | + | } | |
| 355 | + | css += " }" | |
| 356 | + | ||
| 357 | + | // Create or reuse CSS provider for this widget | |
| 358 | + | if t.customCSSProvider == nil { | |
| 359 | + | t.customCSSProvider = gtk.NewCssProvider() | |
| 360 | + | } | |
| 361 | + | t.customCSSProvider.LoadFromString(css) | |
| 362 | + | ||
| 363 | + | // Apply to widget's style context | |
| 364 | + | styleCtx := t.container.GtkWidget().GetStyleContext() | |
| 365 | + | if styleCtx != nil { | |
| 366 | + | styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1)) | |
| 367 | + | } | |
| 330 | 368 | } | |
| 331 | 369 | ||
| 332 | 370 | // clearCustomStyle removes any custom styling applied. | |
| @@ -338,6 +376,13 @@ func (t *Toaster) clearCustomStyle() { | |||
| 338 | 376 | t.container.RemoveCssClass(t.customModeClass) | |
| 339 | 377 | t.customModeClass = "" | |
| 340 | 378 | } | |
| 379 | + | // Remove custom CSS provider if one was added | |
| 380 | + | if t.customCSSProvider != nil { | |
| 381 | + | styleCtx := t.container.GtkWidget().GetStyleContext() | |
| 382 | + | if styleCtx != nil { | |
| 383 | + | styleCtx.RemoveProvider(t.customCSSProvider) | |
| 384 | + | } | |
| 385 | + | } | |
| 341 | 386 | // Re-add level class if it was removed due to custom background | |
| 342 | 387 | if t.customBgColor != "" { | |
| 343 | 388 | t.container.AddCssClass(t.currentLevel.cssClass()) | |
| @@ -348,15 +393,8 @@ func (t *Toaster) clearCustomStyle() { | |||
| 348 | 393 | } | |
| 349 | 394 | } | |
| 350 | 395 | ||
| 351 | - | // ApplyModeClass applies a mode-specific CSS class for styling. | |
| 352 | - | // This uses the pre-defined CSS classes that reference CSS variables. | |
| 353 | - | func (t *Toaster) ApplyModeClass(modeClass string) { | |
| 354 | - | t.mu.Lock() | |
| 355 | - | defer t.mu.Unlock() | |
| 356 | - | ||
| 357 | - | // Clear any existing custom style first | |
| 358 | - | t.clearCustomStyle() | |
| 359 | - | ||
| 396 | + | // applyModeClassInternal applies mode class without locking (must be called with lock held). | |
| 397 | + | func (t *Toaster) applyModeClassInternal(modeClass string) { | |
| 360 | 398 | // Remove level class and add mode class | |
| 361 | 399 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | |
| 362 | 400 | t.container.AddCssClass(modeClass) | |
| @@ -78,6 +78,9 @@ type ToastOptions struct { | |||||
| 78 | TextColor string | 78 | TextColor string | ||
| 79 | // Position determines where the toast appears on screen. | 79 | // Position determines where the toast appears on screen. | ||
| 80 | Position ToastPosition | 80 | Position ToastPosition | ||
| 81 | + | // ModeClass is a CSS class for mode-specific styling (e.g., "toast-pane-mode"). | |||
| 82 | + | // When set, this class is applied atomically with Show() to avoid visual flicker. | |||
| 83 | + | ModeClass string | |||
| 81 | } | 84 | } | ||
| 82 | 85 | ||||
| 83 | // ToastOption is a functional option for configuring toast display. | 86 | // ToastOption is a functional option for configuring toast display. | ||
| @@ -112,6 +115,14 @@ func WithPosition(pos ToastPosition) ToastOption { | |||||
| 112 | } | 115 | } | ||
| 113 | } | 116 | } | ||
| 114 | 117 | ||||
| 118 | + | // WithModeClass sets a mode-specific CSS class for styling. | |||
| 119 | + | // This is applied atomically within Show() to avoid visual flicker. | |||
| 120 | + | func WithModeClass(class string) ToastOption { | |||
| 121 | + | return func(o *ToastOptions) { | |||
| 122 | + | o.ModeClass = class | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | ||||
| 115 | // defaultToastOptions returns the default toast options. | 126 | // defaultToastOptions returns the default toast options. | ||
| 116 | func defaultToastOptions() ToastOptions { | 127 | func defaultToastOptions() ToastOptions { | ||
| 117 | return ToastOptions{ | 128 | return ToastOptions{ | ||
| @@ -133,11 +144,12 @@ type Toaster struct { | |||||
| 133 | dismissTimer uint // GLib timer source ID | 144 | dismissTimer uint // GLib timer source ID | ||
| 134 | 145 | ||||
| 135 | // Track current options for cleanup | 146 | // Track current options for cleanup | ||
| 136 | - | currentOpts ToastOptions | 147 | + | currentOpts ToastOptions |
| 137 | - | hasCustomStyle bool | 148 | + | hasCustomStyle bool |
| 138 | - | customBgColor string // Current custom background color | 149 | + | customBgColor string // Current custom background color |
| 139 | - | customTextColor string // Current custom text color | 150 | + | customTextColor string // Current custom text color |
| 140 | - | customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode") | 151 | + | customModeClass string // Current mode-specific CSS class (e.g., "toast-pane-mode") |
| 152 | + | customCSSProvider *gtk.CssProvider // CSS provider for inline custom colors | |||
| 141 | 153 | ||||
| 142 | mu sync.Mutex | 154 | mu sync.Mutex | ||
| 143 | } | 155 | } | ||
| @@ -209,6 +221,11 @@ func (t *Toaster) Show(ctx context.Context, message string, level ToastLevel, op | |||||
| 209 | // Apply custom colors if specified | 221 | // Apply custom colors if specified | ||
| 210 | t.applyCustomStyle(options) | 222 | t.applyCustomStyle(options) | ||
| 211 | 223 | ||||
| 224 | + | // Apply mode class if specified (atomically to avoid flicker) | |||
| 225 | + | if options.ModeClass != "" { | |||
| 226 | + | t.applyModeClassInternal(options.ModeClass) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 212 | // Apply position | 229 | // Apply position | ||
| 213 | t.applyPosition(options.Position) | 230 | t.applyPosition(options.Position) | ||
| 214 | 231 | ||||
| @@ -308,8 +325,7 @@ func (t *Toaster) applyPosition(pos ToastPosition) { | |||||
| 308 | t.container.SetValign(valign) | 325 | t.container.SetValign(valign) | ||
| 309 | } | 326 | } | ||
| 310 | 327 | ||||
| 311 | - | // applyCustomStyle applies custom background and text colors. | 328 | + | // applyCustomStyle applies custom background and text colors via inline CSS. |
| 312 | - | // Uses a CSS class that is styled dynamically by the theme manager. | |||
| 313 | func (t *Toaster) applyCustomStyle(opts ToastOptions) { | 329 | func (t *Toaster) applyCustomStyle(opts ToastOptions) { | ||
| 314 | if opts.BackgroundColor == "" && opts.TextColor == "" { | 330 | if opts.BackgroundColor == "" && opts.TextColor == "" { | ||
| 315 | return | 331 | return | ||
| @@ -319,7 +335,7 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |||||
| 319 | t.container.AddCssClass("toast-custom") | 335 | t.container.AddCssClass("toast-custom") | ||
| 320 | t.hasCustomStyle = true | 336 | t.hasCustomStyle = true | ||
| 321 | 337 | ||||
| 322 | - | // Store the colors for access by theme/CSS generation | 338 | + | // Store the colors |
| 323 | t.customBgColor = opts.BackgroundColor | 339 | t.customBgColor = opts.BackgroundColor | ||
| 324 | t.customTextColor = opts.TextColor | 340 | t.customTextColor = opts.TextColor | ||
| 325 | 341 | ||||
| @@ -327,6 +343,28 @@ func (t *Toaster) applyCustomStyle(opts ToastOptions) { | |||||
| 327 | if opts.BackgroundColor != "" { | 343 | if opts.BackgroundColor != "" { | ||
| 328 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | 344 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | ||
| 329 | } | 345 | } | ||
| 346 | + | ||||
| 347 | + | // Generate inline CSS for custom colors | |||
| 348 | + | css := ".toast-custom {" | |||
| 349 | + | if opts.BackgroundColor != "" { | |||
| 350 | + | css += fmt.Sprintf(" background-color: %s;", opts.BackgroundColor) | |||
| 351 | + | } | |||
| 352 | + | if opts.TextColor != "" { | |||
| 353 | + | css += fmt.Sprintf(" color: %s;", opts.TextColor) | |||
| 354 | + | } | |||
| 355 | + | css += " }" | |||
| 356 | + | ||||
| 357 | + | // Create or reuse CSS provider for this widget | |||
| 358 | + | if t.customCSSProvider == nil { | |||
| 359 | + | t.customCSSProvider = gtk.NewCssProvider() | |||
| 360 | + | } | |||
| 361 | + | t.customCSSProvider.LoadFromString(css) | |||
| 362 | + | ||||
| 363 | + | // Apply to widget's style context | |||
| 364 | + | styleCtx := t.container.GtkWidget().GetStyleContext() | |||
| 365 | + | if styleCtx != nil { | |||
| 366 | + | styleCtx.AddProvider(t.customCSSProvider, uint(gtk.STYLE_PROVIDER_PRIORITY_APPLICATION+1)) | |||
| 367 | + | } | |||
| 330 | } | 368 | } | ||
| 331 | 369 | ||||
| 332 | // clearCustomStyle removes any custom styling applied. | 370 | // clearCustomStyle removes any custom styling applied. | ||
| @@ -338,6 +376,13 @@ func (t *Toaster) clearCustomStyle() { | |||||
| 338 | t.container.RemoveCssClass(t.customModeClass) | 376 | t.container.RemoveCssClass(t.customModeClass) | ||
| 339 | t.customModeClass = "" | 377 | t.customModeClass = "" | ||
| 340 | } | 378 | } | ||
| 379 | + | // Remove custom CSS provider if one was added | |||
| 380 | + | if t.customCSSProvider != nil { | |||
| 381 | + | styleCtx := t.container.GtkWidget().GetStyleContext() | |||
| 382 | + | if styleCtx != nil { | |||
| 383 | + | styleCtx.RemoveProvider(t.customCSSProvider) | |||
| 384 | + | } | |||
| 385 | + | } | |||
| 341 | // Re-add level class if it was removed due to custom background | 386 | // Re-add level class if it was removed due to custom background | ||
| 342 | if t.customBgColor != "" { | 387 | if t.customBgColor != "" { | ||
| 343 | t.container.AddCssClass(t.currentLevel.cssClass()) | 388 | t.container.AddCssClass(t.currentLevel.cssClass()) | ||
| @@ -348,15 +393,8 @@ func (t *Toaster) clearCustomStyle() { | |||||
| 348 | } | 393 | } | ||
| 349 | } | 394 | } | ||
| 350 | 395 | ||||
| 351 | - | // ApplyModeClass applies a mode-specific CSS class for styling. | 396 | + | // applyModeClassInternal applies mode class without locking (must be called with lock held). |
| 352 | - | // This uses the pre-defined CSS classes that reference CSS variables. | 397 | + | func (t *Toaster) applyModeClassInternal(modeClass string) { |
| 353 | - | func (t *Toaster) ApplyModeClass(modeClass string) { | |||
| 354 | - | t.mu.Lock() | |||
| 355 | - | defer t.mu.Unlock() | |||
| 356 | - | ||||
| 357 | - | // Clear any existing custom style first | |||
| 358 | - | t.clearCustomStyle() | |||
| 359 | - | ||||
| 360 | // Remove level class and add mode class | 398 | // Remove level class and add mode class | ||
| 361 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | 399 | t.container.RemoveCssClass(t.currentLevel.cssClass()) | ||
| 362 | t.container.AddCssClass(modeClass) | 400 | t.container.AddCssClass(modeClass) | ||