import SwiftUI /// The right-hand side on a wide screen: the table across the top, the item you are reading /// underneath it, and a bar between them to move the split. That is ipx's own arrangement -- /// you pick a row and read it without losing the list. struct ContentPaneView: View { @ObservedObject var store: LibraryStore @ObservedObject var playback: Playback var play: (IPX.Entry, IPX.Enclosure?) -> Void /// How much of the height the table gets. Kept, because where someone puts the divider is a /// preference, not a thing to reset every launch. @AppStorage("ipx.split") private var fraction: Double = 0.45 @State private var dragging: Double? private var split: Double { min(max(dragging ?? fraction, 0.2), 0.85) } var body: some View { GeometryReader { geo in VStack(spacing: 0) { header ItemTableView(store: store, playback: playback, play: play) .frame(height: max(120, geo.size.height * split)) grab reading } // The drag is a fraction of the height, so it needs the height it is a fraction of. .onAppear { paneHeight = geo.size.height } .onChange(of: geo.size.height) { paneHeight = $0 } } .coordinateSpace(name: "panes") .navigationTitle(store.title) .toolbar { toolbar } .searchable(text: $store.search, prompt: "Search \(store.title)") } private var header: some View { HStack(spacing: 12) { FeedArt(feed: store.place.feedId.flatMap(store.feed), size: 40) VStack(alignment: .leading, spacing: 1) { Text(store.title).font(.headline).foregroundStyle(Glass.text) Text(summary).font(.caption).foregroundStyle(Glass.faint) } Spacer() Picker("Show", selection: $store.filter) { ForEach(IPX.Filter.tabs, id: \.self) { Text($0.label).tag($0) } } .pickerStyle(.segmented) .frame(maxWidth: 360) .disabled(store.place == .listening) } .padding(.horizontal, 14).padding(.vertical, 9) .glassPane(Glass.sticky) } private var summary: String { if let failure = store.failure { return failure } return "\(store.total) items ยท \(store.entries.filter { !$0.read }.count) unread loaded" } /// The divider, which is also the handle. Two pixels of hairline is not something anyone can /// hit, so the grab area is wider than the line it draws. private var grab: some View { ZStack { Color.clear.frame(height: 10).contentShape(Rectangle()) Rectangle().fill(Glass.line).frame(height: 1) } .gesture( DragGesture(coordinateSpace: .named("panes")) .onChanged { value in dragging = fraction + value.translation.height / max(paneHeight, 1) } .onEnded { _ in if let dragging { fraction = min(max(dragging, 0.2), 0.85) } dragging = nil } ) #if targetEnvironment(macCatalyst) .onHover { inside in // The pointer should say the thing can be moved before anyone tries it. if inside { NSCursorShim.resizeUpDown() } else { NSCursorShim.arrow() } } #endif } @State private var paneHeight: Double = 600 @ViewBuilder private var reading: some View { if let entry = store.selectedEntry { ItemDetailView(store: store, playback: playback, entry: entry, play: play) .frame(maxWidth: .infinity, maxHeight: .infinity) } else { VStack { Spacer() Text("Pick an item to read it.").font(.callout).foregroundStyle(Glass.faint) Spacer() } .frame(maxWidth: .infinity) } } @ToolbarContentBuilder private var toolbar: some ToolbarContent { ToolbarItemGroup { Button { store.scan() } label: { Image(systemName: "arrow.trianglehead.2.clockwise") } .help("Check for new items") Button { store.readAll() } label: { Image(systemName: "checkmark.circle") } .help("Mark everything here read") } } } #if targetEnvironment(macCatalyst) /// AppKit's cursors are not on Catalyst's side of the wall, so this asks for them by name. It /// is cosmetic: without it the divider still drags, the pointer just never says so. enum NSCursorShim { private static func cursor(_ selector: String) -> NSObject? { guard let klass = NSClassFromString("NSCursor") as? NSObject.Type else { return nil } return klass.perform(Selector(selector))?.takeUnretainedValue() as? NSObject } static func resizeUpDown() { cursor("resizeUpDownCursor")?.perform(Selector("set")) } static func arrow() { cursor("arrowCursor")?.perform(Selector("set")) } } #endif