From 0150be2d87d88092d93272ec1f2972ba86da2ce4 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 15:41:06 +1300 Subject: [PATCH 01/15] sidebar toggle: titlebar title and minimal tab bar inset glide with the slide the title row rode the content root's slide with the hidden layout's padding, so a show carried it a sidebar width right and snapped it back at the landing, and a hide threw it right at the keypress. it now sits in its own layer and glides from its hidden rest to its docked rest on the same spring, render server only. its padding and the titlebar border follow the docked layout flag, so they change in the same commit as the layout. minimal mode's tab bar inset now changes inside the atomic layout commits, and the first pane's tabs ride the slide as a picture that glides by the inset over plain tab bar. Co-Authored-By: Claude Opus 5.5 --- Sources/ContentView+SidebarPeek.swift | 4 +- Sources/ContentView.swift | 81 +++--- Sources/Sidebar/SidebarLayoutModel.swift | 3 + Sources/Sidebar/SidebarSlideGlide.swift | 231 ++++++++++++++++++ Sources/Sidebar/SidebarToggleAnimator.swift | 70 +++++- cmux.xcodeproj/project.pbxproj | 4 + .../SidebarFocusBoundaryLifecycleTests.swift | 72 ++++++ 7 files changed, 415 insertions(+), 50 deletions(-) create mode 100644 Sources/Sidebar/SidebarSlideGlide.swift diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index ed0a1411d51f..b34aaf872388 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -238,7 +238,9 @@ extension ContentView { && !(rightSidebarVisible && fileExplorerState.mode == .dock) }, trailingStillWidth: { rightSidebarWidth }, - isPeekPresenting: { sidebarPeek.presentsPanel } + isPeekPresenting: { sidebarPeek.presentsPanel }, + dockedLayoutWillCommit: { docked in syncTrafficLightInset(docked: docked) }, + tabBarInsetDelta: { tabBarLeadingInset(docked: false) - tabBarLeadingInset(docked: true) } ) sidebarPeek.setPolicy(SidebarCustomizationSettings.peekPolicy()) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b9e7e4651341..f629466b8c3e 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2198,42 +2198,39 @@ struct ContentView: View { .allowsHitTesting(false) SidebarWidthReader(layout: sidebarLayout) { width in - HStack(spacing: 8) { - if isFullScreen && !sidebarState.isVisible { - // Reserve the controls' width so the title flows to their right. - // The visible controls are rendered once in the band overlay (see - // `workspaceTitlebarBand`) so their position never depends on - // sidebar visibility. - Color.clear - .frame(width: fullscreenControlsWidth, height: titlebarContentHeight) - .allowsHitTesting(false) - } - - // Draggable folder icon + focused command name - if let directory = focusedDirectory { - DetachedFolderDragIcon(directory: directory) - .frame(width: 16, height: 16) - .padding(.leading, -6) - } - - Text(titlebarText) - .cmuxFont(size: 13, weight: .bold) - .foregroundColor(fakeTitlebarTextColor(appearance: appearance)) - .lineLimit(1) - .allowsHitTesting(false) - - Spacer() - - } - .frame(height: titlebarContentHeight) - .padding(.top, 2) - .padding(.leading, Self.customTitlebarLeadingPadding( + // Docked and hidden layouts rest the title at different x (the + // hidden one reserves the fullscreen controls' width, which are + // drawn once in the band overlay); its own layer lets the + // toggle's slide glide it between them. + let leading = Self.titlebarTitleLeadings( isFullScreen: isFullScreen, - isSidebarVisible: sidebarState.isVisible, sidebarWidth: width, minimumSidebarWidth: minimumSidebarWidth, - titlebarLeadingInset: titlebarLeadingInset - )) + titlebarLeadingInset: titlebarLeadingInset, + reservedControlsWidth: fullscreenControlsWidth + ) + SidebarSlideGlideHost( + hiddenLeading: leading.hidden, + dockedLeading: leading.docked, + layout: sidebarLayout, + content: AnyView(HStack(spacing: 8) { + // Draggable folder icon + focused command name + if let directory = focusedDirectory { + DetachedFolderDragIcon(directory: directory) + .frame(width: 16, height: 16) + } + Text(titlebarText) + .cmuxFont(size: 13, weight: .bold) + .foregroundColor(fakeTitlebarTextColor(appearance: appearance)) + .lineLimit(1) + .allowsHitTesting(false) + Spacer(minLength: 0) + }.cmuxFontMagnificationEnvironment()) + ) + .padding(.leading, focusedDirectory == nil ? 0 : -6) + .frame(height: titlebarContentHeight) + .padding(.top, 2) + .padding(.leading, sidebarState.isVisible && sidebarLayout.docksSidebar ? leading.docked : leading.hidden) .padding(.trailing, 8) } } @@ -2247,7 +2244,7 @@ struct ContentView: View { orientation: .horizontal, backgroundColor: appearance.resolvedChromeBackgroundColor ) - .padding(.leading, sidebarState.isVisible ? width : 0) + .padding(.leading, sidebarState.isVisible && sidebarLayout.docksSidebar ? width : 0) } } } @@ -2299,12 +2296,16 @@ struct ContentView: View { } } - private func syncTrafficLightInset(isMinimalMode: Bool? = nil) { - let resolvedIsMinimalMode = isMinimalMode ?? currentIsMinimalMode - let inset: CGFloat = (resolvedIsMinimalMode && !sidebarState.isVisible && !isFullScreen) - ? CGFloat(titlebarDebugChromeSnapshot.trafficLightTabBarLeadingInset) - : 0 - tabManager.syncWorkspaceTabBarLeadingInset(inset) + /// Minimal mode's tab bar makes room for the traffic lights while the + /// docked layout gives the sidebar no width. + func tabBarLeadingInset(docked: Bool, isMinimalMode: Bool? = nil) -> CGFloat { + (isMinimalMode ?? currentIsMinimalMode) && !docked && !isFullScreen + ? CGFloat(titlebarDebugChromeSnapshot.trafficLightTabBarLeadingInset) : 0 + } + + func syncTrafficLightInset(isMinimalMode: Bool? = nil, docked: Bool? = nil) { + let docked = docked ?? (sidebarState.isVisible && sidebarLayout.docksSidebar) + tabManager.syncWorkspaceTabBarLeadingInset(tabBarLeadingInset(docked: docked, isMinimalMode: isMinimalMode)) } private func handleWorkspacePresentationModeChange(isMinimalMode: Bool) { diff --git a/Sources/Sidebar/SidebarLayoutModel.swift b/Sources/Sidebar/SidebarLayoutModel.swift index 2f8d55953fa3..aa0bda8557af 100644 --- a/Sources/Sidebar/SidebarLayoutModel.swift +++ b/Sources/Sidebar/SidebarLayoutModel.swift @@ -24,6 +24,9 @@ final class SidebarLayoutModel: ObservableObject { /// The docked pane's host, so the toggle animator can start row /// animations at a show's first frame without a SwiftUI pass. weak var dockedPane: SidebarDockedPaneHost.ContainerView? + /// The titlebar title's host, which the toggle's slide glides between + /// its hidden and docked resting x. + weak var titlebarTitle: SidebarSlideGlideHost.ContainerView? /// How far the window ground reaches past the leading edge, so a toggle /// slide (which translates the content root by up to the sidebar width) diff --git a/Sources/Sidebar/SidebarSlideGlide.swift b/Sources/Sidebar/SidebarSlideGlide.swift new file mode 100644 index 000000000000..70d3b8c2b10a --- /dev/null +++ b/Sources/Sidebar/SidebarSlideGlide.swift @@ -0,0 +1,231 @@ +import AppKit +import Bonsplit +import SwiftUI + +/// Chrome inside the sliding content root that rests at a different x in +/// the docked and the hidden layout: the titlebar title (room for the +/// traffic lights and titlebar buttons when hidden, past the sidebar when +/// docked) and, in minimal mode, the first pane's tabs (room for the traffic +/// lights when hidden). The toggle's slide carries the content root +/// rigidly, so on its own such chrome would ride the slide with the hidden +/// layout's padding and snap at the landing. Each piece instead glides from +/// one resting x to the other in step with the spring, on the render server. +enum SidebarSlideGlide { + /// The extra translation, per point of content offset, that takes chrome + /// laid out at `hiddenLeading` (hidden layout, content offset 0) to + /// `dockedLeading` (docked layout, offset 0) as the content offset runs + /// from 0 to `sidebarWidth`. Linear in the offset, so one spring with the + /// slide's parameters draws it exactly. + static func factor(hiddenLeading: CGFloat, dockedLeading: CGFloat, sidebarWidth: CGFloat) -> Double { + guard sidebarWidth > 0 else { return 0 } + return Double((dockedLeading - hiddenLeading) / sidebarWidth - 1) + } + + /// One layer the slide moves by `factor` points per point of content + /// offset, on top of the content root's own translation. + struct Layer { + let layer: CALayer + let keyPath: String + let factor: Double + } +} + +extension ContentView { + /// Where the titlebar title rests in the hidden and in the docked layout. + /// Fullscreen and hidden, it also clears the always-visible titlebar + /// controls (their width plus the row's 8 pt spacing). + nonisolated static func titlebarTitleLeadings( + isFullScreen: Bool, + sidebarWidth: CGFloat, + minimumSidebarWidth: CGFloat, + titlebarLeadingInset: CGFloat, + reservedControlsWidth: CGFloat + ) -> (hidden: CGFloat, docked: CGFloat) { + func padding(visible: Bool) -> CGFloat { + customTitlebarLeadingPadding( + isFullScreen: isFullScreen, + isSidebarVisible: visible, + sidebarWidth: sidebarWidth, + minimumSidebarWidth: minimumSidebarWidth, + titlebarLeadingInset: titlebarLeadingInset + ) + } + let reserved = isFullScreen ? reservedControlsWidth + 8 : 0 + return (padding(visible: false) + reserved, padding(visible: true)) + } +} + +/// Hosts the titlebar title in its own layer so the toggle's slide can glide +/// it (`SidebarSlideGlide`). Clicks pass through to the titlebar's drag and +/// double-click surface except on AppKit views inside (the folder icon). +struct SidebarSlideGlideHost: NSViewRepresentable { + /// Leading x of this view in the hidden and in the docked layout. + let hiddenLeading: CGFloat + let dockedLeading: CGFloat + let layout: SidebarLayoutModel + let content: AnyView + + final class ContainerView: NSView { + let hostingView: NSHostingView + fileprivate(set) var hiddenLeading: CGFloat = 0 + fileprivate(set) var dockedLeading: CGFloat = 0 + + init(content: AnyView) { + hostingView = NSHostingView(rootView: content) + super.init(frame: .zero) + wantsLayer = true + hostingView.sizingOptions = [] + hostingView.autoresizingMask = [.width, .height] + hostingView.frame = bounds + addSubview(hostingView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + let hit = super.hitTest(point) + return hit === self || hit === hostingView ? nil : hit + } + + func glide(sidebarWidth: CGFloat) -> SidebarSlideGlide.Layer? { + let factor = SidebarSlideGlide.factor(hiddenLeading: hiddenLeading, dockedLeading: dockedLeading, sidebarWidth: sidebarWidth) + guard factor != 0, window != nil, let layer else { return nil } + return SidebarSlideGlide.Layer(layer: layer, keyPath: "sublayerTransform.translation.x", factor: factor) + } + } + + func makeNSView(context: Context) -> ContainerView { + let view = ContainerView(content: content) + layout.titlebarTitle = view + updateNSView(view, context: context) + return view + } + + func updateNSView(_ view: ContainerView, context: Context) { + view.hiddenLeading = hiddenLeading + view.dockedLeading = dockedLeading + view.hostingView.rootView = content + } +} + +/// Minimal mode's first-pane tabs, carried through a slide as a picture. +/// +/// The tabs live in Bonsplit's SwiftUI tree, which has no layer of their +/// own to move. So the slide takes a picture of them at the press, lays it +/// over a stretch of plain tab bar that hides the real tabs, and glides the +/// picture by the inset in step with the spring. Both go when the slide +/// lands, in the same commit that applies the landed layout's inset. +@MainActor +struct SidebarSlideTabRowCapture { + let picture: CGImage + let ground: CGImage + /// Where the plain ground and the picture sit, in the content root's + /// coordinates under the hidden layout. + let groundRect: NSRect + let pictureRect: NSRect + let factor: Double + + /// `docked` is the layout on screen now: true for a hide's press (taken + /// before the hidden layout commits), false for a show's. + static func capture(in reference: NSView, docked: Bool, inset: CGFloat, sidebarWidth: CGFloat) -> Self? { + guard inset > 0, sidebarWidth > 0 else { return nil } + var regions: [(pane: NSView, rect: NSRect)] = [] + func walk(_ view: NSView) { + if view is BonsplitTabItemHitRegionProviding, !view.isHiddenOrHasHiddenAncestor, + !view.visibleRect.isEmpty, let pane = paneHost(of: view) { + regions.append((pane, reference.convert(view.visibleRect, from: view))) + } + view.subviews.forEach(walk) + } + walk(reference) + // The inset belongs to the leading, topmost pane. + let flipped = reference.isFlipped + func paneRect(_ pane: NSView) -> NSRect { reference.convert(pane.bounds, from: pane) } + guard let pane = regions.map(\.pane).min(by: { lhs, rhs in + let l = paneRect(lhs), r = paneRect(rhs) + if abs(l.minX - r.minX) > 0.5 { return l.minX < r.minX } + return flipped ? l.minY < r.minY : l.maxY > r.maxY + }) else { return nil } + let paneFrame = paneRect(pane) + let rects = regions.filter { $0.pane === pane }.map(\.rect) + guard var tabs = rects.first else { return nil } + rects.dropFirst().forEach { tabs = tabs.union($0) } + let trailing = min(paneFrame.maxX, splitButtonsMinX(in: pane, reference: reference) ?? paneFrame.maxX) + tabs.size.width = min(tabs.width, trailing - tabs.minX) + guard tabs.width > 1, tabs.height > 1 else { return nil } + // A column of plain tab bar: inside the inset strip when hidden, + // past the last tab when docked. + let groundX = docked ? min(tabs.maxX + 1, trailing - 1) : min(paneFrame.minX + 1, tabs.minX - 1) + guard let picture = snapshot(reference, tabs), + let ground = snapshot(reference, NSRect(x: groundX, y: tabs.minY, width: 1, height: tabs.height)) else { + return nil + } + // Docked, the hidden layout moves the pane by the sidebar width and + // its tabs by the sidebar width less the inset. + let paneShift = docked ? -sidebarWidth : 0 + let tabsShift = docked ? inset - sidebarWidth : 0 + return Self( + picture: picture, + ground: ground, + groundRect: NSRect(x: paneFrame.minX + paneShift, y: tabs.minY, width: trailing - paneFrame.minX - paneShift, height: tabs.height), + pictureRect: tabs.offsetBy(dx: tabsShift, dy: 0), + // Hidden, the tabs rest `inset` past the pane; docked, at the + // pane, which the docked layout puts a sidebar width further on. + factor: SidebarSlideGlide.factor(hiddenLeading: inset, dockedLeading: sidebarWidth, sidebarWidth: sidebarWidth) + ) + } + + private static func paneHost(of view: NSView) -> NSView? { + var current = view.superview + while let candidate = current { + if NSStringFromClass(type(of: candidate)).contains("NonDraggableHostingView") { return candidate } + current = candidate.superview + } + return nil + } + + private static func splitButtonsMinX(in pane: NSView, reference: NSView) -> CGFloat? { + var xs: [CGFloat] = [] + func walk(_ view: NSView) { + if NSStringFromClass(type(of: view)).contains("SplitActionMouseDown"), !view.isHiddenOrHasHiddenAncestor { + xs.append(reference.convert(view.bounds, from: view).minX) + } + view.subviews.forEach(walk) + } + walk(pane) + return xs.min() + } + + private static func snapshot(_ view: NSView, _ rect: NSRect) -> CGImage? { + guard let rep = view.bitmapImageRepForCachingDisplay(in: rect) else { return nil } + view.cacheDisplay(in: rect, to: rep) + return rep.cgImage + } + + /// The overlay the slide moves with the content root, and the picture + /// layer inside it that glides on top of that. + func makeOverlay(above reference: NSView, in container: NSView) -> (view: NSView, glide: SidebarSlideGlide.Layer)? { + let view = PassthroughView(frame: container.convert(groundRect, from: reference)) + view.wantsLayer = true + container.addSubview(view, positioned: .above, relativeTo: reference) + guard let layer = view.layer else { + view.removeFromSuperview() + return nil + } + layer.contents = ground + layer.contentsGravity = .resize + let pictureLayer = CALayer() + pictureLayer.contents = picture + pictureLayer.contentsGravity = .resize + pictureLayer.frame = view.convert(pictureRect, from: reference) + layer.addSublayer(pictureLayer) + return (view, SidebarSlideGlide.Layer(layer: pictureLayer, keyPath: "transform.translation.x", factor: factor)) + } + + private final class PassthroughView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } +} diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 2bfd8ac02487..706bbdf0b419 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -27,6 +27,13 @@ final class SidebarToggleAnimator: ObservableObject { private var canSlide: () -> Bool = { false } private var trailingStillWidth: () -> CGFloat = { 0 } private var isPeekPresenting: () -> Bool = { false } + /// Applies what else follows the docked layout (minimal mode's tab bar + /// inset) inside the same atomic commit. + private var dockedLayoutWillCommit: (Bool) -> Void = { _ in } + /// How much further right the tab bar's first tab rests hidden. + private var tabBarInsetDelta: () -> CGFloat = { 0 } + /// A hide's tab row picture, taken before the hidden layout commits. + private var pendingTabRow: SidebarSlideTabRowCapture? private var machine = SidebarToggleSlideMachine(docked: true) /// What carries the running slide, captured when it starts and kept for /// every retarget until it lands. @@ -44,7 +51,9 @@ final class SidebarToggleAnimator: ObservableObject { window: @escaping () -> NSWindow?, canSlide: @escaping () -> Bool, trailingStillWidth: @escaping () -> CGFloat, - isPeekPresenting: @escaping () -> Bool + isPeekPresenting: @escaping () -> Bool, + dockedLayoutWillCommit: @escaping (Bool) -> Void, + tabBarInsetDelta: @escaping () -> CGFloat ) { self.sidebarState = sidebarState self.layout = layout @@ -52,6 +61,8 @@ final class SidebarToggleAnimator: ObservableObject { self.canSlide = canSlide self.trailingStillWidth = trailingStillWidth self.isPeekPresenting = isPeekPresenting + self.dockedLayoutWillCommit = dockedLayoutWillCommit + self.tabBarInsetDelta = tabBarInsetDelta // A re-install drops any running slide; `reset` keeps the generation // counting, so a late stop from the old slide stays stale. if session != nil { @@ -143,11 +154,14 @@ final class SidebarToggleAnimator: ObservableObject { } else { slide = nil } + pendingTabRow = captureTabRow(in: window, docked: true) commitAtomically(in: window) { layout?.docksSidebar = false + dockedLayoutWillCommit(false) } layers: { if let slide { addSlideAnimation(slide, in: window) } } + pendingTabRow = nil if let slide { machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) } case let .animate(slide): CATransaction.begin() @@ -159,6 +173,7 @@ final class SidebarToggleAnimator: ObservableObject { case .commitShownLayout: commitAtomically(in: window) { layout?.docksSidebar = true + dockedLayoutWillCommit(true) commitVisibility(true) } layers: { removeSlideAnimations() @@ -223,8 +238,15 @@ final class SidebarToggleAnimator: ObservableObject { // A visibility commit during the atomic commit's run loop pass can // drop the slide this was for; a session built for it would never land. guard machine.slide?.generation == slide.generation else { return } - if session == nil, let views = Self.slidingViews(in: window) { - session = SlideSession(views: views, trailingStillWidth: trailingStillWidth()) + if session == nil, let views = Self.slidingViews(in: window), let layout { + let tabRow = pendingTabRow ?? captureTabRow(in: window, docked: false) + pendingTabRow = nil + session = SlideSession( + views: views, + trailingStillWidth: trailingStillWidth(), + titleGlide: layout.titlebarTitle?.glide(sidebarWidth: layout.width), + tabRow: tabRow + ) } guard let session, !session.movingLayers.isEmpty else { // Nothing to move: land now, so the press still takes effect. @@ -233,8 +255,9 @@ final class SidebarToggleAnimator: ObservableObject { return } let distance = slide.to - slide.from + let velocity = distance == 0 ? 0 : slide.velocity / distance for (index, layer) in session.movingLayers.enumerated() { - let animation = slideSpring(from: slide.from, to: slide.to, velocity: distance == 0 ? 0 : slide.velocity / distance, duration: slide.duration) + let animation = slideSpring(from: slide.from, to: slide.to, velocity: velocity, duration: slide.duration) if index == 0 { animation.delegate = SlideLandingDelegate { [weak self] in self?.land(generation: slide.generation) @@ -245,14 +268,26 @@ final class SidebarToggleAnimator: ObservableObject { // The masks run the same spring backwards, so their edge stays put on // screen while the content under them moves. for mask in session.masks { - let animation = slideSpring(from: -slide.from, to: -slide.to, velocity: distance == 0 ? 0 : slide.velocity / distance, duration: slide.duration) + let animation = slideSpring(from: -slide.from, to: -slide.to, velocity: velocity, duration: slide.duration) mask.add(animation, forKey: Self.animationKey) } + // Chrome that rests elsewhere in the two layouts glides on top of the + // content root's motion, by the same spring scaled (`SidebarSlideGlide`). + for glide in session.glides { + let animation = slideSpring(from: slide.from * glide.factor, to: slide.to * glide.factor, velocity: velocity, duration: slide.duration, keyPath: glide.keyPath) + glide.layer.add(animation, forKey: Self.animationKey) + } + } + + private func captureTabRow(in window: NSWindow, docked: Bool) -> SidebarSlideTabRowCapture? { + guard let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView, + let layout else { return nil } + return SidebarSlideTabRowCapture.capture(in: reference, docked: docked, inset: tabBarInsetDelta(), sidebarWidth: layout.width) } - private func slideSpring(from: Double, to: Double, velocity: Double, duration: Double) -> CASpringAnimation { + private func slideSpring(from: Double, to: Double, velocity: Double, duration: Double, keyPath: String = "transform.translation.x") -> CASpringAnimation { let spring = machine.spring - let animation = CASpringAnimation(keyPath: "transform.translation.x") + let animation = CASpringAnimation(keyPath: keyPath) animation.fromValue = from animation.toValue = to animation.mass = 1 @@ -369,11 +404,26 @@ final class SidebarToggleAnimator: ObservableObject { /// terminal reads as sliding under the right sidebar, which never moves. @MainActor private final class SlideSession { - let movingLayers: [CALayer] + private(set) var movingLayers: [CALayer] let masks: [CALayer] + private(set) var glides: [SidebarSlideGlide.Layer] = [] private let stillOverlay: NSView? + private var tabRowOverlay: NSView? + + convenience init(views: [NSView], trailingStillWidth: CGFloat, titleGlide: SidebarSlideGlide.Layer?, tabRow: SidebarSlideTabRowCapture?) { + self.init(views: views, trailingStillWidth: trailingStillWidth) + glides = [titleGlide].compactMap { $0 } + // The tab row picture rides with the content root (below the right + // sidebar's still snapshot) and glides inside it. + if let tabRow, let reference = views.first, let container = reference.superview, + let overlay = tabRow.makeOverlay(above: reference, in: container), let layer = overlay.view.layer { + tabRowOverlay = overlay.view + movingLayers.append(layer) + glides.append(overlay.glide) + } + } - init(views: [NSView], trailingStillWidth: CGFloat) { + private init(views: [NSView], trailingStillWidth: CGFloat) { movingLayers = views.compactMap(\.layer) guard trailingStillWidth > 0, let reference = views.first, @@ -418,6 +468,8 @@ private final class SlideSession { layer.removeAnimation(forKey: animationKey) layer.mask = nil } + glides.forEach { $0.layer.removeAnimation(forKey: animationKey) } + tabRowOverlay?.removeFromSuperview() stillOverlay?.removeFromSuperview() } } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index dc0c2abc7950..43a5d3d348ea 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3370,6 +3370,7 @@ B804A0280000000000000028 /* SidebarSelectionCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0280000000000000028 /* SidebarSelectionCoalescer.swift */; }; B804C0020000000000000002 /* SidebarSelectionCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; + B8DE2DF68EBAC8B061BE60C8 /* SidebarSlideGlide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */; }; C12791000000000000000007 /* SidebarSnapshotOwnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */; }; F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; D7344F010000000000000001 /* SidebarTabDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7344F010000000000000002 /* SidebarTabDragPayload.swift */; }; @@ -7842,6 +7843,7 @@ B804B0280000000000000028 /* SidebarSelectionCoalescer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarSelectionCoalescer.swift; sourceTree = ""; }; B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionCoalescerTests.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; + 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarSlideGlide.swift; sourceTree = ""; }; C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSnapshotOwnerTests.swift; sourceTree = ""; }; D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; D7344F010000000000000002 /* SidebarTabDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarTabDragPayload.swift; sourceTree = ""; }; @@ -10270,6 +10272,7 @@ 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */, 3FAC0DA415898D27426B2292 /* SidebarDockedPaneHost.swift */, 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */, + 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */, 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */, 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */, A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */, @@ -16274,6 +16277,7 @@ 5A1180B9BE6B4395A220318A /* SidebarSearchFieldView.swift in Sources */, B804A0280000000000000028 /* SidebarSelectionCoalescer.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, + B8DE2DF68EBAC8B061BE60C8 /* SidebarSlideGlide.swift in Sources */, F57072635F25EBCA741E125D /* SidebarState.swift in Sources */, D7344F010000000000000001 /* SidebarTabDragPayload.swift in Sources */, F7216006000000000000001 /* SidebarTabItemContextMenuState.swift in Sources */, diff --git a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift index 0aee2523676d..287b520b6ae7 100644 --- a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift +++ b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift @@ -275,3 +275,75 @@ struct SidebarToggleSlideMachineTests { #expect(landing < 0.25) } } + +/// Chrome that rests at a different x in the docked and hidden layouts +/// glides between them in step with the slide: at content offset 0 it sits +/// at its hidden rest, at the full sidebar width at its docked rest, so +/// neither the keypress commit nor the landing commit moves it. +@Suite +struct SidebarSlideGlideTests { + /// Where the glided chrome is on screen under the hidden layout. + private func onScreen(hidden: CGFloat, factor: Double, offset: Double) -> Double { + Double(hidden) + offset + offset * factor + } + + @Test + func titleGlidesFromItsHiddenRestToItsDockedRest() { + for (width, titlebarInset) in [(240.0, 82.0), (320, 82), (180, 300)] { + let leading = ContentView.titlebarTitleLeadings( + isFullScreen: false, + sidebarWidth: width, + minimumSidebarWidth: 180, + titlebarLeadingInset: titlebarInset, + reservedControlsWidth: 120 + ) + let factor = SidebarSlideGlide.factor(hiddenLeading: leading.hidden, dockedLeading: leading.docked, sidebarWidth: width) + #expect(onScreen(hidden: leading.hidden, factor: factor, offset: 0) == Double(leading.hidden)) + #expect(abs(onScreen(hidden: leading.hidden, factor: factor, offset: width) - Double(leading.docked)) < 1e-9) + } + } + + @Test + func titleThatRestsInPlaceHoldsStillWhileTheContentSlides() { + // A minimum-width sidebar leaves the title where the titlebar + // buttons put it in both layouts: it cancels the slide exactly. + let leading = ContentView.titlebarTitleLeadings( + isFullScreen: false, + sidebarWidth: 180, + minimumSidebarWidth: 180, + titlebarLeadingInset: 300, + reservedControlsWidth: 0 + ) + #expect(leading.hidden == leading.docked) + #expect(SidebarSlideGlide.factor(hiddenLeading: leading.hidden, dockedLeading: leading.docked, sidebarWidth: 180) == -1) + } + + @Test + func fullscreenHiddenTitleClearsTheAlwaysVisibleControls() { + let leading = ContentView.titlebarTitleLeadings( + isFullScreen: true, + sidebarWidth: 240, + minimumSidebarWidth: 180, + titlebarLeadingInset: 82, + reservedControlsWidth: 120 + ) + #expect(leading.hidden == 8 + 120 + 8) + #expect(leading.docked == 252) + } + + @Test + func minimalModeTabsGlideFromPastTheTrafficLightsToTheSidebarEdge() { + let width = 240.0, inset = 80.0, paneLeading = 6.0 + let factor = SidebarSlideGlide.factor(hiddenLeading: inset, dockedLeading: width, sidebarWidth: width) + #expect(factor == -inset / width) + // Hidden layout: the tabs rest `inset` past the pane; landed, at the + // docked pane's leading edge, a sidebar width further on. + #expect(onScreen(hidden: paneLeading + inset, factor: factor, offset: 0) == paneLeading + inset) + #expect(abs(onScreen(hidden: paneLeading + inset, factor: factor, offset: width) - (paneLeading + width)) < 1e-9) + } + + @Test + func noSidebarWidthMeansNoGlide() { + #expect(SidebarSlideGlide.factor(hiddenLeading: 10, dockedLeading: 200, sidebarWidth: 0) == 0) + } +} From a7e958c3111bf32a633560ed2a0ed719a75e8512 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 17:20:06 +1300 Subject: [PATCH 02/15] sidebar toggle: trailing pane buttons hold still through the slide the slide carries the full-width layout past the content's trailing edge, so the action buttons of panes on that edge slid out under the right sidebar and popped back at a show's landing, and vanished at a hide's press. they rest at the same x in both layouts, so the slide now shows a still picture of them in place, taken at the press, and drops it in the landing commit. also fixes the pane lookup the minimal tab row picture relies on (split leaves are plain hosting views in a split slot, and the tab bar background shares the tab hit region protocol), keeps pictures through display passes, and moves the slide session into its own file. Co-Authored-By: Claude Opus 5.5 --- Sources/ContentView+SidebarPeek.swift | 5 +- Sources/Sidebar/SidebarSlideGlide.swift | 119 ++++++++++++++++-- Sources/Sidebar/SidebarToggleAnimator.swift | 104 +++------------ .../Sidebar/SidebarToggleSlideSession.swift | 92 ++++++++++++++ cmux.xcodeproj/project.pbxproj | 4 + 5 files changed, 231 insertions(+), 93 deletions(-) create mode 100644 Sources/Sidebar/SidebarToggleSlideSession.swift diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index b34aaf872388..f1b4e8550660 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -240,7 +240,10 @@ extension ContentView { trailingStillWidth: { rightSidebarWidth }, isPeekPresenting: { sidebarPeek.presentsPanel }, dockedLayoutWillCommit: { docked in syncTrafficLightInset(docked: docked) }, - tabBarInsetDelta: { tabBarLeadingInset(docked: false) - tabBarLeadingInset(docked: true) } + tabBarInsetDelta: { tabBarLeadingInset(docked: false) - tabBarLeadingInset(docked: true) }, + splitButtonCount: { + tabManager.selectedWorkspace?.bonsplitController.configuration.appearance.splitButtons.count ?? 0 + } ) sidebarPeek.setPolicy(SidebarCustomizationSettings.peekPolicy()) } diff --git a/Sources/Sidebar/SidebarSlideGlide.swift b/Sources/Sidebar/SidebarSlideGlide.swift index 70d3b8c2b10a..5234e1ae7bbc 100644 --- a/Sources/Sidebar/SidebarSlideGlide.swift +++ b/Sources/Sidebar/SidebarSlideGlide.swift @@ -134,7 +134,7 @@ struct SidebarSlideTabRowCapture { guard inset > 0, sidebarWidth > 0 else { return nil } var regions: [(pane: NSView, rect: NSRect)] = [] func walk(_ view: NSView) { - if view is BonsplitTabItemHitRegionProviding, !view.isHiddenOrHasHiddenAncestor, + if isTabItemRegion(view), !view.isHiddenOrHasHiddenAncestor, !view.visibleRect.isEmpty, let pane = paneHost(of: view) { regions.append((pane, reference.convert(view.visibleRect, from: view))) } @@ -178,10 +178,26 @@ struct SidebarSlideTabRowCapture { ) } - private static func paneHost(of view: NSView) -> NSView? { + /// One tab's hit region (the tab bar's background view shares the + /// protocol but spans the whole pane). + static func isTabItemRegion(_ view: NSView) -> Bool { + view is BonsplitTabItemHitRegionProviding && NSStringFromClass(type(of: view)).contains("TabItemHitRegionView") + } + + /// One side of a Bonsplit split. + static func isSplitSlot(_ view: NSView) -> Bool { + NSStringFromClass(type(of: view)).contains("SplitArrangedContainerView") + } + + static func paneHost(of view: NSView) -> NSView? { var current = view.superview while let candidate = current { - if NSStringFromClass(type(of: candidate)).contains("NonDraggableHostingView") { return candidate } + // A split's leaf (the hosting view in a split slot), or a lone + // pane's own hosting view. + if candidate.superview.map(isSplitSlot) == true + || NSStringFromClass(type(of: candidate)).contains("PaneContainerView") { + return candidate + } current = candidate.superview } return nil @@ -199,23 +215,33 @@ struct SidebarSlideTabRowCapture { return xs.min() } - private static func snapshot(_ view: NSView, _ rect: NSRect) -> CGImage? { + static func snapshot(_ view: NSView, _ rect: NSRect) -> CGImage? { guard let rep = view.bitmapImageRepForCachingDisplay(in: rect) else { return nil } view.cacheDisplay(in: rect, to: rep) +#if DEBUG + if let dir = ProcessInfo.processInfo.environment["CMUX_SIDEBAR_SLIDE_DUMP"], let data = rep.representation(using: .png, properties: [:]) { + dumpIndex += 1 + try? data.write(to: URL(fileURLWithPath: dir).appendingPathComponent("snap-\(dumpIndex)-\(Int(rect.minX))x\(Int(rect.minY)).png")) + } + SidebarNavigationTimings.record("slide.snapshot rect=\(rect)") +#endif return rep.cgImage } +#if DEBUG + private static var dumpIndex = 0 +#endif /// The overlay the slide moves with the content root, and the picture /// layer inside it that glides on top of that. func makeOverlay(above reference: NSView, in container: NSView) -> (view: NSView, glide: SidebarSlideGlide.Layer)? { - let view = PassthroughView(frame: container.convert(groundRect, from: reference)) + let view = SidebarSlidePassthroughView(frame: container.convert(groundRect, from: reference)) view.wantsLayer = true container.addSubview(view, positioned: .above, relativeTo: reference) guard let layer = view.layer else { view.removeFromSuperview() return nil } - layer.contents = ground + view.image = ground layer.contentsGravity = .resize let pictureLayer = CALayer() pictureLayer.contents = picture @@ -224,8 +250,85 @@ struct SidebarSlideTabRowCapture { layer.addSublayer(pictureLayer) return (view, SidebarSlideGlide.Layer(layer: pictureLayer, keyPath: "transform.translation.x", factor: factor)) } +} - private final class PassthroughView: NSView { - override func hitTest(_ point: NSPoint) -> NSView? { nil } +/// The action buttons on the trailing end of each pane that ends at the +/// content's trailing edge rest at the same x in both layouts. The slide +/// carries the full-width layout past that edge, though, so on their own +/// they would slide out under the right sidebar (or the window edge) and +/// pop back at the landing, or vanish at a hide's press and slide back in. +/// The slide shows a still picture of them in place instead, taken at the +/// press, over the real ones, and drops it in the commit that lands. +@MainActor +struct SidebarSlideTrailingChromeCapture { + let pictures: [(image: CGImage, rect: NSRect)] + + /// Bonsplit's action lane: 6 pt leading and 8 pt trailing padding, 22 pt + /// per button and 4 pt between them, all shown up to five buttons. + nonisolated static func laneWidth(buttonCount: Int, paneWidth: CGFloat) -> CGFloat { + guard buttonCount > 0 else { return 0 } + func width(_ count: Int) -> CGFloat { 10 + 26 * CGFloat(count) } + guard buttonCount > 5 else { return width(buttonCount) } + return min(width(buttonCount), max(paneWidth / 4, width(5))) } + + static func capture(in reference: NSView, buttonCount: Int) -> Self? { + guard buttonCount > 0 else { +#if DEBUG + SidebarNavigationTimings.record("slide.trailing buttons=0") +#endif + return nil + } + var rows: [ObjectIdentifier: (pane: NSRect, tabs: NSRect)] = [:] + func walk(_ view: NSView) { + if SidebarSlideTabRowCapture.isTabItemRegion(view), !view.isHiddenOrHasHiddenAncestor, + !view.visibleRect.isEmpty, let pane = SidebarSlideTabRowCapture.paneHost(of: view) { + let rect = reference.convert(view.visibleRect, from: view) + let key = ObjectIdentifier(pane) + rows[key] = (reference.convert(pane.bounds, from: pane), rows[key].map { $0.tabs.union(rect) } ?? rect) + } + view.subviews.forEach(walk) + } + walk(reference) + guard let trailing = rows.values.map(\.pane.maxX).max() else { return nil } + var pictures: [(image: CGImage, rect: NSRect)] = [] + for row in rows.values where row.pane.maxX > trailing - 1 { + let lane = laneWidth(buttonCount: buttonCount, paneWidth: row.pane.width) + let rect = NSRect(x: row.pane.maxX - lane, y: row.tabs.minY, width: lane, height: row.tabs.height) + if let image = SidebarSlideTabRowCapture.snapshot(reference, rect) { pictures.append((image, rect)) } + } +#if DEBUG + SidebarNavigationTimings.record("slide.trailing rows=\(rows.count) pictures=\(pictures.count) buttons=\(buttonCount)") +#endif + return pictures.isEmpty ? nil : Self(pictures: pictures) + } + + func makeOverlays(above reference: NSView, in container: NSView) -> [NSView] { + pictures.map { picture in + let view = SidebarSlidePassthroughView(frame: container.convert(picture.rect, from: reference)) + view.wantsLayer = true + view.image = picture.image + view.layer?.contentsGravity = .resize + container.addSubview(view, positioned: .above, relativeTo: nil) + return view + } + } +} + +/// Shows a picture and takes no clicks. Drawing goes through `updateLayer`, +/// so a display pass (the atomic commit runs one) keeps the picture instead +/// of repainting the layer with an empty `draw(_:)`. +private final class SidebarSlidePassthroughView: NSView { + var image: CGImage? { + didSet { layer?.contents = image } + } + + override var wantsUpdateLayer: Bool { true } + + override func updateLayer() { + layer?.contents = image + layer?.contentsGravity = .resize + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } } diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 706bbdf0b419..e031a21c2738 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -32,12 +32,15 @@ final class SidebarToggleAnimator: ObservableObject { private var dockedLayoutWillCommit: (Bool) -> Void = { _ in } /// How much further right the tab bar's first tab rests hidden. private var tabBarInsetDelta: () -> CGFloat = { 0 } - /// A hide's tab row picture, taken before the hidden layout commits. + /// How many action buttons a pane's tab bar shows on its trailing end. + private var splitButtonCount: () -> Int = { 0 } + /// A hide's pictures, taken before the hidden layout commits. private var pendingTabRow: SidebarSlideTabRowCapture? + private var pendingTrailingChrome: SidebarSlideTrailingChromeCapture? private var machine = SidebarToggleSlideMachine(docked: true) /// What carries the running slide, captured when it starts and kept for /// every retarget until it lands. - private var session: SlideSession? + private var session: SidebarToggleSlideSession? /// Effects are being carried out; a press arriving meanwhile (the atomic /// commit runs the run loop once) waits its turn. private var isExecuting = false @@ -53,7 +56,8 @@ final class SidebarToggleAnimator: ObservableObject { trailingStillWidth: @escaping () -> CGFloat, isPeekPresenting: @escaping () -> Bool, dockedLayoutWillCommit: @escaping (Bool) -> Void, - tabBarInsetDelta: @escaping () -> CGFloat + tabBarInsetDelta: @escaping () -> CGFloat, + splitButtonCount: @escaping () -> Int ) { self.sidebarState = sidebarState self.layout = layout @@ -63,6 +67,7 @@ final class SidebarToggleAnimator: ObservableObject { self.isPeekPresenting = isPeekPresenting self.dockedLayoutWillCommit = dockedLayoutWillCommit self.tabBarInsetDelta = tabBarInsetDelta + self.splitButtonCount = splitButtonCount // A re-install drops any running slide; `reset` keeps the generation // counting, so a late stop from the old slide stays stale. if session != nil { @@ -155,6 +160,7 @@ final class SidebarToggleAnimator: ObservableObject { slide = nil } pendingTabRow = captureTabRow(in: window, docked: true) + pendingTrailingChrome = captureTrailingChrome(in: window) commitAtomically(in: window) { layout?.docksSidebar = false dockedLayoutWillCommit(false) @@ -162,6 +168,7 @@ final class SidebarToggleAnimator: ObservableObject { if let slide { addSlideAnimation(slide, in: window) } } pendingTabRow = nil + pendingTrailingChrome = nil if let slide { machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) } case let .animate(slide): CATransaction.begin() @@ -240,12 +247,15 @@ final class SidebarToggleAnimator: ObservableObject { guard machine.slide?.generation == slide.generation else { return } if session == nil, let views = Self.slidingViews(in: window), let layout { let tabRow = pendingTabRow ?? captureTabRow(in: window, docked: false) + let trailingChrome = pendingTrailingChrome ?? captureTrailingChrome(in: window) pendingTabRow = nil - session = SlideSession( + pendingTrailingChrome = nil + session = SidebarToggleSlideSession( views: views, trailingStillWidth: trailingStillWidth(), titleGlide: layout.titlebarTitle?.glide(sidebarWidth: layout.width), - tabRow: tabRow + tabRow: tabRow, + trailingChrome: trailingChrome ) } guard let session, !session.movingLayers.isEmpty else { @@ -285,6 +295,11 @@ final class SidebarToggleAnimator: ObservableObject { return SidebarSlideTabRowCapture.capture(in: reference, docked: docked, inset: tabBarInsetDelta(), sidebarWidth: layout.width) } + private func captureTrailingChrome(in window: NSWindow) -> SidebarSlideTrailingChromeCapture? { + guard let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView else { return nil } + return SidebarSlideTrailingChromeCapture.capture(in: reference, buttonCount: splitButtonCount()) + } + private func slideSpring(from: Double, to: Double, velocity: Double, duration: Double, keyPath: String = "transform.translation.x") -> CASpringAnimation { let spring = machine.spring let animation = CASpringAnimation(keyPath: keyPath) @@ -395,85 +410,6 @@ final class SidebarToggleAnimator: ObservableObject { #endif } -/// The layers a slide moves, plus what keeps an open right sidebar still. -/// -/// The right sidebar lives in the moving content root. While a slide runs, -/// every moving layer is masked off at the right sidebar's leading edge (the -/// mask runs the same spring backwards, so the edge stays put on screen) and -/// a snapshot of the column is shown in its place, above everything. The -/// terminal reads as sliding under the right sidebar, which never moves. -@MainActor -private final class SlideSession { - private(set) var movingLayers: [CALayer] - let masks: [CALayer] - private(set) var glides: [SidebarSlideGlide.Layer] = [] - private let stillOverlay: NSView? - private var tabRowOverlay: NSView? - - convenience init(views: [NSView], trailingStillWidth: CGFloat, titleGlide: SidebarSlideGlide.Layer?, tabRow: SidebarSlideTabRowCapture?) { - self.init(views: views, trailingStillWidth: trailingStillWidth) - glides = [titleGlide].compactMap { $0 } - // The tab row picture rides with the content root (below the right - // sidebar's still snapshot) and glides inside it. - if let tabRow, let reference = views.first, let container = reference.superview, - let overlay = tabRow.makeOverlay(above: reference, in: container), let layer = overlay.view.layer { - tabRowOverlay = overlay.view - movingLayers.append(layer) - glides.append(overlay.glide) - } - } - - private init(views: [NSView], trailingStillWidth: CGFloat) { - movingLayers = views.compactMap(\.layer) - guard trailingStillWidth > 0, - let reference = views.first, - let container = reference.superview else { - masks = [] - stillOverlay = nil - return - } - let stillRect = NSRect( - x: reference.bounds.maxX - trailingStillWidth, - y: reference.bounds.minY, - width: trailingStillWidth, - height: reference.bounds.height - ) - if let rep = reference.bitmapImageRepForCachingDisplay(in: stillRect) { - reference.cacheDisplay(in: stillRect, to: rep) - let overlay = NSView(frame: container.convert(stillRect, from: reference)) - overlay.wantsLayer = true - overlay.layer?.contents = rep.cgImage - overlay.layer?.contentsGravity = .resize - container.addSubview(overlay, positioned: .above, relativeTo: nil) - stillOverlay = overlay - } else { - stillOverlay = nil - } - let bleed: CGFloat = 10_000 - var masks: [CALayer] = [] - for view in views { - guard let layer = view.layer else { continue } - let edge = view.convert(NSPoint(x: stillRect.minX, y: 0), from: reference).x - let mask = CALayer() - mask.backgroundColor = NSColor.black.cgColor - mask.frame = CGRect(x: -bleed, y: -bleed, width: edge + bleed, height: layer.bounds.height + 2 * bleed) - layer.mask = mask - masks.append(mask) - } - self.masks = masks - } - - func tearDown(animationKey: String) { - for layer in movingLayers { - layer.removeAnimation(forKey: animationKey) - layer.mask = nil - } - glides.forEach { $0.layer.removeAnimation(forKey: animationKey) } - tabRowOverlay?.removeFromSuperview() - stillOverlay?.removeFromSuperview() - } -} - /// Lands the slide when its spring stops. Core Animation retains its /// delegate; the closure holds the animator weakly. /// diff --git a/Sources/Sidebar/SidebarToggleSlideSession.swift b/Sources/Sidebar/SidebarToggleSlideSession.swift new file mode 100644 index 000000000000..40e82213e47f --- /dev/null +++ b/Sources/Sidebar/SidebarToggleSlideSession.swift @@ -0,0 +1,92 @@ +import AppKit +import QuartzCore + +/// The layers a slide moves, plus what keeps an open right sidebar still. +/// +/// The right sidebar lives in the moving content root. While a slide runs, +/// every moving layer is masked off at the right sidebar's leading edge (the +/// mask runs the same spring backwards, so the edge stays put on screen) and +/// a snapshot of the column is shown in its place, above everything. The +/// terminal reads as sliding under the right sidebar, which never moves. +@MainActor +final class SidebarToggleSlideSession { + private(set) var movingLayers: [CALayer] + let masks: [CALayer] + private(set) var glides: [SidebarSlideGlide.Layer] = [] + private let stillOverlay: NSView? + private var tabRowOverlay: NSView? + private var stillChrome: [NSView] = [] + + convenience init( + views: [NSView], + trailingStillWidth: CGFloat, + titleGlide: SidebarSlideGlide.Layer?, + tabRow: SidebarSlideTabRowCapture?, + trailingChrome: SidebarSlideTrailingChromeCapture? + ) { + self.init(views: views, trailingStillWidth: trailingStillWidth) + if let reference = views.first, let container = reference.superview { + stillChrome = trailingChrome?.makeOverlays(above: reference, in: container) ?? [] + } + glides = [titleGlide].compactMap { $0 } + // The tab row picture rides with the content root (below the right + // sidebar's still snapshot) and glides inside it. + if let tabRow, let reference = views.first, let container = reference.superview, + let overlay = tabRow.makeOverlay(above: reference, in: container), let layer = overlay.view.layer { + tabRowOverlay = overlay.view + movingLayers.append(layer) + glides.append(overlay.glide) + } + } + + private init(views: [NSView], trailingStillWidth: CGFloat) { + movingLayers = views.compactMap(\.layer) + guard trailingStillWidth > 0, + let reference = views.first, + let container = reference.superview else { + masks = [] + stillOverlay = nil + return + } + let stillRect = NSRect( + x: reference.bounds.maxX - trailingStillWidth, + y: reference.bounds.minY, + width: trailingStillWidth, + height: reference.bounds.height + ) + if let rep = reference.bitmapImageRepForCachingDisplay(in: stillRect) { + reference.cacheDisplay(in: stillRect, to: rep) + let overlay = NSView(frame: container.convert(stillRect, from: reference)) + overlay.wantsLayer = true + overlay.layer?.contents = rep.cgImage + overlay.layer?.contentsGravity = .resize + container.addSubview(overlay, positioned: .above, relativeTo: nil) + stillOverlay = overlay + } else { + stillOverlay = nil + } + let bleed: CGFloat = 10_000 + var masks: [CALayer] = [] + for view in views { + guard let layer = view.layer else { continue } + let edge = view.convert(NSPoint(x: stillRect.minX, y: 0), from: reference).x + let mask = CALayer() + mask.backgroundColor = NSColor.black.cgColor + mask.frame = CGRect(x: -bleed, y: -bleed, width: edge + bleed, height: layer.bounds.height + 2 * bleed) + layer.mask = mask + masks.append(mask) + } + self.masks = masks + } + + func tearDown(animationKey: String) { + for layer in movingLayers { + layer.removeAnimation(forKey: animationKey) + layer.mask = nil + } + glides.forEach { $0.layer.removeAnimation(forKey: animationKey) } + tabRowOverlay?.removeFromSuperview() + stillChrome.forEach { $0.removeFromSuperview() } + stillOverlay?.removeFromSuperview() + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 43a5d3d348ea..b792d84e2344 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3381,6 +3381,7 @@ 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */; }; 5B1D66585F4ECB32768ACE69 /* SidebarToggleSlideMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */; }; E50824578CDCF63DB210D64F /* SidebarToggleSlideProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */; }; + DDCDEB2A91D3671D8A6EA2AA /* SidebarToggleSlideSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2902B955A8F8604A2E6174E4 /* SidebarToggleSlideSession.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430B0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift */; }; EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */; }; @@ -7854,6 +7855,7 @@ 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleAnimator.swift; sourceTree = ""; }; 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideMachine.swift; sourceTree = ""; }; 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideProbe.swift; sourceTree = ""; }; + 2902B955A8F8604A2E6174E4 /* SidebarToggleSlideSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideSession.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; 8430B0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceBranchDirectorySettings.swift; sourceTree = ""; }; BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceChecklistPopover.swift"; sourceTree = ""; }; @@ -10272,6 +10274,7 @@ 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */, 3FAC0DA415898D27426B2292 /* SidebarDockedPaneHost.swift */, 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */, + 2902B955A8F8604A2E6174E4 /* SidebarToggleSlideSession.swift */, 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */, 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */, 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */, @@ -16286,6 +16289,7 @@ 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */, 5B1D66585F4ECB32768ACE69 /* SidebarToggleSlideMachine.swift in Sources */, E50824578CDCF63DB210D64F /* SidebarToggleSlideProbe.swift in Sources */, + DDCDEB2A91D3671D8A6EA2AA /* SidebarToggleSlideSession.swift in Sources */, 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */, EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */, B290FAD92C810ACB2D9CD9C6 /* SidebarWorkspaceChecklistView.swift in Sources */, From 8a8aeed3db2dc78ff8b3594082a720e534a2814b Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 17:38:20 +1300 Subject: [PATCH 03/15] sidebar toggle: every pane glides to its own docked edges bonsplit keeps split ratios, so the hidden layout's panes are wider in proportion and one rigid slide carried every edge past the leading one too far: a show threw an inner divider, the pane right of it and the left pane's buttons about 120 pt past their place for a 50/50 split and snapped them back at the landing, a hide the mirror. now each split slot (and a lone pane) moves by its own leading edge relative to the slot around it and clips at its own trailing edge, the portal's terminal and browser views follow their pane, divider strips in each split's colour ride the gaps (the portal's divider overlay is faded out meanwhile), and each pane's action buttons ride its trailing edge as a picture. all of it is spring animations in the same transaction, render server only. the docked rects come from the hide's press, from the last hide over the same hidden layout, or failing both from bonsplit's rule. adds a DEBUG per-frame edge log (CMUX_SIDEBAR_SLIDE_EDGES) for checking the motion without screenshots. Co-Authored-By: Claude Opus 5.5 --- Sources/Sidebar/SidebarSlideGlide.swift | 24 +- Sources/Sidebar/SidebarSlidePaneGlide.swift | 344 ++++++++++++++++++ Sources/Sidebar/SidebarToggleAnimator.swift | 51 +-- Sources/Sidebar/SidebarToggleSlideEdges.swift | 50 +++ Sources/Sidebar/SidebarToggleSlideProbe.swift | 3 + .../Sidebar/SidebarToggleSlideSession.swift | 36 +- cmux.xcodeproj/project.pbxproj | 8 + 7 files changed, 484 insertions(+), 32 deletions(-) create mode 100644 Sources/Sidebar/SidebarSlidePaneGlide.swift create mode 100644 Sources/Sidebar/SidebarToggleSlideEdges.swift diff --git a/Sources/Sidebar/SidebarSlideGlide.swift b/Sources/Sidebar/SidebarSlideGlide.swift index 5234e1ae7bbc..324715237ffe 100644 --- a/Sources/Sidebar/SidebarSlideGlide.swift +++ b/Sources/Sidebar/SidebarSlideGlide.swift @@ -23,10 +23,12 @@ enum SidebarSlideGlide { /// One layer the slide moves by `factor` points per point of content /// offset, on top of the content root's own translation. + /// The animated value is `base + factor * offset`. struct Layer { let layer: CALayer let keyPath: String let factor: Double + var base: Double = 0 } } @@ -261,7 +263,10 @@ struct SidebarSlideTabRowCapture { /// press, over the real ones, and drops it in the commit that lands. @MainActor struct SidebarSlideTrailingChromeCapture { - let pictures: [(image: CGImage, rect: NSRect)] + /// Every pane's action lane, keyed by the pane's hosting view. + let lanes: [ObjectIdentifier: (image: CGImage, rect: NSRect)] + /// The panes that end at the content's trailing edge. + let trailingEdge: Set /// Bonsplit's action lane: 6 pt leading and 8 pt trailing padding, 22 pt /// per button and 4 pt between them, all shown up to five buttons. @@ -291,20 +296,25 @@ struct SidebarSlideTrailingChromeCapture { } walk(reference) guard let trailing = rows.values.map(\.pane.maxX).max() else { return nil } - var pictures: [(image: CGImage, rect: NSRect)] = [] - for row in rows.values where row.pane.maxX > trailing - 1 { + var lanes: [ObjectIdentifier: (image: CGImage, rect: NSRect)] = [:] + var trailingEdge: Set = [] + for (id, row) in rows { let lane = laneWidth(buttonCount: buttonCount, paneWidth: row.pane.width) let rect = NSRect(x: row.pane.maxX - lane, y: row.tabs.minY, width: lane, height: row.tabs.height) - if let image = SidebarSlideTabRowCapture.snapshot(reference, rect) { pictures.append((image, rect)) } + guard let image = SidebarSlideTabRowCapture.snapshot(reference, rect) else { continue } + lanes[id] = (image, rect) + if row.pane.maxX > trailing - 1 { trailingEdge.insert(id) } } #if DEBUG - SidebarNavigationTimings.record("slide.trailing rows=\(rows.count) pictures=\(pictures.count) buttons=\(buttonCount)") + SidebarNavigationTimings.record("slide.trailing rows=\(rows.count) lanes=\(lanes.count) buttons=\(buttonCount)") #endif - return pictures.isEmpty ? nil : Self(pictures: pictures) + return lanes.isEmpty ? nil : Self(lanes: lanes, trailingEdge: trailingEdge) } + /// Fallback without per-pane motion: still pictures of the trailing-edge + /// panes' lanes, which rest at the same x in both layouts. func makeOverlays(above reference: NSView, in container: NSView) -> [NSView] { - pictures.map { picture in + lanes.filter { trailingEdge.contains($0.key) }.values.map { picture in let view = SidebarSlidePassthroughView(frame: container.convert(picture.rect, from: reference)) view.wantsLayer = true view.image = picture.image diff --git a/Sources/Sidebar/SidebarSlidePaneGlide.swift b/Sources/Sidebar/SidebarSlidePaneGlide.swift new file mode 100644 index 000000000000..90d2394ec42d --- /dev/null +++ b/Sources/Sidebar/SidebarSlidePaneGlide.swift @@ -0,0 +1,344 @@ +import AppKit +import QuartzCore + +/// Where Bonsplit's panes and split views sit, in the content root's +/// coordinates, for one layout (docked or hidden). +/// +/// Bonsplit keeps split ratios, so the hidden layout's panes are wider in +/// proportion and every pane edge but the leading one rests elsewhere than +/// the docked edge shifted by the sidebar width. +@MainActor +struct SidebarSlidePaneLayout { + struct Split { + let rect: NSRect + let first: NSRect + let second: NSRect + let isVertical: Bool + let thickness: CGFloat + } + + private(set) var panes: [ObjectIdentifier: NSRect] = [:] + private(set) var splits: [ObjectIdentifier: Split] = [:] + /// Each side of each split (a Bonsplit slot), which carries everything + /// drawn inside it. + private(set) var slots: [ObjectIdentifier: NSRect] = [:] + private var views: [ObjectIdentifier: WeakView] = [:] + + private struct WeakView { + weak var view: NSView? + } + + func view(_ id: ObjectIdentifier) -> NSView? { views[id]?.view } + + var isEmpty: Bool { panes.isEmpty } + + static func measure(in reference: NSView) -> Self { + var layout = Self() + func rect(_ view: NSView) -> NSRect { reference.convert(view.bounds, from: view) } + func walk(_ view: NSView) { + // Hidden workspaces stay mounted at zero opacity. + guard !view.isHidden, view.alphaValue > 0, (view.layer?.opacity ?? 1) > 0 else { return } + let id = ObjectIdentifier(view) + if SidebarSlideTabRowCapture.isSplitSlot(view) { + layout.slots[id] = rect(view) + layout.views[id] = WeakView(view: view) + } + if let split = splitView(view) { + let slots = split.arrangedSubviews + layout.splits[id] = Split( + rect: rect(split), + first: rect(slots[0]), + second: rect(slots[1]), + isVertical: split.isVertical, + thickness: max(split.dividerThickness, 1) + ) + layout.views[id] = WeakView(view: split) + } else if isLeaf(view) { + layout.panes[id] = rect(view) + layout.views[id] = WeakView(view: view) + return + } + view.subviews.forEach(walk) + } + walk(reference) + return layout + } + + /// A Bonsplit split view: two sides, each a split slot. + static func splitView(_ view: NSView) -> NSSplitView? { + guard let split = view as? NSSplitView, split.arrangedSubviews.count == 2, + split.arrangedSubviews.allSatisfy(SidebarSlideTabRowCapture.isSplitSlot) else { return nil } + return split + } + + /// A pane's own hosting view: in a split slot, or a lone pane, and not + /// itself holding a further split. + private static func isLeaf(_ view: NSView) -> Bool { + let inSlot = view.superview.map(SidebarSlideTabRowCapture.isSplitSlot) == true + guard inSlot || NSStringFromClass(type(of: view)).contains("PaneContainerView") else { return false } + func holdsSplit(_ view: NSView) -> Bool { + view.subviews.contains { splitView($0) != nil || holdsSplit($0) } + } + return !holdsSplit(view) + } + + /// The same panes, rect for rect, within half a point. + func matches(_ other: Self) -> Bool { + func close(_ a: NSRect, _ b: NSRect) -> Bool { + abs(a.minX - b.minX) < 0.5 && abs(a.width - b.width) < 0.5 && abs(a.minY - b.minY) < 0.5 && abs(a.height - b.height) < 0.5 + } + guard panes.count == other.panes.count, splits.count == other.splits.count else { return false } + return panes.allSatisfy { id, rect in other.panes[id].map { close($0, rect) } == true } + } + + /// The docked layout predicted from this (hidden) one by Bonsplit's + /// rule: the root loses the sidebar width at its leading edge, and each + /// vertical split puts its divider at the pixel-rounded fraction of its + /// available width. Used when no docked layout was seen for this one. + func predictedDocked(in reference: NSView, sidebarWidth: CGFloat) -> Self { + var docked = Self() + docked.views = views + let scale = reference.window?.backingScaleFactor ?? 2 + func rect(_ view: NSView) -> NSRect { reference.convert(view.bounds, from: view) } + func assign(_ view: NSView, hiddenSlot: NSRect, dockedSlot: NSRect) { + let id = ObjectIdentifier(view) + let own = rect(view) + let mapped = NSRect( + x: own.minX - hiddenSlot.minX + dockedSlot.minX, + y: own.minY, + width: own.width - (hiddenSlot.width - dockedSlot.width), + height: own.height + ) + if panes[id] != nil { + docked.panes[id] = mapped + return + } + if slots[id] != nil { + docked.slots[id] = mapped + } + if let split = splits[id], let splitView = Self.splitView(view) { + var first = NSRect(x: mapped.minX, y: split.first.minY, width: mapped.width, height: split.first.height) + var second = NSRect(x: mapped.minX, y: split.second.minY, width: mapped.width, height: split.second.height) + if split.isVertical { + let available = split.rect.width - split.thickness + let fraction = available > 0 ? split.first.width / available : 0.5 + let position = ((mapped.width - split.thickness) * fraction * scale).rounded() / scale + first.size.width = position + second.origin.x = mapped.minX + position + split.thickness + second.size.width = max(0, mapped.maxX - second.minX) + } + docked.splits[id] = Split(rect: mapped, first: first, second: second, isVertical: split.isVertical, thickness: split.thickness) + let slots = splitView.arrangedSubviews + assign(slots[0], hiddenSlot: rect(slots[0]), dockedSlot: first) + assign(slots[1], hiddenSlot: rect(slots[1]), dockedSlot: second) + return + } + for subview in view.subviews where !subview.isHidden { + assign(subview, hiddenSlot: own, dockedSlot: mapped) + } + } + func roots(_ view: NSView) { + guard !view.isHidden else { return } + let id = ObjectIdentifier(view) + if panes[id] != nil || splits[id] != nil { + let own = rect(view) + assign(view, hiddenSlot: own, dockedSlot: NSRect(x: own.minX + sidebarWidth, y: own.minY, width: own.width - sidebarWidth, height: own.height)) + return + } + view.subviews.forEach(roots) + } + roots(reference) + return docked + } +} + +/// Per-pane motion for the toggle slide, on top of the content root's. +/// +/// Each pane (its Bonsplit hosting view and its portal-hosted terminal or +/// browser views) glides so its leading edge runs from its hidden-layout x +/// to its docked x, and a mask narrows or widens it so its trailing edge +/// does the same. Neighbours meet at every frame and dividers land where +/// they end. Divider lines are drawn by the split views in the content +/// root's frame, so strips in their colour ride the gaps instead, and each +/// pane's trailing action buttons ride its trailing edge as a picture. +@MainActor +final class SidebarSlidePaneGlide { + private(set) var animations: [SidebarSlideGlide.Layer] = [] + private(set) var overlay: NSView? + private var masked: [CALayer] = [] + + init?( + reference: NSView, + container: NSView, + above: NSView, + hostedViews: [NSView], + hidden: SidebarSlidePaneLayout, + docked: SidebarSlidePaneLayout, + sidebarWidth: CGFloat, + lanes: [ObjectIdentifier: (image: CGImage, rect: NSRect)] + ) { + guard sidebarWidth > 0, !hidden.isEmpty else { return nil } + let width = Double(sidebarWidth) + func factor(_ hiddenX: CGFloat, _ dockedX: CGFloat) -> Double { + SidebarSlideGlide.factor(hiddenLeading: hiddenX, dockedLeading: dockedX, sidebarWidth: sidebarWidth) + } + func rect(_ view: NSView) -> NSRect { reference.convert(view.bounds, from: view) } + + // Each split slot moves with its own leading edge, relative to the + // slot around it, and clips at its own trailing edge; a lone pane + // only clips. Panes inside slots ride along. + func absoluteFactor(_ id: ObjectIdentifier) -> Double { + guard let hiddenRect = hidden.slots[id] ?? hidden.panes[id], let dockedRect = docked.slots[id] ?? docked.panes[id] else { return 0 } + return factor(hiddenRect.minX, dockedRect.minX) + } + func enclosingSlot(_ view: NSView) -> ObjectIdentifier? { + sequence(first: view.superview, next: { $0?.superview }).lazy.compactMap { $0 } + .map(ObjectIdentifier.init).first { hidden.slots[$0] != nil } + } + var nodes = Array(hidden.slots.keys) + nodes += hidden.panes.keys.filter { id in hidden.view(id).map { enclosingSlot($0) == nil } == true } + for id in nodes { + guard let view = hidden.view(id), let hiddenRect = hidden.slots[id] ?? hidden.panes[id], + let dockedRect = docked.slots[id] ?? docked.panes[id] else { continue } + let parent = enclosingSlot(view).map(absoluteFactor) ?? 0 + glide(view, factor: absoluteFactor(id) - parent, frame: hiddenRect, widthChange: dockedRect.width - hiddenRect.width, sidebarWidth: width) + } + // The hosted views follow their pane's absolute motion. + var paneMotion: [(hidden: NSRect, docked: NSRect)] = [] + for (id, hiddenRect) in hidden.panes { + if let dockedRect = docked.panes[id] { paneMotion.append((hiddenRect, dockedRect)) } + } + for view in hostedViews where !view.isHidden && view.alphaValue > 0 + && !NSStringFromClass(type(of: view)).contains("Overlay") { + let frame = rect(view) + guard frame.width > 1, let motion = paneMotion.first(where: { $0.hidden.contains(NSPoint(x: frame.midX, y: frame.midY)) }) else { continue } + glide(view, factor: factor(motion.hidden.minX, motion.docked.minX), frame: frame, widthChange: motion.docked.width - motion.hidden.width, sidebarWidth: width) + } + // Strips and pictures ride above everything that moves. + let overlay = PictureLayerHostView(frame: container.convert(reference.bounds, from: reference)) + overlay.wantsLayer = true + container.addSubview(overlay, positioned: .above, relativeTo: above) + guard let overlayLayer = overlay.layer else { + overlay.removeFromSuperview() + return nil + } + self.overlay = overlay + func addLayer(_ layer: CALayer, at frameInReference: NSRect) { + let frame = overlay.convert(frameInReference, from: reference) + layer.anchorPoint = .zero + layer.position = frame.origin + layer.bounds = CGRect(origin: .zero, size: frame.size) + overlayLayer.addSublayer(layer) + } + for (id, split) in hidden.splits { + guard let dockedSplit = docked.splits[id], let view = hidden.view(id) as? NSSplitView else { continue } + var color: CGColor? + view.effectiveAppearance.performAsCurrentDrawingAppearance { color = view.dividerColor.cgColor } + let strip = CALayer() + strip.backgroundColor = color + let gap = Self.divider(split) + addLayer(strip, at: gap) + if split.isVertical { + animations.append(.init(layer: strip, keyPath: "transform.translation.x", factor: factor(gap.minX, Self.divider(dockedSplit).minX))) + } else { + animations.append(.init(layer: strip, keyPath: "transform.translation.x", factor: factor(split.rect.minX, dockedSplit.rect.minX))) + animations.append(Self.width(strip, from: split.rect.width, to: dockedSplit.rect.width, sidebarWidth: width)) + } + } + for (id, lane) in lanes { + guard let hiddenRect = hidden.panes[id], let dockedRect = docked.panes[id] else { continue } + let picture = CALayer() + picture.contents = lane.image + picture.contentsGravity = .resize + let laneWidth = lane.rect.width + addLayer(picture, at: NSRect(x: hiddenRect.maxX - laneWidth, y: lane.rect.minY, width: laneWidth, height: lane.rect.height)) + animations.append(.init(layer: picture, keyPath: "transform.translation.x", factor: factor(hiddenRect.maxX, dockedRect.maxX))) + } +#if DEBUG + SidebarNavigationTimings.record("slide.panes slots=\(hidden.slots.count) panes=\(paneMotion.map { "\($0.hidden.minX)-\($0.hidden.maxX)>\($0.docked.minX)-\($0.docked.maxX)" }) splits=\(hidden.splits.count) animations=\(animations.count) lanes=\(lanes.count)") +#endif + // The portal's own divider overlay draws at the hidden layout's + // dividers; the strips stand in for it until the landing. + // An animation, not the model value: AppKit owns a view layer's + // opacity and would put it back on the next display pass. + for view in hostedViews where NSStringFromClass(type(of: view)).contains("SplitDividerOverlayView") { + guard let layer = view.layer else { continue } + animations.append(.init(layer: layer, keyPath: "opacity", factor: 0, base: 0)) + } + } + + /// The divider gap between a split's two sides. + private static func divider(_ split: SidebarSlidePaneLayout.Split) -> NSRect { + if split.isVertical { + let lead = min(split.first.maxX, split.second.maxX) + return NSRect(x: lead, y: split.rect.minY, width: split.thickness, height: split.rect.height) + } + let top = split.first.minY < split.second.minY ? split.first : split.second + return NSRect(x: split.rect.minX, y: top.maxY, width: split.rect.width, height: split.thickness) + } + + private static func width(_ layer: CALayer, from: CGFloat, to: CGFloat, sidebarWidth: Double) -> SidebarSlideGlide.Layer { + SidebarSlideGlide.Layer(layer: layer, keyPath: "bounds.size.width", factor: Double(to - from) / sidebarWidth, base: Double(from)) + } + + /// Moves `view` (laid out at `frame` in the hidden layout) by `factor` + /// and clips it at a trailing edge that moves by `widthChange` over the + /// slide. + private func glide(_ view: NSView, factor: Double, frame: NSRect, widthChange: CGFloat, sidebarWidth width: Double) { + guard let layer = view.layer else { return } + if factor != 0 { + animations.append(.init(layer: layer, keyPath: "transform.translation.x", factor: factor)) + } + guard layer.mask == nil else { return } + let mask = CALayer() + mask.backgroundColor = NSColor.black.cgColor + let bleed: CGFloat = 10_000 + mask.anchorPoint = .zero + mask.position = CGPoint(x: layer.bounds.minX, y: layer.bounds.minY - bleed) + mask.bounds = CGRect(x: 0, y: 0, width: frame.width, height: layer.bounds.height + 2 * bleed) + layer.mask = mask + masked.append(layer) + animations.append(Self.width(mask, from: frame.width, to: frame.width + widthChange, sidebarWidth: width)) + } + + func tearDown(animationKey: String) { + animations.forEach { $0.layer.removeAnimation(forKey: animationKey) } + masked.forEach { $0.mask = nil } + overlay?.removeFromSuperview() + } + + /// A layer host for the strips and pictures that takes no clicks and + /// never repaints its layer. + private final class PictureLayerHostView: NSView { + override var wantsUpdateLayer: Bool { true } + override func updateLayer() {} + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } +} + +/// What a slide needs from the layout on screen at the press: pictures of +/// the tab bars, and on a hide (taken before the hidden layout commits) the +/// docked panes' rects. +@MainActor +struct SidebarSlideStart { + var tabRow: SidebarSlideTabRowCapture? + var lanes: SidebarSlideTrailingChromeCapture? + var docked: SidebarSlidePaneLayout? + + static func capture(in window: NSWindow, docked: Bool, inset: CGFloat, sidebarWidth: CGFloat, buttonCount: Int) -> Self { + guard let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView else { return Self() } + return Self( + tabRow: SidebarSlideTabRowCapture.capture(in: reference, docked: docked, inset: inset, sidebarWidth: sidebarWidth), + lanes: SidebarSlideTrailingChromeCapture.capture(in: reference, buttonCount: buttonCount), + docked: docked ? SidebarSlidePaneLayout.measure(in: reference) : nil + ) + } + + /// The terminal and browser views the portals draw over the panes. + static func hostedViews(in window: NSWindow) -> [NSView] { + guard let portal = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)], + let container = portal.installedReferenceView?.superview else { return [] } + let browsers = container.subviews.filter { $0 is WindowBrowserHostView }.flatMap(\.subviews) + return portal.hostView.subviews + browsers + } +} diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index e031a21c2738..5d9de1770746 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -34,9 +34,11 @@ final class SidebarToggleAnimator: ObservableObject { private var tabBarInsetDelta: () -> CGFloat = { 0 } /// How many action buttons a pane's tab bar shows on its trailing end. private var splitButtonCount: () -> Int = { 0 } - /// A hide's pictures, taken before the hidden layout commits. - private var pendingTabRow: SidebarSlideTabRowCapture? - private var pendingTrailingChrome: SidebarSlideTrailingChromeCapture? + /// A hide's start, taken before the hidden layout commits. + private var pendingStart: SidebarSlideStart? + /// The last hide's hidden and docked pane layouts: a show over the same + /// hidden layout lands on exactly that docked one. + private var paneLayouts: (hidden: SidebarSlidePaneLayout, docked: SidebarSlidePaneLayout, width: CGFloat)? private var machine = SidebarToggleSlideMachine(docked: true) /// What carries the running slide, captured when it starts and kept for /// every retarget until it lands. @@ -159,16 +161,14 @@ final class SidebarToggleAnimator: ObservableObject { } else { slide = nil } - pendingTabRow = captureTabRow(in: window, docked: true) - pendingTrailingChrome = captureTrailingChrome(in: window) + pendingStart = captureStart(in: window, docked: true) commitAtomically(in: window) { layout?.docksSidebar = false dockedLayoutWillCommit(false) } layers: { if let slide { addSlideAnimation(slide, in: window) } } - pendingTabRow = nil - pendingTrailingChrome = nil + pendingStart = nil if let slide { machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) } case let .animate(slide): CATransaction.begin() @@ -246,16 +246,15 @@ final class SidebarToggleAnimator: ObservableObject { // drop the slide this was for; a session built for it would never land. guard machine.slide?.generation == slide.generation else { return } if session == nil, let views = Self.slidingViews(in: window), let layout { - let tabRow = pendingTabRow ?? captureTabRow(in: window, docked: false) - let trailingChrome = pendingTrailingChrome ?? captureTrailingChrome(in: window) - pendingTabRow = nil - pendingTrailingChrome = nil + let start = pendingStart ?? captureStart(in: window, docked: false) + pendingStart = nil session = SidebarToggleSlideSession( views: views, trailingStillWidth: trailingStillWidth(), titleGlide: layout.titlebarTitle?.glide(sidebarWidth: layout.width), - tabRow: tabRow, - trailingChrome: trailingChrome + tabRow: start.tabRow, + lanes: start.lanes, + panes: paneLayouts(for: start, reference: views[0], width: layout.width, window: window) ) } guard let session, !session.movingLayers.isEmpty else { @@ -284,20 +283,30 @@ final class SidebarToggleAnimator: ObservableObject { // Chrome that rests elsewhere in the two layouts glides on top of the // content root's motion, by the same spring scaled (`SidebarSlideGlide`). for glide in session.glides { - let animation = slideSpring(from: slide.from * glide.factor, to: slide.to * glide.factor, velocity: velocity, duration: slide.duration, keyPath: glide.keyPath) + let animation = slideSpring(from: glide.base + slide.from * glide.factor, to: glide.base + slide.to * glide.factor, velocity: velocity, duration: slide.duration, keyPath: glide.keyPath) glide.layer.add(animation, forKey: Self.animationKey) } } - private func captureTabRow(in window: NSWindow, docked: Bool) -> SidebarSlideTabRowCapture? { - guard let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView, - let layout else { return nil } - return SidebarSlideTabRowCapture.capture(in: reference, docked: docked, inset: tabBarInsetDelta(), sidebarWidth: layout.width) + private func captureStart(in window: NSWindow, docked: Bool) -> SidebarSlideStart { + SidebarSlideStart.capture(in: window, docked: docked, inset: tabBarInsetDelta(), sidebarWidth: layout?.width ?? 0, buttonCount: splitButtonCount()) } - private func captureTrailingChrome(in window: NSWindow) -> SidebarSlideTrailingChromeCapture? { - guard let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView else { return nil } - return SidebarSlideTrailingChromeCapture.capture(in: reference, buttonCount: splitButtonCount()) + /// The hidden layout is on screen now; the docked one was measured at a + /// hide's press, seen at the last hide over this same hidden layout, or + /// failing both, predicted. + private func paneLayouts(for start: SidebarSlideStart, reference: NSView, width: CGFloat, window: NSWindow) -> SidebarToggleSlideSession.Panes { + let hidden = SidebarSlidePaneLayout.measure(in: reference) + let docked: SidebarSlidePaneLayout + if let measured = start.docked { + docked = measured + paneLayouts = (hidden, measured, width) + } else if let seen = paneLayouts, seen.width == width, seen.hidden.matches(hidden) { + docked = seen.docked + } else { + docked = hidden.predictedDocked(in: reference, sidebarWidth: width) + } + return .init(hidden: hidden, docked: docked, hostedViews: SidebarSlideStart.hostedViews(in: window), sidebarWidth: width) } private func slideSpring(from: Double, to: Double, velocity: Double, duration: Double, keyPath: String = "transform.translation.x") -> CASpringAnimation { diff --git a/Sources/Sidebar/SidebarToggleSlideEdges.swift b/Sources/Sidebar/SidebarToggleSlideEdges.swift new file mode 100644 index 000000000000..ed0f87cf41dd --- /dev/null +++ b/Sources/Sidebar/SidebarToggleSlideEdges.swift @@ -0,0 +1,50 @@ +import AppKit +import QuartzCore + +#if DEBUG +/// DEBUG-only per-frame pane geometry for checking the toggle's slide +/// without screenshots (`CMUX_SIDEBAR_SLIDE_EDGES=1` with +/// `CMUX_NAV_TIMINGS_LOG`): the presented leading and trailing x of every +/// pane and every portal-hosted view, clipped by the masks the slide puts +/// on them and their ancestors, in window points. +@MainActor +enum SidebarToggleSlideEdges { + static let isEnabled = ProcessInfo.processInfo.environment["CMUX_SIDEBAR_SLIDE_EDGES"] != nil + + static func record(_ phase: String, window: NSWindow?) { + guard isEnabled, let window, + let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView, + let rootView = reference.superview, let rootLayer = rootView.layer else { return } + // The landing reads the model: its commit replaced the motion, and + // the presentation catches up only on the next frame. + let presents = phase != "landed" + func shown(_ layer: CALayer) -> CALayer { presents ? (layer.presentation() ?? layer) : layer } + let root = shown(rootLayer) + func span(_ view: NSView) -> String? { + guard let layer = view.layer else { return nil } + let presented = shown(layer) + var rect = presented.convert(presented.bounds, to: root) + var current: NSView? = view + while let ancestor = current, ancestor !== rootView { + if let ancestorLayer = ancestor.layer, let mask = ancestorLayer.mask { + // A mask's frame is in its layer's own coordinates. + let host = shown(ancestorLayer) + let clip = host.convert(shown(mask).frame, to: root) + let minX = max(rect.minX, clip.minX), maxX = min(rect.maxX, clip.maxX) + rect = NSRect(x: minX, y: rect.minY, width: max(0, maxX - minX), height: rect.height) + } + current = ancestor.superview + } + return String(format: "%.1f:%.1f", rect.minX, rect.maxX) + } + let layout = SidebarSlidePaneLayout.measure(in: reference) + let panes = layout.panes.sorted { ($0.value.minY, $0.value.minX) < ($1.value.minY, $1.value.minX) } + .compactMap { layout.view($0.key).flatMap(span) } + let hosted = SidebarSlideStart.hostedViews(in: window) + .filter { !$0.isHidden && $0.alphaValue > 0 && $0.frame.width > 1 && !NSStringFromClass(type(of: $0)).contains("Overlay") } + .sorted { ($0.frame.minY, $0.frame.minX) < ($1.frame.minY, $1.frame.minX) } + .compactMap(span) + SidebarNavigationTimings.record("slide.edges phase=\(phase) t=\(String(format: "%.4f", CACurrentMediaTime())) panes=\(panes.joined(separator: ",")) hosted=\(hosted.joined(separator: ","))") + } +} +#endif diff --git a/Sources/Sidebar/SidebarToggleSlideProbe.swift b/Sources/Sidebar/SidebarToggleSlideProbe.swift index 4d0afbc6b9d3..a45cd3d47c12 100644 --- a/Sources/Sidebar/SidebarToggleSlideProbe.swift +++ b/Sources/Sidebar/SidebarToggleSlideProbe.swift @@ -64,6 +64,7 @@ final class SidebarToggleSlideProbe: NSObject { static func begin(visible: Bool, window: NSWindow, animator: SidebarToggleAnimator) -> SidebarToggleSlideProbe? { guard SidebarNavigationTimings.isEnabled, let view = window.contentView else { return nil } live?.finish() + SidebarToggleSlideEdges.record("start", window: window) let probe = SidebarToggleSlideProbe(visible: visible, window: window) let link = view.displayLink(target: probe, selector: #selector(tick(_:))) link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) @@ -100,11 +101,13 @@ final class SidebarToggleSlideProbe: NSObject { } func didLand(cpu: Double) { + SidebarToggleSlideEdges.record("landed", window: window) landingCPU = cpu landedWall = CACurrentMediaTime() } @objc private func tick(_ link: CADisplayLink) { + if landedWall == nil { SidebarToggleSlideEdges.record("frame", window: window) } let now = (wall: CACurrentMediaTime(), cpu: Self.threadCPU()) if firstFrameMs == nil { firstFrameMs = (now.wall - startWall) * 1000 diff --git a/Sources/Sidebar/SidebarToggleSlideSession.swift b/Sources/Sidebar/SidebarToggleSlideSession.swift index 40e82213e47f..8b49efb74951 100644 --- a/Sources/Sidebar/SidebarToggleSlideSession.swift +++ b/Sources/Sidebar/SidebarToggleSlideSession.swift @@ -16,19 +16,46 @@ final class SidebarToggleSlideSession { private let stillOverlay: NSView? private var tabRowOverlay: NSView? private var stillChrome: [NSView] = [] + private var paneGlide: SidebarSlidePaneGlide? + + /// The panes' rects in both layouts, for per-pane motion. + struct Panes { + let hidden: SidebarSlidePaneLayout + let docked: SidebarSlidePaneLayout + let hostedViews: [NSView] + let sidebarWidth: CGFloat + } convenience init( views: [NSView], trailingStillWidth: CGFloat, titleGlide: SidebarSlideGlide.Layer?, tabRow: SidebarSlideTabRowCapture?, - trailingChrome: SidebarSlideTrailingChromeCapture? + lanes: SidebarSlideTrailingChromeCapture?, + panes: Panes? ) { self.init(views: views, trailingStillWidth: trailingStillWidth) - if let reference = views.first, let container = reference.superview { - stillChrome = trailingChrome?.makeOverlays(above: reference, in: container) ?? [] - } glides = [titleGlide].compactMap { $0 } + if let reference = views.first, let container = reference.superview, let last = views.last { + // Each pane moves on its own; without that, the trailing-edge + // panes' action buttons at least hold still. + if let panes, let glide = SidebarSlidePaneGlide( + reference: reference, + container: container, + above: last, + hostedViews: panes.hostedViews, + hidden: panes.hidden, + docked: panes.docked, + sidebarWidth: panes.sidebarWidth, + lanes: lanes?.lanes ?? [:] + ), let layer = glide.overlay?.layer { + paneGlide = glide + movingLayers.append(layer) + glides.append(contentsOf: glide.animations) + } else { + stillChrome = lanes?.makeOverlays(above: reference, in: container) ?? [] + } + } // The tab row picture rides with the content root (below the right // sidebar's still snapshot) and glides inside it. if let tabRow, let reference = views.first, let container = reference.superview, @@ -85,6 +112,7 @@ final class SidebarToggleSlideSession { layer.mask = nil } glides.forEach { $0.layer.removeAnimation(forKey: animationKey) } + paneGlide?.tearDown(animationKey: animationKey) tabRowOverlay?.removeFromSuperview() stillChrome.forEach { $0.removeFromSuperview() } stillOverlay?.removeFromSuperview() diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index b792d84e2344..9e53a30e6826 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3371,6 +3371,7 @@ B804C0020000000000000002 /* SidebarSelectionCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B8DE2DF68EBAC8B061BE60C8 /* SidebarSlideGlide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */; }; + 03BF5A233704835699AFBBB1 /* SidebarSlidePaneGlide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C1C85B461C391B6E5CDB7B1 /* SidebarSlidePaneGlide.swift */; }; C12791000000000000000007 /* SidebarSnapshotOwnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */; }; F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; D7344F010000000000000001 /* SidebarTabDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7344F010000000000000002 /* SidebarTabDragPayload.swift */; }; @@ -3379,6 +3380,7 @@ 8430A0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430B0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift */; }; A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73F10000000000000002 /* SidebarTitleFirstLineCenterAlignment.swift */; }; 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */; }; + D512067252189A0C927DE9B5 /* SidebarToggleSlideEdges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5009D8840B8255E315C5CB6D /* SidebarToggleSlideEdges.swift */; }; 5B1D66585F4ECB32768ACE69 /* SidebarToggleSlideMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */; }; E50824578CDCF63DB210D64F /* SidebarToggleSlideProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */; }; DDCDEB2A91D3671D8A6EA2AA /* SidebarToggleSlideSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2902B955A8F8604A2E6174E4 /* SidebarToggleSlideSession.swift */; }; @@ -7845,6 +7847,7 @@ B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionCoalescerTests.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarSlideGlide.swift; sourceTree = ""; }; + 8C1C85B461C391B6E5CDB7B1 /* SidebarSlidePaneGlide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarSlidePaneGlide.swift; sourceTree = ""; }; C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSnapshotOwnerTests.swift; sourceTree = ""; }; D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; D7344F010000000000000002 /* SidebarTabDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarTabDragPayload.swift; sourceTree = ""; }; @@ -7853,6 +7856,7 @@ 8430B0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabItemSettingsSnapshot.swift; sourceTree = ""; }; A6AC73F10000000000000002 /* SidebarTitleFirstLineCenterAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarTitleFirstLineCenterAlignment.swift; sourceTree = ""; }; 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleAnimator.swift; sourceTree = ""; }; + 5009D8840B8255E315C5CB6D /* SidebarToggleSlideEdges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideEdges.swift; sourceTree = ""; }; 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideMachine.swift; sourceTree = ""; }; 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideProbe.swift; sourceTree = ""; }; 2902B955A8F8604A2E6174E4 /* SidebarToggleSlideSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideSession.swift; sourceTree = ""; }; @@ -10274,6 +10278,8 @@ 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */, 3FAC0DA415898D27426B2292 /* SidebarDockedPaneHost.swift */, 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */, + 5009D8840B8255E315C5CB6D /* SidebarToggleSlideEdges.swift */, + 8C1C85B461C391B6E5CDB7B1 /* SidebarSlidePaneGlide.swift */, 2902B955A8F8604A2E6174E4 /* SidebarToggleSlideSession.swift */, 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */, 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */, @@ -16281,12 +16287,14 @@ B804A0280000000000000028 /* SidebarSelectionCoalescer.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B8DE2DF68EBAC8B061BE60C8 /* SidebarSlideGlide.swift in Sources */, + 03BF5A233704835699AFBBB1 /* SidebarSlidePaneGlide.swift in Sources */, F57072635F25EBCA741E125D /* SidebarState.swift in Sources */, D7344F010000000000000001 /* SidebarTabDragPayload.swift in Sources */, F7216006000000000000001 /* SidebarTabItemContextMenuState.swift in Sources */, 8430A0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift in Sources */, A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */, 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */, + D512067252189A0C927DE9B5 /* SidebarToggleSlideEdges.swift in Sources */, 5B1D66585F4ECB32768ACE69 /* SidebarToggleSlideMachine.swift in Sources */, E50824578CDCF63DB210D64F /* SidebarToggleSlideProbe.swift in Sources */, DDCDEB2A91D3671D8A6EA2AA /* SidebarToggleSlideSession.swift in Sources */, From 7e2501d6c6420737a46835352a7ca90391e49dd8 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 20:18:36 +1300 Subject: [PATCH 04/15] sidebar toggle: every pane's whole subtree glides, trailing chrome stays flush with its pane per-pane motion now covers everything drawn for a pane, by construction: - the portals' views (terminals, browser pages, in or out of window glass) come from the portals' own entries and follow the pane their anchor sits in, glided and clipped - each pane's trailing chrome is pictured from the layout that is live for the slide (a hide's right after its commit) and rides the pane's trailing edge as a sublayer of the pane's own clip, so it always sits flush and whole inside the pane and can never leave it. the tab bar's part is bonsplit's action lane by its fixed width; other bars (browser toolbar, path bar, composer) are cut at a seam between rows alike all the way across, only the bar at the strip's edge, never more than half the pane, never in a web view - overlays inside a portal view keep their anchoring by geometry (scroller, badges trailing, cards centred), drawn ones (find bars, rings, borders) are pictured and cut the same way inside the view's clip - bonsplit's own divider drawing is blanked for the slide (the strips stand in), and overlays painting at every pane's rect at once (portal divider, pane swap, drop zones, tmux rings, cloud failure card) are hidden with an empty mask, which survives the portal re-adding them - minimal mode's leading tabs ride inside the leading pane's clip - a bonsplit split resize mid-slide (a split, a close) lands the slide at once on the new layout the debug edge log now includes browser pages and the trailing pictures, and a layer dump (CMUX_SIDEBAR_SLIDE_LAYER_DUMP) for finding strays. Co-Authored-By: Claude Opus 5.5 --- Sources/BrowserWindowPortal.swift | 6 +- Sources/Sidebar/SidebarSlideGlide.swift | 107 +--- Sources/Sidebar/SidebarSlidePaneChrome.swift | 314 ++++++++++++ Sources/Sidebar/SidebarSlidePaneGlide.swift | 481 ++++++++++-------- Sources/Sidebar/SidebarSlidePaneLayout.swift | 158 ++++++ Sources/Sidebar/SidebarToggleAnimator.swift | 47 +- Sources/Sidebar/SidebarToggleSlideEdges.swift | 78 ++- Sources/Sidebar/SidebarToggleSlideProbe.swift | 6 +- .../Sidebar/SidebarToggleSlideSession.swift | 12 +- cmux.xcodeproj/project.pbxproj | 8 + .../SidebarFocusBoundaryLifecycleTests.swift | 20 + 11 files changed, 910 insertions(+), 327 deletions(-) create mode 100644 Sources/Sidebar/SidebarSlidePaneChrome.swift create mode 100644 Sources/Sidebar/SidebarSlidePaneLayout.swift diff --git a/Sources/BrowserWindowPortal.swift b/Sources/BrowserWindowPortal.swift index dfc78e142b69..90dccd596d12 100644 --- a/Sources/BrowserWindowPortal.swift +++ b/Sources/BrowserWindowPortal.swift @@ -1989,7 +1989,7 @@ final class WindowBrowserPortal: NSObject { private var nextHostedWebViewRefreshGeneration: UInt64 = 0 private var pendingHostedWebViewRefreshes: [ObjectIdentifier: PendingHostedWebViewRefresh] = [:] - private struct Entry { + struct Entry { weak var webView: WKWebView? weak var containerView: WindowBrowserSlotView? weak var anchorView: NSView? @@ -2018,7 +2018,7 @@ final class WindowBrowserPortal: NSObject { let delayedScheduler = MainActorDeferredActionScheduler() } - private var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] + private(set) var entriesByWebViewId: [ObjectIdentifier: Entry] = [:] private var webViewByAnchorId: [ObjectIdentifier: ObjectIdentifier] = [:] #if DEBUG @@ -4147,7 +4147,7 @@ enum BrowserWindowPortalRegistry { let frameInWindow: CGRect } - private static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] + private(set) static var portalsByWindowId: [ObjectIdentifier: WindowBrowserPortal] = [:] private static var webViewToWindowId: [ObjectIdentifier: ObjectIdentifier] = [:] private static func postRegistryDidChange(for webView: WKWebView) { NotificationCenter.default.post(name: .browserPortalRegistryDidChange, object: webView) diff --git a/Sources/Sidebar/SidebarSlideGlide.swift b/Sources/Sidebar/SidebarSlideGlide.swift index 324715237ffe..b12c37ab8d8e 100644 --- a/Sources/Sidebar/SidebarSlideGlide.swift +++ b/Sources/Sidebar/SidebarSlideGlide.swift @@ -235,8 +235,24 @@ struct SidebarSlideTabRowCapture { /// The overlay the slide moves with the content root, and the picture /// layer inside it that glides on top of that. + /// The plain ground and the gliding picture as layers inside `clip` + /// (the leading pane's own clip), so neither can reach the next pane. + func makeLayers(in clip: NSView, from reference: NSView) -> (ground: CALayer, picture: CALayer) { + let groundLayer = CALayer() + groundLayer.contents = ground + groundLayer.contentsGravity = .resize + groundLayer.zPosition = 999 + groundLayer.frame = clip.convert(groundRect, from: reference) + let pictureLayer = CALayer() + pictureLayer.contents = picture + pictureLayer.contentsGravity = .resize + pictureLayer.zPosition = 1_000 + pictureLayer.frame = clip.convert(pictureRect, from: reference) + return (groundLayer, pictureLayer) + } + func makeOverlay(above reference: NSView, in container: NSView) -> (view: NSView, glide: SidebarSlideGlide.Layer)? { - let view = SidebarSlidePassthroughView(frame: container.convert(groundRect, from: reference)) + let view = SidebarSlidePictureView(frame: container.convert(groundRect, from: reference)) view.wantsLayer = true container.addSubview(view, positioned: .above, relativeTo: reference) guard let layer = view.layer else { @@ -253,92 +269,3 @@ struct SidebarSlideTabRowCapture { return (view, SidebarSlideGlide.Layer(layer: pictureLayer, keyPath: "transform.translation.x", factor: factor)) } } - -/// The action buttons on the trailing end of each pane that ends at the -/// content's trailing edge rest at the same x in both layouts. The slide -/// carries the full-width layout past that edge, though, so on their own -/// they would slide out under the right sidebar (or the window edge) and -/// pop back at the landing, or vanish at a hide's press and slide back in. -/// The slide shows a still picture of them in place instead, taken at the -/// press, over the real ones, and drops it in the commit that lands. -@MainActor -struct SidebarSlideTrailingChromeCapture { - /// Every pane's action lane, keyed by the pane's hosting view. - let lanes: [ObjectIdentifier: (image: CGImage, rect: NSRect)] - /// The panes that end at the content's trailing edge. - let trailingEdge: Set - - /// Bonsplit's action lane: 6 pt leading and 8 pt trailing padding, 22 pt - /// per button and 4 pt between them, all shown up to five buttons. - nonisolated static func laneWidth(buttonCount: Int, paneWidth: CGFloat) -> CGFloat { - guard buttonCount > 0 else { return 0 } - func width(_ count: Int) -> CGFloat { 10 + 26 * CGFloat(count) } - guard buttonCount > 5 else { return width(buttonCount) } - return min(width(buttonCount), max(paneWidth / 4, width(5))) - } - - static func capture(in reference: NSView, buttonCount: Int) -> Self? { - guard buttonCount > 0 else { -#if DEBUG - SidebarNavigationTimings.record("slide.trailing buttons=0") -#endif - return nil - } - var rows: [ObjectIdentifier: (pane: NSRect, tabs: NSRect)] = [:] - func walk(_ view: NSView) { - if SidebarSlideTabRowCapture.isTabItemRegion(view), !view.isHiddenOrHasHiddenAncestor, - !view.visibleRect.isEmpty, let pane = SidebarSlideTabRowCapture.paneHost(of: view) { - let rect = reference.convert(view.visibleRect, from: view) - let key = ObjectIdentifier(pane) - rows[key] = (reference.convert(pane.bounds, from: pane), rows[key].map { $0.tabs.union(rect) } ?? rect) - } - view.subviews.forEach(walk) - } - walk(reference) - guard let trailing = rows.values.map(\.pane.maxX).max() else { return nil } - var lanes: [ObjectIdentifier: (image: CGImage, rect: NSRect)] = [:] - var trailingEdge: Set = [] - for (id, row) in rows { - let lane = laneWidth(buttonCount: buttonCount, paneWidth: row.pane.width) - let rect = NSRect(x: row.pane.maxX - lane, y: row.tabs.minY, width: lane, height: row.tabs.height) - guard let image = SidebarSlideTabRowCapture.snapshot(reference, rect) else { continue } - lanes[id] = (image, rect) - if row.pane.maxX > trailing - 1 { trailingEdge.insert(id) } - } -#if DEBUG - SidebarNavigationTimings.record("slide.trailing rows=\(rows.count) lanes=\(lanes.count) buttons=\(buttonCount)") -#endif - return lanes.isEmpty ? nil : Self(lanes: lanes, trailingEdge: trailingEdge) - } - - /// Fallback without per-pane motion: still pictures of the trailing-edge - /// panes' lanes, which rest at the same x in both layouts. - func makeOverlays(above reference: NSView, in container: NSView) -> [NSView] { - lanes.filter { trailingEdge.contains($0.key) }.values.map { picture in - let view = SidebarSlidePassthroughView(frame: container.convert(picture.rect, from: reference)) - view.wantsLayer = true - view.image = picture.image - view.layer?.contentsGravity = .resize - container.addSubview(view, positioned: .above, relativeTo: nil) - return view - } - } -} - -/// Shows a picture and takes no clicks. Drawing goes through `updateLayer`, -/// so a display pass (the atomic commit runs one) keeps the picture instead -/// of repainting the layer with an empty `draw(_:)`. -private final class SidebarSlidePassthroughView: NSView { - var image: CGImage? { - didSet { layer?.contents = image } - } - - override var wantsUpdateLayer: Bool { true } - - override func updateLayer() { - layer?.contents = image - layer?.contentsGravity = .resize - } - - override func hitTest(_ point: NSPoint) -> NSView? { nil } -} diff --git a/Sources/Sidebar/SidebarSlidePaneChrome.swift b/Sources/Sidebar/SidebarSlidePaneChrome.swift new file mode 100644 index 000000000000..e54b4d26ff23 --- /dev/null +++ b/Sources/Sidebar/SidebarSlidePaneChrome.swift @@ -0,0 +1,314 @@ +import AppKit +import QuartzCore + +/// The trailing ends of every pane's own chrome, as pictures that ride the +/// pane's trailing edge through a slide. +/// +/// Through a slide each pane stays laid out at its hidden-layout width, so +/// anything aligned to its trailing edge (tab bar action buttons, a browser +/// toolbar's buttons, a file viewer's path bar buttons, a fill-width tab's +/// close button) rests where the hidden layout put it and would be clipped +/// or uncovered by the pane's moving trailing edge. No list of such views is +/// kept: each pane's drawing outside its portal-hosted content is cut into +/// horizontal bands, and each band at the widest run of columns that are all +/// alike (the stretch between leading and trailing items). The part past +/// that seam is pictured and rides the trailing edge; the part before it +/// rides the leading edge with the pane. The seam falls in uniform space, +/// so the two halves meet invisibly however far apart the edges get. +@MainActor +struct SidebarSlidePaneChrome { + struct Band { + let image: CGImage + /// In the content root's coordinates, in the layout pictured. + let rect: NSRect + } + + private enum VerticalEdge { case top, bottom } + + static let topChrome: CGFloat = 120 + static let bottomChrome: CGFloat = 80 + + let bands: [ObjectIdentifier: [Band]] + /// The panes that end at the content's trailing edge. + let trailingEdge: Set + + /// `covered` are the portal-hosted views' rects (terminals, browsers): + /// rows they span are their own business. + static func capture(in reference: NSView, layout: SidebarSlidePaneLayout, covered: [NSRect], buttonCount: Int) -> Self? { + guard let trailing = layout.panes.values.map(\.maxX).max() else { return nil } + // Each pane's tab bar rows: their trailing part is the action lane, + // whose width Bonsplit fixes; the rest of a pane's chrome is cut at + // a seam found in its pixels. + var tabRows: [ObjectIdentifier: ClosedRange] = [:] + func findTabs(_ view: NSView) { + if SidebarSlideTabRowCapture.isTabItemRegion(view), !view.isHiddenOrHasHiddenAncestor, !view.visibleRect.isEmpty, + let pane = layout.pane(containing: view) { + let rect = reference.convert(view.visibleRect, from: view) + tabRows[pane] = tabRows[pane].map { min($0.lowerBound, rect.minY)...max($0.upperBound, rect.maxY) } ?? rect.minY...rect.maxY + } + view.subviews.forEach(findTabs) + } + findTabs(reference) + var bands: [ObjectIdentifier: [Band]] = [:] + var trailingEdge: Set = [] + // Each pane's strips outside its portal content; strips that share + // a row range across panes are pictured in one pass. + var strips: [ClosedRange: [(id: ObjectIdentifier, pane: NSRect)]] = [:] + var stripEdge: [ClosedRange: VerticalEdge] = [:] + for (id, pane) in layout.panes { + if pane.maxX > trailing - 1 { trailingEdge.insert(id) } + var rows: [ClosedRange] = [pane.minY...pane.maxY] + for rect in covered where rect.intersects(pane) && rect.width >= pane.width / 2 { + rows = rows.flatMap { row -> [ClosedRange] in + guard rect.minY < row.upperBound, rect.maxY > row.lowerBound else { return [row] } + return [row.lowerBound...max(row.lowerBound, rect.minY), min(row.upperBound, rect.maxY)...row.upperBound] + .filter { $0.upperBound - $0.lowerBound >= 4 } + } + } + // The tab bar is a strip of its own. + if let tabs = tabRows[id] { + rows = rows.flatMap { row -> [ClosedRange] in + guard tabs.lowerBound < row.upperBound, tabs.upperBound > row.lowerBound else { return [row] } + return [row.lowerBound...max(row.lowerBound, tabs.lowerBound), max(row.lowerBound, tabs.lowerBound)...min(row.upperBound, tabs.upperBound), min(row.upperBound, tabs.upperBound)...row.upperBound] + .filter { $0.upperBound - $0.lowerBound >= 4 } + } + } + // Chrome lives at a stretch's top and bottom (tab bar, toolbars, + // path bars, composers); content between rides with the pane. + for row in rows { + let parts = row.upperBound - row.lowerBound > Self.topChrome + Self.bottomChrome + ? [row.lowerBound...(row.lowerBound + Self.topChrome), (row.upperBound - Self.bottomChrome)...row.upperBound] + : [row] + for (index, part) in parts.enumerated() { + strips[part, default: []].append((id, pane)) + // Under a tab bar or other content, only the bar touching + // the strip's edge is chrome (a toolbar, a path bar, a + // composer); what follows is content. + stripEdge[part] = parts.count == 2 && index == 1 ? .bottom : .top + } + } + } + for (rows, panes) in strips { + let minX = panes.map(\.pane.minX).min() ?? 0, maxX = panes.map(\.pane.maxX).max() ?? 0 + let rect = NSRect(x: minX, y: rows.lowerBound, width: maxX - minX, height: rows.upperBound - rows.lowerBound) + guard let rep = reference.bitmapImageRepForCachingDisplay(in: rect) else { continue } + reference.cacheDisplay(in: rect, to: rep) + let scale = CGFloat(rep.pixelsWide) / max(1, rect.width) + for (id, pane) in panes { + // This pane's columns of the shared picture. + let columns = Int(((pane.minX - minX) * scale).rounded()).. 0, seam > 0, let band = picture(paneRep, rect: paneRect, rows: 0.. CGFloat { + guard buttonCount > 0 else { return 0 } + func width(_ count: Int) -> CGFloat { 10 + 26 * CGFloat(count) } + guard buttonCount > 5 else { return width(buttonCount) } + return min(width(buttonCount), max(paneWidth / 4, width(5))) + } + + /// Every pixel of the row the same. + private static func rowIsUniform(_ row: UnsafeMutablePointer, width: Int) -> Bool { + let pixels = UnsafeRawPointer(row).assumingMemoryBound(to: UInt32.self) + let first = pixels[0] + for index in 1..) -> NSBitmapImageRep? { + let clamped = max(0, columns.lowerBound).. 2, let image = rep.cgImage?.cropping(to: CGRect(x: clamped.lowerBound, y: 0, width: clamped.count, height: rep.pixelsHigh)) else { return nil } + let copy = NSBitmapImageRep(cgImage: image) + copy.size = NSSize(width: rep.size.width * CGFloat(clamped.count) / CGFloat(rep.pixelsWide), height: rep.size.height) + return copy + } + + /// Crops `columns` x `rows` (pixels) of `rep`, which pictures `rect`. + static func picture(_ rep: NSBitmapImageRep, rect: NSRect, rows: Range, columns: Range) -> Band? { + let scale = CGFloat(rep.pixelsWide) / max(1, rect.width) + let crop = CGRect(x: columns.lowerBound, y: rows.lowerBound, width: columns.count, height: rows.count) + guard crop.width >= scale * 2, let picture = rep.cgImage?.cropping(to: crop) else { return nil } + return Band(image: picture, rect: NSRect( + x: rect.minX + CGFloat(columns.lowerBound) / scale, + y: rect.minY + CGFloat(rows.lowerBound) / scale, + width: CGFloat(columns.count) / scale, + height: CGFloat(rows.count) / scale + )) + } + + /// The bands of a picture (pixel rows) and each band's seam (the pixel + /// column starting its trailing part). A band is the rows between two + /// rows alike all the way across, split further only where no run of + /// columns stays alike across all of them; one seam serves every row of + /// a bar, so nothing spanning rows (a tab, a pill) is torn. The + /// seam ends the widest such run, so the trailing part is just the + /// trailing items: the stretch stays live and can shrink as far as the + /// docked layout's own stretch without the picture covering anything. + /// Of near-equal runs (centred content), the trailing-most. Rows alike + /// all the way across fit any band; bands with no run have no seam. + static func seams(in rep: NSBitmapImageRep) -> [(rows: Range, seam: Int?)] { + guard let data = rep.bitmapData, rep.bitsPerPixel == 32 else { return [] } + let width = rep.pixelsWide, height = rep.pixelsHigh, rowBytes = rep.bytesPerRow + let step = max(1, Int((CGFloat(width) / max(1, rep.size.width)).rounded())) + let columns = (width - 1) / step + guard columns > 2, height > 0 else { return [] } + // Runs at keypress time on full panes and overlays: no allocation + // per row, plain loops over the pixels. + let current = UnsafeMutableBufferPointer.allocate(capacity: columns) + let next = UnsafeMutableBufferPointer.allocate(capacity: columns) + defer { + current.deallocate() + next.deallocate() + } + func fill(_ buffer: UnsafeMutableBufferPointer, row y: Int, and previous: UnsafeMutableBufferPointer?) { + let pixels = UnsafeRawPointer(data + y * rowBytes).assumingMemoryBound(to: UInt32.self) + var column = 0, offset = 0 + if let previous { + while column < columns { + buffer[column] = previous[column] && pixels[offset] == pixels[offset + step] + column += 1 + offset += step + } + } else { + while column < columns { + buffer[column] = pixels[offset] == pixels[offset + step] + column += 1 + offset += step + } + } + } + // A run reaching the trailing edge is padding past the last item, + // not a stretch between items. + func widest(_ buffer: UnsafeMutableBufferPointer) -> Int { + var best = 0, run = 0 + for column in 0.. best, column < columns - 1 || run == columns { best = run } + } + return best + } + /// Start of the trailing-most run at least 60% of the widest. + /// End of the widest run (where the trailing items start); of + /// near-equal ones (centred content between margins), the + /// trailing-most. + func seamEnd(_ buffer: UnsafeMutableBufferPointer, widest: Int) -> Int { + var found = 0, run = 0 + for column in 0.. 0, run >= widest - 2, !buffer[column + 1] { found = column } + } + return found + } + var result: [(rows: Range, seam: Int?)] = [] + func close(_ rows: Range) { + let best = widest(current) + guard best > 0, best < columns * 9 / 10 else { + result.append((rows, nil)) + return + } + result.append((rows, (seamEnd(current, widest: best) + 1) * step)) + } + // Rows alike all the way across (padding, a separator) end a band: + // a bar and the line or bar under it are pictured apart. + var bandStart = 0 + var inBand = false + var previousRow = 0 + var y = 0 + while y < height { + let row = data + y * rowBytes + if Self.rowIsUniform(row, width: width) { + if inBand { close(bandStart..= 4 { + _ = current.update(fromContentsOf: next) + } else { + close(bandStart.. [NSView] { + bands.filter { trailingEdge.contains($0.key) }.values.flatMap { $0 }.map { band in + let view = SidebarSlidePictureView(frame: container.convert(band.rect, from: reference)) + view.wantsLayer = true + view.image = band.image + container.addSubview(view, positioned: .above, relativeTo: nil) + return view + } + } +} + +/// Shows a picture and takes no clicks. Drawing goes through `updateLayer`, +/// so a display pass (the atomic commit runs one) keeps the picture instead +/// of repainting the layer with an empty `draw(_:)`. +final class SidebarSlidePictureView: NSView { + var image: CGImage? { + didSet { layer?.contents = image } + } + + override var wantsUpdateLayer: Bool { true } + + override func updateLayer() { + layer?.contents = image + layer?.contentsGravity = .resize + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} diff --git a/Sources/Sidebar/SidebarSlidePaneGlide.swift b/Sources/Sidebar/SidebarSlidePaneGlide.swift index 90d2394ec42d..4ea87e00ca20 100644 --- a/Sources/Sidebar/SidebarSlidePaneGlide.swift +++ b/Sources/Sidebar/SidebarSlidePaneGlide.swift @@ -1,157 +1,7 @@ import AppKit +import WebKit import QuartzCore -/// Where Bonsplit's panes and split views sit, in the content root's -/// coordinates, for one layout (docked or hidden). -/// -/// Bonsplit keeps split ratios, so the hidden layout's panes are wider in -/// proportion and every pane edge but the leading one rests elsewhere than -/// the docked edge shifted by the sidebar width. -@MainActor -struct SidebarSlidePaneLayout { - struct Split { - let rect: NSRect - let first: NSRect - let second: NSRect - let isVertical: Bool - let thickness: CGFloat - } - - private(set) var panes: [ObjectIdentifier: NSRect] = [:] - private(set) var splits: [ObjectIdentifier: Split] = [:] - /// Each side of each split (a Bonsplit slot), which carries everything - /// drawn inside it. - private(set) var slots: [ObjectIdentifier: NSRect] = [:] - private var views: [ObjectIdentifier: WeakView] = [:] - - private struct WeakView { - weak var view: NSView? - } - - func view(_ id: ObjectIdentifier) -> NSView? { views[id]?.view } - - var isEmpty: Bool { panes.isEmpty } - - static func measure(in reference: NSView) -> Self { - var layout = Self() - func rect(_ view: NSView) -> NSRect { reference.convert(view.bounds, from: view) } - func walk(_ view: NSView) { - // Hidden workspaces stay mounted at zero opacity. - guard !view.isHidden, view.alphaValue > 0, (view.layer?.opacity ?? 1) > 0 else { return } - let id = ObjectIdentifier(view) - if SidebarSlideTabRowCapture.isSplitSlot(view) { - layout.slots[id] = rect(view) - layout.views[id] = WeakView(view: view) - } - if let split = splitView(view) { - let slots = split.arrangedSubviews - layout.splits[id] = Split( - rect: rect(split), - first: rect(slots[0]), - second: rect(slots[1]), - isVertical: split.isVertical, - thickness: max(split.dividerThickness, 1) - ) - layout.views[id] = WeakView(view: split) - } else if isLeaf(view) { - layout.panes[id] = rect(view) - layout.views[id] = WeakView(view: view) - return - } - view.subviews.forEach(walk) - } - walk(reference) - return layout - } - - /// A Bonsplit split view: two sides, each a split slot. - static func splitView(_ view: NSView) -> NSSplitView? { - guard let split = view as? NSSplitView, split.arrangedSubviews.count == 2, - split.arrangedSubviews.allSatisfy(SidebarSlideTabRowCapture.isSplitSlot) else { return nil } - return split - } - - /// A pane's own hosting view: in a split slot, or a lone pane, and not - /// itself holding a further split. - private static func isLeaf(_ view: NSView) -> Bool { - let inSlot = view.superview.map(SidebarSlideTabRowCapture.isSplitSlot) == true - guard inSlot || NSStringFromClass(type(of: view)).contains("PaneContainerView") else { return false } - func holdsSplit(_ view: NSView) -> Bool { - view.subviews.contains { splitView($0) != nil || holdsSplit($0) } - } - return !holdsSplit(view) - } - - /// The same panes, rect for rect, within half a point. - func matches(_ other: Self) -> Bool { - func close(_ a: NSRect, _ b: NSRect) -> Bool { - abs(a.minX - b.minX) < 0.5 && abs(a.width - b.width) < 0.5 && abs(a.minY - b.minY) < 0.5 && abs(a.height - b.height) < 0.5 - } - guard panes.count == other.panes.count, splits.count == other.splits.count else { return false } - return panes.allSatisfy { id, rect in other.panes[id].map { close($0, rect) } == true } - } - - /// The docked layout predicted from this (hidden) one by Bonsplit's - /// rule: the root loses the sidebar width at its leading edge, and each - /// vertical split puts its divider at the pixel-rounded fraction of its - /// available width. Used when no docked layout was seen for this one. - func predictedDocked(in reference: NSView, sidebarWidth: CGFloat) -> Self { - var docked = Self() - docked.views = views - let scale = reference.window?.backingScaleFactor ?? 2 - func rect(_ view: NSView) -> NSRect { reference.convert(view.bounds, from: view) } - func assign(_ view: NSView, hiddenSlot: NSRect, dockedSlot: NSRect) { - let id = ObjectIdentifier(view) - let own = rect(view) - let mapped = NSRect( - x: own.minX - hiddenSlot.minX + dockedSlot.minX, - y: own.minY, - width: own.width - (hiddenSlot.width - dockedSlot.width), - height: own.height - ) - if panes[id] != nil { - docked.panes[id] = mapped - return - } - if slots[id] != nil { - docked.slots[id] = mapped - } - if let split = splits[id], let splitView = Self.splitView(view) { - var first = NSRect(x: mapped.minX, y: split.first.minY, width: mapped.width, height: split.first.height) - var second = NSRect(x: mapped.minX, y: split.second.minY, width: mapped.width, height: split.second.height) - if split.isVertical { - let available = split.rect.width - split.thickness - let fraction = available > 0 ? split.first.width / available : 0.5 - let position = ((mapped.width - split.thickness) * fraction * scale).rounded() / scale - first.size.width = position - second.origin.x = mapped.minX + position + split.thickness - second.size.width = max(0, mapped.maxX - second.minX) - } - docked.splits[id] = Split(rect: mapped, first: first, second: second, isVertical: split.isVertical, thickness: split.thickness) - let slots = splitView.arrangedSubviews - assign(slots[0], hiddenSlot: rect(slots[0]), dockedSlot: first) - assign(slots[1], hiddenSlot: rect(slots[1]), dockedSlot: second) - return - } - for subview in view.subviews where !subview.isHidden { - assign(subview, hiddenSlot: own, dockedSlot: mapped) - } - } - func roots(_ view: NSView) { - guard !view.isHidden else { return } - let id = ObjectIdentifier(view) - if panes[id] != nil || splits[id] != nil { - let own = rect(view) - assign(view, hiddenSlot: own, dockedSlot: NSRect(x: own.minX + sidebarWidth, y: own.minY, width: own.width - sidebarWidth, height: own.height)) - return - } - view.subviews.forEach(roots) - } - roots(reference) - return docked - } -} - /// Per-pane motion for the toggle slide, on top of the content root's. /// /// Each pane (its Bonsplit hosting view and its portal-hosted terminal or @@ -166,16 +16,37 @@ final class SidebarSlidePaneGlide { private(set) var animations: [SidebarSlideGlide.Layer] = [] private(set) var overlay: NSView? private var masked: [CALayer] = [] + private var pictures: [CALayer] = [] + private var hidden: [(layer: CALayer, mask: CALayer?)] = [] + private var undrawn: [(layer: CALayer, contentsRect: CGRect)] = [] + + /// Hides a view's own drawing (not its subviews) for the slide: a + /// split view's divider is drawn at the hidden layout's x in its frame, + /// and would show through a translucent terminal next to the strip that + /// stands in for it. + private func undraw(_ layer: CALayer) { + undrawn.append((layer, layer.contentsRect)) + layer.contentsRect = .zero + } + + /// Hides a view's drawing for the slide with an empty mask: AppKit owns + /// a view layer's opacity and hidden state and resets them, and a + /// portal re-adding the view drops its animations, but it leaves masks. + private func hide(_ layer: CALayer) { + hidden.append((layer, layer.mask)) + layer.mask = CALayer() + } init?( reference: NSView, container: NSView, above: NSView, - hostedViews: [NSView], + portalViews: [SidebarSlideStart.PortalView], hidden: SidebarSlidePaneLayout, docked: SidebarSlidePaneLayout, sidebarWidth: CGFloat, - lanes: [ObjectIdentifier: (image: CGImage, rect: NSRect)] + chrome: [ObjectIdentifier: [SidebarSlidePaneChrome.Band]], + tabRow: SidebarSlideTabRowCapture? ) { guard sidebarWidth > 0, !hidden.isEmpty else { return nil } let width = Double(sidebarWidth) @@ -184,6 +55,23 @@ final class SidebarSlidePaneGlide { } func rect(_ view: NSView) -> NSRect { reference.convert(view.bounds, from: view) } + // Strips and pictures ride above everything that moves. + let overlay = PictureLayerHostView(frame: container.convert(reference.bounds, from: reference)) + overlay.wantsLayer = true + container.addSubview(overlay, positioned: .above, relativeTo: above) + guard let overlayLayer = overlay.layer else { + overlay.removeFromSuperview() + return nil + } + self.overlay = overlay + func addLayer(_ layer: CALayer, at frameInReference: NSRect) { + let frame = overlay.convert(frameInReference, from: reference) + layer.anchorPoint = .zero + layer.position = frame.origin + layer.bounds = CGRect(origin: .zero, size: frame.size) + overlayLayer.addSublayer(layer) + } + // Each split slot moves with its own leading edge, relative to the // slot around it, and clips at its own trailing edge; a lone pane // only clips. Panes inside slots ride along. @@ -203,35 +91,29 @@ final class SidebarSlidePaneGlide { let parent = enclosingSlot(view).map(absoluteFactor) ?? 0 glide(view, factor: absoluteFactor(id) - parent, frame: hiddenRect, widthChange: dockedRect.width - hiddenRect.width, sidebarWidth: width) } - // The hosted views follow their pane's absolute motion. + // The portals' views (terminals, browser pages) follow the pane + // their anchor sits in, by its absolute motion; neither portal is + // inside a slot. var paneMotion: [(hidden: NSRect, docked: NSRect)] = [] for (id, hiddenRect) in hidden.panes { if let dockedRect = docked.panes[id] { paneMotion.append((hiddenRect, dockedRect)) } } - for view in hostedViews where !view.isHidden && view.alphaValue > 0 - && !NSStringFromClass(type(of: view)).contains("Overlay") { - let frame = rect(view) - guard frame.width > 1, let motion = paneMotion.first(where: { $0.hidden.contains(NSPoint(x: frame.midX, y: frame.midY)) }) else { continue } - glide(view, factor: factor(motion.hidden.minX, motion.docked.minX), frame: frame, widthChange: motion.docked.width - motion.hidden.width, sidebarWidth: width) - } - // Strips and pictures ride above everything that moves. - let overlay = PictureLayerHostView(frame: container.convert(reference.bounds, from: reference)) - overlay.wantsLayer = true - container.addSubview(overlay, positioned: .above, relativeTo: above) - guard let overlayLayer = overlay.layer else { - overlay.removeFromSuperview() - return nil - } - self.overlay = overlay - func addLayer(_ layer: CALayer, at frameInReference: NSRect) { - let frame = overlay.convert(frameInReference, from: reference) - layer.anchorPoint = .zero - layer.position = frame.origin - layer.bounds = CGRect(origin: .zero, size: frame.size) - overlayLayer.addSublayer(layer) + for portalView in portalViews { + guard let id = hidden.pane(containing: portalView.anchor), + let hiddenRect = hidden.panes[id], let dockedRect = docked.panes[id] else { continue } + let widthChange = dockedRect.width - hiddenRect.width + let leading = factor(hiddenRect.minX, dockedRect.minX) + glide(portalView.view, factor: leading, frame: rect(portalView.view), widthChange: widthChange, sidebarWidth: width) + followContent(of: portalView.view, widthChange: widthChange, sidebarWidth: width) } for (id, split) in hidden.splits { guard let dockedSplit = docked.splits[id], let view = hidden.view(id) as? NSSplitView else { continue } + // AppKit draws a layer-backed view into its layer or into a + // content sublayer of its own (not a subview's). + if let layer = view.layer { + undraw(layer) + (layer.sublayers ?? []).filter { !($0.delegate is NSView) }.forEach(undraw) + } var color: CGColor? view.effectiveAppearance.performAsCurrentDrawingAppearance { color = view.dividerColor.cgColor } let strip = CALayer() @@ -245,28 +127,71 @@ final class SidebarSlidePaneGlide { animations.append(Self.width(strip, from: split.rect.width, to: dockedSplit.rect.width, sidebarWidth: width)) } } - for (id, lane) in lanes { - guard let hiddenRect = hidden.panes[id], let dockedRect = docked.panes[id] else { continue } - let picture = CALayer() - picture.contents = lane.image - picture.contentsGravity = .resize - let laneWidth = lane.rect.width - addLayer(picture, at: NSRect(x: hiddenRect.maxX - laneWidth, y: lane.rect.minY, width: laneWidth, height: lane.rect.height)) - animations.append(.init(layer: picture, keyPath: "transform.translation.x", factor: factor(hiddenRect.maxX, dockedRect.maxX))) + // Each pane's trailing chrome rides its trailing edge, inside the + // pane's own clip (its slot, or a lone pane itself), so a picture + // can never leave its pane. + for (id, bands) in chrome { + guard let pane = hidden.view(id), let hiddenRect = hidden.panes[id], let dockedRect = docked.panes[id] else { continue } + let clipId = enclosingSlot(pane) ?? id + guard let clip = hidden.view(clipId), let clipLayer = clip.layer else { continue } + let relative = factor(hiddenRect.maxX, dockedRect.maxX) - absoluteFactor(clipId) + for band in bands { + let picture = CALayer() + picture.contents = band.image + picture.contentsGravity = .resize + picture.zPosition = 1_000 + picture.name = SidebarSlidePaneGlide.trailingPictureName + picture.frame = clip.convert(band.rect, from: reference) + clipLayer.addSublayer(picture) + pictures.append(picture) + animations.append(.init(layer: picture, keyPath: "transform.translation.x", factor: relative)) + } } #if DEBUG - SidebarNavigationTimings.record("slide.panes slots=\(hidden.slots.count) panes=\(paneMotion.map { "\($0.hidden.minX)-\($0.hidden.maxX)>\($0.docked.minX)-\($0.docked.maxX)" }) splits=\(hidden.splits.count) animations=\(animations.count) lanes=\(lanes.count)") + SidebarNavigationTimings.record("slide.panes slots=\(hidden.slots.count) panes=\(paneMotion.map { "\($0.hidden.minX)-\($0.hidden.maxX)>\($0.docked.minX)-\($0.docked.maxX)" }) splits=\(hidden.splits.count) animations=\(animations.count) chrome=\(chrome.values.map(\.count))") #endif - // The portal's own divider overlay draws at the hidden layout's - // dividers; the strips stand in for it until the landing. - // An animation, not the model value: AppKit owns a view layer's - // opacity and would put it back on the next display pass. - for view in hostedViews where NSStringFromClass(type(of: view)).contains("SplitDividerOverlayView") { - guard let layer = view.layer else { continue } - animations.append(.init(layer: layer, keyPath: "opacity", factor: 0, base: 0)) + // Minimal mode's leading tabs: ground and picture inside the + // leading pane's clip. + if let tabRow, let id = hidden.panes.first(where: { $0.value.insetBy(dx: -1, dy: -1).contains(NSPoint(x: tabRow.groundRect.minX + 1, y: tabRow.groundRect.midY)) })?.key, + let pane = hidden.view(id) { + let clipId = enclosingSlot(pane) ?? id + if let clip = hidden.view(clipId), let clipLayer = clip.layer { + let layers = tabRow.makeLayers(in: clip, from: reference) + clipLayer.addSublayer(layers.ground) + clipLayer.addSublayer(layers.picture) + pictures += [layers.ground, layers.picture] + animations.append(.init(layer: layers.picture, keyPath: "transform.translation.x", factor: tabRow.factor - absoluteFactor(clipId))) + } + } + // Overlays painting at every pane's hidden-layout rect at once (the + // terminal portal's divider, pane-swap and drop-zone overlays, the + // tmux rings, a cloud failure card) can follow no single pane, and + // are hidden for the slide. + for view in Self.paneWideOverlays(in: container, portalViews: portalViews) { + if let layer = view.layer { hide(layer) } } } + /// Names the pictures that ride a pane's trailing edge (for checks). + static let trailingPictureName = "cmux.slide.trailingPicture" + + /// Window-level views known to paint per-pane geometry across panes. + static let paneWideOverlayIdentifiers: Set = [ + "cmux.tmuxWorkspacePane.overlay.container", + "cmux.cloudPaneCreationFailure.card", + ] + + private static func paneWideOverlays(in container: NSView, portalViews: [SidebarSlideStart.PortalView]) -> [NSView] { + let hosts = Set(portalViews.compactMap { $0.view.superview }.map(ObjectIdentifier.init)) + let portalOverlays = container.subviews.flatMap { host -> [NSView] in + guard hosts.contains(ObjectIdentifier(host)) || host is WindowBrowserHostView else { return [] } + let entries = Set(portalViews.map { ObjectIdentifier($0.view) }) + return host.subviews.filter { !entries.contains(ObjectIdentifier($0)) && !$0.isHidden } + } + let windowOverlays = container.subviews.filter { paneWideOverlayIdentifiers.contains($0.identifier?.rawValue ?? "") } + return portalOverlays + windowOverlays + } + /// The divider gap between a split's two sides. private static func divider(_ split: SidebarSlidePaneLayout.Split) -> NSRect { if split.isVertical { @@ -289,7 +214,13 @@ final class SidebarSlidePaneGlide { if factor != 0 { animations.append(.init(layer: layer, keyPath: "transform.translation.x", factor: factor)) } - guard layer.mask == nil else { return } + if let existing = layer.mask { +#if DEBUG + SidebarNavigationTimings.record("slide.mask existing on \(NSStringFromClass(type(of: view)))") +#endif + _ = existing + return + } let mask = CALayer() mask.backgroundColor = NSColor.black.cgColor let bleed: CGFloat = 10_000 @@ -301,9 +232,96 @@ final class SidebarSlidePaneGlide { animations.append(Self.width(mask, from: frame.width, to: frame.width + widthChange, sidebarWidth: width)) } + /// What a portal view (terminal, browser page) draws on top of its + /// content keeps its own anchoring, found by geometry: AppKit pieces in + /// its trailing half that hug its trailing edge (a scroller, a badge) + /// follow that edge, centred ones its centre, and drawn overlays (find + /// bars, rings, borders: any hosting or overlay view) are pictured and + /// cut at a seam like the panes' chrome, both parts inside the view's + /// own clip, the trailing part riding the trailing edge, while the live + /// overlay is hidden for the slide. + private func followContent(of view: NSView, widthChange: CGFloat, sidebarWidth width: Double) { + let bounds = view.bounds + let trailing = Double(widthChange) / width + guard let host = view.layer else { return } + func split(_ overlay: NSView) { + guard let layer = overlay.layer, Self.mayDrawShapes(layer), + let rep = overlay.bitmapImageRepForCachingDisplay(in: overlay.bounds) else { return } + overlay.cacheDisplay(in: overlay.bounds, to: rep) + // Nothing drawn, or nothing trailing: it rides with its view. + guard Self.hasInk(rep) else { return } + let bands = SidebarSlidePaneChrome.seams(in: rep) + guard bands.contains(where: { $0.seam != nil }) else { return } + let rect = view.convert(overlay.bounds, from: overlay) + hide(layer) + for band in bands { + let seam = band.seam ?? rep.pixelsWide + for (columns, factor) in [(0.. 0 { + let frame = view.convert(child.bounds, from: child) + let name = NSStringFromClass(type(of: child)) + if frame.width < bounds.width * 0.6 { + guard let layer = child.layer else { continue } + if frame.minX >= bounds.midX, bounds.maxX - frame.maxX <= 48 { + animations.append(.init(layer: layer, keyPath: "transform.translation.x", factor: trailing)) + } else if abs(frame.midX - bounds.midX) <= 4 { + animations.append(.init(layer: layer, keyPath: "transform.translation.x", factor: trailing / 2)) + } + } else if name.contains("HostingView") || name.contains("Overlay") { + split(child) + } else { + visit(child) + } + } + } + visit(view) + } + + /// Whether a layer tree may draw something with a shape (and so a + /// trailing part): one that draws nothing (an idle ring, an empty host) + /// or only flat fills (a dimming veil) has none. Known without + /// rendering it. + private static func mayDrawShapes(_ layer: CALayer) -> Bool { + if layer.isHidden || layer.opacity == 0 { return false } + if layer.contents != nil || layer.borderWidth > 0 { return true } + if let shape = layer as? CAShapeLayer, shape.path != nil { return true } + if layer is CATextLayer || layer is CAGradientLayer { return true } + return (layer.sublayers ?? []).contains(where: mayDrawShapes) + } + + /// Whether any sampled pixel has some opacity. + private static func hasInk(_ rep: NSBitmapImageRep) -> Bool { + guard rep.hasAlpha, let data = rep.bitmapData, rep.bitsPerPixel == 32 else { return true } + let alphaMask: UInt32 = rep.bitmapFormat.contains(.alphaFirst) ? 0x0000_00FF : 0xFF00_0000 + for y in stride(from: 0, to: rep.pixelsHigh, by: 4) { + let row = UnsafeRawPointer(data + y * rep.bytesPerRow).assumingMemoryBound(to: UInt32.self) + for x in stride(from: 0, to: rep.pixelsWide, by: 4) where row[x] & alphaMask != 0 { return true } + } + return false + } + func tearDown(animationKey: String) { animations.forEach { $0.layer.removeAnimation(forKey: animationKey) } masked.forEach { $0.mask = nil } + hidden.forEach { $0.layer.mask = $0.mask } + pictures.forEach { $0.removeFromSuperlayer() } + undrawn.forEach { $0.layer.contentsRect = $0.contentsRect } overlay?.removeFromSuperview() } @@ -316,29 +334,78 @@ final class SidebarSlidePaneGlide { } } -/// What a slide needs from the layout on screen at the press: pictures of -/// the tab bars, and on a hide (taken before the hidden layout commits) the -/// docked panes' rects. +/// What a slide needs from the layout on screen: the panes' rects, the +/// portals' views, and pictures of the panes' trailing chrome. A show takes +/// it all at the press; a hide takes the docked rects before its commit and +/// the rest from the hidden layout right after, which is what is live for +/// the whole slide. @MainActor struct SidebarSlideStart { + /// A view a portal draws over a pane, with the anchor that places it. + struct PortalView { + let view: NSView + let anchor: NSView + } + var tabRow: SidebarSlideTabRowCapture? - var lanes: SidebarSlideTrailingChromeCapture? + var chrome: SidebarSlidePaneChrome? var docked: SidebarSlidePaneLayout? + /// A show's start is the hidden layout itself. + var hidden: SidebarSlidePaneLayout? + + /// DEBUG-only A/B switch (`CMUX_SIDEBAR_SLIDE_RIGID=1`): the old rigid + /// slide, for measuring what per-pane motion costs. + static let isRigid: Bool = { +#if DEBUG + return ProcessInfo.processInfo.environment["CMUX_SIDEBAR_SLIDE_RIGID"] != nil +#else + return false +#endif + }() + + /// The docked layout, taken before a hide commits. + static func dockedLayout(in window: NSWindow) -> SidebarSlidePaneLayout? { + guard !isRigid, let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView else { return nil } + return SidebarSlidePaneLayout.measure(in: reference) + } - static func capture(in window: NSWindow, docked: Bool, inset: CGFloat, sidebarWidth: CGFloat, buttonCount: Int) -> Self { - guard let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView else { return Self() } + /// Everything else, from the layout live for the slide. + static func capture(in window: NSWindow, docked: SidebarSlidePaneLayout?, inset: CGFloat, sidebarWidth: CGFloat, buttonCount: Int) -> Self { + guard !isRigid, let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView else { return Self() } + let layout = SidebarSlidePaneLayout.measure(in: reference) + // Content no picture can show (portal views, and web views a panel + // hosts in the pane itself) is left out of the chrome strips. + var covered = portalViews(in: window).map { reference.convert($0.view.bounds, from: $0.view) }.filter { $0.width > 1 } + func findWebViews(_ view: NSView) { + if view is WKWebView, !view.isHiddenOrHasHiddenAncestor { + covered.append(reference.convert(view.bounds, from: view)) + return + } + view.subviews.forEach(findWebViews) + } + layout.panes.keys.compactMap(layout.view).forEach(findWebViews) return Self( - tabRow: SidebarSlideTabRowCapture.capture(in: reference, docked: docked, inset: inset, sidebarWidth: sidebarWidth), - lanes: SidebarSlideTrailingChromeCapture.capture(in: reference, buttonCount: buttonCount), - docked: docked ? SidebarSlidePaneLayout.measure(in: reference) : nil + tabRow: SidebarSlideTabRowCapture.capture(in: reference, docked: false, inset: inset, sidebarWidth: sidebarWidth), + chrome: SidebarSlidePaneChrome.capture(in: reference, layout: layout, covered: covered, buttonCount: buttonCount), + docked: docked, + hidden: layout ) } - /// The terminal and browser views the portals draw over the panes. - static func hostedViews(in window: NSWindow) -> [NSView] { - guard let portal = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)], - let container = portal.installedReferenceView?.superview else { return [] } - let browsers = container.subviews.filter { $0 is WindowBrowserHostView }.flatMap(\.subviews) - return portal.hostView.subviews + browsers + /// The terminal and browser views the portals show, from the portals' + /// own entries (wherever each portal is installed). + static func portalViews(in window: NSWindow) -> [PortalView] { + var result: [PortalView] = [] + func add(_ view: NSView?, _ anchor: NSView?) { + guard let view, let anchor, !view.isHiddenOrHasHiddenAncestor, view.alphaValue > 0, view.frame.width > 1 else { return } + result.append(PortalView(view: view, anchor: anchor)) + } + if let portal = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)] { + portal.entriesByHostedId.values.forEach { add($0.hostedView, $0.anchorView) } + } + if let portal = BrowserWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)] { + portal.entriesByWebViewId.values.forEach { add($0.containerView, $0.anchorView) } + } + return result } } diff --git a/Sources/Sidebar/SidebarSlidePaneLayout.swift b/Sources/Sidebar/SidebarSlidePaneLayout.swift new file mode 100644 index 000000000000..680564349e9a --- /dev/null +++ b/Sources/Sidebar/SidebarSlidePaneLayout.swift @@ -0,0 +1,158 @@ +import AppKit + +/// Where Bonsplit's panes and split views sit, in the content root's +/// coordinates, for one layout (docked or hidden). +/// +/// Bonsplit keeps split ratios, so the hidden layout's panes are wider in +/// proportion and every pane edge but the leading one rests elsewhere than +/// the docked edge shifted by the sidebar width. +@MainActor +struct SidebarSlidePaneLayout { + struct Split { + let rect: NSRect + let first: NSRect + let second: NSRect + let isVertical: Bool + let thickness: CGFloat + } + + private(set) var panes: [ObjectIdentifier: NSRect] = [:] + private(set) var splits: [ObjectIdentifier: Split] = [:] + /// Each side of each split (a Bonsplit slot), which carries everything + /// drawn inside it. + private(set) var slots: [ObjectIdentifier: NSRect] = [:] + private var views: [ObjectIdentifier: WeakView] = [:] + + private struct WeakView { + weak var view: NSView? + } + + func view(_ id: ObjectIdentifier) -> NSView? { views[id]?.view } + + /// The pane a view (a portal anchor) sits in. + func pane(containing view: NSView) -> ObjectIdentifier? { + sequence(first: view, next: { $0.superview }).lazy.map(ObjectIdentifier.init).first { panes[$0] != nil } + } + + var isEmpty: Bool { panes.isEmpty } + + static func measure(in reference: NSView) -> Self { + var layout = Self() + func rect(_ view: NSView) -> NSRect { reference.convert(view.bounds, from: view) } + + func walk(_ view: NSView) { + // Hidden workspaces stay mounted at zero opacity. + guard !view.isHidden, view.alphaValue > 0, (view.layer?.opacity ?? 1) > 0 else { return } + let id = ObjectIdentifier(view) + if SidebarSlideTabRowCapture.isSplitSlot(view) { + layout.slots[id] = rect(view) + layout.views[id] = WeakView(view: view) + } + if let split = splitView(view) { + let slots = split.arrangedSubviews + layout.splits[id] = Split( + rect: rect(split), + first: rect(slots[0]), + second: rect(slots[1]), + isVertical: split.isVertical, + thickness: max(split.dividerThickness, 1) + ) + layout.views[id] = WeakView(view: split) + } else if isLeaf(view) { + layout.panes[id] = rect(view) + layout.views[id] = WeakView(view: view) + return + } + view.subviews.forEach(walk) + } + walk(reference) + return layout + } + + /// A Bonsplit split view: two sides, each a split slot. + static func splitView(_ view: NSView) -> NSSplitView? { + guard let split = view as? NSSplitView, split.arrangedSubviews.count == 2, + split.arrangedSubviews.allSatisfy(SidebarSlideTabRowCapture.isSplitSlot) else { return nil } + return split + } + + /// A pane's own hosting view: in a split slot, or a lone pane, and not + /// itself holding a further split. + private static func isLeaf(_ view: NSView) -> Bool { + let inSlot = view.superview.map(SidebarSlideTabRowCapture.isSplitSlot) == true + guard inSlot || NSStringFromClass(type(of: view)).contains("PaneContainerView") else { return false } + func holdsSplit(_ view: NSView) -> Bool { + view.subviews.contains { splitView($0) != nil || holdsSplit($0) } + } + return !holdsSplit(view) + } + + /// The same panes, rect for rect, within half a point. + func matches(_ other: Self) -> Bool { + func close(_ a: NSRect, _ b: NSRect) -> Bool { + abs(a.minX - b.minX) < 0.5 && abs(a.width - b.width) < 0.5 && abs(a.minY - b.minY) < 0.5 && abs(a.height - b.height) < 0.5 + } + guard panes.count == other.panes.count, splits.count == other.splits.count else { return false } + return panes.allSatisfy { id, rect in other.panes[id].map { close($0, rect) } == true } + } + + /// The docked layout predicted from this (hidden) one by Bonsplit's + /// rule: the root loses the sidebar width at its leading edge, and each + /// vertical split puts its divider at the pixel-rounded fraction of its + /// available width. Used when no docked layout was seen for this one. + func predictedDocked(in reference: NSView, sidebarWidth: CGFloat) -> Self { + var docked = Self() + docked.views = views + let scale = reference.window?.backingScaleFactor ?? 2 + func rect(_ view: NSView) -> NSRect { reference.convert(view.bounds, from: view) } + func assign(_ view: NSView, hiddenSlot: NSRect, dockedSlot: NSRect) { + let id = ObjectIdentifier(view) + let own = rect(view) + let mapped = NSRect( + x: own.minX - hiddenSlot.minX + dockedSlot.minX, + y: own.minY, + width: own.width - (hiddenSlot.width - dockedSlot.width), + height: own.height + ) + if panes[id] != nil { + docked.panes[id] = mapped + return + } + if slots[id] != nil { + docked.slots[id] = mapped + } + if let split = splits[id], let splitView = Self.splitView(view) { + var first = NSRect(x: mapped.minX, y: split.first.minY, width: mapped.width, height: split.first.height) + var second = NSRect(x: mapped.minX, y: split.second.minY, width: mapped.width, height: split.second.height) + if split.isVertical { + let available = split.rect.width - split.thickness + let fraction = available > 0 ? split.first.width / available : 0.5 + let position = ((mapped.width - split.thickness) * fraction * scale).rounded() / scale + first.size.width = position + second.origin.x = mapped.minX + position + split.thickness + second.size.width = max(0, mapped.maxX - second.minX) + } + docked.splits[id] = Split(rect: mapped, first: first, second: second, isVertical: split.isVertical, thickness: split.thickness) + let slots = splitView.arrangedSubviews + assign(slots[0], hiddenSlot: rect(slots[0]), dockedSlot: first) + assign(slots[1], hiddenSlot: rect(slots[1]), dockedSlot: second) + return + } + for subview in view.subviews where !subview.isHidden { + assign(subview, hiddenSlot: own, dockedSlot: mapped) + } + } + func roots(_ view: NSView) { + guard !view.isHidden else { return } + let id = ObjectIdentifier(view) + if panes[id] != nil || splits[id] != nil { + let own = rect(view) + assign(view, hiddenSlot: own, dockedSlot: NSRect(x: own.minX + sidebarWidth, y: own.minY, width: own.width - sidebarWidth, height: own.height)) + return + } + view.subviews.forEach(roots) + } + roots(reference) + return docked + } +} diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 5d9de1770746..6a2a43fe810f 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -32,10 +32,10 @@ final class SidebarToggleAnimator: ObservableObject { private var dockedLayoutWillCommit: (Bool) -> Void = { _ in } /// How much further right the tab bar's first tab rests hidden. private var tabBarInsetDelta: () -> CGFloat = { 0 } + /// A hide's docked pane rects, taken before the hidden layout commits. + private var pendingDocked: SidebarSlidePaneLayout? /// How many action buttons a pane's tab bar shows on its trailing end. private var splitButtonCount: () -> Int = { 0 } - /// A hide's start, taken before the hidden layout commits. - private var pendingStart: SidebarSlideStart? /// The last hide's hidden and docked pane layouts: a show over the same /// hidden layout lands on exactly that docked one. private var paneLayouts: (hidden: SidebarSlidePaneLayout, docked: SidebarSlidePaneLayout, width: CGFloat)? @@ -43,6 +43,7 @@ final class SidebarToggleAnimator: ObservableObject { /// What carries the running slide, captured when it starts and kept for /// every retarget until it lands. private var session: SidebarToggleSlideSession? + private var layoutObserver: NSObjectProtocol? /// Effects are being carried out; a press arriving meanwhile (the atomic /// commit runs the run loop once) waits its turn. private var isExecuting = false @@ -161,14 +162,14 @@ final class SidebarToggleAnimator: ObservableObject { } else { slide = nil } - pendingStart = captureStart(in: window, docked: true) + pendingDocked = SidebarSlideStart.dockedLayout(in: window) commitAtomically(in: window) { layout?.docksSidebar = false dockedLayoutWillCommit(false) } layers: { if let slide { addSlideAnimation(slide, in: window) } } - pendingStart = nil + pendingDocked = nil if let slide { machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) } case let .animate(slide): CATransaction.begin() @@ -246,16 +247,18 @@ final class SidebarToggleAnimator: ObservableObject { // drop the slide this was for; a session built for it would never land. guard machine.slide?.generation == slide.generation else { return } if session == nil, let views = Self.slidingViews(in: window), let layout { - let start = pendingStart ?? captureStart(in: window, docked: false) - pendingStart = nil + let start = captureStart(in: window, docked: pendingDocked) + pendingDocked = nil + let panes = SidebarSlideStart.isRigid ? nil : paneLayouts(for: start, reference: views[0], width: layout.width, window: window) session = SidebarToggleSlideSession( views: views, trailingStillWidth: trailingStillWidth(), titleGlide: layout.titlebarTitle?.glide(sidebarWidth: layout.width), tabRow: start.tabRow, - lanes: start.lanes, - panes: paneLayouts(for: start, reference: views[0], width: layout.width, window: window) + chrome: start.chrome, + panes: panes ) + watchLayout(in: window) } guard let session, !session.movingLayers.isEmpty else { // Nothing to move: land now, so the press still takes effect. @@ -288,7 +291,7 @@ final class SidebarToggleAnimator: ObservableObject { } } - private func captureStart(in window: NSWindow, docked: Bool) -> SidebarSlideStart { + private func captureStart(in window: NSWindow, docked: SidebarSlidePaneLayout?) -> SidebarSlideStart { SidebarSlideStart.capture(in: window, docked: docked, inset: tabBarInsetDelta(), sidebarWidth: layout?.width ?? 0, buttonCount: splitButtonCount()) } @@ -296,7 +299,7 @@ final class SidebarToggleAnimator: ObservableObject { /// hide's press, seen at the last hide over this same hidden layout, or /// failing both, predicted. private func paneLayouts(for start: SidebarSlideStart, reference: NSView, width: CGFloat, window: NSWindow) -> SidebarToggleSlideSession.Panes { - let hidden = SidebarSlidePaneLayout.measure(in: reference) + let hidden = start.hidden ?? SidebarSlidePaneLayout.measure(in: reference) let docked: SidebarSlidePaneLayout if let measured = start.docked { docked = measured @@ -306,7 +309,7 @@ final class SidebarToggleAnimator: ObservableObject { } else { docked = hidden.predictedDocked(in: reference, sidebarWidth: width) } - return .init(hidden: hidden, docked: docked, hostedViews: SidebarSlideStart.hostedViews(in: window), sidebarWidth: width) + return .init(hidden: hidden, docked: docked, portalViews: SidebarSlideStart.portalViews(in: window), sidebarWidth: width) } private func slideSpring(from: Double, to: Double, velocity: Double, duration: Double, keyPath: String = "transform.translation.x") -> CASpringAnimation { @@ -330,6 +333,28 @@ final class SidebarToggleAnimator: ObservableObject { private func removeSlideAnimations() { session?.tearDown(animationKey: Self.animationKey) session = nil + layoutObserver.map(NotificationCenter.default.removeObserver) + layoutObserver = nil + } + + /// The slide's motion is built for the pane layout at its start. A + /// split, a close or a workspace switch mid-slide resizes split views; + /// the slide then lands at once on the new layout instead of moving + /// stale geometry. + private func watchLayout(in window: NSWindow) { + layoutObserver = NotificationCenter.default.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, object: nil, queue: .main + ) { [weak self, weak window] note in + guard let splitView = note.object as? NSSplitView, let window, splitView.window === window, + SidebarSlidePaneLayout.splitView(splitView) != nil else { return } + MainActor.assumeIsolated { + guard let self, self.session != nil, let slide = self.machine.slide else { return } +#if DEBUG + SidebarNavigationTimings.record("slide.layoutChanged split=\(ObjectIdentifier(splitView).hashValue) frame=\(splitView.frame) executing=\(self.isExecuting)") +#endif + self.land(generation: slide.generation) + } + } } /// Idempotent: a stale or repeated landing does nothing. diff --git a/Sources/Sidebar/SidebarToggleSlideEdges.swift b/Sources/Sidebar/SidebarToggleSlideEdges.swift index ed0f87cf41dd..b4a362866e75 100644 --- a/Sources/Sidebar/SidebarToggleSlideEdges.swift +++ b/Sources/Sidebar/SidebarToggleSlideEdges.swift @@ -11,6 +11,33 @@ import QuartzCore enum SidebarToggleSlideEdges { static let isEnabled = ProcessInfo.processInfo.environment["CMUX_SIDEBAR_SLIDE_EDGES"] != nil + private static var frameCount = 0 + + /// DEBUG-only (`CMUX_SIDEBAR_SLIDE_LAYER_DUMP=`): every layer's + /// presented frame in window points at that frame of each slide, for + /// finding what draws where it should not. + static func dumpLayers(window: NSWindow?) { + guard let raw = ProcessInfo.processInfo.environment["CMUX_SIDEBAR_SLIDE_LAYER_DUMP"], let target = Int(raw), + let window, let rootView = window.contentView?.superview, let rootLayer = rootView.layer else { return } + frameCount += 1 + guard frameCount == target else { return } + let root = rootLayer.presentation() ?? rootLayer + var lines: [String] = [] + func visit(_ layer: CALayer, depth: Int) { + let presented = layer.presentation() ?? layer + let frame = presented.convert(presented.bounds, to: root) + let owner = (layer.delegate as? NSView).map { String(NSStringFromClass(type(of: $0)).suffix(48)) } ?? String(describing: type(of: layer)) + if frame.width > 0, frame.height > 0, !layer.isHidden, presented.opacity > 0 { + lines.append(String(repeating: " ", count: min(depth, 30)) + String(format: "%@ x=%.1f..%.1f y=%.1f..%.1f%@%@", owner, frame.minX, frame.maxX, frame.minY, frame.maxY, layer.mask != nil ? " masked" : "", layer.contents != nil ? " contents" : "")) + } + (layer.sublayers ?? []).forEach { visit($0, depth: depth + 1) } + } + visit(rootLayer, depth: 0) + SidebarNavigationTimings.record("slide.layers\n" + lines.joined(separator: "\n")) + } + + static func resetDump() { frameCount = 0 } + static func record(_ phase: String, window: NSWindow?) { guard isEnabled, let window, let reference = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView, @@ -20,11 +47,14 @@ enum SidebarToggleSlideEdges { let presents = phase != "landed" func shown(_ layer: CALayer) -> CALayer { presents ? (layer.presentation() ?? layer) : layer } let root = shown(rootLayer) - func span(_ view: NSView) -> String? { - guard let layer = view.layer else { return nil } + func onScreen(_ view: NSView) -> NSRect? { + // The nearest view with a layer carries the motion. + guard let host = sequence(first: view, next: { $0.superview }).first(where: { $0.layer != nil }), + let layer = host.layer else { return nil } let presented = shown(layer) - var rect = presented.convert(presented.bounds, to: root) - var current: NSView? = view + let local = host.convert(view.bounds, from: view) + var rect = presented.convert(local, to: root) + var current: NSView? = host while let ancestor = current, ancestor !== rootView { if let ancestorLayer = ancestor.layer, let mask = ancestorLayer.mask { // A mask's frame is in its layer's own coordinates. @@ -35,16 +65,44 @@ enum SidebarToggleSlideEdges { } current = ancestor.superview } - return String(format: "%.1f:%.1f", rect.minX, rect.maxX) + return rect + } + func span(_ view: NSView) -> String? { + onScreen(view).map { String(format: "%.1f:%.1f", $0.minX, $0.maxX) } + } + func box(_ rect: NSRect) -> String { + String(format: "%.1f:%.1f:%.1f:%.1f", rect.minX, rect.maxX, rect.minY, rect.maxY) } let layout = SidebarSlidePaneLayout.measure(in: reference) let panes = layout.panes.sorted { ($0.value.minY, $0.value.minX) < ($1.value.minY, $1.value.minX) } .compactMap { layout.view($0.key).flatMap(span) } - let hosted = SidebarSlideStart.hostedViews(in: window) - .filter { !$0.isHidden && $0.alphaValue > 0 && $0.frame.width > 1 && !NSStringFromClass(type(of: $0)).contains("Overlay") } - .sorted { ($0.frame.minY, $0.frame.minX) < ($1.frame.minY, $1.frame.minX) } - .compactMap(span) - SidebarNavigationTimings.record("slide.edges phase=\(phase) t=\(String(format: "%.4f", CACurrentMediaTime())) panes=\(panes.joined(separator: ",")) hosted=\(hosted.joined(separator: ","))") + let hosted = SidebarSlideStart.portalViews(in: window).map(\.view) + .compactMap { onScreen($0) } + .sorted { ($0.minY, $0.minX) < ($1.minY, $1.minX) } + .map { String(format: "%.1f:%.1f", $0.minX, $0.maxX) } + // Tabs (live), and the pictures the slide shows (none may sit on a + // live tab). + var chips: [NSRect] = [] + func findTabs(_ view: NSView) { + if SidebarSlideTabRowCapture.isTabItemRegion(view), !view.isHiddenOrHasHiddenAncestor, !view.visibleRect.isEmpty, + let rect = onScreen(view) { + chips.append(rect) + } + view.subviews.forEach(findTabs) + } + findTabs(reference) + chips.sort { ($0.minY, $0.minX) < ($1.minY, $1.minX) } + var pictures: [NSRect] = [] + func findPictures(_ layer: CALayer) { + if layer.name == SidebarSlidePaneGlide.trailingPictureName { + let presented = shown(layer) + pictures.append(presented.convert(presented.bounds, to: root)) + } + (layer.sublayers ?? []).forEach(findPictures) + } + findPictures(rootLayer) + pictures.sort { ($0.minY, $0.minX) < ($1.minY, $1.minX) } + SidebarNavigationTimings.record("slide.edges phase=\(phase) t=\(String(format: "%.4f", CACurrentMediaTime())) panes=\(panes.joined(separator: ",")) hosted=\(hosted.joined(separator: ",")) chips=\(chips.map(box).joined(separator: ",")) pics=\(pictures.map(box).joined(separator: ","))") } } #endif diff --git a/Sources/Sidebar/SidebarToggleSlideProbe.swift b/Sources/Sidebar/SidebarToggleSlideProbe.swift index a45cd3d47c12..bdb470232032 100644 --- a/Sources/Sidebar/SidebarToggleSlideProbe.swift +++ b/Sources/Sidebar/SidebarToggleSlideProbe.swift @@ -65,6 +65,7 @@ final class SidebarToggleSlideProbe: NSObject { guard SidebarNavigationTimings.isEnabled, let view = window.contentView else { return nil } live?.finish() SidebarToggleSlideEdges.record("start", window: window) + SidebarToggleSlideEdges.resetDump() let probe = SidebarToggleSlideProbe(visible: visible, window: window) let link = view.displayLink(target: probe, selector: #selector(tick(_:))) link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) @@ -107,7 +108,10 @@ final class SidebarToggleSlideProbe: NSObject { } @objc private func tick(_ link: CADisplayLink) { - if landedWall == nil { SidebarToggleSlideEdges.record("frame", window: window) } + if landedWall == nil { + SidebarToggleSlideEdges.record("frame", window: window) + SidebarToggleSlideEdges.dumpLayers(window: window) + } let now = (wall: CACurrentMediaTime(), cpu: Self.threadCPU()) if firstFrameMs == nil { firstFrameMs = (now.wall - startWall) * 1000 diff --git a/Sources/Sidebar/SidebarToggleSlideSession.swift b/Sources/Sidebar/SidebarToggleSlideSession.swift index 8b49efb74951..2859bf332342 100644 --- a/Sources/Sidebar/SidebarToggleSlideSession.swift +++ b/Sources/Sidebar/SidebarToggleSlideSession.swift @@ -22,7 +22,7 @@ final class SidebarToggleSlideSession { struct Panes { let hidden: SidebarSlidePaneLayout let docked: SidebarSlidePaneLayout - let hostedViews: [NSView] + let portalViews: [SidebarSlideStart.PortalView] let sidebarWidth: CGFloat } @@ -31,7 +31,7 @@ final class SidebarToggleSlideSession { trailingStillWidth: CGFloat, titleGlide: SidebarSlideGlide.Layer?, tabRow: SidebarSlideTabRowCapture?, - lanes: SidebarSlideTrailingChromeCapture?, + chrome: SidebarSlidePaneChrome?, panes: Panes? ) { self.init(views: views, trailingStillWidth: trailingStillWidth) @@ -43,17 +43,19 @@ final class SidebarToggleSlideSession { reference: reference, container: container, above: last, - hostedViews: panes.hostedViews, + portalViews: panes.portalViews, hidden: panes.hidden, docked: panes.docked, sidebarWidth: panes.sidebarWidth, - lanes: lanes?.lanes ?? [:] + chrome: chrome?.bands ?? [:], + tabRow: tabRow ), let layer = glide.overlay?.layer { paneGlide = glide movingLayers.append(layer) glides.append(contentsOf: glide.animations) + return } else { - stillChrome = lanes?.makeOverlays(above: reference, in: container) ?? [] + stillChrome = chrome?.makeStillOverlays(above: reference, in: container) ?? [] } } // The tab row picture rides with the content root (below the right diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 9e53a30e6826..5569eb98a9f3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3371,7 +3371,9 @@ B804C0020000000000000002 /* SidebarSelectionCoalescerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */; }; E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */; }; B8DE2DF68EBAC8B061BE60C8 /* SidebarSlideGlide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */; }; + D1E696A20137CDE0A32CA6C2 /* SidebarSlidePaneChrome.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC1ECD71422729D5DB3A3D1C /* SidebarSlidePaneChrome.swift */; }; 03BF5A233704835699AFBBB1 /* SidebarSlidePaneGlide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C1C85B461C391B6E5CDB7B1 /* SidebarSlidePaneGlide.swift */; }; + CF30E77C06DFA43758C1A68E /* SidebarSlidePaneLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0DF345819723AD8E68F23146 /* SidebarSlidePaneLayout.swift */; }; C12791000000000000000007 /* SidebarSnapshotOwnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */; }; F57072635F25EBCA741E125D /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */; }; D7344F010000000000000001 /* SidebarTabDragPayload.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7344F010000000000000002 /* SidebarTabDragPayload.swift */; }; @@ -7847,7 +7849,9 @@ B804D0020000000000000002 /* SidebarSelectionCoalescerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionCoalescerTests.swift; sourceTree = ""; }; 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSelectionState.swift; sourceTree = ""; }; 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarSlideGlide.swift; sourceTree = ""; }; + FC1ECD71422729D5DB3A3D1C /* SidebarSlidePaneChrome.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarSlidePaneChrome.swift; sourceTree = ""; }; 8C1C85B461C391B6E5CDB7B1 /* SidebarSlidePaneGlide.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarSlidePaneGlide.swift; sourceTree = ""; }; + 0DF345819723AD8E68F23146 /* SidebarSlidePaneLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarSlidePaneLayout.swift; sourceTree = ""; }; C12791000000000000000008 /* SidebarSnapshotOwnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarSnapshotOwnerTests.swift; sourceTree = ""; }; D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarState.swift; sourceTree = ""; }; D7344F010000000000000002 /* SidebarTabDragPayload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarTabDragPayload.swift; sourceTree = ""; }; @@ -10278,6 +10282,8 @@ 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */, 3FAC0DA415898D27426B2292 /* SidebarDockedPaneHost.swift */, 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */, + 0DF345819723AD8E68F23146 /* SidebarSlidePaneLayout.swift */, + FC1ECD71422729D5DB3A3D1C /* SidebarSlidePaneChrome.swift */, 5009D8840B8255E315C5CB6D /* SidebarToggleSlideEdges.swift */, 8C1C85B461C391B6E5CDB7B1 /* SidebarSlidePaneGlide.swift */, 2902B955A8F8604A2E6174E4 /* SidebarToggleSlideSession.swift */, @@ -16287,7 +16293,9 @@ B804A0280000000000000028 /* SidebarSelectionCoalescer.swift in Sources */, E62155868BB29FEB5DAAAF25 /* SidebarSelectionState.swift in Sources */, B8DE2DF68EBAC8B061BE60C8 /* SidebarSlideGlide.swift in Sources */, + D1E696A20137CDE0A32CA6C2 /* SidebarSlidePaneChrome.swift in Sources */, 03BF5A233704835699AFBBB1 /* SidebarSlidePaneGlide.swift in Sources */, + CF30E77C06DFA43758C1A68E /* SidebarSlidePaneLayout.swift in Sources */, F57072635F25EBCA741E125D /* SidebarState.swift in Sources */, D7344F010000000000000001 /* SidebarTabDragPayload.swift in Sources */, F7216006000000000000001 /* SidebarTabItemContextMenuState.swift in Sources */, diff --git a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift index 287b520b6ae7..6f8fbd2fdb34 100644 --- a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift +++ b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift @@ -347,3 +347,23 @@ struct SidebarSlideGlideTests { #expect(SidebarSlideGlide.factor(hiddenLeading: 10, dockedLeading: 200, sidebarWidth: 0) == 0) } } + +/// The tab bar's trailing part a slide pins to each pane's moving trailing +/// edge is Bonsplit's action lane, whose width must match Bonsplit's rule. +@Suite +struct SidebarSlidePaneChromeLaneTests { + @Test + func laneIsPaddingPlusButtonsUpToFive() { + #expect(SidebarSlidePaneChrome.laneWidth(buttonCount: 0, paneWidth: 500) == 0) + #expect(SidebarSlidePaneChrome.laneWidth(buttonCount: 1, paneWidth: 500) == 36) + // The default four buttons: 6 + 8 padding, 4 x 22, 3 x 4 spacing. + #expect(SidebarSlidePaneChrome.laneWidth(buttonCount: 4, paneWidth: 500) == 114) + #expect(SidebarSlidePaneChrome.laneWidth(buttonCount: 5, paneWidth: 120) == 140) + } + + @Test + func pastFiveTheLaneIsCappedNearAQuarterOfThePane() { + #expect(SidebarSlidePaneChrome.laneWidth(buttonCount: 8, paneWidth: 400) == 140) + #expect(SidebarSlidePaneChrome.laneWidth(buttonCount: 8, paneWidth: 1000) == 218) + } +} From f46e03c9a06aea51a3e164b958a6af5c027617ab Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 22:07:33 +1300 Subject: [PATCH 05/15] sidebar toggle: tab bars lay out live at each pane's moving width tab chips cut by the action lane picture read as broken. the tab bars now lay themselves out at the pane's visible width every frame (bonsplit's new tab bar width hook, driven by a display link on the slide's own clock), so tabs truncate for real with their ellipsis and close button and the action lane is live, whole and flush with the pane's moving trailing edge. no tab row pictures remain. the slide's clock now starts at core animation's actual begin time, so the per-frame width follows the spring frame for frame. a split or close mid-slide now rebuilds the slide for the new layout and goes on from where it is with its speed, instead of landing at once. points vendor/bonsplit at a local commit (needs its own bonsplit pr). Co-Authored-By: Claude Opus 5.5 --- Sources/Sidebar/SidebarSlidePaneChrome.swift | 11 +--- Sources/Sidebar/SidebarSlidePaneGlide.swift | 33 +++++++++++ Sources/Sidebar/SidebarToggleAnimator.swift | 51 +++++++++++++--- Sources/Sidebar/SidebarToggleSlideEdges.swift | 18 +++++- .../Sidebar/SidebarToggleSlideMachine.swift | 11 ++++ .../Sidebar/SidebarToggleSlideSession.swift | 59 ++++++++++++++++++- .../SidebarFocusBoundaryLifecycleTests.swift | 27 +++++++++ vendor/bonsplit | 2 +- 8 files changed, 190 insertions(+), 22 deletions(-) diff --git a/Sources/Sidebar/SidebarSlidePaneChrome.swift b/Sources/Sidebar/SidebarSlidePaneChrome.swift index e54b4d26ff23..217a214a7f37 100644 --- a/Sources/Sidebar/SidebarSlidePaneChrome.swift +++ b/Sources/Sidebar/SidebarSlidePaneChrome.swift @@ -107,14 +107,9 @@ struct SidebarSlidePaneChrome { } #endif let isTabBar = tabRows[id].map { abs($0.lowerBound - rows.lowerBound) < 1 && abs($0.upperBound - rows.upperBound) < 1 } ?? false - if isTabBar { - let lane = laneWidth(buttonCount: buttonCount, paneWidth: pane.width) - let seam = Int(((pane.width - lane) * scale).rounded()) - if lane > 0, seam > 0, let band = picture(paneRep, rect: paneRect, rows: 0..