@@ -6,8 +6,37 @@ import SwiftUI
66
77struct 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