From 2cc3917b3199b052f1cd738cf4219159d437517c Mon Sep 17 00:00:00 2001 From: Eugene Date: Sun, 14 Jun 2026 16:37:18 +0300 Subject: [PATCH] feat: persist window geometry and refresh app styling --- domain/gitstatus.go | 1 - domain/window.go | 17 +++ infrastructure/storage/json_store.go | 1 + main.go | 64 +++++++++- ...recent_service.go => app_state_service.go} | 60 ++++++--- service/values_service.go | 5 +- ui/app.go | 115 ++++++++++++++---- ui/page/cards.go | 39 +++--- ui/page/repos.go | 2 +- ui/page/values_controller.go | 14 +-- ui/page/values_controller_actions.go | 8 +- ui/page/values_page.go | 16 +-- ui/page/values_page_columns.go | 13 +- ui/page/values_page_render.go | 6 +- ui/platform/revealer/revealer_windows.go | 13 +- ui/platform/screen/screen.go | 12 ++ ui/platform/screen/screen_other.go | 9 ++ ui/platform/screen/screen_windows.go | 57 +++++++++ ui/state/values_state.go | 7 -- ui/theme/anchor_colors.go | 23 ++-- ui/theme/tokens.go | 20 +-- ui/widget/anchor_menu.go | 2 +- ui/widget/breadcrumb.go | 2 +- ui/widget/extras_filter_pill.go | 92 -------------- ui/widget/notification_bar.go | 2 +- ui/widget/override_table.go | 34 ++---- ui/widget/paint_primitives.go | 2 +- ui/widget/search_bar.go | 10 +- 28 files changed, 373 insertions(+), 273 deletions(-) create mode 100644 domain/window.go rename service/{recent_service.go => app_state_service.go} (78%) create mode 100644 ui/platform/screen/screen.go create mode 100644 ui/platform/screen/screen_other.go create mode 100644 ui/platform/screen/screen_windows.go delete mode 100644 ui/widget/extras_filter_pill.go diff --git a/domain/gitstatus.go b/domain/gitstatus.go index 4d082cc..5ebf8e5 100644 --- a/domain/gitstatus.go +++ b/domain/gitstatus.go @@ -5,6 +5,5 @@ type GitChangeStatus uint8 const ( GitUnchanged GitChangeStatus = iota - GitAdded // present in working copy, absent in HEAD GitModified // present in both but value differs ) diff --git a/domain/window.go b/domain/window.go new file mode 100644 index 0000000..a9b8c59 --- /dev/null +++ b/domain/window.go @@ -0,0 +1,17 @@ +package domain + +// WindowGeometry is the persisted main-window geometry, restored on the next +// launch. +// +// Sizes are stored in density-independent pixels (Dp), not device pixels, so +// they round-trip correctly across monitors with different DPI scaling — a +// window sized on a 2x display reopens at the same logical size on a 1x one. +// +// Position is intentionally absent: Gio's cross-platform window Config exposes +// only the window's size and mode, not its on-screen location, so location +// cannot be captured or restored through the portable API. +type WindowGeometry struct { + WidthDp int `json:"widthDp"` + HeightDp int `json:"heightDp"` + Maximized bool `json:"maximized"` +} diff --git a/infrastructure/storage/json_store.go b/infrastructure/storage/json_store.go index e5f4ac3..9ea949a 100644 --- a/infrastructure/storage/json_store.go +++ b/infrastructure/storage/json_store.go @@ -25,6 +25,7 @@ type AppData struct { RecentValuesEntries []domain.RecentValuesEntry `json:"recentValuesEntries,omitempty"` ShowDocs *bool `json:"showDocs,omitempty"` ChartUIStates map[string]domain.ChartUIState `json:"chartUiStates,omitempty"` + WindowGeometry *domain.WindowGeometry `json:"windowGeometry,omitempty"` } // JSONStore reads and writes AppData to a JSON file. diff --git a/main.go b/main.go index a182128..b224691 100644 --- a/main.go +++ b/main.go @@ -1,6 +1,7 @@ package main import ( + "context" "log" "log/slog" "os" @@ -14,11 +15,17 @@ import ( "github.com/qdeck-app/qdeck/infrastructure/storage" "github.com/qdeck-app/qdeck/service" "github.com/qdeck-app/qdeck/ui" + "github.com/qdeck-app/qdeck/ui/platform/screen" ) const ( defaultWindowWidth = 1200 defaultWindowHeight = 800 + + // preferredWindowWidth is used on a first launch + preferredWindowWidth = 1600 + // windowScreenMargin is the gap left between the default window and the work-area edges + windowScreenMargin = 80 ) func main() { @@ -35,12 +42,12 @@ func main() { repoSvc := service.NewRepoService(settings) chartSvc := service.NewChartService(settings) valuesSvc := service.NewValuesService() - recentSvc := service.NewRecentService(jsonStore) + appState := service.NewAppStateService(jsonStore) templateSvc := service.NewTemplateService() w := new(app.Window) w.Option(app.Title("QDeck - Helm Values Editor")) - w.Option(app.Size(unit.Dp(defaultWindowWidth), unit.Dp(defaultWindowHeight))) + w.Option(windowGeometryOptions(appState)...) // On Linux and Windows, disable compositor decorations and draw our own // window control buttons in the breadcrumb bar. @@ -49,7 +56,7 @@ func main() { w.Option(app.Decorated(false)) } - application := ui.NewApplication(w, repoSvc, chartSvc, valuesSvc, recentSvc, templateSvc, customDecor) + application := ui.NewApplication(w, repoSvc, chartSvc, valuesSvc, appState, templateSvc, customDecor) if err := application.Run(); err != nil { log.Fatal(err) } @@ -59,3 +66,54 @@ func main() { app.Main() } + +// windowGeometryOptions restores the last persisted window size and maximized +// state, falling back to the default size when nothing has been saved yet (or +// the saved record is unreadable). Size is stored in Dp, so app.Size rescales +// it correctly for the current monitor's DPI. +func windowGeometryOptions(appState *service.AppStateService) []app.Option { + geom, err := appState.LoadWindowGeometry(context.Background()) + if err != nil || geom == nil || geom.WidthDp <= 0 || geom.HeightDp <= 0 { + w, h := defaultWindowSize() + + return []app.Option{app.Size(w, h)} + } + + opts := []app.Option{app.Size(unit.Dp(geom.WidthDp), unit.Dp(geom.HeightDp))} + if geom.Maximized { + opts = append(opts, app.Maximized.Option()) + } + + return opts +} + +// defaultWindowSize is the size used on a first launch (no saved geometry). +func defaultWindowSize() (unit.Dp, unit.Dp) { + areaW, areaH, ok := screen.WorkAreaDp() + + return fitWindowSize(areaW, areaH, ok) +} + +// fitWindowSize picks the first-launch window size from the primary work area +// (in Dp). Width grows toward preferredWindowWidth when the display allows it +// and shrinks below the base default on a narrow screen, so the window never +// opens wider or taller than the work area (less a margin). When the work area +// is unknown (ok == false), the fixed base default is used unchanged. +func fitWindowSize(areaW, areaH int, ok bool) (unit.Dp, unit.Dp) { + w := unit.Dp(defaultWindowWidth) + h := unit.Dp(defaultWindowHeight) + + if !ok { + return w, h + } + + if fit := unit.Dp(areaW) - windowScreenMargin; fit > 0 { + w = min(unit.Dp(preferredWindowWidth), fit) + } + + if fit := unit.Dp(areaH) - windowScreenMargin; fit > 0 { + h = min(unit.Dp(defaultWindowHeight), fit) + } + + return w, h +} diff --git a/service/recent_service.go b/service/app_state_service.go similarity index 78% rename from service/recent_service.go rename to service/app_state_service.go index 73c2d13..9ac9145 100644 --- a/service/recent_service.go +++ b/service/app_state_service.go @@ -17,21 +17,23 @@ const ( maxRecentValues = 10 ) -// RecentService manages recent charts and values files. +// AppStateService is the persistence facade over the application's on-disk +// AppData blob (JSONStore): recent charts and values, per-chart UI state, and +// user preferences such as "show docs" and the window geometry. // All mutating methods use JSONStore.Update for atomic read-modify-write, // eliminating the need for a service-level mutex. -type RecentService struct { +type AppStateService struct { store *storage.JSONStore } -func NewRecentService(store *storage.JSONStore) *RecentService { - return &RecentService{store: store} +func NewAppStateService(store *storage.JSONStore) *AppStateService { + return &AppStateService{store: store} } // ListRecentCharts returns all recent charts sorted by most recently opened. // //nolint:dupl // Structurally similar to ListRecentValues but operates on different slice. -func (s *RecentService) ListRecentCharts(ctx context.Context) ([]domain.RecentChart, error) { +func (s *AppStateService) ListRecentCharts(ctx context.Context) ([]domain.RecentChart, error) { if ctx.Err() != nil { return nil, fmt.Errorf("list recent charts: %w", ctx.Err()) } @@ -45,7 +47,7 @@ func (s *RecentService) ListRecentCharts(ctx context.Context) ([]domain.RecentCh } // AddRecentChart adds a chart to the recent list, deduplicating and enforcing max limit. -func (s *RecentService) AddRecentChart(ctx context.Context, entry domain.RecentChart) error { +func (s *AppStateService) AddRecentChart(ctx context.Context, entry domain.RecentChart) error { if err := entry.IsValid(); err != nil { return fmt.Errorf("add recent chart: %w", err) } @@ -69,7 +71,7 @@ func (s *RecentService) AddRecentChart(ctx context.Context, entry domain.RecentC // RemoveRecentChart removes a chart at the given index. // //nolint:dupl // same shape as RemoveRecentValues{,Entry} but a different AppData slice -func (s *RecentService) RemoveRecentChart(ctx context.Context, idx int) error { +func (s *AppStateService) RemoveRecentChart(ctx context.Context, idx int) error { return removeAt(ctx, s.store, "remove recent chart", idx, func(data *storage.AppData) *[]domain.RecentChart { return &data.RecentCharts }, ) @@ -78,7 +80,7 @@ func (s *RecentService) RemoveRecentChart(ctx context.Context, idx int) error { // ListRecentValues returns all recent values files sorted by most recently opened. // //nolint:dupl // Structurally similar to ListRecentCharts but operates on different slice. -func (s *RecentService) ListRecentValues(ctx context.Context) ([]domain.RecentValuesFile, error) { +func (s *AppStateService) ListRecentValues(ctx context.Context) ([]domain.RecentValuesFile, error) { if ctx.Err() != nil { return nil, fmt.Errorf("list recent values: %w", ctx.Err()) } @@ -92,7 +94,7 @@ func (s *RecentService) ListRecentValues(ctx context.Context) ([]domain.RecentVa } // AddRecentValues adds a values file path to the recent list. -func (s *RecentService) AddRecentValues(ctx context.Context, path string) error { +func (s *AppStateService) AddRecentValues(ctx context.Context, path string) error { if path == "" { return errors.New("add recent values: path required") } @@ -109,7 +111,7 @@ func (s *RecentService) AddRecentValues(ctx context.Context, path string) error // RemoveRecentValues removes a values file at the given index. // //nolint:dupl // same shape as RemoveRecentChart / RemoveRecentValuesEntry but a different AppData slice -func (s *RecentService) RemoveRecentValues(ctx context.Context, idx int) error { +func (s *AppStateService) RemoveRecentValues(ctx context.Context, idx int) error { return removeAt(ctx, s.store, "remove recent values", idx, func(data *storage.AppData) *[]domain.RecentValuesFile { return &data.RecentValues }, ) @@ -117,7 +119,7 @@ func (s *RecentService) RemoveRecentValues(ctx context.Context, idx int) error { // LoadShowDocs returns the persisted "show docs" preference. // Returns false when the preference has never been saved. -func (s *RecentService) LoadShowDocs(ctx context.Context) (bool, error) { +func (s *AppStateService) LoadShowDocs(ctx context.Context) (bool, error) { data, err := s.store.Load(ctx) if err != nil { return false, fmt.Errorf("load show docs: %w", err) @@ -131,7 +133,7 @@ func (s *RecentService) LoadShowDocs(ctx context.Context) (bool, error) { } // SaveShowDocs persists the "show docs" preference. -func (s *RecentService) SaveShowDocs(ctx context.Context, show bool) error { +func (s *AppStateService) SaveShowDocs(ctx context.Context, show bool) error { if err := s.store.Update(ctx, func(data *storage.AppData) error { data.ShowDocs = &show @@ -143,9 +145,33 @@ func (s *RecentService) SaveShowDocs(ctx context.Context, show bool) error { return nil } +// LoadWindowGeometry returns the persisted main-window geometry, or nil when +// none has been saved yet (the caller falls back to default size/mode). +func (s *AppStateService) LoadWindowGeometry(ctx context.Context) (*domain.WindowGeometry, error) { + data, err := s.store.Load(ctx) + if err != nil { + return nil, fmt.Errorf("load window geometry: %w", err) + } + + return data.WindowGeometry, nil +} + +// SaveWindowGeometry persists the main-window geometry restored on next launch. +func (s *AppStateService) SaveWindowGeometry(ctx context.Context, geom domain.WindowGeometry) error { + if err := s.store.Update(ctx, func(data *storage.AppData) error { + data.WindowGeometry = &geom + + return nil + }); err != nil { + return fmt.Errorf("save window geometry: %w", err) + } + + return nil +} + // LoadChartUIState returns the persisted UI state for the given chart key. // The bool is false when no state has been saved for the key yet. -func (s *RecentService) LoadChartUIState(ctx context.Context, key string) (domain.ChartUIState, bool, error) { +func (s *AppStateService) LoadChartUIState(ctx context.Context, key string) (domain.ChartUIState, bool, error) { if key == "" { return domain.ChartUIState{}, false, nil } @@ -170,7 +196,7 @@ const maxChartUIStates = 100 // maxChartUIStates, the entry with the oldest LastTouchedAt is evicted — // approximate LRU, so the most-recently-used charts survive. One eviction per // Save call is sufficient because the cap can only be exceeded by one entry. -func (s *RecentService) SaveChartUIState(ctx context.Context, key string, st domain.ChartUIState) error { +func (s *AppStateService) SaveChartUIState(ctx context.Context, key string, st domain.ChartUIState) error { if key == "" { return nil } @@ -221,7 +247,7 @@ const maxRecentValuesEntries = 10 // ListRecentValuesEntries returns all recent values+chart pairs. // //nolint:dupl // Structurally similar to ListRecentCharts but operates on different slice. -func (s *RecentService) ListRecentValuesEntries(ctx context.Context) ([]domain.RecentValuesEntry, error) { +func (s *AppStateService) ListRecentValuesEntries(ctx context.Context) ([]domain.RecentValuesEntry, error) { if ctx.Err() != nil { return nil, fmt.Errorf("list recent values entries: %w", ctx.Err()) } @@ -235,7 +261,7 @@ func (s *RecentService) ListRecentValuesEntries(ctx context.Context) ([]domain.R } // AddRecentValuesEntry adds a values+chart pair, deduplicating and enforcing max limit. -func (s *RecentService) AddRecentValuesEntry(ctx context.Context, entry domain.RecentValuesEntry) error { +func (s *AppStateService) AddRecentValuesEntry(ctx context.Context, entry domain.RecentValuesEntry) error { if entry.ValuesPath == "" { return errors.New("add recent values entry: valuesPath required") } @@ -255,7 +281,7 @@ func (s *RecentService) AddRecentValuesEntry(ctx context.Context, entry domain.R // RemoveRecentValuesEntry removes a values entry at the given index. // //nolint:dupl // same shape as RemoveRecentChart / RemoveRecentValues but a different AppData slice -func (s *RecentService) RemoveRecentValuesEntry(ctx context.Context, idx int) error { +func (s *AppStateService) RemoveRecentValuesEntry(ctx context.Context, idx int) error { return removeAt(ctx, s.store, "remove recent values entry", idx, func(data *storage.AppData) *[]domain.RecentValuesEntry { return &data.RecentValuesEntries }, ) diff --git a/service/values_service.go b/service/values_service.go index a90d0c8..5ef1c72 100644 --- a/service/values_service.go +++ b/service/values_service.go @@ -458,7 +458,6 @@ func (s *ValuesService) SaveRawBytes(ctx context.Context, raw []byte, destPath s } // CompareWithBaseline compares a current values file against baseline content (e.g. from git HEAD). -// Returns a map of flat keys to their change status. Only added/modified keys are included. func (s *ValuesService) CompareWithBaseline( ctx context.Context, currentFilePath string, baselineContent []byte, ) (map[string]domain.GitChangeStatus, error) { @@ -494,9 +493,7 @@ func (s *ValuesService) CompareWithBaseline( key := string(e.Key) baseVal, exists := baseLookup[key] - if !exists { - changes[key] = domain.GitAdded - } else if baseVal != e.Value { + if exists && baseVal != e.Value { changes[key] = domain.GitModified } } diff --git a/ui/app.go b/ui/app.go index b044352..581397e 100644 --- a/ui/app.go +++ b/ui/app.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "math" "path/filepath" "strings" "time" @@ -51,9 +52,9 @@ type Application struct { closeGuard *closeguard.CloseGuard // Services - repoService *service.RepoService - chartService *service.ChartService - recentService *service.RecentService + repoService *service.RepoService + chartService *service.ChartService + appState *service.AppStateService // Navigation navState state.NavigationState @@ -106,6 +107,16 @@ type Application struct { customDecor bool winButtons customwidget.WinButtons + // Window geometry persistence. captureWindowSize / handleConfigEvent feed + // the latest size + maximized state into geometrySaver, which debounces the + // writes (coalescing a drag-resize into one) and is flushed on close. + // lastSavedGeom skips re-scheduling identical geometry. + lastWindowedWidthDp int + lastWindowedHeightDp int + windowMaximized bool + lastSavedGeom domain.WindowGeometry + geometrySaver *async.Debouncer[domain.WindowGeometry] + // ops reused across frames ops op.Ops } @@ -115,7 +126,7 @@ func NewApplication( repoSvc *service.RepoService, chartSvc *service.ChartService, valuesSvc *service.ValuesService, - recentSvc *service.RecentService, + appState *service.AppStateService, templateSvc *service.TemplateService, customDecor bool, ) *Application { @@ -124,15 +135,15 @@ func NewApplication( expl := explorer.NewExplorer(w) a := &Application{ - window: w, - theme: th, - explorer: expl, - nativeDrop: nativedrop.New(w), - closeGuard: closeguard.New(w), - repoService: repoSvc, - chartService: chartSvc, - recentService: recentSvc, - customDecor: customDecor, + window: w, + theme: th, + explorer: expl, + nativeDrop: nativedrop.New(w), + closeGuard: closeguard.New(w), + repoService: repoSvc, + chartService: chartSvc, + appState: appState, + customDecor: customDecor, } a.breadcrumb.MoveArea = customDecor @@ -147,9 +158,15 @@ func NewApplication( a.recentChartsRunner = async.NewRunner[[]domain.RecentChart](w, 1) a.recentValuesEntriesRunner = async.NewRunner[[]domain.RecentValuesEntry](w, 1) + a.geometrySaver = async.NewDebouncer(geometrySaveDelay, func(g domain.WindowGeometry) { + if err := a.appState.SaveWindowGeometry(context.Background(), g); err != nil { + slog.Error("save window geometry", "error", err) + } + }) + a.valuesCtrl = page.NewValuesController( w, &a.navState, &a.valuesState, &a.chartState, &a.notificationState, - expl, valuesSvc, templateSvc, recentSvc, chartSvc, + expl, valuesSvc, templateSvc, appState, chartSvc, ) a.valuesCtrl.CustomDecor = customDecor a.valuesCtrl.OnOpenLocalChart = a.onOpenLocalChart @@ -207,11 +224,14 @@ func (a *Application) Run() error { switch e := e.(type) { case app.DestroyEvent: + a.geometrySaver.Flush() + return e.Err case app.ConfigEvent: a.handleConfigEvent(e) case app.FrameEvent: gtx := app.NewContext(&a.ops, e) + a.captureWindowSize(e) a.pollExternalEvents() a.pollAsyncResults() a.layout(gtx) @@ -220,8 +240,51 @@ func (a *Application) Run() error { } } +// geometrySaveDelay coalesces a drag-resize into a single geometry write while +// still capturing the final size if the process dies before the close handler +// (DestroyEvent) runs. +const geometrySaveDelay = 500 * time.Millisecond + func (a *Application) handleConfigEvent(e app.ConfigEvent) { - a.winButtons.Maximized = e.Config.Mode == app.Maximized + a.windowMaximized = e.Config.Mode == app.Maximized + a.winButtons.Maximized = a.windowMaximized + a.scheduleGeometrySave() +} + +// captureWindowSize records the current windowed size in Dp and schedules a +// debounced persist. Maximized frames are skipped so the saved restore size +// stays the real windowed size — windowMaximized is set from ConfigEvent, which +// Gio delivers before the first FrameEvent, so a maximized restore never records +// the screen-filling size here. +func (a *Application) captureWindowSize(e app.FrameEvent) { + if a.windowMaximized { + return + } + + a.lastWindowedWidthDp = int(math.Round(float64(e.Metric.PxToDp(e.Size.X)))) + a.lastWindowedHeightDp = int(math.Round(float64(e.Metric.PxToDp(e.Size.Y)))) + a.scheduleGeometrySave() +} + +// scheduleGeometrySave debounces a write of the latest size + maximized state. +// No-op until a real windowed size has been observed, and skips re-scheduling +// when nothing changed so a stream of identical frames doesn't re-arm the timer. +func (a *Application) scheduleGeometrySave() { + if a.lastWindowedWidthDp <= 0 || a.lastWindowedHeightDp <= 0 { + return + } + + geom := domain.WindowGeometry{ + WidthDp: a.lastWindowedWidthDp, + HeightDp: a.lastWindowedHeightDp, + Maximized: a.windowMaximized, + } + if geom == a.lastSavedGeom { + return + } + + a.lastSavedGeom = geom + a.geometrySaver.Schedule(geom) } func (a *Application) pollExternalEvents() { @@ -761,7 +824,7 @@ func (a *Application) preloadCharts(repos []domain.HelmRepository) { } func (a *Application) loadShowDocs() { - show, err := a.recentService.LoadShowDocs(context.Background()) + show, err := a.appState.LoadShowDocs(context.Background()) if err != nil { slog.Error("load show docs preference", "error", err) @@ -773,18 +836,18 @@ func (a *Application) loadShowDocs() { func (a *Application) loadRecentCharts() { a.recentChartsRunner.RunWithTimeout(config.RecentChartsLoadOperation, func(ctx context.Context) ([]domain.RecentChart, error) { - return a.recentService.ListRecentCharts(ctx) + return a.appState.ListRecentCharts(ctx) }) } //nolint:dupl // addRecentChart and onRemoveRecentChart call different service methods. func (a *Application) addRecentChart(entry domain.RecentChart) { a.recentChartsRunner.RunWithTimeout(config.RecentChartsLoadOperation, func(ctx context.Context) ([]domain.RecentChart, error) { - if err := a.recentService.AddRecentChart(ctx, entry); err != nil { + if err := a.appState.AddRecentChart(ctx, entry); err != nil { return nil, fmt.Errorf("add recent chart: %w", err) } - return a.recentService.ListRecentCharts(ctx) + return a.appState.ListRecentCharts(ctx) }) } @@ -1027,11 +1090,11 @@ func (a *Application) openChartByRef(ref domain.RecentChart) { //nolint:dupl // addRecentChart and onRemoveRecentChart call different service methods. func (a *Application) onRemoveRecentChart(idx int) { a.recentChartsRunner.RunWithTimeout(config.RecentChartsLoadOperation, func(ctx context.Context) ([]domain.RecentChart, error) { - if err := a.recentService.RemoveRecentChart(ctx, idx); err != nil { + if err := a.appState.RemoveRecentChart(ctx, idx); err != nil { return nil, fmt.Errorf("remove recent chart: %w", err) } - return a.recentService.ListRecentCharts(ctx) + return a.appState.ListRecentCharts(ctx) }) } @@ -1056,11 +1119,11 @@ func (a *Application) onSelectRecentValuesEntry(entry domain.RecentValuesEntry) //nolint:dupl // Structurally similar to onRemoveRecentChart but operates on different type. func (a *Application) onRemoveRecentValuesEntry(idx int) { a.recentValuesEntriesRunner.RunWithTimeout(config.RecentValuesEntriesLoadOperation, func(ctx context.Context) ([]domain.RecentValuesEntry, error) { - if err := a.recentService.RemoveRecentValuesEntry(ctx, idx); err != nil { + if err := a.appState.RemoveRecentValuesEntry(ctx, idx); err != nil { return nil, fmt.Errorf("remove recent values entry: %w", err) } - return a.recentService.ListRecentValuesEntries(ctx) + return a.appState.ListRecentValuesEntries(ctx) }) } @@ -1089,18 +1152,18 @@ func (a *Application) onPendingValuesConsumed(valuesPath string) { func (a *Application) loadRecentValuesEntries() { a.recentValuesEntriesRunner.RunWithTimeout(config.RecentValuesEntriesLoadOperation, func(ctx context.Context) ([]domain.RecentValuesEntry, error) { - return a.recentService.ListRecentValuesEntries(ctx) + return a.appState.ListRecentValuesEntries(ctx) }) } //nolint:dupl // Structurally similar to addRecentChart but operates on different type. func (a *Application) addRecentValuesEntry(entry domain.RecentValuesEntry) { a.recentValuesEntriesRunner.RunWithTimeout(config.RecentValuesEntriesLoadOperation, func(ctx context.Context) ([]domain.RecentValuesEntry, error) { - if err := a.recentService.AddRecentValuesEntry(ctx, entry); err != nil { + if err := a.appState.AddRecentValuesEntry(ctx, entry); err != nil { return nil, fmt.Errorf("add recent values entry: %w", err) } - return a.recentService.ListRecentValuesEntries(ctx) + return a.appState.ListRecentValuesEntries(ctx) }) } diff --git a/ui/page/cards.go b/ui/page/cards.go index 92a5f1c..f523dbf 100644 --- a/ui/page/cards.go +++ b/ui/page/cards.go @@ -22,10 +22,10 @@ import ( // since the design tokens are explicitly OKLCH colors with hue and // these have no hue. // -//nolint:mnd // 12/8 alpha values are inherent to the shadow design. +//nolint:mnd // 28/20 alpha values are inherent to the shadow design. var ( - cardShadowOuter = color.NRGBA{A: 12} - cardShadowInner = color.NRGBA{A: 8} + cardShadowOuter = color.NRGBA{A: 28} + cardShadowInner = color.NRGBA{A: 20} ) const ( @@ -122,25 +122,36 @@ func layoutCardFocusable(gtx layout.Context, click *widget.Clickable, focused bo paintCardShadow(gtx, bounds, radius) + // Fill lighter than the Bg2 section card so each row reads as a raised + // tile instead of blending into the panel behind it. bgRect := clip.UniformRRect(bounds, radius).Push(gtx.Ops) - paint.ColorOp{Color: theme.Default.Bg2}.Add(gtx.Ops) + paint.ColorOp{Color: theme.Default.Bg}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) bgRect.Pop() + // Active-state wash painted over the tile fill. + var wash color.NRGBA + switch { case focused: - focusRect := clip.UniformRRect(bounds, radius).Push(gtx.Ops) - paint.ColorOp{Color: theme.Default.RowSelected}.Add(gtx.Ops) - paint.PaintOp{}.Add(gtx.Ops) - focusRect.Pop() - - bw := gtx.Dp(focusBorderWidth) - paintFocusBorder(gtx, bounds, bw) + wash = theme.Default.RowSelected case hovered: - hoverRect := clip.UniformRRect(bounds, radius).Push(gtx.Ops) - paint.ColorOp{Color: theme.Default.RowHover}.Add(gtx.Ops) + wash = theme.Default.RowHover + } + + if wash != (color.NRGBA{}) { + washRect := clip.UniformRRect(bounds, radius).Push(gtx.Ops) + paint.ColorOp{Color: wash}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops) - hoverRect.Pop() + washRect.Pop() + } + + // Edge: a focused row gets the strong focus ring; every other row gets a + // hairline border so its boundary stays crisp against the section card. + if focused { + paintFocusBorder(gtx, bounds, gtx.Dp(focusBorderWidth)) + } else { + paintEdgeBorder(gtx, bounds, gtx.Dp(theme.Default.HairlineWidth), theme.Default.Border) } c.Add(gtx.Ops) diff --git a/ui/page/repos.go b/ui/page/repos.go index 9302f46..198ad78 100644 --- a/ui/page/repos.go +++ b/ui/page/repos.go @@ -268,7 +268,7 @@ func (p *ReposPage) layoutValuesSection(gtx layout.Context) layout.Dimensions { return layoutSectionCard(gtx, func(gtx layout.Context) layout.Dimensions { return layout.Flex{Axis: layout.Vertical}.Layout(gtx, layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layoutSectionHeaderWithHint(gtx, p.Theme, "Values", []string{tabKeyHint, tabKeyHint}, "to focus", + return layoutSectionHeaderWithHint(gtx, p.Theme, "Recent Values", []string{tabKeyHint, tabKeyHint}, "to focus", sectionHeaderPaddingTop, sectionHeaderPaddingBottom) }), layout.Rigid(p.layoutValuesDropZone), diff --git a/ui/page/values_controller.go b/ui/page/values_controller.go index 343920a..ff30b5d 100644 --- a/ui/page/values_controller.go +++ b/ui/page/values_controller.go @@ -64,7 +64,7 @@ type ValuesController struct { // Services ValuesService *service.ValuesService TemplateService *service.TemplateService - RecentService *service.RecentService + AppState *service.AppStateService ChartService *service.ChartService // Async runners @@ -148,7 +148,7 @@ func NewValuesController( expl *explorer.Explorer, valuesSvc *service.ValuesService, templateSvc *service.TemplateService, - recentSvc *service.RecentService, + appState *service.AppStateService, chartSvc *service.ChartService, ) *ValuesController { vc := &ValuesController{ @@ -160,7 +160,7 @@ func NewValuesController( Explorer: expl, ValuesService: valuesSvc, TemplateService: templateSvc, - RecentService: recentSvc, + AppState: appState, ChartService: chartSvc, } @@ -181,7 +181,7 @@ func NewValuesController( vc.overwriteDialog.NoButton = &vc.overwriteDialogNo vc.focusSaver = async.NewDebouncer(cellFocusSaveDelay, func(j chartFocusJob) { - if err := recentSvc.SaveChartUIState(context.Background(), j.chartKey, j.state); err != nil { + if err := appState.SaveChartUIState(context.Background(), j.chartKey, j.state); err != nil { slog.Error("save chart ui state", "error", err, "key", j.chartKey) } }) @@ -536,7 +536,7 @@ func (vc *ValuesController) LoadDefaultValues(chartPath string) { func (vc *ValuesController) LoadRecentValues() { vc.RecentValuesRunner.RunWithTimeout(config.RecentValuesLoadOperation, func(ctx context.Context) ([]domain.RecentValuesFile, error) { - return vc.RecentService.ListRecentValues(ctx) + return vc.AppState.ListRecentValues(ctx) }) } @@ -851,10 +851,10 @@ func (vc *ValuesController) loadSavedCellFocusAsync() { return } - recentSvc := vc.RecentService + appState := vc.AppState vc.ChartUIStateRunner.RunWithTimeout(config.ChartUIStateLoadOperation, func(ctx context.Context) (chartUIStateResult, error) { - st, ok, err := recentSvc.LoadChartUIState(ctx, key) + st, ok, err := appState.LoadChartUIState(ctx, key) if err != nil { return chartUIStateResult{}, fmt.Errorf("load chart ui state: %w", err) } diff --git a/ui/page/values_controller_actions.go b/ui/page/values_controller_actions.go index a54fdf4..b781e7a 100644 --- a/ui/page/values_controller_actions.go +++ b/ui/page/values_controller_actions.go @@ -95,7 +95,7 @@ func (vc *ValuesController) OnColumnFilesSelected(colIdx int, paths []string) { return } - if err := vc.RecentService.AddRecentValues(ctx, p); err != nil { + if err := vc.AppState.AddRecentValues(ctx, p); err != nil { slog.Warn("failed to save recent values entry", "path", p, "error", err) } } @@ -190,11 +190,11 @@ func (vc *ValuesController) onSelectRecentValues(path string) { //nolint:dupl // same Runner pattern as addRecentChart but calls different service method on different receiver. func (vc *ValuesController) onRemoveRecentValues(idx int) { vc.RecentValuesRunner.RunWithTimeout(config.RecentValuesLoadOperation, func(ctx context.Context) ([]domain.RecentValuesFile, error) { - if err := vc.RecentService.RemoveRecentValues(ctx, idx); err != nil { + if err := vc.AppState.RemoveRecentValues(ctx, idx); err != nil { return nil, fmt.Errorf("remove recent values: %w", err) } - return vc.RecentService.ListRecentValues(ctx) + return vc.AppState.ListRecentValues(ctx) }) } @@ -549,7 +549,7 @@ func (vc *ValuesController) OnSaveChartVersion(chartName, version string) { func (vc *ValuesController) onShowDocsChanged(show bool) { go func() { - if err := vc.RecentService.SaveShowDocs(context.Background(), show); err != nil { + if err := vc.AppState.SaveShowDocs(context.Background(), show); err != nil { slog.Error("save show docs preference", "error", err) } }() diff --git a/ui/page/values_page.go b/ui/page/values_page.go index ed15ead..f2bf429 100644 --- a/ui/page/values_page.go +++ b/ui/page/values_page.go @@ -383,7 +383,6 @@ func (p *ValuesPage) Layout(gtx layout.Context) layout.Dimensions { p.State.FilteredIndices = p.Search.FilterEntriesWithMultiOverrides( p.State.Entries, p.columnEditorSlices[:p.State.ColumnCount], - p.State.ExtrasOnly, p.State.FilteredIndices, ) @@ -584,20 +583,7 @@ func (p *ValuesPage) Layout(gtx layout.Context) layout.Dimensions { layout.Rigid(func(gtx layout.Context) layout.Dimensions { searchHint := platform.ShortcutLabel("\u2318+F", "Ctrl+F") - if p.State.ExtrasFilterClick.Clicked(gtx) { - p.State.ExtrasOnly = !p.State.ExtrasOnly - } - - dims := layout.Flex{Axis: layout.Horizontal, Alignment: layout.Middle}.Layout(gtx, - layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { - return p.Search.Layout(gtx, p.Theme, "Search values... ("+searchHint+")") - }), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return layout.Inset{Right: valuesSpacing}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return customwidget.LayoutExtrasFilterPill(gtx, p.Theme, &p.State.ExtrasFilterClick, p.State.ExtrasOnly) - }) - }), - ) + dims := p.Search.Layout(gtx, p.Theme, "Search values... ("+searchHint+")") totalRigidH += dims.Size.Y diff --git a/ui/page/values_page_columns.go b/ui/page/values_page_columns.go index 709d680..04605db 100644 --- a/ui/page/values_page_columns.go +++ b/ui/page/values_page_columns.go @@ -343,10 +343,10 @@ func extraCountLabel(n int) string { } if n == 1 { - return "1 extra" + return "1 not in chart" } - return formatStickyCount(n) + " extras" + return formatStickyCount(n) + " not in chart" } // formatEncodingLabel joins an encoding label and a line-ending label with @@ -591,14 +591,11 @@ func (p *ValuesPage) layoutColumnFileStatus(gtx layout.Context, colIdx int) layo children[n] = layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Left: valuesPaddingSmall}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - iconColor := theme.Default.Override - if col.OpenInEditorButton.Hovered() { - iconColor = theme.Default.OverrideStrong - } - return layoutIconButton(gtx, p.Theme, &col.OpenInEditorButton, func(gtx layout.Context) layout.Dimensions { - return customwidget.LayoutVSCodeIcon(gtx, editorIconSize, iconColor) + // Neutral dark icon; the button's hover background + // (layoutIconButton) supplies the hover affordance. + return customwidget.LayoutVSCodeIcon(gtx, editorIconSize, theme.Default.Ink) }) }) }) diff --git a/ui/page/values_page_render.go b/ui/page/values_page_render.go index e2fa2f7..d5c9367 100644 --- a/ui/page/values_page_render.go +++ b/ui/page/values_page_render.go @@ -169,17 +169,13 @@ func (p *ValuesPage) LayoutShortcutsHelp(gtx layout.Context) layout.Dimensions { // "extra" — keys defined only in the overlay with no chart // default. The cyan-teal swatch matches the in-grid wash and // the "+" chip on the key cell. - return p.layoutLegendItem(gtx, theme.Default.Extra, "extra (override-only)") + return p.layoutLegendItem(gtx, theme.Default.Extra, "not in chart") }), layout.Rigid(layout.Spacer{Width: helpLegendItemGap}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return p.layoutLegendItem(gtx, theme.Default.Override, "override") }), layout.Rigid(layout.Spacer{Width: helpLegendItemGap}.Layout), - layout.Rigid(func(gtx layout.Context) layout.Dimensions { - return p.layoutLegendItem(gtx, theme.Default.Added, "git added") - }), - layout.Rigid(layout.Spacer{Width: helpLegendItemGap}.Layout), layout.Rigid(func(gtx layout.Context) layout.Dimensions { return p.layoutLegendItem(gtx, theme.Default.Modified, "git modified") }), diff --git a/ui/platform/revealer/revealer_windows.go b/ui/platform/revealer/revealer_windows.go index abb6702..0341728 100644 --- a/ui/platform/revealer/revealer_windows.go +++ b/ui/platform/revealer/revealer_windows.go @@ -5,21 +5,24 @@ package revealer import ( "context" "os/exec" - - "github.com/qdeck-app/qdeck/infrastructure/executil" + "syscall" ) // revealFile opens Windows Explorer with the specified file selected. -// The /select, flag tells Explorer to open the parent folder and highlight the file. func revealFile(path string) { ctx, cancel := context.WithTimeout(context.Background(), revealTimeout) go func() { defer cancel() - cmd := exec.CommandContext(ctx, "explorer", `/select,`+path) //nolint:gosec // Path is pre-validated by resolve(): Clean, Abs, EvalSymlinks. - executil.HideWindow(cmd) + // CommandContext resolves explorer.exe on PATH into cmd.Path; the + // explicit CmdLine then controls the exact quoting passed to it. + cmd := exec.CommandContext(ctx, "explorer") + cmd.SysProcAttr = &syscall.SysProcAttr{ + CmdLine: `explorer /select,"` + path + `"`, //nolint:gosec // Path pre-validated by resolve(): Clean, Abs, EvalSymlinks. + } + // Explorer returns exit code 1 even on success _ = cmd.Run() }() } diff --git a/ui/platform/screen/screen.go b/ui/platform/screen/screen.go new file mode 100644 index 0000000..4e3749a --- /dev/null +++ b/ui/platform/screen/screen.go @@ -0,0 +1,12 @@ +// Package screen reports primary-display metrics that Gio's portable window +// API doesn't expose, so the app can pick a sensible default window size before +// the window (and thus its monitor) exists. +package screen + +// WorkAreaDp returns the primary monitor's usable work area — the desktop minus +// the taskbar/dock — in density-independent pixels. ok is false when the value +// can't be determined (every platform except Windows today, plus Windows query +// failures); callers should fall back to a fixed default size. +func WorkAreaDp() (widthDp, heightDp int, ok bool) { + return workAreaDp() +} diff --git a/ui/platform/screen/screen_other.go b/ui/platform/screen/screen_other.go new file mode 100644 index 0000000..513944c --- /dev/null +++ b/ui/platform/screen/screen_other.go @@ -0,0 +1,9 @@ +//go:build !windows + +package screen + +// workAreaDp is unsupported on this platform; the caller falls back to a fixed +// default window size. +func workAreaDp() (widthDp, heightDp int, ok bool) { + return 0, 0, false +} diff --git a/ui/platform/screen/screen_windows.go b/ui/platform/screen/screen_windows.go new file mode 100644 index 0000000..c5d1ad9 --- /dev/null +++ b/ui/platform/screen/screen_windows.go @@ -0,0 +1,57 @@ +//go:build windows + +package screen + +import "syscall" + +//nolint:gochecknoglobals // Windows DLL handles must be package-level. +var ( + user32 = syscall.NewLazyDLL("user32.dll") + + procGetSystemMetrics = user32.NewProc("GetSystemMetrics") + procGetDpiForSystem = user32.NewProc("GetDpiForSystem") +) + +const ( + smCXFullScreen = 16 // SM_CXFULLSCREEN — width of the maximized client area + smCYFullScreen = 17 // SM_CYFULLSCREEN — height of the maximized client area + baseDPI = 96 // DPI at 100% scaling +) + +// workAreaDp returns the primary monitor's usable area (the maximized client +// area, which already excludes the taskbar) converted from physical pixels to +// Dp via the system DPI. The metric and DPI share a coordinate space, so the +// conversion holds whether the process is per-monitor DPI aware or virtualized. +func workAreaDp() (widthDp, heightDp int, ok bool) { + wPx := getSystemMetrics(smCXFullScreen) + hPx := getSystemMetrics(smCYFullScreen) + + if wPx <= 0 || hPx <= 0 { + return 0, 0, false + } + + dpi := systemDPI() + + return wPx * baseDPI / dpi, hPx * baseDPI / dpi, true +} + +func getSystemMetrics(index int) int { + r, _, _ := procGetSystemMetrics.Call(uintptr(index)) + + return int(r) +} + +// systemDPI returns the system DPI, falling back to baseDPI (96) when +// GetDpiForSystem is unavailable (pre-Windows 10 1607) or fails. +func systemDPI() int { + if procGetDpiForSystem.Find() != nil { + return baseDPI + } + + dpi, _, _ := procGetDpiForSystem.Call() + if dpi == 0 { + return baseDPI + } + + return int(dpi) +} diff --git a/ui/state/values_state.go b/ui/state/values_state.go index a337bf0..5df1606 100644 --- a/ui/state/values_state.go +++ b/ui/state/values_state.go @@ -372,13 +372,6 @@ type ValuesPageState struct { RenderLoading bool ShowDocs widget.Bool - // ExtrasOnly is the toggle state for the "✚ extras-only" filter pill - // in the search bar. When true, FilterEntriesWithMultiOverrides only - // returns entries with IsCustomOnly == true (keys defined only in - // the overlay file with no chart-defaults counterpart). - ExtrasOnly bool - ExtrasFilterClick widget.Clickable - // Helm install command (cached, rebuilt on chart/file changes) HelmInstallCmd string CopyInstallButton widget.Clickable diff --git a/ui/theme/anchor_colors.go b/ui/theme/anchor_colors.go index 3a68249..8b25470 100644 --- a/ui/theme/anchor_colors.go +++ b/ui/theme/anchor_colors.go @@ -9,7 +9,7 @@ import ( // Anchor color tokens. Each anchor name maps deterministically to a hue via // FNV-1a, with fixed saturation and lightness so badges and stripes for the // same anchor render identically across runs. Hues that fall too close to the -// git-indicator bar colors are rotated out so anchor stripes never blur into +// git-indicator bar color are rotated out so anchor stripes never blur into // "this row has uncommitted git changes". Both anchor (`&name`) and alias // (`*name`) badges use the same color — the sigil already disambiguates role, // and matching colors group an anchor and its aliases at a glance. @@ -19,10 +19,9 @@ const ( anchorSaturation = 0.55 anchorLightness = 0.55 - anchorForbiddenHueGreen = 120.0 // matches ColorGitAddedBar hue anchorForbiddenHueBlue = 215.0 // matches ColorGitModifiedBar hue - anchorForbiddenHalfBand = 15.0 // ± degrees around each forbidden center - anchorHueRotation = 30.0 // rotation applied when hue lands inside a forbidden band + anchorForbiddenHalfBand = 15.0 // ± degrees around the forbidden center + anchorHueRotation = 30.0 // rotation applied when hue lands inside the forbidden band anchorHueWheel = 360.0 ) @@ -34,14 +33,13 @@ func AnchorColor(name string) color.NRGBA { } // hueFromName maps a string to a hue in [0, 360) using FNV-1a, then rotates it -// out of the forbidden bands around git-indicator hues so anchor stripes can't -// be confused with git status. Pure, allocation-free. +// out of the forbidden band around the git-indicator hue so anchor stripes +// can't be confused with git status. Pure, allocation-free. // -// Invariant: anchorHueRotation must exceed every forbidden band's full width -// (2 × anchorForbiddenHalfBand) AND each rotation must land outside every -// other forbidden band. With the current bands (120°, 215°) separated by -// 95° and rotation 30°, a single pass clears both. If a new band is added -// or rotation is reduced, replace the single rotation with a re-check loop. +// Invariant: anchorHueRotation must exceed the forbidden band's full width +// (2 × anchorForbiddenHalfBand) so a single rotation always clears it. If a +// new band is added, verify the rotation lands outside every band or replace +// the single rotation with a re-check loop. func hueFromName(name string) float64 { h := fnv.New32a() _, _ = h.Write([]byte(name)) @@ -56,8 +54,7 @@ func hueFromName(name string) float64 { } func hueInForbiddenBand(hue float64) bool { - return hueDistance(hue, anchorForbiddenHueGreen) < anchorForbiddenHalfBand || - hueDistance(hue, anchorForbiddenHueBlue) < anchorForbiddenHalfBand + return hueDistance(hue, anchorForbiddenHueBlue) < anchorForbiddenHalfBand } func hueDistance(a, b float64) float64 { diff --git a/ui/theme/tokens.go b/ui/theme/tokens.go index 5b0316a..c01fd28 100644 --- a/ui/theme/tokens.go +++ b/ui/theme/tokens.go @@ -47,12 +47,10 @@ type Tokens struct { Override color.NRGBA // amber stripe OverrideBg color.NRGBA // wash behind overridden cell OverrideStrong color.NRGBA // text on override wash - Added color.NRGBA // git-added stripe (green) - AddedBg color.NRGBA // green wash - AddedStrong color.NRGBA // text on green wash Modified color.NRGBA // git-modified stripe (blue) ModifiedBg color.NRGBA // blue wash ModifiedStrong color.NRGBA // text on blue wash + Success color.NRGBA // green accent for success notifications // Danger — error / destructive accent. Used for failure notifications, // destructive button labels (Delete), and stats showing removed keys. @@ -66,10 +64,8 @@ type Tokens struct { // Cyan-teal sits in the unused slot of the wheel (we already use // amber/green/blue/purple) and reads as "additive" without claiming a // git-staged meaning. - Extra color.NRGBA // strip color, key chip border - ExtraBg color.NRGBA // wash behind override cell when row is extra - ExtraStrong color.NRGBA // text on the wash, key chip text - ExtraFaint color.NRGBA // wash behind key cell when descendant of an extra branch + Extra color.NRGBA // strip color, key chip border + ExtraFaint color.NRGBA // wash behind key cell when descendant of an extra branch // Traffic-light dots — titlebar (mac-style). TrafficRed color.NRGBA @@ -189,19 +185,15 @@ func newDefaultTokens() (t struct { Override: oklchOpaque(0.72, 0.13, 75), OverrideBg: oklchOpaque(0.96, 0.04, 80), OverrideStrong: oklchOpaque(0.58, 0.14, 60), - Added: oklchOpaque(0.68, 0.12, 145), - AddedBg: oklchOpaque(0.96, 0.035, 145), - AddedStrong: oklchOpaque(0.40, 0.10, 145), Modified: oklchOpaque(0.63, 0.13, 240), ModifiedBg: oklchOpaque(0.96, 0.025, 240), ModifiedStrong: oklchOpaque(0.40, 0.12, 240), + Success: oklchOpaque(0.68, 0.12, 145), Danger: oklchOpaque(0.58, 0.20, 25), - Extra: oklchOpaque(0.66, 0.13, 195), - ExtraBg: oklchOpaque(0.96, 0.03, 195), - ExtraStrong: oklchOpaque(0.48, 0.14, 195), - ExtraFaint: oklchOpaque(0.985, 0.012, 195), + Extra: oklchOpaque(0.66, 0.13, 195), + ExtraFaint: oklchOpaque(0.985, 0.012, 195), TrafficRed: oklchOpaque(0.70, 0.15, 25), TrafficAmber: oklchOpaque(0.82, 0.13, 85), diff --git a/ui/widget/anchor_menu.go b/ui/widget/anchor_menu.go index 7d4d088..dfc05ea 100644 --- a/ui/widget/anchor_menu.go +++ b/ui/widget/anchor_menu.go @@ -34,7 +34,7 @@ const ( anchorMenuPadding unit.Dp = 4 anchorMenuRowPadH unit.Dp = 10 anchorMenuRadius unit.Dp = 6 - anchorMenuShadowA = 40 + anchorMenuShadowA = 64 anchorMenuShadowOffX = 1 anchorMenuShadowOffY = 2 ) diff --git a/ui/widget/breadcrumb.go b/ui/widget/breadcrumb.go index 94518af..0010851 100644 --- a/ui/widget/breadcrumb.go +++ b/ui/widget/breadcrumb.go @@ -120,7 +120,7 @@ func (b *Breadcrumb) layoutContent(gtx layout.Context, th *material.Theme, actio children[n] = layout.Rigid(func(gtx layout.Context) layout.Dimensions { return layout.Inset{Right: breadcrumbLogoGap}.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - return LayoutLogo(gtx, breadcrumbLogoSize, theme.Default.Override) + return LayoutLogo(gtx, breadcrumbLogoSize, theme.Default.Ink) }) }) n++ diff --git a/ui/widget/extras_filter_pill.go b/ui/widget/extras_filter_pill.go deleted file mode 100644 index 5db01c5..0000000 --- a/ui/widget/extras_filter_pill.go +++ /dev/null @@ -1,92 +0,0 @@ -package widget - -import ( - "image" - "image/color" - - "gioui.org/font" - "gioui.org/io/pointer" - "gioui.org/layout" - "gioui.org/op" - "gioui.org/op/clip" - "gioui.org/op/paint" - "gioui.org/text" - giowidget "gioui.org/widget" - "gioui.org/widget/material" - - "github.com/qdeck-app/qdeck/ui/theme" -) - -// LayoutExtrasFilterPill renders the small "✚ extras-only" toggle pill -// that lives at the right edge of the search bar. State lives in the -// supplied *giowidget.Clickable; the caller polls .Clicked elsewhere -// and tracks the active flag (passed in here for color resolution). -// -// Inactive: Bg fill, Border outline, Muted text — reads as available. -// Active: ExtraBg fill, Extra border, ExtraStrong text — same cyan -// palette as the in-grid extras visuals so the user can tell at a -// glance which filter is engaged. -func LayoutExtrasFilterPill( - gtx layout.Context, - th *material.Theme, - clickable *giowidget.Clickable, - active bool, -) layout.Dimensions { - padH := gtx.Dp(theme.Default.ButtonPaddingH) - hairline := gtx.Dp(theme.Default.HairlineWidth) - height := gtx.Dp(theme.Default.ButtonHeight) - radius := height / 2 //nolint:mnd // half-height = full radius for pill shape. - - fill, borderColor, textColor := extrasPillColors(active) - - gtx.Constraints.Min.Y = height - gtx.Constraints.Max.Y = height - - return clickable.Layout(gtx, func(gtx layout.Context) layout.Dimensions { - // Measure content first so we know the pill's width. - measureGtx := gtx - measureGtx.Constraints.Min = image.Point{} - measureGtx.Constraints.Max.Y = height - - content := op.Record(gtx.Ops) - contentDims := extrasPillContent(measureGtx, th, textColor) - contentCall := content.Stop() - - w := contentDims.Size.X + 2*padH //nolint:mnd // 2 sides of padding. - h := height - - bgStack := clip.UniformRRect(image.Rectangle{Max: image.Pt(w, h)}, radius).Push(gtx.Ops) - paint.ColorOp{Color: fill}.Add(gtx.Ops) - paint.PaintOp{}.Add(gtx.Ops) - bgStack.Pop() - - paintHairlineBorder(gtx, w, h, hairline, borderColor) - - pointer.CursorPointer.Add(gtx.Ops) - - yOff := (h - contentDims.Size.Y) / 2 //nolint:mnd // vertical center. - t := op.Offset(image.Pt(padH, yOff)).Push(gtx.Ops) - contentCall.Add(gtx.Ops) - t.Pop() - - return layout.Dimensions{Size: image.Pt(w, h)} - }) -} - -func extrasPillColors(active bool) (fill, borderCol, textCol color.NRGBA) { - if active { - return theme.Default.ExtraBg, theme.Default.Extra, theme.Default.ExtraStrong - } - - return theme.Default.Bg, theme.Default.Border, theme.Default.Muted -} - -func extrasPillContent(gtx layout.Context, th *material.Theme, textCol color.NRGBA) layout.Dimensions { - lbl := material.Label(th, theme.Default.SizeSM, "✚ extras-only") - lbl.Color = textCol - lbl.Font.Weight = font.Normal - lbl.MaxLines = 1 - lbl.Alignment = text.Middle - - return LayoutLabel(gtx, lbl) -} diff --git a/ui/widget/notification_bar.go b/ui/widget/notification_bar.go index 2311208..41b02a9 100644 --- a/ui/widget/notification_bar.go +++ b/ui/widget/notification_bar.go @@ -140,7 +140,7 @@ func (n *NotificationBar) Layout( func colorForLevel(level state.NotificationLevel) color.NRGBA { if level == state.NotificationSuccess { - return theme.Default.Added + return theme.Default.Success } return theme.Default.Danger diff --git a/ui/widget/override_table.go b/ui/widget/override_table.go index 4d9e0c7..c3bac54 100644 --- a/ui/widget/override_table.go +++ b/ui/widget/override_table.go @@ -733,8 +733,10 @@ func (t *OverrideTable) layoutRow( // to communicate (the only value IS the override). switch { case entry.IsCustomOnly: + // Extras carry their cyan signal through the faint key-cell tint and + // the left strip only — the editable value cell keeps the plain grid + // background so its wash never competes with the editor content. paintRowBgTo(gtx, g.rightStart, dims.Size.Y, theme.Default.ExtraFaint) - paintRowBgFrom(gtx, g.rightStart, dims.Size.Y, theme.Default.ExtraBg) paintOverrideStrip(gtx, g.rightStart, dims.Size.Y, theme.Default.Extra) case hasOverride: // Override and git tints both describe a change in the user's file, @@ -743,9 +745,6 @@ func (t *OverrideTable) layoutRow( // key+default columns also changed, which they didn't. paintRowBgFrom(gtx, g.rightStart, dims.Size.Y, theme.Default.OverrideBg) paintOverrideStrip(gtx, g.rightStart, dims.Size.Y, theme.Default.Override) - case gitStatus == domain.GitAdded: - paintRowBgFrom(gtx, g.rightStart, dims.Size.Y, theme.Default.AddedBg) - paintOverrideStrip(gtx, g.rightStart, dims.Size.Y, theme.Default.Added) case gitStatus == domain.GitModified: paintRowBgFrom(gtx, g.rightStart, dims.Size.Y, theme.Default.ModifiedBg) paintOverrideStrip(gtx, g.rightStart, dims.Size.Y, theme.Default.Modified) @@ -801,13 +800,8 @@ func (t *OverrideTable) layoutRow( t.drawRowDecorations(gtx, g, entry, dims, totalW) // Git change indicator bar on the override cell's left edge. - if gitStatus != domain.GitUnchanged { - barColor := theme.Default.Added - if gitStatus == domain.GitModified { - barColor = theme.Default.Modified - } - - paintGitIndicator(gtx, g.rightStart, dims.Size.Y, barColor) + if gitStatus == domain.GitModified { + paintGitIndicator(gtx, g.rightStart, dims.Size.Y, theme.Default.Modified) } return dims @@ -1193,26 +1187,18 @@ func (t *OverrideTable) hasAnyOverride(entryIdx int) bool { return false } -// gitChangeStatus returns the highest-priority git change status for the given flat key -// across all active columns. GitModified takes precedence over GitAdded. +// gitChangeStatus returns GitModified if any active column reports the given +// flat key as differing from the git HEAD revision, else GitUnchanged. func (t *OverrideTable) gitChangeStatus(key string) domain.GitChangeStatus { - best := domain.GitUnchanged - for c := range t.colCount() { if t.ColumnStates[c] != nil && t.ColumnStates[c].GitChanges != nil { - if status, ok := t.ColumnStates[c].GitChanges[key]; ok { - if status == domain.GitModified { - return domain.GitModified - } - - if status > best { - best = status - } + if status, ok := t.ColumnStates[c].GitChanges[key]; ok && status == domain.GitModified { + return domain.GitModified } } } - return best + return domain.GitUnchanged } // CurrentParent returns the parent key path of the first row currently visible diff --git a/ui/widget/paint_primitives.go b/ui/widget/paint_primitives.go index 3913825..9b2aa48 100644 --- a/ui/widget/paint_primitives.go +++ b/ui/widget/paint_primitives.go @@ -40,7 +40,7 @@ func paintRect(gtx layout.Context, r image.Rectangle, c color.NRGBA) { // on rounded-rect chrome at sizes where a true clip.Stroke around an RRect // would be overkill — the corners read square at hairline thickness anyway. // Replaces the open-coded "four paintRect calls" idiom that lived in -// null_pill, nullify_icon, extras_filter_pill, and button. +// null_pill, nullify_icon, and button. func paintHairlineBorder(gtx layout.Context, w, h, hairline int, c color.NRGBA) { paintRect(gtx, image.Rect(0, 0, w, hairline), c) paintRect(gtx, image.Rect(0, h-hairline, w, h), c) diff --git a/ui/widget/search_bar.go b/ui/widget/search_bar.go index 0ca8481..8765999 100644 --- a/ui/widget/search_bar.go +++ b/ui/widget/search_bar.go @@ -78,16 +78,12 @@ func (s *SearchBar) rebuildCacheIfNeeded(entries []service.FlatValueEntry) { } // FilterEntriesWithMultiOverrides returns indices matching key, value, comment, -// or override editor text across multiple columns. When extrasOnly is true, -// the result is further restricted to entries with IsCustomOnly == true -// (keys defined only in the overlay file with no chart-defaults -// counterpart) — used by the "✚ extras-only" toolbar pill. +// or override editor text across multiple columns. // // Reuses the provided out slice to avoid per-frame allocations. func (s *SearchBar) FilterEntriesWithMultiOverrides( entries []service.FlatValueEntry, columnEditors [][]widget.Editor, - extrasOnly bool, out []int, ) []int { query := strings.ToLower(s.Editor.Text()) @@ -118,10 +114,6 @@ func (s *SearchBar) FilterEntriesWithMultiOverrides( } for i := range entries { - if extrasOnly && !entries[i].IsCustomOnly { - continue - } - if !matchesQuery(i) { continue }