feat(systemviews): add config editing controls
- commit
- 9da5cc35e9393221aa73b3271ec292afe16b890b
- author
- brice · · 2026-04-24 03:02 UTC
- parent
- 18449b5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +1790 -106
M assets/systemviews/systemviews.css +37 -3
| @@ -411,7 +411,8 @@ body { | |||
| 411 | 411 | } | |
| 412 | 412 | ||
| 413 | 413 | .sv-card-form label, | |
| 414 | - | .sv-inline-form label { | |
| 414 | + | .sv-inline-form label, | |
| 415 | + | .sv-fieldset label { | |
| 415 | 416 | display: grid; | |
| 416 | 417 | gap: 0.25rem; | |
| 417 | 418 | min-width: min(14rem, 100%); | |
| @@ -423,7 +424,9 @@ body { | |||
| 423 | 424 | .sv-card-form input, | |
| 424 | 425 | .sv-card-form select, | |
| 425 | 426 | .sv-inline-form input, | |
| 426 | - | .sv-inline-form select { | |
| 427 | + | .sv-inline-form select, | |
| 428 | + | .sv-fieldset input, | |
| 429 | + | .sv-fieldset select { | |
| 427 | 430 | border: 1px solid var(--sv-border, #2a313d); | |
| 428 | 431 | border-radius: 0.55rem; | |
| 429 | 432 | padding: 0.5rem 0.6rem; | |
| @@ -432,6 +435,35 @@ body { | |||
| 432 | 435 | font: inherit; | |
| 433 | 436 | } | |
| 434 | 437 | ||
| 438 | + | .sv-config-form, | |
| 439 | + | .sv-fieldset { | |
| 440 | + | display: flex; | |
| 441 | + | flex-wrap: wrap; | |
| 442 | + | gap: 0.65rem; | |
| 443 | + | align-items: end; | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | .sv-fieldset { | |
| 447 | + | width: 100%; | |
| 448 | + | margin: 0; | |
| 449 | + | padding: 0.75rem; | |
| 450 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 451 | + | border-radius: 0.65rem; | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | .sv-fieldset legend { | |
| 455 | + | color: var(--sv-muted, #9aa3b2); | |
| 456 | + | font-weight: 700; | |
| 457 | + | padding: 0 0.35rem; | |
| 458 | + | } | |
| 459 | + | ||
| 460 | + | .sv-keybinding-row { | |
| 461 | + | display: grid; | |
| 462 | + | grid-template-columns: minmax(0, 1fr) minmax(18rem, auto); | |
| 463 | + | gap: 1rem; | |
| 464 | + | align-items: start; | |
| 465 | + | } | |
| 466 | + | ||
| 435 | 467 | .sv-filter-panel { | |
| 436 | 468 | display: grid; | |
| 437 | 469 | gap: 0.6rem; | |
| @@ -478,7 +510,9 @@ body { | |||
| 478 | 510 | } | |
| 479 | 511 | ||
| 480 | 512 | @media (max-width: 720px) { | |
| 481 | - | .sv-favorite-header { | |
| 513 | + | .sv-favorite-header, | |
| 514 | + | .sv-keybinding-row { | |
| 482 | 515 | display: grid; | |
| 516 | + | grid-template-columns: 1fr; | |
| 483 | 517 | } | |
| 484 | 518 | } | |
| @@ -411,7 +411,8 @@ body { | |||||
| 411 | } | 411 | } | ||
| 412 | 412 | ||||
| 413 | .sv-card-form label, | 413 | .sv-card-form label, | ||
| 414 | - | .sv-inline-form label { | 414 | + | .sv-inline-form label, |
| 415 | + | .sv-fieldset label { | |||
| 415 | display: grid; | 416 | display: grid; | ||
| 416 | gap: 0.25rem; | 417 | gap: 0.25rem; | ||
| 417 | min-width: min(14rem, 100%); | 418 | min-width: min(14rem, 100%); | ||
| @@ -423,7 +424,9 @@ body { | |||||
| 423 | .sv-card-form input, | 424 | .sv-card-form input, | ||
| 424 | .sv-card-form select, | 425 | .sv-card-form select, | ||
| 425 | .sv-inline-form input, | 426 | .sv-inline-form input, | ||
| 426 | - | .sv-inline-form select { | 427 | + | .sv-inline-form select, |
| 428 | + | .sv-fieldset input, | |||
| 429 | + | .sv-fieldset select { | |||
| 427 | border: 1px solid var(--sv-border, #2a313d); | 430 | border: 1px solid var(--sv-border, #2a313d); | ||
| 428 | border-radius: 0.55rem; | 431 | border-radius: 0.55rem; | ||
| 429 | padding: 0.5rem 0.6rem; | 432 | padding: 0.5rem 0.6rem; | ||
| @@ -432,6 +435,35 @@ body { | |||||
| 432 | font: inherit; | 435 | font: inherit; | ||
| 433 | } | 436 | } | ||
| 434 | 437 | ||||
| 438 | + | .sv-config-form, | |||
| 439 | + | .sv-fieldset { | |||
| 440 | + | display: flex; | |||
| 441 | + | flex-wrap: wrap; | |||
| 442 | + | gap: 0.65rem; | |||
| 443 | + | align-items: end; | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | .sv-fieldset { | |||
| 447 | + | width: 100%; | |||
| 448 | + | margin: 0; | |||
| 449 | + | padding: 0.75rem; | |||
| 450 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 451 | + | border-radius: 0.65rem; | |||
| 452 | + | } | |||
| 453 | + | ||||
| 454 | + | .sv-fieldset legend { | |||
| 455 | + | color: var(--sv-muted, #9aa3b2); | |||
| 456 | + | font-weight: 700; | |||
| 457 | + | padding: 0 0.35rem; | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | .sv-keybinding-row { | |||
| 461 | + | display: grid; | |||
| 462 | + | grid-template-columns: minmax(0, 1fr) minmax(18rem, auto); | |||
| 463 | + | gap: 1rem; | |||
| 464 | + | align-items: start; | |||
| 465 | + | } | |||
| 466 | + | ||||
| 435 | .sv-filter-panel { | 467 | .sv-filter-panel { | ||
| 436 | display: grid; | 468 | display: grid; | ||
| 437 | gap: 0.6rem; | 469 | gap: 0.6rem; | ||
| @@ -478,7 +510,9 @@ body { | |||||
| 478 | } | 510 | } | ||
| 479 | 511 | ||||
| 480 | @media (max-width: 720px) { | 512 | @media (max-width: 720px) { | ||
| 481 | - | .sv-favorite-header { | 513 | + | .sv-favorite-header, |
| 514 | + | .sv-keybinding-row { | |||
| 482 | display: grid; | 515 | display: grid; | ||
| 516 | + | grid-template-columns: 1fr; | |||
| 483 | } | 517 | } | ||
| 484 | } | 518 | } | ||
M internal/ui/systemviews/app.go +10 -1
| @@ -38,6 +38,8 @@ type App struct { | |||
| 38 | 38 | favoritesError string | |
| 39 | 39 | config *port.SystemviewConfigPayload | |
| 40 | 40 | keybindings any | |
| 41 | + | configNotice string | |
| 42 | + | configError string | |
| 41 | 43 | renderedHTML string | |
| 42 | 44 | } | |
| 43 | 45 | ||
| @@ -261,7 +263,12 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||
| 261 | 263 | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 262 | 264 | route: RouteConfig, | |
| 263 | 265 | subtitle: "Browser settings", | |
| 264 | - | body: configHTML(config, keybindings), | |
| 266 | + | body: configHTML(configRenderData{ | |
| 267 | + | Config: config, | |
| 268 | + | Keybindings: keybindings, | |
| 269 | + | Notice: a.configNotice, | |
| 270 | + | Error: a.configError, | |
| 271 | + | }), | |
| 265 | 272 | }, a.shellTheme) | |
| 266 | 273 | return nil | |
| 267 | 274 | } | |
| @@ -300,6 +307,8 @@ func (a *App) resetRouteState() { | |||
| 300 | 307 | a.favoritesError = "" | |
| 301 | 308 | a.config = nil | |
| 302 | 309 | a.keybindings = nil | |
| 310 | + | a.configNotice = "" | |
| 311 | + | a.configError = "" | |
| 303 | 312 | } | |
| 304 | 313 | ||
| 305 | 314 | func (a *App) CurrentRoute() Route { | |
| @@ -38,6 +38,8 @@ type App struct { | |||||
| 38 | favoritesError string | 38 | favoritesError string | ||
| 39 | config *port.SystemviewConfigPayload | 39 | config *port.SystemviewConfigPayload | ||
| 40 | keybindings any | 40 | keybindings any | ||
| 41 | + | configNotice string | |||
| 42 | + | configError string | |||
| 41 | renderedHTML string | 43 | renderedHTML string | ||
| 42 | } | 44 | } | ||
| 43 | 45 | ||||
| @@ -261,7 +263,12 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||||
| 261 | a.renderedHTML = renderAppFrame(renderedPage{ | 263 | a.renderedHTML = renderAppFrame(renderedPage{ | ||
| 262 | route: RouteConfig, | 264 | route: RouteConfig, | ||
| 263 | subtitle: "Browser settings", | 265 | subtitle: "Browser settings", | ||
| 264 | - | body: configHTML(config, keybindings), | 266 | + | body: configHTML(configRenderData{ |
| 267 | + | Config: config, | |||
| 268 | + | Keybindings: keybindings, | |||
| 269 | + | Notice: a.configNotice, | |||
| 270 | + | Error: a.configError, | |||
| 271 | + | }), | |||
| 265 | }, a.shellTheme) | 272 | }, a.shellTheme) | ||
| 266 | return nil | 273 | return nil | ||
| 267 | } | 274 | } | ||
| @@ -300,6 +307,8 @@ func (a *App) resetRouteState() { | |||||
| 300 | a.favoritesError = "" | 307 | a.favoritesError = "" | ||
| 301 | a.config = nil | 308 | a.config = nil | ||
| 302 | a.keybindings = nil | 309 | a.keybindings = nil | ||
| 310 | + | a.configNotice = "" | |||
| 311 | + | a.configError = "" | |||
| 303 | } | 312 | } | ||
| 304 | 313 | ||||
| 305 | func (a *App) CurrentRoute() Route { | 314 | func (a *App) CurrentRoute() Route { | ||
M internal/ui/systemviews/app_test.go +161 -5
| @@ -455,12 +455,149 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||
| 455 | 455 | func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) { | |
| 456 | 456 | t.Parallel() | |
| 457 | 457 | ||
| 458 | - | html := configHTML(port.SystemviewConfigPayload{}, map[string]any{"groups": "oops"}) | |
| 458 | + | html := configHTML(configRenderData{Config: port.SystemviewConfigPayload{}, Keybindings: map[string]any{"groups": "oops"}}) | |
| 459 | 459 | ||
| 460 | 460 | assert.Contains(t, html, "Keybindings unavailable") | |
| 461 | 461 | assert.NotContains(t, html, "groups[0].bindings[0].action") | |
| 462 | 462 | } | |
| 463 | 463 | ||
| 464 | + | func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) { | |
| 465 | + | t.Parallel() | |
| 466 | + | ||
| 467 | + | service := &fakeConfigService{ | |
| 468 | + | current: testConfigPayload(), | |
| 469 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{ | |
| 470 | + | Mode: "default", | |
| 471 | + | DisplayName: "Default", | |
| 472 | + | Bindings: []port.KeybindingEntry{{ | |
| 473 | + | Action: "toggle_history_systemview", | |
| 474 | + | Description: "Toggle history", | |
| 475 | + | Keys: []string{"ctrl+h"}, | |
| 476 | + | DefaultKeys: []string{"ctrl+h"}, | |
| 477 | + | }}, | |
| 478 | + | }}}, | |
| 479 | + | } | |
| 480 | + | app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"}) | |
| 481 | + | ||
| 482 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 483 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`) | |
| 484 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`) | |
| 485 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`) | |
| 486 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`) | |
| 487 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`) | |
| 488 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`) | |
| 489 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`) | |
| 490 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`) | |
| 491 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`) | |
| 492 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`) | |
| 493 | + | assert.Contains(t, app.renderedHTML, "may require restarting") | |
| 494 | + | } | |
| 495 | + | ||
| 496 | + | func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |
| 497 | + | dom := &fakeDOM{} | |
| 498 | + | service := &fakeConfigService{ | |
| 499 | + | current: testConfigPayload(), | |
| 500 | + | defaultCfg: testDefaultConfigPayload(), | |
| 501 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{ | |
| 502 | + | Mode: "default", | |
| 503 | + | DisplayName: "Default", | |
| 504 | + | Bindings: []port.KeybindingEntry{{ | |
| 505 | + | Action: "toggle_history_systemview", | |
| 506 | + | Description: "Toggle history", | |
| 507 | + | Keys: []string{"ctrl+h"}, | |
| 508 | + | DefaultKeys: []string{"ctrl+h"}, | |
| 509 | + | }}, | |
| 510 | + | }}}, | |
| 511 | + | setResp: port.SetKeybindingResponse{}, | |
| 512 | + | } | |
| 513 | + | app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"}) | |
| 514 | + | ||
| 515 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 516 | + | Action: configActionSaveAppearance, | |
| 517 | + | Data: map[string]string{ | |
| 518 | + | "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono", | |
| 519 | + | "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light", | |
| 520 | + | "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd", | |
| 521 | + | "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333", | |
| 522 | + | }, | |
| 523 | + | })) | |
| 524 | + | assert.True(t, service.calledSave) | |
| 525 | + | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | |
| 526 | + | assert.Equal(t, 1.25, service.savedConfig.DefaultUIScale) | |
| 527 | + | assert.Contains(t, dom.html, "Saved appearance settings") | |
| 528 | + | assert.Contains(t, dom.html, `class="sv-app sv-light"`) | |
| 529 | + | ||
| 530 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 531 | + | Action: configActionCreateSearchShortcut, | |
| 532 | + | Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"}, | |
| 533 | + | })) | |
| 534 | + | assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description) | |
| 535 | + | assert.Contains(t, dom.html, "Created search shortcut g") | |
| 536 | + | ||
| 537 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 538 | + | Action: configActionSavePerformance, | |
| 539 | + | Data: map[string]string{ | |
| 540 | + | "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2", | |
| 541 | + | "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4", | |
| 542 | + | }, | |
| 543 | + | })) | |
| 544 | + | assert.Equal(t, "custom", service.savedConfig.Performance.Profile) | |
| 545 | + | assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB) | |
| 546 | + | assert.Contains(t, dom.html, "Saved performance settings") | |
| 547 | + | ||
| 548 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 549 | + | Action: configActionSetKeybinding, | |
| 550 | + | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"}, | |
| 551 | + | })) | |
| 552 | + | assert.True(t, service.calledSet) | |
| 553 | + | assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys) | |
| 554 | + | assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview") | |
| 555 | + | ||
| 556 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 557 | + | Action: configActionResetKeybinding, | |
| 558 | + | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"}, | |
| 559 | + | })) | |
| 560 | + | assert.True(t, service.calledReset) | |
| 561 | + | assert.Equal(t, "toggle_history_systemview", service.resetReq.Action) | |
| 562 | + | assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview") | |
| 563 | + | ||
| 564 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings})) | |
| 565 | + | assert.True(t, service.calledResetAll) | |
| 566 | + | assert.Contains(t, dom.html, "Reset all keybindings to defaults") | |
| 567 | + | } | |
| 568 | + | ||
| 569 | + | func testConfigPayload() port.SystemviewConfigPayload { | |
| 570 | + | return port.SystemviewConfigPayload{ | |
| 571 | + | EngineType: "webkit", | |
| 572 | + | Appearance: port.WebUIAppearanceConfig{ | |
| 573 | + | ColorScheme: "prefer-dark", | |
| 574 | + | SansFont: "Inter", | |
| 575 | + | SerifFont: "Georgia", | |
| 576 | + | MonospaceFont: "JetBrains Mono", | |
| 577 | + | DefaultFontSize: 16, | |
| 578 | + | LightPalette: port.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"}, | |
| 579 | + | DarkPalette: port.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"}, | |
| 580 | + | }, | |
| 581 | + | DefaultUIScale: 1, | |
| 582 | + | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |
| 583 | + | SearchShortcuts: map[string]port.SearchShortcut{ | |
| 584 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"}, | |
| 585 | + | }, | |
| 586 | + | Performance: port.SystemviewPerformancePayload{ | |
| 587 | + | Profile: "default", | |
| 588 | + | Custom: port.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2}, | |
| 589 | + | Hardware: port.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096}, | |
| 590 | + | }, | |
| 591 | + | } | |
| 592 | + | } | |
| 593 | + | ||
| 594 | + | func testDefaultConfigPayload() port.SystemviewConfigPayload { | |
| 595 | + | cfg := testConfigPayload() | |
| 596 | + | cfg.Appearance.DefaultFontSize = 14 | |
| 597 | + | cfg.DefaultUIScale = 1 | |
| 598 | + | return cfg | |
| 599 | + | } | |
| 600 | + | ||
| 464 | 601 | type fakeDOM struct { | |
| 465 | 602 | mounted bool | |
| 466 | 603 | html string | |
| @@ -641,6 +778,10 @@ type fakeConfigService struct { | |||
| 641 | 778 | current port.SystemviewConfigPayload | |
| 642 | 779 | defaultCfg port.SystemviewConfigPayload | |
| 643 | 780 | keybindings any | |
| 781 | + | savedConfig port.WebUIConfig | |
| 782 | + | setReq port.SetKeybindingRequest | |
| 783 | + | setResp any | |
| 784 | + | resetReq port.ResetKeybindingRequest | |
| 644 | 785 | } | |
| 645 | 786 | ||
| 646 | 787 | func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) { | |
| @@ -653,8 +794,21 @@ func (s *fakeConfigService) Default(context.Context) (port.SystemviewConfigPaylo | |||
| 653 | 794 | return s.defaultCfg, nil | |
| 654 | 795 | } | |
| 655 | 796 | ||
| 656 | - | func (s *fakeConfigService) Save(context.Context, port.WebUIConfig) error { | |
| 797 | + | func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error { | |
| 657 | 798 | s.calledSave = true | |
| 799 | + | s.savedConfig = cfg | |
| 800 | + | s.current.Appearance = cfg.Appearance | |
| 801 | + | s.current.DefaultUIScale = cfg.DefaultUIScale | |
| 802 | + | s.current.DefaultSearchEngine = cfg.DefaultSearchEngine | |
| 803 | + | s.current.SearchShortcuts = cfg.SearchShortcuts | |
| 804 | + | s.current.Performance.Profile = cfg.Performance.Profile | |
| 805 | + | s.current.Performance.Custom = port.SystemviewCustomPerformancePayload{ | |
| 806 | + | SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads, | |
| 807 | + | SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads, | |
| 808 | + | WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB, | |
| 809 | + | NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB, | |
| 810 | + | WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm, | |
| 811 | + | } | |
| 658 | 812 | return nil | |
| 659 | 813 | } | |
| 660 | 814 | ||
| @@ -663,13 +817,15 @@ func (s *fakeConfigService) GetKeybindings(context.Context) (any, error) { | |||
| 663 | 817 | return s.keybindings, nil | |
| 664 | 818 | } | |
| 665 | 819 | ||
| 666 | - | func (s *fakeConfigService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) { | |
| 820 | + | func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (any, error) { | |
| 667 | 821 | s.calledSet = true | |
| 668 | - | return nil, nil | |
| 822 | + | s.setReq = req | |
| 823 | + | return s.setResp, nil | |
| 669 | 824 | } | |
| 670 | 825 | ||
| 671 | - | func (s *fakeConfigService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error { | |
| 826 | + | func (s *fakeConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error { | |
| 672 | 827 | s.calledReset = true | |
| 828 | + | s.resetReq = req | |
| 673 | 829 | return nil | |
| 674 | 830 | } | |
| 675 | 831 | ||
| @@ -455,12 +455,149 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||||
| 455 | func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) { | 455 | func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) { | ||
| 456 | t.Parallel() | 456 | t.Parallel() | ||
| 457 | 457 | ||||
| 458 | - | html := configHTML(port.SystemviewConfigPayload{}, map[string]any{"groups": "oops"}) | 458 | + | html := configHTML(configRenderData{Config: port.SystemviewConfigPayload{}, Keybindings: map[string]any{"groups": "oops"}}) |
| 459 | 459 | ||||
| 460 | assert.Contains(t, html, "Keybindings unavailable") | 460 | assert.Contains(t, html, "Keybindings unavailable") | ||
| 461 | assert.NotContains(t, html, "groups[0].bindings[0].action") | 461 | assert.NotContains(t, html, "groups[0].bindings[0].action") | ||
| 462 | } | 462 | } | ||
| 463 | 463 | ||||
| 464 | + | func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) { | |||
| 465 | + | t.Parallel() | |||
| 466 | + | ||||
| 467 | + | service := &fakeConfigService{ | |||
| 468 | + | current: testConfigPayload(), | |||
| 469 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{ | |||
| 470 | + | Mode: "default", | |||
| 471 | + | DisplayName: "Default", | |||
| 472 | + | Bindings: []port.KeybindingEntry{{ | |||
| 473 | + | Action: "toggle_history_systemview", | |||
| 474 | + | Description: "Toggle history", | |||
| 475 | + | Keys: []string{"ctrl+h"}, | |||
| 476 | + | DefaultKeys: []string{"ctrl+h"}, | |||
| 477 | + | }}, | |||
| 478 | + | }}}, | |||
| 479 | + | } | |||
| 480 | + | app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"}) | |||
| 481 | + | ||||
| 482 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 483 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`) | |||
| 484 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`) | |||
| 485 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`) | |||
| 486 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`) | |||
| 487 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`) | |||
| 488 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`) | |||
| 489 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`) | |||
| 490 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`) | |||
| 491 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`) | |||
| 492 | + | assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`) | |||
| 493 | + | assert.Contains(t, app.renderedHTML, "may require restarting") | |||
| 494 | + | } | |||
| 495 | + | ||||
| 496 | + | func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 497 | + | dom := &fakeDOM{} | |||
| 498 | + | service := &fakeConfigService{ | |||
| 499 | + | current: testConfigPayload(), | |||
| 500 | + | defaultCfg: testDefaultConfigPayload(), | |||
| 501 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{ | |||
| 502 | + | Mode: "default", | |||
| 503 | + | DisplayName: "Default", | |||
| 504 | + | Bindings: []port.KeybindingEntry{{ | |||
| 505 | + | Action: "toggle_history_systemview", | |||
| 506 | + | Description: "Toggle history", | |||
| 507 | + | Keys: []string{"ctrl+h"}, | |||
| 508 | + | DefaultKeys: []string{"ctrl+h"}, | |||
| 509 | + | }}, | |||
| 510 | + | }}}, | |||
| 511 | + | setResp: port.SetKeybindingResponse{}, | |||
| 512 | + | } | |||
| 513 | + | app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"}) | |||
| 514 | + | ||||
| 515 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 516 | + | Action: configActionSaveAppearance, | |||
| 517 | + | Data: map[string]string{ | |||
| 518 | + | "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono", | |||
| 519 | + | "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light", | |||
| 520 | + | "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd", | |||
| 521 | + | "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333", | |||
| 522 | + | }, | |||
| 523 | + | })) | |||
| 524 | + | assert.True(t, service.calledSave) | |||
| 525 | + | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | |||
| 526 | + | assert.Equal(t, 1.25, service.savedConfig.DefaultUIScale) | |||
| 527 | + | assert.Contains(t, dom.html, "Saved appearance settings") | |||
| 528 | + | assert.Contains(t, dom.html, `class="sv-app sv-light"`) | |||
| 529 | + | ||||
| 530 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 531 | + | Action: configActionCreateSearchShortcut, | |||
| 532 | + | Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"}, | |||
| 533 | + | })) | |||
| 534 | + | assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description) | |||
| 535 | + | assert.Contains(t, dom.html, "Created search shortcut g") | |||
| 536 | + | ||||
| 537 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 538 | + | Action: configActionSavePerformance, | |||
| 539 | + | Data: map[string]string{ | |||
| 540 | + | "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2", | |||
| 541 | + | "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4", | |||
| 542 | + | }, | |||
| 543 | + | })) | |||
| 544 | + | assert.Equal(t, "custom", service.savedConfig.Performance.Profile) | |||
| 545 | + | assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB) | |||
| 546 | + | assert.Contains(t, dom.html, "Saved performance settings") | |||
| 547 | + | ||||
| 548 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 549 | + | Action: configActionSetKeybinding, | |||
| 550 | + | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"}, | |||
| 551 | + | })) | |||
| 552 | + | assert.True(t, service.calledSet) | |||
| 553 | + | assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys) | |||
| 554 | + | assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview") | |||
| 555 | + | ||||
| 556 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |||
| 557 | + | Action: configActionResetKeybinding, | |||
| 558 | + | Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"}, | |||
| 559 | + | })) | |||
| 560 | + | assert.True(t, service.calledReset) | |||
| 561 | + | assert.Equal(t, "toggle_history_systemview", service.resetReq.Action) | |||
| 562 | + | assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview") | |||
| 563 | + | ||||
| 564 | + | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings})) | |||
| 565 | + | assert.True(t, service.calledResetAll) | |||
| 566 | + | assert.Contains(t, dom.html, "Reset all keybindings to defaults") | |||
| 567 | + | } | |||
| 568 | + | ||||
| 569 | + | func testConfigPayload() port.SystemviewConfigPayload { | |||
| 570 | + | return port.SystemviewConfigPayload{ | |||
| 571 | + | EngineType: "webkit", | |||
| 572 | + | Appearance: port.WebUIAppearanceConfig{ | |||
| 573 | + | ColorScheme: "prefer-dark", | |||
| 574 | + | SansFont: "Inter", | |||
| 575 | + | SerifFont: "Georgia", | |||
| 576 | + | MonospaceFont: "JetBrains Mono", | |||
| 577 | + | DefaultFontSize: 16, | |||
| 578 | + | LightPalette: port.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"}, | |||
| 579 | + | DarkPalette: port.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"}, | |||
| 580 | + | }, | |||
| 581 | + | DefaultUIScale: 1, | |||
| 582 | + | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |||
| 583 | + | SearchShortcuts: map[string]port.SearchShortcut{ | |||
| 584 | + | "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"}, | |||
| 585 | + | }, | |||
| 586 | + | Performance: port.SystemviewPerformancePayload{ | |||
| 587 | + | Profile: "default", | |||
| 588 | + | Custom: port.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2}, | |||
| 589 | + | Hardware: port.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096}, | |||
| 590 | + | }, | |||
| 591 | + | } | |||
| 592 | + | } | |||
| 593 | + | ||||
| 594 | + | func testDefaultConfigPayload() port.SystemviewConfigPayload { | |||
| 595 | + | cfg := testConfigPayload() | |||
| 596 | + | cfg.Appearance.DefaultFontSize = 14 | |||
| 597 | + | cfg.DefaultUIScale = 1 | |||
| 598 | + | return cfg | |||
| 599 | + | } | |||
| 600 | + | ||||
| 464 | type fakeDOM struct { | 601 | type fakeDOM struct { | ||
| 465 | mounted bool | 602 | mounted bool | ||
| 466 | html string | 603 | html string | ||
| @@ -641,6 +778,10 @@ type fakeConfigService struct { | |||||
| 641 | current port.SystemviewConfigPayload | 778 | current port.SystemviewConfigPayload | ||
| 642 | defaultCfg port.SystemviewConfigPayload | 779 | defaultCfg port.SystemviewConfigPayload | ||
| 643 | keybindings any | 780 | keybindings any | ||
| 781 | + | savedConfig port.WebUIConfig | |||
| 782 | + | setReq port.SetKeybindingRequest | |||
| 783 | + | setResp any | |||
| 784 | + | resetReq port.ResetKeybindingRequest | |||
| 644 | } | 785 | } | ||
| 645 | 786 | ||||
| 646 | func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) { | 787 | func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) { | ||
| @@ -653,8 +794,21 @@ func (s *fakeConfigService) Default(context.Context) (port.SystemviewConfigPaylo | |||||
| 653 | return s.defaultCfg, nil | 794 | return s.defaultCfg, nil | ||
| 654 | } | 795 | } | ||
| 655 | 796 | ||||
| 656 | - | func (s *fakeConfigService) Save(context.Context, port.WebUIConfig) error { | 797 | + | func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error { |
| 657 | s.calledSave = true | 798 | s.calledSave = true | ||
| 799 | + | s.savedConfig = cfg | |||
| 800 | + | s.current.Appearance = cfg.Appearance | |||
| 801 | + | s.current.DefaultUIScale = cfg.DefaultUIScale | |||
| 802 | + | s.current.DefaultSearchEngine = cfg.DefaultSearchEngine | |||
| 803 | + | s.current.SearchShortcuts = cfg.SearchShortcuts | |||
| 804 | + | s.current.Performance.Profile = cfg.Performance.Profile | |||
| 805 | + | s.current.Performance.Custom = port.SystemviewCustomPerformancePayload{ | |||
| 806 | + | SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads, | |||
| 807 | + | SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads, | |||
| 808 | + | WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB, | |||
| 809 | + | NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB, | |||
| 810 | + | WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm, | |||
| 811 | + | } | |||
| 658 | return nil | 812 | return nil | ||
| 659 | } | 813 | } | ||
| 660 | 814 | ||||
| @@ -663,13 +817,15 @@ func (s *fakeConfigService) GetKeybindings(context.Context) (any, error) { | |||||
| 663 | return s.keybindings, nil | 817 | return s.keybindings, nil | ||
| 664 | } | 818 | } | ||
| 665 | 819 | ||||
| 666 | - | func (s *fakeConfigService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) { | 820 | + | func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (any, error) { |
| 667 | s.calledSet = true | 821 | s.calledSet = true | ||
| 668 | - | return nil, nil | 822 | + | s.setReq = req |
| 823 | + | return s.setResp, nil | |||
| 669 | } | 824 | } | ||
| 670 | 825 | ||||
| 671 | - | func (s *fakeConfigService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error { | 826 | + | func (s *fakeConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error { |
| 672 | s.calledReset = true | 827 | s.calledReset = true | ||
| 828 | + | s.resetReq = req | |||
| 673 | return nil | 829 | return nil | ||
| 674 | } | 830 | } | ||
| 675 | 831 | ||||
M internal/ui/systemviews/config.templ +136 -12
| @@ -1,17 +1,134 @@ | |||
| 1 | 1 | package systemviews | |
| 2 | 2 | ||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |
| 4 | + | ||
| 3 | 5 | templ ConfigView(data configRenderData) { | |
| 4 | - | @Section("sv-config-values", "Config values") { | |
| 5 | - | @KV(flattenValues(data.Config)) | |
| 6 | + | @Alert("success", data.Notice) | |
| 7 | + | @Alert("error", data.Error) | |
| 8 | + | @Section("sv-config-appearance", "Appearance") { | |
| 9 | + | @AppearanceForm(data.Config) | |
| 10 | + | } | |
| 11 | + | @Section("sv-config-search", "Search") { | |
| 12 | + | @SearchSettings(data.Config) | |
| 13 | + | } | |
| 14 | + | @Section("sv-config-performance", "Performance") { | |
| 15 | + | @PerformanceSettings(data.Config) | |
| 6 | 16 | } | |
| 7 | 17 | @Section("sv-config-keybindings", "Keybindings") { | |
| 8 | 18 | @Meta(configKeybindingSummary(data.Keybindings)) | |
| 9 | 19 | @ConfigKeybindings(data.Keybindings) | |
| 10 | 20 | } | |
| 21 | + | @Section("sv-config-values", "Current payload") { | |
| 22 | + | @KV(flattenValues(data.Config)) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | templ AppearanceForm(config port.SystemviewConfigPayload) { | |
| 27 | + | <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save"> | |
| 28 | + | <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label> | |
| 29 | + | <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label> | |
| 30 | + | <label><span>Monospace font</span><input name="monospace_font" type="text" value={ config.Appearance.MonospaceFont } required/></label> | |
| 31 | + | <label><span>Default font size</span><input name="default_font_size" type="number" min="1" max="72" value={ formatConfigInt(config.Appearance.DefaultFontSize) } required/></label> | |
| 32 | + | <label><span>UI scale</span><input name="default_ui_scale" type="number" min="0.5" max="3" step="0.05" value={ formatConfigFloat(config.DefaultUIScale) } required/></label> | |
| 33 | + | <label><span>Color scheme</span><select name="color_scheme"> | |
| 34 | + | <option value="default" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "default") }>System default</option> | |
| 35 | + | <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option> | |
| 36 | + | <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option> | |
| 37 | + | </select></label> | |
| 38 | + | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | |
| 39 | + | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | |
| 40 | + | <div class="sv-button-row sv-form-actions"> | |
| 41 | + | <button class="sv-button" type="submit">Save appearance</button> | |
| 42 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">Reset appearance</button> | |
| 43 | + | </div> | |
| 44 | + | </form> | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | templ PaletteFields(title string, prefix string, palette port.ColorPalette) { | |
| 48 | + | <fieldset class="sv-fieldset"> | |
| 49 | + | <legend>{ title }</legend> | |
| 50 | + | <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label> | |
| 51 | + | <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label> | |
| 52 | + | <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label> | |
| 53 | + | <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label> | |
| 54 | + | <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label> | |
| 55 | + | <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label> | |
| 56 | + | <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label> | |
| 57 | + | </fieldset> | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | templ SearchSettings(config port.SystemviewConfigPayload) { | |
| 61 | + | <form class="sv-card-form" data-sv-action="config.search.save"> | |
| 62 | + | <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label> | |
| 63 | + | <button class="sv-button" type="submit">Save search default</button> | |
| 64 | + | </form> | |
| 65 | + | <div class="sv-group"> | |
| 66 | + | <div class="sv-group-header"><h4>Search shortcuts</h4></div> | |
| 67 | + | <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p> | |
| 68 | + | <form class="sv-inline-form" data-sv-action="config.searchShortcut.create"> | |
| 69 | + | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | |
| 70 | + | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label> | |
| 71 | + | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo" required/></label> | |
| 72 | + | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | |
| 73 | + | </form> | |
| 74 | + | if len(searchShortcutRows(config.SearchShortcuts)) == 0 { | |
| 75 | + | @EmptyState("No search shortcuts configured") | |
| 76 | + | } else { | |
| 77 | + | <ul class="sv-list"> | |
| 78 | + | for _, shortcut := range searchShortcutRows(config.SearchShortcuts) { | |
| 79 | + | @SearchShortcutItem(shortcut) | |
| 80 | + | } | |
| 81 | + | </ul> | |
| 82 | + | } | |
| 83 | + | </div> | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | templ SearchShortcutItem(shortcut searchShortcutRow) { | |
| 87 | + | <li class="sv-list-row"> | |
| 88 | + | <form class="sv-inline-form" data-sv-action="config.searchShortcut.update"> | |
| 89 | + | <input type="hidden" name="key" value={ shortcut.Key }/> | |
| 90 | + | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | |
| 91 | + | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label> | |
| 92 | + | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description } required/></label> | |
| 93 | + | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 94 | + | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button> | |
| 95 | + | </form> | |
| 96 | + | </li> | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | templ PerformanceSettings(config port.SystemviewConfigPayload) { | |
| 100 | + | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save"> | |
| 101 | + | <label><span>Profile</span><select name="profile"> | |
| 102 | + | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | |
| 103 | + | <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option> | |
| 104 | + | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | |
| 105 | + | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | |
| 106 | + | </select></label> | |
| 107 | + | <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) }/></label> | |
| 108 | + | <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) }/></label> | |
| 109 | + | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) }/></label> | |
| 110 | + | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) }/></label> | |
| 111 | + | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) }/></label> | |
| 112 | + | <button class="sv-button" type="submit">Save performance</button> | |
| 113 | + | </form> | |
| 114 | + | <div class="sv-stat-grid"> | |
| 115 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div> | |
| 116 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.TotalRAMMB) }</span><span class="sv-stat-label">RAM MB</span></div> | |
| 117 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ config.Performance.Hardware.GPUName }</span><span class="sv-stat-label">GPU</span></div> | |
| 118 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ config.EngineType }</span><span class="sv-stat-label">Engine</span></div> | |
| 119 | + | </div> | |
| 120 | + | if !engineIsWebKit(config.EngineType) { | |
| 121 | + | <p class="sv-meta">Performance tuning applies only to the WebKit engine.</p> | |
| 122 | + | } else { | |
| 123 | + | <p class="sv-meta">Performance profile changes may require restarting the browser to affect existing engine processes.</p> | |
| 124 | + | } | |
| 11 | 125 | } | |
| 12 | 126 | ||
| 13 | 127 | templ ConfigKeybindings(keybindings any) { | |
| 14 | 128 | {{ groups := configKeybindingGroups(keybindings) }} | |
| 129 | + | <div class="sv-button-row"> | |
| 130 | + | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">Reset all keybindings</button> | |
| 131 | + | </div> | |
| 15 | 132 | if groups == nil { | |
| 16 | 133 | @EmptyState("Keybindings unavailable") | |
| 17 | 134 | } else if len(groups) == 0 { | |
| @@ -39,7 +156,7 @@ templ ConfigKeybindingGroup(group renderedKeybindingGroup) { | |||
| 39 | 156 | } else { | |
| 40 | 157 | <ul class="sv-list"> | |
| 41 | 158 | for _, binding := range group.Bindings { | |
| 42 | - | @ConfigKeybinding(binding) | |
| 159 | + | @ConfigKeybinding(group, binding) | |
| 43 | 160 | } | |
| 44 | 161 | </ul> | |
| 45 | 162 | } | |
| @@ -47,21 +164,28 @@ templ ConfigKeybindingGroup(group renderedKeybindingGroup) { | |||
| 47 | 164 | </div> | |
| 48 | 165 | } | |
| 49 | 166 | ||
| 50 | - | templ ConfigKeybinding(binding renderedKeybinding) { | |
| 167 | + | templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) { | |
| 51 | 168 | <li class="sv-list-row"> | |
| 52 | 169 | <div class="sv-keybinding-row"> | |
| 53 | 170 | <div> | |
| 54 | 171 | <strong>{ keybindingDescription(binding) }</strong> | |
| 55 | 172 | <p class="sv-meta">{ keybindingAction(binding) }</p> | |
| 173 | + | <div class="sv-binding-keys"> | |
| 174 | + | @KeyList(binding.Keys) | |
| 175 | + | <span class="sv-badge">{ keybindingStatus(binding) }</span> | |
| 176 | + | if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 { | |
| 177 | + | <span class="sv-meta">default:</span> | |
| 178 | + | @KeyList(binding.DefaultKeys) | |
| 179 | + | } | |
| 180 | + | </div> | |
| 56 | 181 | </div> | |
| 57 | - | <div class="sv-keybinding-keys"> | |
| 58 | - | @KeyList(binding.Keys) | |
| 59 | - | <span class="sv-badge">{ keybindingStatus(binding) }</span> | |
| 60 | - | if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 { | |
| 61 | - | <span class="sv-meta">default:</span> | |
| 62 | - | @KeyList(binding.DefaultKeys) | |
| 63 | - | } | |
| 64 | - | </div> | |
| 182 | + | <form class="sv-inline-form" data-sv-action="config.keybinding.set"> | |
| 183 | + | <input type="hidden" name="mode" value={ group.Mode }/> | |
| 184 | + | <input type="hidden" name="action" value={ binding.Action }/> | |
| 185 | + | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h" required/></label> | |
| 186 | + | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 187 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button> | |
| 188 | + | </form> | |
| 65 | 189 | </div> | |
| 66 | 190 | </li> | |
| 67 | 191 | } | |
| @@ -1,17 +1,134 @@ | |||||
| 1 | package systemviews | 1 | package systemviews | ||
| 2 | 2 | ||||
| 3 | + | import "github.com/bnema/dumber/internal/application/port" | |||
| 4 | + | ||||
| 3 | templ ConfigView(data configRenderData) { | 5 | templ ConfigView(data configRenderData) { | ||
| 4 | - | @Section("sv-config-values", "Config values") { | 6 | + | @Alert("success", data.Notice) |
| 5 | - | @KV(flattenValues(data.Config)) | 7 | + | @Alert("error", data.Error) |
| 8 | + | @Section("sv-config-appearance", "Appearance") { | |||
| 9 | + | @AppearanceForm(data.Config) | |||
| 10 | + | } | |||
| 11 | + | @Section("sv-config-search", "Search") { | |||
| 12 | + | @SearchSettings(data.Config) | |||
| 13 | + | } | |||
| 14 | + | @Section("sv-config-performance", "Performance") { | |||
| 15 | + | @PerformanceSettings(data.Config) | |||
| 6 | } | 16 | } | ||
| 7 | @Section("sv-config-keybindings", "Keybindings") { | 17 | @Section("sv-config-keybindings", "Keybindings") { | ||
| 8 | @Meta(configKeybindingSummary(data.Keybindings)) | 18 | @Meta(configKeybindingSummary(data.Keybindings)) | ||
| 9 | @ConfigKeybindings(data.Keybindings) | 19 | @ConfigKeybindings(data.Keybindings) | ||
| 10 | } | 20 | } | ||
| 21 | + | @Section("sv-config-values", "Current payload") { | |||
| 22 | + | @KV(flattenValues(data.Config)) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | templ AppearanceForm(config port.SystemviewConfigPayload) { | |||
| 27 | + | <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save"> | |||
| 28 | + | <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label> | |||
| 29 | + | <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label> | |||
| 30 | + | <label><span>Monospace font</span><input name="monospace_font" type="text" value={ config.Appearance.MonospaceFont } required/></label> | |||
| 31 | + | <label><span>Default font size</span><input name="default_font_size" type="number" min="1" max="72" value={ formatConfigInt(config.Appearance.DefaultFontSize) } required/></label> | |||
| 32 | + | <label><span>UI scale</span><input name="default_ui_scale" type="number" min="0.5" max="3" step="0.05" value={ formatConfigFloat(config.DefaultUIScale) } required/></label> | |||
| 33 | + | <label><span>Color scheme</span><select name="color_scheme"> | |||
| 34 | + | <option value="default" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "default") }>System default</option> | |||
| 35 | + | <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option> | |||
| 36 | + | <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option> | |||
| 37 | + | </select></label> | |||
| 38 | + | @PaletteFields("Light palette", "light", config.Appearance.LightPalette) | |||
| 39 | + | @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette) | |||
| 40 | + | <div class="sv-button-row sv-form-actions"> | |||
| 41 | + | <button class="sv-button" type="submit">Save appearance</button> | |||
| 42 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">Reset appearance</button> | |||
| 43 | + | </div> | |||
| 44 | + | </form> | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | templ PaletteFields(title string, prefix string, palette port.ColorPalette) { | |||
| 48 | + | <fieldset class="sv-fieldset"> | |||
| 49 | + | <legend>{ title }</legend> | |||
| 50 | + | <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label> | |||
| 51 | + | <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label> | |||
| 52 | + | <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label> | |||
| 53 | + | <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label> | |||
| 54 | + | <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label> | |||
| 55 | + | <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label> | |||
| 56 | + | <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label> | |||
| 57 | + | </fieldset> | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | templ SearchSettings(config port.SystemviewConfigPayload) { | |||
| 61 | + | <form class="sv-card-form" data-sv-action="config.search.save"> | |||
| 62 | + | <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label> | |||
| 63 | + | <button class="sv-button" type="submit">Save search default</button> | |||
| 64 | + | </form> | |||
| 65 | + | <div class="sv-group"> | |||
| 66 | + | <div class="sv-group-header"><h4>Search shortcuts</h4></div> | |||
| 67 | + | <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p> | |||
| 68 | + | <form class="sv-inline-form" data-sv-action="config.searchShortcut.create"> | |||
| 69 | + | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | |||
| 70 | + | <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label> | |||
| 71 | + | <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo" required/></label> | |||
| 72 | + | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | |||
| 73 | + | </form> | |||
| 74 | + | if len(searchShortcutRows(config.SearchShortcuts)) == 0 { | |||
| 75 | + | @EmptyState("No search shortcuts configured") | |||
| 76 | + | } else { | |||
| 77 | + | <ul class="sv-list"> | |||
| 78 | + | for _, shortcut := range searchShortcutRows(config.SearchShortcuts) { | |||
| 79 | + | @SearchShortcutItem(shortcut) | |||
| 80 | + | } | |||
| 81 | + | </ul> | |||
| 82 | + | } | |||
| 83 | + | </div> | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | templ SearchShortcutItem(shortcut searchShortcutRow) { | |||
| 87 | + | <li class="sv-list-row"> | |||
| 88 | + | <form class="sv-inline-form" data-sv-action="config.searchShortcut.update"> | |||
| 89 | + | <input type="hidden" name="key" value={ shortcut.Key }/> | |||
| 90 | + | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | |||
| 91 | + | <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label> | |||
| 92 | + | <label><span>Description</span><input name="description" type="text" value={ shortcut.Description } required/></label> | |||
| 93 | + | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |||
| 94 | + | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button> | |||
| 95 | + | </form> | |||
| 96 | + | </li> | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | templ PerformanceSettings(config port.SystemviewConfigPayload) { | |||
| 100 | + | <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save"> | |||
| 101 | + | <label><span>Profile</span><select name="profile"> | |||
| 102 | + | <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option> | |||
| 103 | + | <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option> | |||
| 104 | + | <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option> | |||
| 105 | + | <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option> | |||
| 106 | + | </select></label> | |||
| 107 | + | <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) }/></label> | |||
| 108 | + | <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) }/></label> | |||
| 109 | + | <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) }/></label> | |||
| 110 | + | <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) }/></label> | |||
| 111 | + | <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) }/></label> | |||
| 112 | + | <button class="sv-button" type="submit">Save performance</button> | |||
| 113 | + | </form> | |||
| 114 | + | <div class="sv-stat-grid"> | |||
| 115 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div> | |||
| 116 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.TotalRAMMB) }</span><span class="sv-stat-label">RAM MB</span></div> | |||
| 117 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ config.Performance.Hardware.GPUName }</span><span class="sv-stat-label">GPU</span></div> | |||
| 118 | + | <div class="sv-stat-card"><span class="sv-stat-value">{ config.EngineType }</span><span class="sv-stat-label">Engine</span></div> | |||
| 119 | + | </div> | |||
| 120 | + | if !engineIsWebKit(config.EngineType) { | |||
| 121 | + | <p class="sv-meta">Performance tuning applies only to the WebKit engine.</p> | |||
| 122 | + | } else { | |||
| 123 | + | <p class="sv-meta">Performance profile changes may require restarting the browser to affect existing engine processes.</p> | |||
| 124 | + | } | |||
| 11 | } | 125 | } | ||
| 12 | 126 | ||||
| 13 | templ ConfigKeybindings(keybindings any) { | 127 | templ ConfigKeybindings(keybindings any) { | ||
| 14 | {{ groups := configKeybindingGroups(keybindings) }} | 128 | {{ groups := configKeybindingGroups(keybindings) }} | ||
| 129 | + | <div class="sv-button-row"> | |||
| 130 | + | <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">Reset all keybindings</button> | |||
| 131 | + | </div> | |||
| 15 | if groups == nil { | 132 | if groups == nil { | ||
| 16 | @EmptyState("Keybindings unavailable") | 133 | @EmptyState("Keybindings unavailable") | ||
| 17 | } else if len(groups) == 0 { | 134 | } else if len(groups) == 0 { | ||
| @@ -39,7 +156,7 @@ templ ConfigKeybindingGroup(group renderedKeybindingGroup) { | |||||
| 39 | } else { | 156 | } else { | ||
| 40 | <ul class="sv-list"> | 157 | <ul class="sv-list"> | ||
| 41 | for _, binding := range group.Bindings { | 158 | for _, binding := range group.Bindings { | ||
| 42 | - | @ConfigKeybinding(binding) | 159 | + | @ConfigKeybinding(group, binding) |
| 43 | } | 160 | } | ||
| 44 | </ul> | 161 | </ul> | ||
| 45 | } | 162 | } | ||
| @@ -47,21 +164,28 @@ templ ConfigKeybindingGroup(group renderedKeybindingGroup) { | |||||
| 47 | </div> | 164 | </div> | ||
| 48 | } | 165 | } | ||
| 49 | 166 | ||||
| 50 | - | templ ConfigKeybinding(binding renderedKeybinding) { | 167 | + | templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) { |
| 51 | <li class="sv-list-row"> | 168 | <li class="sv-list-row"> | ||
| 52 | <div class="sv-keybinding-row"> | 169 | <div class="sv-keybinding-row"> | ||
| 53 | <div> | 170 | <div> | ||
| 54 | <strong>{ keybindingDescription(binding) }</strong> | 171 | <strong>{ keybindingDescription(binding) }</strong> | ||
| 55 | <p class="sv-meta">{ keybindingAction(binding) }</p> | 172 | <p class="sv-meta">{ keybindingAction(binding) }</p> | ||
| 173 | + | <div class="sv-binding-keys"> | |||
| 174 | + | @KeyList(binding.Keys) | |||
| 175 | + | <span class="sv-badge">{ keybindingStatus(binding) }</span> | |||
| 176 | + | if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 { | |||
| 177 | + | <span class="sv-meta">default:</span> | |||
| 178 | + | @KeyList(binding.DefaultKeys) | |||
| 179 | + | } | |||
| 180 | + | </div> | |||
| 56 | </div> | 181 | </div> | ||
| 57 | - | <div class="sv-keybinding-keys"> | 182 | + | <form class="sv-inline-form" data-sv-action="config.keybinding.set"> |
| 58 | - | @KeyList(binding.Keys) | 183 | + | <input type="hidden" name="mode" value={ group.Mode }/> |
| 59 | - | <span class="sv-badge">{ keybindingStatus(binding) }</span> | 184 | + | <input type="hidden" name="action" value={ binding.Action }/> |
| 60 | - | if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 { | 185 | + | <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h" required/></label> |
| 61 | - | <span class="sv-meta">default:</span> | 186 | + | <button class="sv-button sv-button-secondary" type="submit">Save</button> |
| 62 | - | @KeyList(binding.DefaultKeys) | 187 | + | <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button> |
| 63 | - | } | 188 | + | </form> |
| 64 | - | </div> | |||
| 65 | </div> | 189 | </div> | ||
| 66 | </li> | 190 | </li> | ||
| 67 | } | 191 | } | ||
A internal/ui/systemviews/config_actions.go +434 -0
| @@ -0,0 +1,434 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "strconv" | |
| 7 | + | "strings" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | configActionSaveAppearance = "config.appearance.save" | |
| 14 | + | configActionResetAppearance = "config.appearance.reset" | |
| 15 | + | configActionSaveSearch = "config.search.save" | |
| 16 | + | configActionCreateSearchShortcut = "config.searchShortcut.create" | |
| 17 | + | configActionUpdateSearchShortcut = "config.searchShortcut.update" | |
| 18 | + | configActionDeleteSearchShortcut = "config.searchShortcut.delete" | |
| 19 | + | configActionSavePerformance = "config.performance.save" | |
| 20 | + | configActionSetKeybinding = "config.keybinding.set" | |
| 21 | + | configActionResetKeybinding = "config.keybinding.reset" | |
| 22 | + | configActionResetAllKeybindings = "config.keybinding.resetAll" | |
| 23 | + | configRequestIDPrefix = "systemviews-config" | |
| 24 | + | ) | |
| 25 | + | ||
| 26 | + | func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error { | |
| 27 | + | if a.deps.Config == nil { | |
| 28 | + | return fmt.Errorf("config service not configured") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | switch event.Action { | |
| 32 | + | case configActionSaveAppearance: | |
| 33 | + | return a.saveAppearanceConfig(ctx, event.Data) | |
| 34 | + | case configActionResetAppearance: | |
| 35 | + | return a.resetAppearanceConfig(ctx) | |
| 36 | + | case configActionSaveSearch: | |
| 37 | + | return a.saveSearchConfig(ctx, event.Data) | |
| 38 | + | case configActionCreateSearchShortcut: | |
| 39 | + | return a.createSearchShortcut(ctx, event.Data) | |
| 40 | + | case configActionUpdateSearchShortcut: | |
| 41 | + | return a.updateSearchShortcut(ctx, event.Data) | |
| 42 | + | case configActionDeleteSearchShortcut: | |
| 43 | + | return a.deleteSearchShortcut(ctx, event.Data) | |
| 44 | + | case configActionSavePerformance: | |
| 45 | + | return a.savePerformanceConfig(ctx, event.Data) | |
| 46 | + | case configActionSetKeybinding: | |
| 47 | + | return a.setConfigKeybinding(ctx, event.Data) | |
| 48 | + | case configActionResetKeybinding: | |
| 49 | + | return a.resetConfigKeybinding(ctx, event.Data) | |
| 50 | + | case configActionResetAllKeybindings: | |
| 51 | + | if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil { | |
| 52 | + | return err | |
| 53 | + | } | |
| 54 | + | a.configNotice = "Reset all keybindings to defaults" | |
| 55 | + | } | |
| 56 | + | return nil | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error { | |
| 60 | + | cfg, err := a.editableConfig(ctx) | |
| 61 | + | if err != nil { | |
| 62 | + | return err | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | fontSize, err := parseConfigInt(data["default_font_size"], "default font size") | |
| 66 | + | if err != nil { | |
| 67 | + | return err | |
| 68 | + | } | |
| 69 | + | uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale") | |
| 70 | + | if err != nil { | |
| 71 | + | return err | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"]) | |
| 75 | + | cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"]) | |
| 76 | + | cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"]) | |
| 77 | + | cfg.Appearance.DefaultFontSize = fontSize | |
| 78 | + | cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"]) | |
| 79 | + | cfg.Appearance.LightPalette = paletteFromForm(data, "light") | |
| 80 | + | cfg.Appearance.DarkPalette = paletteFromForm(data, "dark") | |
| 81 | + | cfg.DefaultUIScale = uiScale | |
| 82 | + | ||
| 83 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 84 | + | return err | |
| 85 | + | } | |
| 86 | + | a.configNotice = "Saved appearance settings" | |
| 87 | + | return nil | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func (a *App) resetAppearanceConfig(ctx context.Context) error { | |
| 91 | + | cfg, err := a.editableConfig(ctx) | |
| 92 | + | if err != nil { | |
| 93 | + | return err | |
| 94 | + | } | |
| 95 | + | defaults, err := a.deps.Config.Default(ctx) | |
| 96 | + | if err != nil { | |
| 97 | + | return err | |
| 98 | + | } | |
| 99 | + | cfg.Appearance = defaults.Appearance | |
| 100 | + | cfg.DefaultUIScale = defaults.DefaultUIScale | |
| 101 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 102 | + | return err | |
| 103 | + | } | |
| 104 | + | a.configNotice = "Reset appearance settings to defaults" | |
| 105 | + | return nil | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error { | |
| 109 | + | cfg, err := a.editableConfig(ctx) | |
| 110 | + | if err != nil { | |
| 111 | + | return err | |
| 112 | + | } | |
| 113 | + | cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"]) | |
| 114 | + | if err := validateSearchURL(cfg.DefaultSearchEngine, "default search engine"); err != nil { | |
| 115 | + | return err | |
| 116 | + | } | |
| 117 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 118 | + | return err | |
| 119 | + | } | |
| 120 | + | a.configNotice = "Saved default search engine" | |
| 121 | + | return nil | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error { | |
| 125 | + | cfg, err := a.editableConfig(ctx) | |
| 126 | + | if err != nil { | |
| 127 | + | return err | |
| 128 | + | } | |
| 129 | + | key, shortcut, err := searchShortcutFromForm(data, "key") | |
| 130 | + | if err != nil { | |
| 131 | + | return err | |
| 132 | + | } | |
| 133 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |
| 134 | + | if _, exists := cfg.SearchShortcuts[key]; exists { | |
| 135 | + | return fmt.Errorf("search shortcut %q already exists", key) | |
| 136 | + | } | |
| 137 | + | cfg.SearchShortcuts[key] = shortcut | |
| 138 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 139 | + | return err | |
| 140 | + | } | |
| 141 | + | a.configNotice = "Created search shortcut " + key | |
| 142 | + | return nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error { | |
| 146 | + | cfg, err := a.editableConfig(ctx) | |
| 147 | + | if err != nil { | |
| 148 | + | return err | |
| 149 | + | } | |
| 150 | + | oldKey := strings.TrimSpace(data["key"]) | |
| 151 | + | if oldKey == "" { | |
| 152 | + | return fmt.Errorf("search shortcut key is required") | |
| 153 | + | } | |
| 154 | + | newKey, shortcut, err := searchShortcutFromForm(data, "new_key") | |
| 155 | + | if err != nil { | |
| 156 | + | return err | |
| 157 | + | } | |
| 158 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |
| 159 | + | if _, exists := cfg.SearchShortcuts[oldKey]; !exists { | |
| 160 | + | return fmt.Errorf("search shortcut %q not found", oldKey) | |
| 161 | + | } | |
| 162 | + | if newKey != oldKey { | |
| 163 | + | if _, exists := cfg.SearchShortcuts[newKey]; exists { | |
| 164 | + | return fmt.Errorf("search shortcut %q already exists", newKey) | |
| 165 | + | } | |
| 166 | + | delete(cfg.SearchShortcuts, oldKey) | |
| 167 | + | } | |
| 168 | + | cfg.SearchShortcuts[newKey] = shortcut | |
| 169 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 170 | + | return err | |
| 171 | + | } | |
| 172 | + | a.configNotice = "Saved search shortcut " + newKey | |
| 173 | + | return nil | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error { | |
| 177 | + | cfg, err := a.editableConfig(ctx) | |
| 178 | + | if err != nil { | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | key := strings.TrimSpace(data["key"]) | |
| 182 | + | if key == "" { | |
| 183 | + | return fmt.Errorf("search shortcut key is required") | |
| 184 | + | } | |
| 185 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |
| 186 | + | delete(cfg.SearchShortcuts, key) | |
| 187 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 188 | + | return err | |
| 189 | + | } | |
| 190 | + | a.configNotice = "Deleted search shortcut " + key | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error { | |
| 195 | + | cfg, err := a.editableConfig(ctx) | |
| 196 | + | if err != nil { | |
| 197 | + | return err | |
| 198 | + | } | |
| 199 | + | profile := strings.TrimSpace(data["profile"]) | |
| 200 | + | if !validPerformanceProfile(profile) { | |
| 201 | + | return fmt.Errorf("invalid performance profile") | |
| 202 | + | } | |
| 203 | + | skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads") | |
| 204 | + | if err != nil { | |
| 205 | + | return err | |
| 206 | + | } | |
| 207 | + | skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads") | |
| 208 | + | if err != nil { | |
| 209 | + | return err | |
| 210 | + | } | |
| 211 | + | webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory") | |
| 212 | + | if err != nil { | |
| 213 | + | return err | |
| 214 | + | } | |
| 215 | + | networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory") | |
| 216 | + | if err != nil { | |
| 217 | + | return err | |
| 218 | + | } | |
| 219 | + | prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm") | |
| 220 | + | if err != nil { | |
| 221 | + | return err | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | cfg.Performance.Profile = profile | |
| 225 | + | cfg.Performance.Custom = port.WebUICustomPerformanceConfig{ | |
| 226 | + | SkiaCPUThreads: skiaCPU, | |
| 227 | + | SkiaGPUThreads: skiaGPU, | |
| 228 | + | WebProcessMemoryMB: webMemory, | |
| 229 | + | NetworkProcessMemoryMB: networkMemory, | |
| 230 | + | WebViewPoolPrewarm: prewarm, | |
| 231 | + | } | |
| 232 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| 233 | + | return err | |
| 234 | + | } | |
| 235 | + | a.configNotice = "Saved performance settings. Restart may be required." | |
| 236 | + | return nil | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error { | |
| 240 | + | mode := strings.TrimSpace(data["mode"]) | |
| 241 | + | action := strings.TrimSpace(data["action"]) | |
| 242 | + | keys := parseKeyList(data["keys"]) | |
| 243 | + | if mode == "" || action == "" { | |
| 244 | + | return fmt.Errorf("mode and action are required") | |
| 245 | + | } | |
| 246 | + | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | |
| 247 | + | RequestID: configRequestIDPrefix, | |
| 248 | + | Mode: mode, | |
| 249 | + | Action: action, | |
| 250 | + | Keys: keys, | |
| 251 | + | }) | |
| 252 | + | if err != nil { | |
| 253 | + | return err | |
| 254 | + | } | |
| 255 | + | if conflicts := keybindingConflictCount(resp); conflicts > 0 { | |
| 256 | + | a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts) | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | a.configNotice = "Saved keybinding " + action | |
| 260 | + | return nil | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error { | |
| 264 | + | mode := strings.TrimSpace(data["mode"]) | |
| 265 | + | action := strings.TrimSpace(data["action"]) | |
| 266 | + | if mode == "" || action == "" { | |
| 267 | + | return fmt.Errorf("mode and action are required") | |
| 268 | + | } | |
| 269 | + | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | |
| 270 | + | RequestID: configRequestIDPrefix, | |
| 271 | + | Mode: mode, | |
| 272 | + | Action: action, | |
| 273 | + | }); err != nil { | |
| 274 | + | return err | |
| 275 | + | } | |
| 276 | + | a.configNotice = "Reset keybinding " + action | |
| 277 | + | return nil | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) { | |
| 281 | + | payload, err := a.currentConfigPayload(ctx) | |
| 282 | + | if err != nil { | |
| 283 | + | return port.WebUIConfig{}, err | |
| 284 | + | } | |
| 285 | + | return webUIConfigFromPayload(payload), nil | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | func (a *App) currentConfigPayload(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 289 | + | if a.config != nil { | |
| 290 | + | return *a.config, nil | |
| 291 | + | } | |
| 292 | + | cfg, err := a.deps.Config.Current(ctx) | |
| 293 | + | if err != nil { | |
| 294 | + | return port.SystemviewConfigPayload{}, err | |
| 295 | + | } | |
| 296 | + | a.config = &cfg | |
| 297 | + | return cfg, nil | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error { | |
| 301 | + | if err := a.deps.Config.Save(ctx, cfg); err != nil { | |
| 302 | + | return err | |
| 303 | + | } | |
| 304 | + | a.config = nil | |
| 305 | + | return nil | |
| 306 | + | } | |
| 307 | + | ||
| 308 | + | func webUIConfigFromPayload(payload port.SystemviewConfigPayload) port.WebUIConfig { | |
| 309 | + | return port.WebUIConfig{ | |
| 310 | + | Appearance: payload.Appearance, | |
| 311 | + | DefaultUIScale: payload.DefaultUIScale, | |
| 312 | + | DefaultSearchEngine: payload.DefaultSearchEngine, | |
| 313 | + | SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts), | |
| 314 | + | Performance: port.WebUIPerformanceConfig{ | |
| 315 | + | Profile: payload.Performance.Profile, | |
| 316 | + | Custom: port.WebUICustomPerformanceConfig{ | |
| 317 | + | SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads, | |
| 318 | + | SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads, | |
| 319 | + | WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB, | |
| 320 | + | NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB, | |
| 321 | + | WebViewPoolPrewarm: payload.Performance.Custom.WebViewPoolPrewarm, | |
| 322 | + | }, | |
| 323 | + | }, | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]port.SearchShortcut { | |
| 328 | + | clone := make(map[string]port.SearchShortcut, len(shortcuts)) | |
| 329 | + | for key, shortcut := range shortcuts { | |
| 330 | + | clone[key] = shortcut | |
| 331 | + | } | |
| 332 | + | return clone | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | func paletteFromForm(data map[string]string, prefix string) port.ColorPalette { | |
| 336 | + | return port.ColorPalette{ | |
| 337 | + | Background: strings.TrimSpace(data[prefix+"_background"]), | |
| 338 | + | Surface: strings.TrimSpace(data[prefix+"_surface"]), | |
| 339 | + | SurfaceVariant: strings.TrimSpace(data[prefix+"_surface_variant"]), | |
| 340 | + | Text: strings.TrimSpace(data[prefix+"_text"]), | |
| 341 | + | Muted: strings.TrimSpace(data[prefix+"_muted"]), | |
| 342 | + | Accent: strings.TrimSpace(data[prefix+"_accent"]), | |
| 343 | + | Border: strings.TrimSpace(data[prefix+"_border"]), | |
| 344 | + | } | |
| 345 | + | } | |
| 346 | + | ||
| 347 | + | func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut, error) { | |
| 348 | + | key := strings.TrimSpace(data[keyField]) | |
| 349 | + | url := strings.TrimSpace(data["url"]) | |
| 350 | + | description := strings.TrimSpace(data["description"]) | |
| 351 | + | if key == "" { | |
| 352 | + | return "", port.SearchShortcut{}, fmt.Errorf("search shortcut key is required") | |
| 353 | + | } | |
| 354 | + | if description == "" { | |
| 355 | + | return "", port.SearchShortcut{}, fmt.Errorf("search shortcut description is required") | |
| 356 | + | } | |
| 357 | + | if err := validateSearchURL(url, "search shortcut URL"); err != nil { | |
| 358 | + | return "", port.SearchShortcut{}, err | |
| 359 | + | } | |
| 360 | + | return key, port.SearchShortcut{URL: url, Description: description}, nil | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func validateSearchURL(raw, label string) error { | |
| 364 | + | if strings.TrimSpace(raw) == "" { | |
| 365 | + | return fmt.Errorf("%s is required", label) | |
| 366 | + | } | |
| 367 | + | if !strings.Contains(raw, "%s") { | |
| 368 | + | return fmt.Errorf("%s must contain %%s placeholder", label) | |
| 369 | + | } | |
| 370 | + | return nil | |
| 371 | + | } | |
| 372 | + | ||
| 373 | + | func validPerformanceProfile(profile string) bool { | |
| 374 | + | switch profile { | |
| 375 | + | case "default", "lite", "max", "custom", "": | |
| 376 | + | return true | |
| 377 | + | default: | |
| 378 | + | return false | |
| 379 | + | } | |
| 380 | + | } | |
| 381 | + | ||
| 382 | + | func parseConfigInt(raw, label string) (int, error) { | |
| 383 | + | value, err := strconv.Atoi(strings.TrimSpace(raw)) | |
| 384 | + | if err != nil { | |
| 385 | + | return 0, fmt.Errorf("invalid %s", label) | |
| 386 | + | } | |
| 387 | + | return value, nil | |
| 388 | + | } | |
| 389 | + | ||
| 390 | + | func parseConfigFloat(raw, label string) (float64, error) { | |
| 391 | + | value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) | |
| 392 | + | if err != nil { | |
| 393 | + | return 0, fmt.Errorf("invalid %s", label) | |
| 394 | + | } | |
| 395 | + | return value, nil | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | func parseKeyList(raw string) []string { | |
| 399 | + | raw = strings.TrimSpace(raw) | |
| 400 | + | if raw == "" { | |
| 401 | + | return nil | |
| 402 | + | } | |
| 403 | + | parts := strings.Split(raw, ",") | |
| 404 | + | if len(parts) == 1 { | |
| 405 | + | parts = strings.Fields(raw) | |
| 406 | + | } | |
| 407 | + | keys := make([]string, 0, len(parts)) | |
| 408 | + | for _, part := range parts { | |
| 409 | + | key := strings.TrimSpace(part) | |
| 410 | + | if key != "" { | |
| 411 | + | keys = append(keys, key) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | return keys | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func keybindingConflictCount(resp any) int { | |
| 418 | + | switch v := resp.(type) { | |
| 419 | + | case nil: | |
| 420 | + | return 0 | |
| 421 | + | case port.SetKeybindingResponse: | |
| 422 | + | return len(v.Conflicts) | |
| 423 | + | case *port.SetKeybindingResponse: | |
| 424 | + | if v == nil { | |
| 425 | + | return 0 | |
| 426 | + | } | |
| 427 | + | return len(v.Conflicts) | |
| 428 | + | case map[string]any: | |
| 429 | + | if conflicts, ok := anySlice(v["conflicts"]); ok { | |
| 430 | + | return len(conflicts) | |
| 431 | + | } | |
| 432 | + | } | |
| 433 | + | return 0 | |
| 434 | + | } | |
| @@ -0,0 +1,434 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strconv" | |||
| 7 | + | "strings" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | const ( | |||
| 13 | + | configActionSaveAppearance = "config.appearance.save" | |||
| 14 | + | configActionResetAppearance = "config.appearance.reset" | |||
| 15 | + | configActionSaveSearch = "config.search.save" | |||
| 16 | + | configActionCreateSearchShortcut = "config.searchShortcut.create" | |||
| 17 | + | configActionUpdateSearchShortcut = "config.searchShortcut.update" | |||
| 18 | + | configActionDeleteSearchShortcut = "config.searchShortcut.delete" | |||
| 19 | + | configActionSavePerformance = "config.performance.save" | |||
| 20 | + | configActionSetKeybinding = "config.keybinding.set" | |||
| 21 | + | configActionResetKeybinding = "config.keybinding.reset" | |||
| 22 | + | configActionResetAllKeybindings = "config.keybinding.resetAll" | |||
| 23 | + | configRequestIDPrefix = "systemviews-config" | |||
| 24 | + | ) | |||
| 25 | + | ||||
| 26 | + | func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error { | |||
| 27 | + | if a.deps.Config == nil { | |||
| 28 | + | return fmt.Errorf("config service not configured") | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | switch event.Action { | |||
| 32 | + | case configActionSaveAppearance: | |||
| 33 | + | return a.saveAppearanceConfig(ctx, event.Data) | |||
| 34 | + | case configActionResetAppearance: | |||
| 35 | + | return a.resetAppearanceConfig(ctx) | |||
| 36 | + | case configActionSaveSearch: | |||
| 37 | + | return a.saveSearchConfig(ctx, event.Data) | |||
| 38 | + | case configActionCreateSearchShortcut: | |||
| 39 | + | return a.createSearchShortcut(ctx, event.Data) | |||
| 40 | + | case configActionUpdateSearchShortcut: | |||
| 41 | + | return a.updateSearchShortcut(ctx, event.Data) | |||
| 42 | + | case configActionDeleteSearchShortcut: | |||
| 43 | + | return a.deleteSearchShortcut(ctx, event.Data) | |||
| 44 | + | case configActionSavePerformance: | |||
| 45 | + | return a.savePerformanceConfig(ctx, event.Data) | |||
| 46 | + | case configActionSetKeybinding: | |||
| 47 | + | return a.setConfigKeybinding(ctx, event.Data) | |||
| 48 | + | case configActionResetKeybinding: | |||
| 49 | + | return a.resetConfigKeybinding(ctx, event.Data) | |||
| 50 | + | case configActionResetAllKeybindings: | |||
| 51 | + | if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil { | |||
| 52 | + | return err | |||
| 53 | + | } | |||
| 54 | + | a.configNotice = "Reset all keybindings to defaults" | |||
| 55 | + | } | |||
| 56 | + | return nil | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error { | |||
| 60 | + | cfg, err := a.editableConfig(ctx) | |||
| 61 | + | if err != nil { | |||
| 62 | + | return err | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | fontSize, err := parseConfigInt(data["default_font_size"], "default font size") | |||
| 66 | + | if err != nil { | |||
| 67 | + | return err | |||
| 68 | + | } | |||
| 69 | + | uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale") | |||
| 70 | + | if err != nil { | |||
| 71 | + | return err | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"]) | |||
| 75 | + | cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"]) | |||
| 76 | + | cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"]) | |||
| 77 | + | cfg.Appearance.DefaultFontSize = fontSize | |||
| 78 | + | cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"]) | |||
| 79 | + | cfg.Appearance.LightPalette = paletteFromForm(data, "light") | |||
| 80 | + | cfg.Appearance.DarkPalette = paletteFromForm(data, "dark") | |||
| 81 | + | cfg.DefaultUIScale = uiScale | |||
| 82 | + | ||||
| 83 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 84 | + | return err | |||
| 85 | + | } | |||
| 86 | + | a.configNotice = "Saved appearance settings" | |||
| 87 | + | return nil | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func (a *App) resetAppearanceConfig(ctx context.Context) error { | |||
| 91 | + | cfg, err := a.editableConfig(ctx) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return err | |||
| 94 | + | } | |||
| 95 | + | defaults, err := a.deps.Config.Default(ctx) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return err | |||
| 98 | + | } | |||
| 99 | + | cfg.Appearance = defaults.Appearance | |||
| 100 | + | cfg.DefaultUIScale = defaults.DefaultUIScale | |||
| 101 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 102 | + | return err | |||
| 103 | + | } | |||
| 104 | + | a.configNotice = "Reset appearance settings to defaults" | |||
| 105 | + | return nil | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error { | |||
| 109 | + | cfg, err := a.editableConfig(ctx) | |||
| 110 | + | if err != nil { | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"]) | |||
| 114 | + | if err := validateSearchURL(cfg.DefaultSearchEngine, "default search engine"); err != nil { | |||
| 115 | + | return err | |||
| 116 | + | } | |||
| 117 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 118 | + | return err | |||
| 119 | + | } | |||
| 120 | + | a.configNotice = "Saved default search engine" | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error { | |||
| 125 | + | cfg, err := a.editableConfig(ctx) | |||
| 126 | + | if err != nil { | |||
| 127 | + | return err | |||
| 128 | + | } | |||
| 129 | + | key, shortcut, err := searchShortcutFromForm(data, "key") | |||
| 130 | + | if err != nil { | |||
| 131 | + | return err | |||
| 132 | + | } | |||
| 133 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |||
| 134 | + | if _, exists := cfg.SearchShortcuts[key]; exists { | |||
| 135 | + | return fmt.Errorf("search shortcut %q already exists", key) | |||
| 136 | + | } | |||
| 137 | + | cfg.SearchShortcuts[key] = shortcut | |||
| 138 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 139 | + | return err | |||
| 140 | + | } | |||
| 141 | + | a.configNotice = "Created search shortcut " + key | |||
| 142 | + | return nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error { | |||
| 146 | + | cfg, err := a.editableConfig(ctx) | |||
| 147 | + | if err != nil { | |||
| 148 | + | return err | |||
| 149 | + | } | |||
| 150 | + | oldKey := strings.TrimSpace(data["key"]) | |||
| 151 | + | if oldKey == "" { | |||
| 152 | + | return fmt.Errorf("search shortcut key is required") | |||
| 153 | + | } | |||
| 154 | + | newKey, shortcut, err := searchShortcutFromForm(data, "new_key") | |||
| 155 | + | if err != nil { | |||
| 156 | + | return err | |||
| 157 | + | } | |||
| 158 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |||
| 159 | + | if _, exists := cfg.SearchShortcuts[oldKey]; !exists { | |||
| 160 | + | return fmt.Errorf("search shortcut %q not found", oldKey) | |||
| 161 | + | } | |||
| 162 | + | if newKey != oldKey { | |||
| 163 | + | if _, exists := cfg.SearchShortcuts[newKey]; exists { | |||
| 164 | + | return fmt.Errorf("search shortcut %q already exists", newKey) | |||
| 165 | + | } | |||
| 166 | + | delete(cfg.SearchShortcuts, oldKey) | |||
| 167 | + | } | |||
| 168 | + | cfg.SearchShortcuts[newKey] = shortcut | |||
| 169 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 170 | + | return err | |||
| 171 | + | } | |||
| 172 | + | a.configNotice = "Saved search shortcut " + newKey | |||
| 173 | + | return nil | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error { | |||
| 177 | + | cfg, err := a.editableConfig(ctx) | |||
| 178 | + | if err != nil { | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | key := strings.TrimSpace(data["key"]) | |||
| 182 | + | if key == "" { | |||
| 183 | + | return fmt.Errorf("search shortcut key is required") | |||
| 184 | + | } | |||
| 185 | + | cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts) | |||
| 186 | + | delete(cfg.SearchShortcuts, key) | |||
| 187 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 188 | + | return err | |||
| 189 | + | } | |||
| 190 | + | a.configNotice = "Deleted search shortcut " + key | |||
| 191 | + | return nil | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error { | |||
| 195 | + | cfg, err := a.editableConfig(ctx) | |||
| 196 | + | if err != nil { | |||
| 197 | + | return err | |||
| 198 | + | } | |||
| 199 | + | profile := strings.TrimSpace(data["profile"]) | |||
| 200 | + | if !validPerformanceProfile(profile) { | |||
| 201 | + | return fmt.Errorf("invalid performance profile") | |||
| 202 | + | } | |||
| 203 | + | skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads") | |||
| 204 | + | if err != nil { | |||
| 205 | + | return err | |||
| 206 | + | } | |||
| 207 | + | skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads") | |||
| 208 | + | if err != nil { | |||
| 209 | + | return err | |||
| 210 | + | } | |||
| 211 | + | webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory") | |||
| 212 | + | if err != nil { | |||
| 213 | + | return err | |||
| 214 | + | } | |||
| 215 | + | networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory") | |||
| 216 | + | if err != nil { | |||
| 217 | + | return err | |||
| 218 | + | } | |||
| 219 | + | prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm") | |||
| 220 | + | if err != nil { | |||
| 221 | + | return err | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | cfg.Performance.Profile = profile | |||
| 225 | + | cfg.Performance.Custom = port.WebUICustomPerformanceConfig{ | |||
| 226 | + | SkiaCPUThreads: skiaCPU, | |||
| 227 | + | SkiaGPUThreads: skiaGPU, | |||
| 228 | + | WebProcessMemoryMB: webMemory, | |||
| 229 | + | NetworkProcessMemoryMB: networkMemory, | |||
| 230 | + | WebViewPoolPrewarm: prewarm, | |||
| 231 | + | } | |||
| 232 | + | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |||
| 233 | + | return err | |||
| 234 | + | } | |||
| 235 | + | a.configNotice = "Saved performance settings. Restart may be required." | |||
| 236 | + | return nil | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error { | |||
| 240 | + | mode := strings.TrimSpace(data["mode"]) | |||
| 241 | + | action := strings.TrimSpace(data["action"]) | |||
| 242 | + | keys := parseKeyList(data["keys"]) | |||
| 243 | + | if mode == "" || action == "" { | |||
| 244 | + | return fmt.Errorf("mode and action are required") | |||
| 245 | + | } | |||
| 246 | + | resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{ | |||
| 247 | + | RequestID: configRequestIDPrefix, | |||
| 248 | + | Mode: mode, | |||
| 249 | + | Action: action, | |||
| 250 | + | Keys: keys, | |||
| 251 | + | }) | |||
| 252 | + | if err != nil { | |||
| 253 | + | return err | |||
| 254 | + | } | |||
| 255 | + | if conflicts := keybindingConflictCount(resp); conflicts > 0 { | |||
| 256 | + | a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts) | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | a.configNotice = "Saved keybinding " + action | |||
| 260 | + | return nil | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error { | |||
| 264 | + | mode := strings.TrimSpace(data["mode"]) | |||
| 265 | + | action := strings.TrimSpace(data["action"]) | |||
| 266 | + | if mode == "" || action == "" { | |||
| 267 | + | return fmt.Errorf("mode and action are required") | |||
| 268 | + | } | |||
| 269 | + | if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{ | |||
| 270 | + | RequestID: configRequestIDPrefix, | |||
| 271 | + | Mode: mode, | |||
| 272 | + | Action: action, | |||
| 273 | + | }); err != nil { | |||
| 274 | + | return err | |||
| 275 | + | } | |||
| 276 | + | a.configNotice = "Reset keybinding " + action | |||
| 277 | + | return nil | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) { | |||
| 281 | + | payload, err := a.currentConfigPayload(ctx) | |||
| 282 | + | if err != nil { | |||
| 283 | + | return port.WebUIConfig{}, err | |||
| 284 | + | } | |||
| 285 | + | return webUIConfigFromPayload(payload), nil | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | func (a *App) currentConfigPayload(ctx context.Context) (port.SystemviewConfigPayload, error) { | |||
| 289 | + | if a.config != nil { | |||
| 290 | + | return *a.config, nil | |||
| 291 | + | } | |||
| 292 | + | cfg, err := a.deps.Config.Current(ctx) | |||
| 293 | + | if err != nil { | |||
| 294 | + | return port.SystemviewConfigPayload{}, err | |||
| 295 | + | } | |||
| 296 | + | a.config = &cfg | |||
| 297 | + | return cfg, nil | |||
| 298 | + | } | |||
| 299 | + | ||||
| 300 | + | func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error { | |||
| 301 | + | if err := a.deps.Config.Save(ctx, cfg); err != nil { | |||
| 302 | + | return err | |||
| 303 | + | } | |||
| 304 | + | a.config = nil | |||
| 305 | + | return nil | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func webUIConfigFromPayload(payload port.SystemviewConfigPayload) port.WebUIConfig { | |||
| 309 | + | return port.WebUIConfig{ | |||
| 310 | + | Appearance: payload.Appearance, | |||
| 311 | + | DefaultUIScale: payload.DefaultUIScale, | |||
| 312 | + | DefaultSearchEngine: payload.DefaultSearchEngine, | |||
| 313 | + | SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts), | |||
| 314 | + | Performance: port.WebUIPerformanceConfig{ | |||
| 315 | + | Profile: payload.Performance.Profile, | |||
| 316 | + | Custom: port.WebUICustomPerformanceConfig{ | |||
| 317 | + | SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads, | |||
| 318 | + | SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads, | |||
| 319 | + | WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB, | |||
| 320 | + | NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB, | |||
| 321 | + | WebViewPoolPrewarm: payload.Performance.Custom.WebViewPoolPrewarm, | |||
| 322 | + | }, | |||
| 323 | + | }, | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]port.SearchShortcut { | |||
| 328 | + | clone := make(map[string]port.SearchShortcut, len(shortcuts)) | |||
| 329 | + | for key, shortcut := range shortcuts { | |||
| 330 | + | clone[key] = shortcut | |||
| 331 | + | } | |||
| 332 | + | return clone | |||
| 333 | + | } | |||
| 334 | + | ||||
| 335 | + | func paletteFromForm(data map[string]string, prefix string) port.ColorPalette { | |||
| 336 | + | return port.ColorPalette{ | |||
| 337 | + | Background: strings.TrimSpace(data[prefix+"_background"]), | |||
| 338 | + | Surface: strings.TrimSpace(data[prefix+"_surface"]), | |||
| 339 | + | SurfaceVariant: strings.TrimSpace(data[prefix+"_surface_variant"]), | |||
| 340 | + | Text: strings.TrimSpace(data[prefix+"_text"]), | |||
| 341 | + | Muted: strings.TrimSpace(data[prefix+"_muted"]), | |||
| 342 | + | Accent: strings.TrimSpace(data[prefix+"_accent"]), | |||
| 343 | + | Border: strings.TrimSpace(data[prefix+"_border"]), | |||
| 344 | + | } | |||
| 345 | + | } | |||
| 346 | + | ||||
| 347 | + | func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut, error) { | |||
| 348 | + | key := strings.TrimSpace(data[keyField]) | |||
| 349 | + | url := strings.TrimSpace(data["url"]) | |||
| 350 | + | description := strings.TrimSpace(data["description"]) | |||
| 351 | + | if key == "" { | |||
| 352 | + | return "", port.SearchShortcut{}, fmt.Errorf("search shortcut key is required") | |||
| 353 | + | } | |||
| 354 | + | if description == "" { | |||
| 355 | + | return "", port.SearchShortcut{}, fmt.Errorf("search shortcut description is required") | |||
| 356 | + | } | |||
| 357 | + | if err := validateSearchURL(url, "search shortcut URL"); err != nil { | |||
| 358 | + | return "", port.SearchShortcut{}, err | |||
| 359 | + | } | |||
| 360 | + | return key, port.SearchShortcut{URL: url, Description: description}, nil | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func validateSearchURL(raw, label string) error { | |||
| 364 | + | if strings.TrimSpace(raw) == "" { | |||
| 365 | + | return fmt.Errorf("%s is required", label) | |||
| 366 | + | } | |||
| 367 | + | if !strings.Contains(raw, "%s") { | |||
| 368 | + | return fmt.Errorf("%s must contain %%s placeholder", label) | |||
| 369 | + | } | |||
| 370 | + | return nil | |||
| 371 | + | } | |||
| 372 | + | ||||
| 373 | + | func validPerformanceProfile(profile string) bool { | |||
| 374 | + | switch profile { | |||
| 375 | + | case "default", "lite", "max", "custom", "": | |||
| 376 | + | return true | |||
| 377 | + | default: | |||
| 378 | + | return false | |||
| 379 | + | } | |||
| 380 | + | } | |||
| 381 | + | ||||
| 382 | + | func parseConfigInt(raw, label string) (int, error) { | |||
| 383 | + | value, err := strconv.Atoi(strings.TrimSpace(raw)) | |||
| 384 | + | if err != nil { | |||
| 385 | + | return 0, fmt.Errorf("invalid %s", label) | |||
| 386 | + | } | |||
| 387 | + | return value, nil | |||
| 388 | + | } | |||
| 389 | + | ||||
| 390 | + | func parseConfigFloat(raw, label string) (float64, error) { | |||
| 391 | + | value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) | |||
| 392 | + | if err != nil { | |||
| 393 | + | return 0, fmt.Errorf("invalid %s", label) | |||
| 394 | + | } | |||
| 395 | + | return value, nil | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func parseKeyList(raw string) []string { | |||
| 399 | + | raw = strings.TrimSpace(raw) | |||
| 400 | + | if raw == "" { | |||
| 401 | + | return nil | |||
| 402 | + | } | |||
| 403 | + | parts := strings.Split(raw, ",") | |||
| 404 | + | if len(parts) == 1 { | |||
| 405 | + | parts = strings.Fields(raw) | |||
| 406 | + | } | |||
| 407 | + | keys := make([]string, 0, len(parts)) | |||
| 408 | + | for _, part := range parts { | |||
| 409 | + | key := strings.TrimSpace(part) | |||
| 410 | + | if key != "" { | |||
| 411 | + | keys = append(keys, key) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | return keys | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func keybindingConflictCount(resp any) int { | |||
| 418 | + | switch v := resp.(type) { | |||
| 419 | + | case nil: | |||
| 420 | + | return 0 | |||
| 421 | + | case port.SetKeybindingResponse: | |||
| 422 | + | return len(v.Conflicts) | |||
| 423 | + | case *port.SetKeybindingResponse: | |||
| 424 | + | if v == nil { | |||
| 425 | + | return 0 | |||
| 426 | + | } | |||
| 427 | + | return len(v.Conflicts) | |||
| 428 | + | case map[string]any: | |||
| 429 | + | if conflicts, ok := anySlice(v["conflicts"]); ok { | |||
| 430 | + | return len(conflicts) | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | return 0 | |||
| 434 | + | } | |||
M internal/ui/systemviews/config_templ.go +946 -83 generated file: collapsed
M internal/ui/systemviews/config_view.go +55 -2
| @@ -12,10 +12,12 @@ import ( | |||
| 12 | 12 | type configRenderData struct { | |
| 13 | 13 | Config port.SystemviewConfigPayload | |
| 14 | 14 | Keybindings any | |
| 15 | + | Notice string | |
| 16 | + | Error string | |
| 15 | 17 | } | |
| 16 | 18 | ||
| 17 | - | func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string { | |
| 18 | - | return mustRenderComponent(ConfigView(configRenderData{Config: cfg, Keybindings: keybindings})) | |
| 19 | + | func configHTML(data configRenderData) string { | |
| 20 | + | return mustRenderComponent(ConfigView(data)) | |
| 19 | 21 | } | |
| 20 | 22 | ||
| 21 | 23 | func configKeybindingSummary(keybindings any) string { | |
| @@ -94,6 +96,57 @@ func keybindingStatus(binding renderedKeybinding) string { | |||
| 94 | 96 | return "default" | |
| 95 | 97 | } | |
| 96 | 98 | ||
| 99 | + | func keybindingKeysValue(keys []string) string { | |
| 100 | + | return strings.Join(keys, ", ") | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | type searchShortcutRow struct { | |
| 104 | + | Key string | |
| 105 | + | URL string | |
| 106 | + | Description string | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func searchShortcutRows(shortcuts map[string]port.SearchShortcut) []searchShortcutRow { | |
| 110 | + | if len(shortcuts) == 0 { | |
| 111 | + | return nil | |
| 112 | + | } | |
| 113 | + | keys := make([]string, 0, len(shortcuts)) | |
| 114 | + | for key := range shortcuts { | |
| 115 | + | keys = append(keys, key) | |
| 116 | + | } | |
| 117 | + | sort.Strings(keys) | |
| 118 | + | rows := make([]searchShortcutRow, 0, len(keys)) | |
| 119 | + | for _, key := range keys { | |
| 120 | + | shortcut := shortcuts[key] | |
| 121 | + | rows = append(rows, searchShortcutRow{ | |
| 122 | + | Key: key, | |
| 123 | + | URL: shortcut.URL, | |
| 124 | + | Description: shortcut.Description, | |
| 125 | + | }) | |
| 126 | + | } | |
| 127 | + | return rows | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func formatConfigInt(value int) string { | |
| 131 | + | return fmt.Sprintf("%d", value) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func formatConfigFloat(value float64) string { | |
| 135 | + | return fmt.Sprintf("%g", value) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func colorSchemeSelected(current, option string) bool { | |
| 139 | + | return strings.EqualFold(strings.TrimSpace(current), option) | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func performanceProfileSelected(current, option string) bool { | |
| 143 | + | return strings.EqualFold(strings.TrimSpace(current), option) | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func engineIsWebKit(engine string) bool { | |
| 147 | + | return strings.EqualFold(strings.TrimSpace(engine), "webkit") | |
| 148 | + | } | |
| 149 | + | ||
| 97 | 150 | func flattenValues(value any) []kvPair { | |
| 98 | 151 | rows := make([]kvPair, 0) | |
| 99 | 152 | flattenValue(&rows, "", reflect.ValueOf(value)) | |
| @@ -12,10 +12,12 @@ import ( | |||||
| 12 | type configRenderData struct { | 12 | type configRenderData struct { | ||
| 13 | Config port.SystemviewConfigPayload | 13 | Config port.SystemviewConfigPayload | ||
| 14 | Keybindings any | 14 | Keybindings any | ||
| 15 | + | Notice string | |||
| 16 | + | Error string | |||
| 15 | } | 17 | } | ||
| 16 | 18 | ||||
| 17 | - | func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string { | 19 | + | func configHTML(data configRenderData) string { |
| 18 | - | return mustRenderComponent(ConfigView(configRenderData{Config: cfg, Keybindings: keybindings})) | 20 | + | return mustRenderComponent(ConfigView(data)) |
| 19 | } | 21 | } | ||
| 20 | 22 | ||||
| 21 | func configKeybindingSummary(keybindings any) string { | 23 | func configKeybindingSummary(keybindings any) string { | ||
| @@ -94,6 +96,57 @@ func keybindingStatus(binding renderedKeybinding) string { | |||||
| 94 | return "default" | 96 | return "default" | ||
| 95 | } | 97 | } | ||
| 96 | 98 | ||||
| 99 | + | func keybindingKeysValue(keys []string) string { | |||
| 100 | + | return strings.Join(keys, ", ") | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | type searchShortcutRow struct { | |||
| 104 | + | Key string | |||
| 105 | + | URL string | |||
| 106 | + | Description string | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func searchShortcutRows(shortcuts map[string]port.SearchShortcut) []searchShortcutRow { | |||
| 110 | + | if len(shortcuts) == 0 { | |||
| 111 | + | return nil | |||
| 112 | + | } | |||
| 113 | + | keys := make([]string, 0, len(shortcuts)) | |||
| 114 | + | for key := range shortcuts { | |||
| 115 | + | keys = append(keys, key) | |||
| 116 | + | } | |||
| 117 | + | sort.Strings(keys) | |||
| 118 | + | rows := make([]searchShortcutRow, 0, len(keys)) | |||
| 119 | + | for _, key := range keys { | |||
| 120 | + | shortcut := shortcuts[key] | |||
| 121 | + | rows = append(rows, searchShortcutRow{ | |||
| 122 | + | Key: key, | |||
| 123 | + | URL: shortcut.URL, | |||
| 124 | + | Description: shortcut.Description, | |||
| 125 | + | }) | |||
| 126 | + | } | |||
| 127 | + | return rows | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func formatConfigInt(value int) string { | |||
| 131 | + | return fmt.Sprintf("%d", value) | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func formatConfigFloat(value float64) string { | |||
| 135 | + | return fmt.Sprintf("%g", value) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func colorSchemeSelected(current, option string) bool { | |||
| 139 | + | return strings.EqualFold(strings.TrimSpace(current), option) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func performanceProfileSelected(current, option string) bool { | |||
| 143 | + | return strings.EqualFold(strings.TrimSpace(current), option) | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | func engineIsWebKit(engine string) bool { | |||
| 147 | + | return strings.EqualFold(strings.TrimSpace(engine), "webkit") | |||
| 148 | + | } | |||
| 149 | + | ||||
| 97 | func flattenValues(value any) []kvPair { | 150 | func flattenValues(value any) []kvPair { | ||
| 98 | rows := make([]kvPair, 0) | 151 | rows := make([]kvPair, 0) | ||
| 99 | flattenValue(&rows, "", reflect.ValueOf(value)) | 152 | flattenValue(&rows, "", reflect.ValueOf(value)) | ||
M internal/ui/systemviews/history_actions.go +11 -0
| @@ -50,6 +50,17 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||
| 50 | 50 | a.renderRouteError(err) | |
| 51 | 51 | } | |
| 52 | 52 | return a.mountRenderedHTML() | |
| 53 | + | case RouteConfig: | |
| 54 | + | a.configError = "" | |
| 55 | + | if err := a.handleConfigAction(ctx, event); err != nil { | |
| 56 | + | a.configNotice = "" | |
| 57 | + | a.configError = err.Error() | |
| 58 | + | } | |
| 59 | + | a.loadShellTheme(ctx) | |
| 60 | + | if err := a.loadConfigRoute(ctx); err != nil { | |
| 61 | + | a.renderRouteError(err) | |
| 62 | + | } | |
| 63 | + | return a.mountRenderedHTML() | |
| 53 | 64 | default: | |
| 54 | 65 | return nil | |
| 55 | 66 | } | |
| @@ -50,6 +50,17 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error { | |||||
| 50 | a.renderRouteError(err) | 50 | a.renderRouteError(err) | ||
| 51 | } | 51 | } | ||
| 52 | return a.mountRenderedHTML() | 52 | return a.mountRenderedHTML() | ||
| 53 | + | case RouteConfig: | |||
| 54 | + | a.configError = "" | |||
| 55 | + | if err := a.handleConfigAction(ctx, event); err != nil { | |||
| 56 | + | a.configNotice = "" | |||
| 57 | + | a.configError = err.Error() | |||
| 58 | + | } | |||
| 59 | + | a.loadShellTheme(ctx) | |||
| 60 | + | if err := a.loadConfigRoute(ctx); err != nil { | |||
| 61 | + | a.renderRouteError(err) | |||
| 62 | + | } | |||
| 63 | + | return a.mountRenderedHTML() | |||
| 53 | default: | 64 | default: | ||
| 54 | return nil | 65 | return nil | ||
| 55 | } | 66 | } | ||