Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,4 @@ Pods/
Carthage
Provisioning
TeamID.xcconfig
CLAUDE.md
9 changes: 9 additions & 0 deletions WWDC/Bootstrap/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion WWDC/Controllers/Base/MainWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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]
Expand Down
90 changes: 88 additions & 2 deletions WWDC/Controllers/Base/TitleBarViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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) {
Expand Down
79 changes: 73 additions & 6 deletions WWDC/Controllers/Sessions/SessionsSplitViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ final class SessionsSplitViewController: NSSplitViewController {
var setupDone = false
private var cancellables: Set<AnyCancellable> = []

/// 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
Expand All @@ -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) {
Expand All @@ -51,22 +66,48 @@ 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)
addSplitViewItem(detailItem)

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
}
Expand All @@ -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 {
Expand All @@ -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")
}
7 changes: 7 additions & 0 deletions WWDC/Resources/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,13 @@
<action selector="viewVideos:" target="Ady-hI-5gd" id="xWC-aO-kEM"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="wwd-c0-sb0"/>
<menuItem title="Hide Sidebar" keyEquivalent="s" id="wwd-c0-sb1">
<modifierMask key="keyEquivalentModifierMask" control="YES" command="YES"/>
<connections>
<action selector="toggleSidebar:" target="Ady-hI-5gd" id="wwd-c0-sb2"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="eT5-6l-QRY"/>
<menuItem title="Reload" keyEquivalent="r" id="VAB-X3-ZCo">
<connections>
Expand Down