@@ -37,27 +37,47 @@ struct ShelfView: View {
3737 books. firstIndex ( where: { $0. id == id } )
3838 }
3939
40+ ZStack ( alignment: . leading) {
41+ spineFlow ( books: books, openBookID: openBookID, openIndex: openIndex)
42+ openBookOverlay ( books: books, openIndex: openIndex, state: state)
43+ }
44+ . frame ( maxWidth: . infinity, maxHeight: . infinity)
45+ . background ( Color ( nsColor: . windowBackgroundColor) . opacity ( surfaceBackgroundOpacity) )
46+ }
47+
48+ @ViewBuilder
49+ private func spineFlow( books: [ ShelfBook ] , openBookID: ShelfBook . ID ? , openIndex: Int ? )
50+ -> some View
51+ {
4052 HStack ( spacing: 0 ) {
4153 ForEach ( Array ( books. enumerated ( ) ) , id: \. element. id) { index, book in
4254 spine ( book: book, index: index, openIndex: openIndex)
4355 if book. id == openBookID {
44- openBookArea ( for: book, state: state)
45- . transition ( . opacity)
56+ terminalPlaceholder ( )
4657 }
4758 }
4859 if openBookID == nil {
49- emptyOpenArea ( )
60+ terminalPlaceholder ( )
5061 }
5162 }
5263 . frame ( maxWidth: . infinity, maxHeight: . infinity)
53- . background ( Color ( nsColor: . windowBackgroundColor) . opacity ( surfaceBackgroundOpacity) )
5464 // Animate on every openBookID change — covers both Shelf-originated
5565 // book switches (which also set their own TCA animation) and
5666 // left-nav-originated switches, so the spine flow is consistent
57- // regardless of entry point.
67+ // regardless of entry point. The real terminal is rendered in a
68+ // non-layout overlay, so this animation only moves spines and the
69+ // lightweight placeholder.
5870 . animation ( . easeInOut( duration: 0.2 ) , value: openBookID)
5971 }
6072
73+ @ViewBuilder
74+ private func terminalPlaceholder( ) -> some View {
75+ Color . clear
76+ . frame ( maxWidth: . infinity, maxHeight: . infinity)
77+ . layoutPriority ( 1 )
78+ . accessibilityHidden ( true )
79+ }
80+
6181 @ViewBuilder
6282 private func spine( book: ShelfBook , index: Int , openIndex: Int ? ) -> some View {
6383 let distance = openIndex. map { abs ( index - $0) }
@@ -87,6 +107,46 @@ struct ShelfView: View {
87107 )
88108 }
89109
110+ @ViewBuilder
111+ private func openBookOverlay(
112+ books: [ ShelfBook ] , openIndex: Int ? , state: RepositoriesFeature . State
113+ ) -> some View {
114+ GeometryReader { proxy in
115+ let frame = openBookOverlayFrame ( in: proxy. size, bookCount: books. count, openIndex: openIndex)
116+ openBookOverlayContent ( books: books, openIndex: openIndex, state: state)
117+ . frame ( width: frame. width, height: proxy. size. height)
118+ . clipped ( )
119+ . offset ( x: frame. minX)
120+ }
121+ }
122+
123+ private func openBookOverlayFrame( in size: CGSize , bookCount: Int , openIndex: Int ? ) -> (
124+ minX: CGFloat , width: CGFloat
125+ ) {
126+ let leftSpineCount = openIndex. map { $0 + 1 } ?? bookCount
127+ let rightSpineCount = openIndex. map { bookCount - $0 - 1 } ?? 0
128+ let minX = CGFloat ( leftSpineCount) * ShelfMetrics. spineWidth
129+ let occupiedWidth = CGFloat ( leftSpineCount + rightSpineCount) * ShelfMetrics. spineWidth
130+ return ( minX, max ( 0 , size. width - occupiedWidth) )
131+ }
132+
133+ @ViewBuilder
134+ private func openBookOverlayContent(
135+ books: [ ShelfBook ] ,
136+ openIndex: Int ? ,
137+ state: RepositoriesFeature . State
138+ ) -> some View {
139+ if let openIndex {
140+ openBookArea ( for: books [ openIndex] , state: state)
141+ . id ( books [ openIndex] . id)
142+ . transition ( . opacity. animation ( . easeInOut( duration: 0.12 ) ) )
143+ } else {
144+ emptyOpenArea ( )
145+ . id ( " __empty__ " )
146+ . transition ( . opacity. animation ( . easeInOut( duration: 0.12 ) ) )
147+ }
148+ }
149+
90150 /// Dispatch the open-book action only when `book` isn't already the open
91151 /// one — idempotent helper for taps that imply a book change.
92152 ///
0 commit comments