diff --git a/.gitignore b/.gitignore index 5091becb..8587851c 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,4 @@ Pods/ Carthage Provisioning TeamID.xcconfig +CLAUDE.md diff --git a/WWDC/Bootstrap/AppCoordinator.swift b/WWDC/Bootstrap/AppCoordinator.swift index d06baf83..082abbc1 100644 --- a/WWDC/Bootstrap/AppCoordinator.swift +++ b/WWDC/Bootstrap/AppCoordinator.swift @@ -248,6 +248,15 @@ final class AppCoordinator: Logging, Signposting { videosController.listViewController.$selectedSession.assign(to: &self.$videosSelectedSessionViewModel) scheduleController.splitViewController.listViewController.$selectedSession.assign(to: &self.$scheduleSelectedSessionViewModel) + // Enable the sidebar toggle only on tabs that have a session-list sidebar. + tabController.$activeTabVar + .receive(on: DispatchQueue.main) + .sink { [weak self] activeTab in + guard let self else { return } + self.windowController.titleBarViewController.sidebarToggleButton.isEnabled = activeTab.hasSidebar + } + .store(in: &cancellables) + Publishers.CombineLatest3( tabController.$activeTabVar, $videosSelectedSessionViewModel, diff --git a/WWDC/Controllers/Base/MainWindowController.swift b/WWDC/Controllers/Base/MainWindowController.swift index 3ec51dfb..b2d381fb 100644 --- a/WWDC/Controllers/Base/MainWindowController.swift +++ b/WWDC/Controllers/Base/MainWindowController.swift @@ -23,6 +23,9 @@ enum MainWindowTab: Int, WWDCTab { } var hidesWindowTitleBar: Bool { self == .explore } + + /// Whether this tab shows the collapsible session-list sidebar. + var hasSidebar: Bool { self == .schedule || self == .videos } } extension Notification.Name { @@ -41,7 +44,14 @@ final class MainWindowController: WWDCWindowController { return NSScreen.main?.visibleFrame.insetBy(dx: 50, dy: 120) ?? NSRect(x: 0, y: 0, width: 1200, height: 600) } - public var sidebarInitWidth: CGFloat? + + /// Shared, session-only sidebar layout state so both tabs agree and a + /// lazily-loaded tab adopts the current state. Not persisted across launches. + struct SidebarState { + var initialWidth: CGFloat? + var collapsed = false + } + var sidebarState = SidebarState() override func loadWindow() { let mask: NSWindow.StyleMask = [.titled, .resizable, .miniaturizable, .closable, .fullSizeContentView] diff --git a/WWDC/Controllers/Base/TitleBarViewController.swift b/WWDC/Controllers/Base/TitleBarViewController.swift index a8607fdf..391ea3c3 100644 --- a/WWDC/Controllers/Base/TitleBarViewController.swift +++ b/WWDC/Controllers/Base/TitleBarViewController.swift @@ -6,12 +6,22 @@ // Copyright © 2018 Guilherme Rambo. All rights reserved. // -import Foundation +import Cocoa +import OSLog final class TitleBarViewController: NSTitlebarAccessoryViewController { private var horizontalPositioningConstraints = [NSLayoutConstraint]() + /// Fallback leading inset used until the traffic-light buttons can be measured. + private static let trafficLightInset: CGFloat = 76 + + /// Gap between the rightmost traffic-light (zoom) button and the sidebar toggle. + private static let trafficLightGap: CGFloat = 8 + + /// Leading constraint for the toggle button; its constant tracks the traffic lights. + private var leadingButtonConstraint: NSLayoutConstraint? + private var centerOffset: CGFloat = 0 { didSet { horizontalPositioningConstraints.forEach { $0.constant = centerOffset } @@ -32,6 +42,58 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { return v }() + private lazy var leadingContainer: NSView = { + let v = NSView() + v.translatesAutoresizingMaskIntoConstraints = false + + return v + }() + + private static let log = Logger(subsystem: Bundle.main.bundleIdentifier ?? "io.wwdc.app", category: "TitleBarViewController") + + private static func makeToggleSymbol() -> NSImage { + guard let image = NSImage(systemSymbolName: "sidebar.left", accessibilityDescription: "Toggle Sidebar") else { + assertionFailure("Missing system symbol: sidebar.left") + log.error("Missing system symbol sidebar.left; falling back to a text glyph for the sidebar toggle") + return fallbackToggleSymbol() + } + // withSymbolConfiguration returns nil if the configuration can't be applied; + // fall back to the unconfigured symbol rather than a blank image. + return image.withSymbolConfiguration(.init(pointSize: 15, weight: .regular)) ?? image + } + + /// Visible last-resort glyph so the toggle button never renders blank if the + /// system symbol is unavailable. + private static func fallbackToggleSymbol() -> NSImage { + let glyph = "◧" as NSString + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 15, weight: .regular), + .foregroundColor: NSColor.labelColor + ] + let glyphSize = glyph.size(withAttributes: attributes) + let image = NSImage(size: NSSize(width: ceil(glyphSize.width), height: ceil(glyphSize.height))) + image.lockFocus() + glyph.draw(at: .zero, withAttributes: attributes) + image.unlockFocus() + image.isTemplate = true + return image + } + + /// Sidebar collapse/expand toggle. Fires `toggleSidebar:` up the responder chain + /// so it reaches the active tab's split view controller. Exposed so the coordinator + /// can disable it on tabs that have no sidebar (Explore). + lazy var sidebarToggleButton: NSButton = { + let b = NSButton(image: Self.makeToggleSymbol(), target: nil, action: #selector(NSSplitViewController.toggleSidebar(_:))) + b.translatesAutoresizingMaskIntoConstraints = false + b.isBordered = false + b.imagePosition = .imageOnly + b.setButtonType(.momentaryChange) + b.imageScaling = .scaleProportionallyDown + b.toolTip = "Toggle Sidebar" + + return b + }() + var statusViewController: NSViewController? { didSet { replace(child: oldValue, with: statusViewController, inContainer: statusContainer) @@ -57,14 +119,27 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { override func loadView() { let view = NSView() + view.addSubview(leadingContainer) view.addSubview(tabBarContainer) view.addSubview(statusContainer) + leadingContainer.addSubview(sidebarToggleButton) + + let leadingConstraint = leadingContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: Self.trafficLightInset) + leadingConstraint.isActive = true + leadingButtonConstraint = leadingConstraint + leadingContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true + leadingContainer.heightAnchor.constraint(equalTo: view.heightAnchor).isActive = true + + sidebarToggleButton.leadingAnchor.constraint(equalTo: leadingContainer.leadingAnchor).isActive = true + sidebarToggleButton.trailingAnchor.constraint(equalTo: leadingContainer.trailingAnchor).isActive = true + sidebarToggleButton.centerYAnchor.constraint(equalTo: leadingContainer.centerYAnchor).isActive = true + let centerXConstraint = tabBarContainer.centerXAnchor.constraint(equalTo: view.centerXAnchor) centerXConstraint.isActive = true horizontalPositioningConstraints.append(centerXConstraint) tabBarContainer.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true - tabBarContainer.leadingAnchor.constraint(greaterThanOrEqualTo: view.leadingAnchor).isActive = true + tabBarContainer.leadingAnchor.constraint(greaterThanOrEqualTo: leadingContainer.trailingAnchor, constant: 8).isActive = true tabBarContainer.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor).isActive = true statusContainer.leadingAnchor.constraint(equalTo: tabBarContainer.trailingAnchor).isActive = true @@ -85,6 +160,17 @@ final class TitleBarViewController: NSTitlebarAccessoryViewController { let windowBounds = window.convertFromScreen(window.frame) let localWindowBounds = view.convert(windowBounds, from: nil) centerOffset = localWindowBounds.midX - view.bounds.midX + + // Tuck the sidebar toggle right up against the rightmost traffic-light (zoom) button. + if let constraint = leadingButtonConstraint, + let zoomButton = window.standardWindowButton(.zoomButton), + let buttonSuperview = zoomButton.superview { + let frameInWindow = buttonSuperview.convert(zoomButton.frame, to: nil) + let newConstant = view.convert(frameInWindow, from: nil).maxX + Self.trafficLightGap + if abs(constraint.constant - newConstant) > 0.5 { + constraint.constant = newConstant + } + } } func replace(child: NSViewController?, with newChild: NSViewController?, inContainer container: NSView) { diff --git a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift index 52ae03e4..1d87667d 100644 --- a/WWDC/Controllers/Sessions/SessionsSplitViewController.swift +++ b/WWDC/Controllers/Sessions/SessionsSplitViewController.swift @@ -23,6 +23,13 @@ final class SessionsSplitViewController: NSSplitViewController { var setupDone = false private var cancellables: Set = [] + /// The split view item hosting the session list sidebar. Retained so its + /// collapsed state can be toggled, observed and synced across tabs. + private var sidebarItem: NSSplitViewItem! + + /// Guards against feedback loops while mirroring collapse state from another tab. + private var isSyncingCollapse = false + init(windowController: MainWindowController, listViewController: SessionsTableViewController) { self.windowController = windowController self.listViewController = listViewController @@ -39,6 +46,14 @@ final class SessionsSplitViewController: NSSplitViewController { self?.syncSplitView(notification: notification) } .store(in: &cancellables) + + NotificationCenter + .default + .publisher(for: .sideBarCollapseSyncNotification) + .sink { [weak self] notification in + self?.applyCollapseSync(notification: notification) + } + .store(in: &cancellables) } required init?(coder: NSCoder) { @@ -51,7 +66,8 @@ final class SessionsSplitViewController: NSSplitViewController { view.wantsLayer = true let listItem = NSSplitViewItem(sidebarWithViewController: listViewController) - listItem.canCollapse = false + listItem.canCollapse = true + sidebarItem = listItem let detailItem = NSSplitViewItem(viewController: detailViewController) addSplitViewItem(listItem) @@ -59,14 +75,39 @@ final class SessionsSplitViewController: NSSplitViewController { listViewController.view.setContentHuggingPriority(.defaultHigh, for: .horizontal) detailViewController.view.setContentHuggingPriority(.defaultLow, for: .horizontal) + + // Mirror collapse state to the other tab's split view so both stay consistent + // during a session. Catches both the toggleSidebar: action and drag-to-collapse. + listItem + .publisher(for: \.isCollapsed) + .dropFirst() + .removeDuplicates() + .sink { [weak self] collapsed in + guard let self, !self.isSyncingCollapse else { return } + // Record the shared session state so the other (possibly not-yet-loaded) tab adopts it. + self.windowController.sidebarState.collapsed = collapsed + NotificationCenter.default.post( + name: .sideBarCollapseSyncNotification, + object: self.splitView, + userInfo: ["collapsed": collapsed] + ) + } + .store(in: &cancellables) } override func viewWillAppear() { super.viewWillAppear() if !setupDone { - if let sidebarInitWidth = windowController.sidebarInitWidth { - splitView.setPosition(sidebarInitWidth, ofDividerAt: 0) + if let initWidth = windowController.sidebarState.initialWidth { + splitView.setPosition(initWidth, ofDividerAt: 0) + } + // Adopt the shared collapsed state in case it changed in another tab before + // this one was lazily loaded. + if sidebarItem.isCollapsed != windowController.sidebarState.collapsed { + isSyncingCollapse = true + sidebarItem.isCollapsed = windowController.sidebarState.collapsed + isSyncingCollapse = false } setupDone = true } @@ -87,11 +128,19 @@ final class SessionsSplitViewController: NSSplitViewController { #endif guard notificationSourceSplitView !== splitView else { - // If own split view is altered, change split view initialisation width for other tabs - windowController.sidebarInitWidth = notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width + // If own split view is altered, change split view initialisation width for other tabs. + // Skip while collapsed so the ~0pt collapsed width is never persisted as the sidebar width. + if !sidebarItem.isCollapsed, notificationSourceSplitView.subviews.indices.contains(targetSubviewIndex) { + windowController.sidebarState.initialWidth = notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width + } return } - guard splitView.subviews.count > 0, notificationSourceSplitView.subviews.count > 0 else { + // If THIS tab's sidebar is already collapsed, skip applying the incoming width. + // Width-sync is throttled 250 ms so a trailing notification can arrive after + // the collapse-sync; calling setPosition here would re-expand the sidebar. + guard !sidebarItem.isCollapsed else { return } + guard splitView.subviews.indices.contains(targetSubviewIndex), + notificationSourceSplitView.subviews.indices.contains(targetSubviewIndex) else { return } guard splitView.subviews[targetSubviewIndex].bounds.width != notificationSourceSplitView.subviews[targetSubviewIndex].bounds.width else { @@ -108,12 +157,30 @@ final class SessionsSplitViewController: NSSplitViewController { override func splitViewDidResizeSubviews(_ notification: Notification) { guard isResizingSplitView == false else { return } guard setupDone else { return } + // Don't broadcast a width sync for a collapse/expand; collapse state has its own sync. + guard !sidebarItem.isCollapsed else { return } // This notification should only be posted in response to user input NotificationCenter.default.post(name: .sideBarSizeSyncNotification, object: splitView, userInfo: nil) } + + /// Mirrors a collapse/expand performed in another tab's split view onto this one. + private func applyCollapseSync(notification: Notification) { + // The split view item only exists once this tab's view has loaded. A not-yet-loaded + // tab will adopt the shared state via windowController.sidebarState.collapsed in viewWillAppear. + guard let sidebarItem else { return } + guard let sourceSplitView = notification.object as? NSSplitView, sourceSplitView !== splitView else { return } + guard let collapsed = notification.userInfo?["collapsed"] as? Bool else { return } + guard sidebarItem.isCollapsed != collapsed else { return } + + // The inactive tab isn't visible, so apply directly without animation. + isSyncingCollapse = true + sidebarItem.isCollapsed = collapsed + isSyncingCollapse = false + } } extension Notification.Name { public static let sideBarSizeSyncNotification = NSNotification.Name("WWDCSplitViewSizeSyncNotification") + public static let sideBarCollapseSyncNotification = NSNotification.Name("WWDCSidebarCollapseSyncNotification") } diff --git a/WWDC/Resources/Base.lproj/Main.storyboard b/WWDC/Resources/Base.lproj/Main.storyboard index c177ae50..da7554e3 100644 --- a/WWDC/Resources/Base.lproj/Main.storyboard +++ b/WWDC/Resources/Base.lproj/Main.storyboard @@ -557,6 +557,13 @@ + + + + + + +