Skip to content

Commit 73a88de

Browse files
committed
Separate row highlight from stable file row content
1 parent a4321f1 commit 73a88de

6 files changed

Lines changed: 302 additions & 89 deletions

File tree

GUI/Sources/Config/ColorThemeStore.swift

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@ final class ColorThemeStore {
3030
@ObservationIgnored @AppStorage("color.selectionInactive") var hexSelInactive: String = ""
3131
@ObservationIgnored @AppStorage("color.selectionBorder") var hexSelBorder: String = ""
3232
@ObservationIgnored @AppStorage("selection.lineWidth") var storedLineWidth: Double = 2.0
33+
@ObservationIgnored @AppStorage("default.color.selectionActive") private var defaultHexSelActive: String = ""
34+
@ObservationIgnored @AppStorage("default.color.selectionInactive") private var defaultHexSelInactive: String = ""
35+
@ObservationIgnored @AppStorage("default.color.selectionBorder") private var defaultHexSelBorder: String = ""
36+
@ObservationIgnored @AppStorage("default.selection.lineWidth") private var defaultStoredLineWidth: Double = 0
3337
@ObservationIgnored @AppStorage("color.separator") var hexSeparator: String = ""
3438
@ObservationIgnored @AppStorage("color.dialogBase") var hexDialogBase: String = ""
3539
@ObservationIgnored @AppStorage("color.dialogStripe") var hexDialogStripe: String = ""
@@ -87,7 +91,7 @@ final class ColorThemeStore {
8791
}
8892

8993
func loadTheme(id: String) {
90-
let base = ColorTheme.allPresets.first { $0.id == id } ?? .defaultTheme
94+
let base = baseTheme(for: id)
9195
savedThemeID = base.id
9296
// Apply custom hex overrides on top of preset
9397
activeTheme = applyOverrides(to: base)
@@ -102,6 +106,42 @@ final class ColorThemeStore {
102106
return v != 0 ? v : fallback
103107
}
104108

109+
private func baseTheme(for id: String) -> ColorTheme {
110+
let preset = ColorTheme.allPresets.first { $0.id == id } ?? .defaultTheme
111+
guard preset.id == ColorTheme.defaultTheme.id else { return preset }
112+
return applyDefaultSelectionOverrides(to: preset)
113+
}
114+
115+
private func applyDefaultSelectionOverrides(to base: ColorTheme) -> ColorTheme {
116+
var theme = base
117+
if let c = Color(hex: defaultHexSelActive) { theme.selectionActive = c }
118+
if let c = Color(hex: defaultHexSelInactive) { theme.selectionInactive = c }
119+
if let c = Color(hex: defaultHexSelBorder) { theme.selectionBorder = c }
120+
if defaultStoredLineWidth > 0 {
121+
theme.selectionLineWidth = CGFloat(defaultStoredLineWidth)
122+
}
123+
return theme
124+
}
125+
126+
func effectivePreset(id: String) -> ColorTheme {
127+
baseTheme(for: id)
128+
}
129+
130+
func updateSelectionDefaults(
131+
active: Color? = nil,
132+
inactive: Color? = nil,
133+
border: Color? = nil,
134+
lineWidth: Double? = nil
135+
) {
136+
if let active { defaultHexSelActive = active.toHex() ?? defaultHexSelActive }
137+
if let inactive { defaultHexSelInactive = inactive.toHex() ?? defaultHexSelInactive }
138+
if let border { defaultHexSelBorder = border.toHex() ?? defaultHexSelBorder }
139+
if let lineWidth { defaultStoredLineWidth = lineWidth }
140+
if savedThemeID == ColorTheme.defaultTheme.id {
141+
reloadOverrides()
142+
}
143+
}
144+
105145
// MARK: - Apply hex overrides to base theme
106146
private func applyOverrides(to base: ColorTheme) -> ColorTheme {
107147
var theme = base
@@ -113,7 +153,9 @@ final class ColorThemeStore {
113153
if let c = Color(hex: ud("color.selectionActive")) { theme.selectionActive = c }
114154
if let c = Color(hex: ud("color.selectionInactive")) { theme.selectionInactive = c }
115155
if let c = Color(hex: ud("color.selectionBorder")) { theme.selectionBorder = c }
116-
theme.selectionLineWidth = CGFloat(udD("selection.lineWidth", fallback: 2.0))
156+
theme.selectionLineWidth = CGFloat(
157+
udD("selection.lineWidth", fallback: Double(base.selectionLineWidth))
158+
)
117159
if let c = Color(hex: ud("color.separator")) { theme.separatorColor = c }
118160
if let c = Color(hex: ud("color.dialogBase")) { theme.dialogBase = c }
119161
if let c = Color(hex: ud("color.dialogStripe")) { theme.dialogStripe = c }
@@ -174,11 +216,12 @@ final class ColorThemeStore {
174216

175217
// MARK: - Reload overrides on top of current preset
176218
func reloadOverrides() {
177-
let base = ColorTheme.allPresets.first { $0.id == savedThemeID } ?? .defaultTheme
219+
let base = baseTheme(for: savedThemeID)
178220
activeTheme = applyOverrides(to: base)
179221
themeVersion += 1
180222
log.debug("[ColorTheme] reloaded v\(themeVersion)")
181223
}
224+
182225
// MARK: - Apply preset
183226
func applyPreset(_ theme: ColorTheme) {
184227
// Reset all custom overrides — original 13 tokens

GUI/Sources/Features/Panels/FileTable/FilePanels/FilePanelStyle.swift

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,21 @@
77
// Selection colors in FileRow and text colors in FileRowView are served
88
// live from ColorThemeStore — constants here act as compile-time fallbacks only.
99

10+
import AppKit
1011
import SwiftUI
1112

1213
// MARK: - Visual styling constants for file panels
1314
// Finder-style design (macOS HIG compliant)
1415
enum FilePanelStyle {
1516

17+
private static var backingScale: CGFloat {
18+
NSScreen.main?.backingScaleFactor ?? 2.0
19+
}
20+
21+
private static func pixelSnapped(_ value: CGFloat) -> CGFloat {
22+
(value * backingScale).rounded() / backingScale
23+
}
24+
1625
// MARK: - Colors (Finder-style: minimal color differentiation)
1726
/// Blue color for symlink directories (subtle differentiation)
1827
static let blueSymlinkDirNameColor = Color(nsColor: .linkColor)
@@ -65,10 +74,10 @@ enum FilePanelStyle {
6574
}
6675

6776
/// Icon size - scaled by InterfaceScaleStore
68-
@MainActor static var iconSize: CGFloat { InterfaceScaleStore.shared.scaled(baseIconSize) }
77+
@MainActor static var iconSize: CGFloat { pixelSnapped(InterfaceScaleStore.shared.scaled(baseIconSize)) }
6978

7079
/// Row height - scaled by InterfaceScaleStore
71-
@MainActor static var rowHeight: CGFloat { InterfaceScaleStore.shared.scaled(baseRowHeight) }
80+
@MainActor static var rowHeight: CGFloat { pixelSnapped(InterfaceScaleStore.shared.scaled(baseRowHeight)) }
7281

7382
/// Modified date column width
7483
static let modifiedColumnWidth: CGFloat = 145

GUI/Sources/Features/Panels/FileTable/FilePanels/FileTableRowsView.swift

Lines changed: 124 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,37 @@ import SwiftUI
66

77
struct FileTableRowsView: View {
88

9+
private enum SelectionOverlayMetrics {
10+
static let cornerRadius: CGFloat = 6
11+
static let horizontalInset: CGFloat = 1
12+
static let fillTopInset: CGFloat = 1
13+
static let fillBottomInset: CGFloat = 1
14+
static let borderTopInset: CGFloat = 1
15+
static let borderBottomInset: CGFloat = 1
16+
static let rowsTopInset: CGFloat = 2
17+
}
18+
19+
private struct SelectionOverlayLayout {
20+
let yOffset: CGFloat
21+
let visibleHeight: CGFloat
22+
23+
init(rowYOffset: CGFloat, rowHeight: CGFloat, topInset: CGFloat, bottomInset: CGFloat) {
24+
self.yOffset = rowYOffset + topInset
25+
self.visibleHeight = max(0, rowHeight - topInset - bottomInset)
26+
}
27+
}
28+
29+
private static let selectionSpring = Animation.interpolatingSpring(
30+
mass: 0.22,
31+
stiffness: 240,
32+
damping: 24,
33+
initialVelocity: 0
34+
)
35+
36+
@Environment(AppState.self) private var appState
937
@Environment(\.displayScale) private var displayScale
1038
private var onePixel: CGFloat { 1.0 / displayScale }
39+
@State private var colorStore = ColorThemeStore.shared
1140

1241
let rows: [CustomFile]
1342
@Binding var selectedID: CustomFile.ID?
@@ -28,26 +57,119 @@ struct FileTableRowsView: View {
2857
makeDisplayRows(from: rows)
2958
}
3059

60+
private var isActivePanel: Bool {
61+
appState.focusedPanel == panelSide
62+
}
63+
64+
private var selectedDisplayIndex: Int? {
65+
displayRows.firstIndex { file in
66+
isRowSelected(file: file, currentSelectedID: currentSelectedID)
67+
}
68+
}
69+
70+
private var selectedRowYOffset: CGFloat? {
71+
guard let selectedDisplayIndex else { return nil }
72+
let precedingHeight = displayRows.prefix(selectedDisplayIndex).reduce(CGFloat.zero) { partial, file in
73+
partial + rowHeight(for: file)
74+
}
75+
return SelectionOverlayMetrics.rowsTopInset + precedingHeight
76+
}
77+
78+
private var selectedRowHeight: CGFloat? {
79+
guard let selectedDisplayIndex else { return nil }
80+
return rowHeight(for: displayRows[selectedDisplayIndex])
81+
}
82+
83+
private var selectionFillLayout: SelectionOverlayLayout? {
84+
guard let selectedRowYOffset, let selectedRowHeight else { return nil }
85+
return SelectionOverlayLayout(
86+
rowYOffset: selectedRowYOffset,
87+
rowHeight: selectedRowHeight,
88+
topInset: SelectionOverlayMetrics.fillTopInset,
89+
bottomInset: SelectionOverlayMetrics.fillBottomInset
90+
)
91+
}
92+
93+
private var selectionBorderLayout: SelectionOverlayLayout? {
94+
guard let selectedRowYOffset, let selectedRowHeight else { return nil }
95+
return SelectionOverlayLayout(
96+
rowYOffset: selectedRowYOffset,
97+
rowHeight: selectedRowHeight,
98+
topInset: SelectionOverlayMetrics.borderTopInset,
99+
bottomInset: SelectionOverlayMetrics.borderBottomInset
100+
)
101+
}
102+
31103
var body: some View {
32104
VStack(spacing: 0) {
33-
rowsStack
105+
rowsLayer
34106
bottomBreathingSpace
35107
}
36-
.transaction { $0.disablesAnimations = true }
37108
}
38109
// MARK: - View Sections
110+
private var rowsLayer: some View {
111+
ZStack(alignment: .topLeading) {
112+
selectionFillOverlay
113+
rowsStack
114+
selectionBorderOverlay
115+
}
116+
.animation(Self.selectionSpring, value: selectedDisplayIndex)
117+
.animation(Self.selectionSpring, value: selectedRowYOffset)
118+
}
119+
39120
private var rowsStack: some View {
40121
LazyVStack(alignment: .leading, spacing: 0) {
41122
ForEach(Array(displayRows.enumerated()), id: \.element.id) { index, file in
42123
sizeAwareRow(index: index, file: file)
43124
}
44125
}
126+
.padding(.top, SelectionOverlayMetrics.rowsTopInset)
127+
.transaction { $0.disablesAnimations = true }
128+
}
129+
130+
@ViewBuilder
131+
private var selectionFillOverlay: some View {
132+
if let layout = selectionFillLayout {
133+
RoundedRectangle(cornerRadius: SelectionOverlayMetrics.cornerRadius, style: .continuous)
134+
.fill(isActivePanel ? colorStore.activeTheme.selectionActive : colorStore.activeTheme.selectionInactive)
135+
.frame(maxWidth: .infinity, alignment: .leading)
136+
.frame(height: layout.visibleHeight)
137+
.padding(.horizontal, SelectionOverlayMetrics.horizontalInset)
138+
.offset(y: layout.yOffset)
139+
.allowsHitTesting(false)
140+
}
141+
}
142+
143+
@ViewBuilder
144+
private var selectionBorderOverlay: some View {
145+
if let layout = selectionBorderLayout {
146+
RoundedRectangle(cornerRadius: SelectionOverlayMetrics.cornerRadius, style: .continuous)
147+
.strokeBorder(selectionBorderColor, lineWidth: selectionBorderLineWidth)
148+
.frame(maxWidth: .infinity, alignment: .leading)
149+
.frame(height: layout.visibleHeight)
150+
.padding(.horizontal, SelectionOverlayMetrics.horizontalInset)
151+
.offset(y: layout.yOffset)
152+
.allowsHitTesting(false)
153+
}
154+
}
155+
156+
private var selectionBorderColor: Color {
157+
let base = colorStore.activeTheme.selectionBorder
158+
return isActivePanel ? base : base.opacity(0.5)
159+
}
160+
161+
private var selectionBorderLineWidth: CGFloat {
162+
max(onePixel, colorStore.activeTheme.selectionLineWidth)
45163
}
46164

47165
private var bottomBreathingSpace: some View {
48166
Color.clear.frame(height: onePixel)
49167
}
50168

169+
private func rowHeight(for file: CustomFile) -> CGFloat {
170+
isParentRow(file) ? ParentEntryStripView.rowHeight : FilePanelStyle.rowHeight
171+
}
172+
51173
@ViewBuilder
52174
private func sizeAwareRow(index: Int, file: CustomFile) -> some View {
53175
let isSelected = isRowSelected(file: file, currentSelectedID: currentSelectedID)

0 commit comments

Comments
 (0)