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/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index ed0a1411d51f..f1b4e8550660 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -238,7 +238,12 @@ 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) }, + splitButtonCount: { + tabManager.selectedWorkspace?.bonsplitController.configuration.appearance.splitButtons.count ?? 0 + } ) sidebarPeek.setPolicy(SidebarCustomizationSettings.peekPolicy()) } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b9e7e4651341..d86e8aef533b 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1866,11 +1866,10 @@ struct ContentView: View { .allowsHitTesting(sidebarSelectionState.selection == .tabs) .accessibilityHidden(sidebarSelectionState.selection != .tabs) } - // Panes meet the band and both sidebars, so the band line, the split - // dividers and the tab bar hairline join the sidebars' own lines. - // They keep an inset only where the card meets the window edge. - .padding(.trailing, rightSidebarVisible ? 0 : WorkspaceCardMetrics.paneInset) - .padding(.bottom, WorkspaceCardMetrics.paneInset) + // Panes meet the band, both sidebars and the window's edges, so the + // band line, the split dividers and the tab bar hairline join the + // sidebars' own lines. No inset at the window's edges: a browser page + // has its own background, so one reads as a dead strip. // Reserves the titlebar band's height inside the card, so the band // (drawn by the window-level overlay at the same fixed position) // reads as the card's header. @@ -2198,42 +2197,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 +2243,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 +2295,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/Panels/BrowserPanelView.swift b/Sources/Panels/BrowserPanelView.swift index 22a63a410f85..74bfa21505dd 100644 --- a/Sources/Panels/BrowserPanelView.swift +++ b/Sources/Panels/BrowserPanelView.swift @@ -322,13 +322,16 @@ struct BrowserPanelView: View { @State private var omnibarPillFrame: CGRect = .zero @State private var addressBarHeight: CGFloat = 0 @State private var addressBarWidth: CGFloat = 0 + /// The compact state held through a sidebar slide (see + /// `SidebarToggleAnimator.isSliding`). + @State private var heldChromeCompact: Bool? /// Below this chrome width the full accessory row would crowd out the /// omnibar, so the toolbar tools collapse into More. private static let compactChromeWidthThreshold: CGFloat = 420 private var isChromeCompact: Bool { - addressBarWidth > 0 && addressBarWidth < Self.compactChromeWidthThreshold + heldChromeCompact ?? (addressBarWidth > 0 && addressBarWidth < Self.compactChromeWidthThreshold) } @State private var isBrowserImportHintPopoverPresented = false @@ -1125,6 +1128,10 @@ struct BrowserPanelView: View { .onReceive(NotificationCenter.default.publisher(for: .webViewDidReceiveClick)) { notification in handleBrowserWebViewClickIntent(notification) } + .onReceive(NotificationCenter.default.publisher(for: SidebarToggleAnimator.slideDidLand)) { _ in + guard heldChromeCompact != nil else { return } + withAnimation(.easeInOut(duration: 0.2)) { heldChromeCompact = nil } + } .onReceive(NotificationCenter.default.publisher(for: .ghosttySurfaceTabBarFontSizeDidChange)) { _ in tabBarFontSize = GhosttyConfig.loadForCmux(globalFontMagnificationPercent: GlobalFontMagnification.storedPercent).surfaceTabBarFontSize } @@ -1146,6 +1153,11 @@ struct BrowserPanelView: View { addressBarHeight = height } .onPreferenceChange(BrowserAddressBarWidthPreferenceKey.self) { width in + // Through a sidebar slide the toolbar keeps the tools it + // started with; they change, faded, once the slide lands. + if SidebarToggleAnimator.isSliding, heldChromeCompact == nil { + heldChromeCompact = isChromeCompact + } addressBarWidth = width } } @@ -1649,21 +1661,31 @@ struct BrowserPanelView: View { Button(action: { isBrowserImportHintPopoverPresented.toggle() }) { - HStack(spacing: 4) { - CmuxSystemSymbolImage(systemName: "square.and.arrow.down.on.square", pointSize: 10, weight: .medium, tint: devToolsColorOption.color(accent: cmuxAccent)) - Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) - .cmuxFont(size: 11, weight: .medium) - .lineLimit(1) + let icon = CmuxSystemSymbolImage(systemName: "square.and.arrow.down.on.square", pointSize: 10, weight: .medium, tint: devToolsColorOption.color(accent: cmuxAccent)) + // The omnibar comes first: a compact toolbar keeps just the icon, + // as square as the other tools, so the label never truncates to + // "I...". + if isChromeCompact { + icon.frame(width: addressBarButtonSize, height: addressBarButtonSize, alignment: .center) + } else { + HStack(spacing: 4) { + icon + Text(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) + .cmuxFont(size: 11, weight: .medium) + .lineLimit(1) + } + .fixedSize() + .foregroundStyle(devToolsColorOption.color(accent: cmuxAccent)) + .padding(.horizontal, 8) + .padding(.vertical, 4) } - .foregroundStyle(devToolsColorOption.color(accent: cmuxAccent)) - .padding(.horizontal, 8) - .padding(.vertical, 4) } .buttonStyle(OmnibarAddressButtonStyle()) .popover(isPresented: $isBrowserImportHintPopoverPresented, arrowEdge: .bottom) { browserImportHintPopover.browserChromePopoverAppearance(resolvedColorScheme) } .safeHelp(String(localized: "browser.import.hint.toolbar.help", defaultValue: "Import browser data")) + .accessibilityLabel(String(localized: "browser.import.hint.toolbar", defaultValue: "Import")) .accessibilityIdentifier("BrowserImportHintToolbarChip") } @@ -4176,7 +4198,9 @@ final class OmnibarNativeTextField: NSTextField { isBezeled = false drawsBackground = false focusRingType = .none - lineBreakMode = .byTruncatingTail + // Clipped, not ellipsized: the text then reads the same while a + // sidebar slide narrows the pane as once it lands. + lineBreakMode = .byClipping usesSingleLineMode = true } @@ -4955,7 +4979,7 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { BrowserOmnibarNativeFieldRegistry.shared.register(field, panelId: panelId) field.identifier = browserOmnibarTextFieldIdentifier field.font = .systemFont(ofSize: fontSize) - field.placeholderString = placeholder + applyPlaceholder(to: field) field.delegate = context.coordinator field.target = nil field.action = nil @@ -4974,6 +4998,22 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { return field } + /// The placeholder, clipped like the field's text. While the omnibar is + /// focused its field editor draws the placeholder and would otherwise + /// wrap it at a word, dropping "URL" outright the moment the field + /// narrows (a sidebar slide's landing). + private func applyPlaceholder(to field: NSTextField) { + let style = NSMutableParagraphStyle() + style.lineBreakMode = .byClipping + let placeholder = NSAttributedString(string: placeholder, attributes: [ + .font: NSFont.systemFont(ofSize: fontSize), + .foregroundColor: NSColor.placeholderTextColor, + .paragraphStyle: style, + ]) + guard field.placeholderAttributedString != placeholder else { return } + field.placeholderAttributedString = placeholder + } + func updateNSView(_ nsView: OmnibarNativeTextField, context: Context) { context.coordinator.parent = self context.coordinator.parentField = nsView @@ -4982,10 +5022,10 @@ struct OmnibarTextFieldRepresentable: NSViewRepresentable { } nsView.panelId = panelId BrowserOmnibarNativeFieldRegistry.shared.register(nsView, panelId: panelId) - nsView.placeholderString = placeholder if nsView.font?.pointSize != fontSize { nsView.font = .systemFont(ofSize: fontSize) } + applyPlaceholder(to: nsView) context.coordinator.queueSelectAllRequest(selectAllRequestId) let activeInlineCompletion = omnibarInlineCompletionIfBufferMatchesTypedPrefix( 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..70e5d82fd506 --- /dev/null +++ b/Sources/Sidebar/SidebarSlideGlide.swift @@ -0,0 +1,289 @@ +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. + /// The animated value is `base + factor * offset`. + struct Layer { + let layer: CALayer + let keyPath: String + let factor: Double + var base: Double = 0 + + /// One key per animated property: a layer can carry more than one + /// glide (a divider strip moves and widens), and a shared key would + /// let the last one added replace the others. + func animationKey(_ slideKey: String) -> String { slideKey + "." + keyPath } + } +} + +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 isTabItemRegion(view), !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) + ) + } + + /// 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 && className(of: view, contains: "TabItemHitRegionView") + } + + /// One side of a Bonsplit split. + static func isSplitSlot(_ view: NSView) -> Bool { + className(of: view, contains: "SplitArrangedContainerView") + } + + /// Whether the view's class name contains `fragment`. A slide asks this + /// of every view in the window at the press, so the answer is kept per + /// class instead of building the name each time. + static func className(of view: NSView, contains fragment: String) -> Bool { + let type = ObjectIdentifier(Swift.type(of: view)) + if let known = classNameMatches[fragment]?[type] { return known } + let matches = NSStringFromClass(Swift.type(of: view)).contains(fragment) + classNameMatches[fragment, default: [:]][type] = matches + return matches + } + + private static var classNameMatches: [String: [ObjectIdentifier: Bool]] = [:] + + static func paneHost(of view: NSView) -> NSView? { + var current = view.superview + while let candidate = current { + // 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 + || className(of: candidate, contains: "PaneContainerView") { + 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 className(of: view, contains: "SplitActionMouseDown"), !view.isHiddenOrHasHiddenAncestor { + xs.append(reference.convert(view.bounds, from: view).minX) + } + view.subviews.forEach(walk) + } + walk(pane) + return xs.min() + } + + 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. + /// 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 = SidebarSlidePictureView(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 + } + view.image = 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)) + } +} diff --git a/Sources/Sidebar/SidebarSlidePaneChrome.swift b/Sources/Sidebar/SidebarSlidePaneChrome.swift new file mode 100644 index 000000000000..eeb7ffdbb4d5 --- /dev/null +++ b/Sources/Sidebar/SidebarSlidePaneChrome.swift @@ -0,0 +1,449 @@ +import AppKit +import Bonsplit +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 + /// How each pane's tabs fade out before its trailing edge at rest. + let tabFades: [ObjectIdentifier: TabFade] + + /// Bonsplit fades a pane's tabs over `fade` points ending `occlusion` + /// points before its trailing edge (before the action lane, partly + /// under it), and clears them past that. + struct TabFade { + var fade: CGFloat = 24 + var occlusion: CGFloat = 0 + + /// Bonsplit's `TabBarActionLaneGeometry` for the app's backdrop + /// effect: without a lane only an overflowing strip's 24 pt fade; + /// with one, the effect's fade, ending a fraction of the lane in, + /// or the whole lane when its buttons overflow it. + static func at(lane: CGFloat, buttonCount: Int) -> Self { + let effect = Workspace.bonsplitSplitButtonBackdropEffect() + guard lane > 0, buttonCount > 0, effect.masksTabContent, effect.style != .hidden else { return Self() } + let overflows = laneWidth(buttonCount: buttonCount, paneWidth: .infinity) > lane + 1 + return Self(fade: effect.contentFadeWidth, occlusion: overflows ? lane : lane * effect.contentOcclusionFraction) + } + } + + /// `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] = [:] + var lanes: [ObjectIdentifier: CGFloat] = [:] + func findTabs(_ view: NSView) { + if let tabBar = view as? BonsplitTabBarSlideWidthControlling, let pane = layout.pane(containing: view) { + lanes[pane] = tabBar.slideActionLaneWidth + } + 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 { + func isTabBar(_ id: ObjectIdentifier) -> Bool { + tabRows[id].map { abs($0.lowerBound - rows.lowerBound) < 1 && abs($0.upperBound - rows.upperBound) < 1 } ?? false + } + // The tabs hold still (SidebarSlidePaneGlide.holdTabBars) with + // Bonsplit's own lane hidden; the lane, whose width Bonsplit + // reports, rides the trailing edge as a picture of the lane + // alone. A tab bar strip needs no picture of its own. + let barPanes = panes.filter { isTabBar($0.id) } + for (id, _) in barPanes { + if let view = layout.view(id), let band = lanePicture(of: view, lane: lanes[id] ?? 0, reference: reference) { + bands[id, default: []].append(band) + } + } + let panes = panes.filter { !isTabBar($0.id) } + guard !panes.isEmpty else { continue } + 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()).. (scrollViews: [NSView], selectionChromes: [NSView], otherScrollViews: [NSView]) { + var scrollViews: [NSView] = [], selectionChromes: [NSView] = [], otherScrollViews: [NSView] = [] + func holdsTab(_ view: NSView) -> Bool { + SidebarSlideTabRowCapture.isTabItemRegion(view) || view.subviews.contains(where: holdsTab) + } + func visit(_ view: NSView) { + if SidebarSlideTabRowCapture.className(of: view, contains: "TabBarSelectionChromeView") { + selectionChromes.append(view) + } else if view is NSScrollView { + if holdsTab(view) { scrollViews.append(view) } else { otherScrollViews.append(view) } + } else { + view.subviews.forEach(visit) + } + } + visit(pane) + return (scrollViews, selectionChromes, otherScrollViews) + } + + /// A tab bar's action lane alone, on clear: its buttons, and the bar's + /// bottom line under it, as Bonsplit draws them at rest. Nothing else + /// of the bar is in it (tab content under the lane, the ground behind + /// the bar), so the live tabs fade out under it untouched. + static func lanePicture(of pane: NSView, lane: CGFloat, reference: NSView) -> Band? { + let views = tabRowViews(in: pane) + guard lane > 0, let chrome = views.selectionChromes.first else { return nil } + let bar = pane.convert(chrome.bounds, from: chrome) + let rect = NSRect(x: bar.maxX - lane, y: bar.minY, width: lane, height: bar.height) + let buttons = views.otherScrollViews.filter { view in + let frame = pane.convert(view.bounds, from: view) + return frame.maxX > rect.minX + 1 && frame.minY < bar.maxY && frame.maxY > bar.minY + } + let scale = pane.window?.backingScaleFactor ?? 2 + let width = Int((rect.width * scale).rounded()), height = Int((rect.height * scale).rounded()) + guard !buttons.isEmpty, width > 0, height > 0, let space = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext(data: nil, width: width, height: height, bitsPerComponent: 8, bytesPerRow: 0, space: space, bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } + func draw(_ view: NSView, below: CGFloat?) { + let local = view.convert(rect, from: pane) + guard let rep = view.bitmapImageRepForCachingDisplay(in: local) else { return } + view.cacheDisplay(in: local, to: rep) + guard let image = rep.cgImage else { return } + context.saveGState() + // Only rows below the top `below` points (the bottom line, not + // the selected tab's indicator: the live one is faded instead). + if let below { context.clip(to: CGRect(x: 0, y: 0, width: CGFloat(width), height: CGFloat(height) - below * scale)) } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + context.restoreGState() + } + draw(chrome, below: indicatorHeight) + buttons.forEach { draw($0, below: nil) } + guard let image = context.makeImage() else { return nil } + return Band(image: image, rect: reference.convert(rect, from: pane)) + } + + /// A mask for a layer of a pane's tab row that fades its drawing out + /// before `edge` (the pane's trailing edge, in the layer's coordinates) + /// the way Bonsplit fades tabs at rest: kept up to `fade + occlusion` + /// before the edge, faded over `fade`, cleared past. The rows `keep` + /// (the bar's bottom line) are kept up to their own edge instead. The + /// slide rides it on the pane's moving trailing edge. + static func trailingFadeMask(for layer: CALayer, edge: CGFloat, fade: CGFloat, occlusion: CGFloat, keep: (rows: ClosedRange, edge: CGFloat)?) -> CALayer { + let bleed: CGFloat = 10_000 + let mask = CALayer() + mask.anchorPoint = .zero + mask.bounds = layer.bounds + mask.position = layer.bounds.origin + let fadeStart = edge - occlusion - fade + func solid(_ x1: CGFloat, _ y0: CGFloat, _ y1: CGFloat) { + let part = CALayer() + part.backgroundColor = NSColor.black.cgColor + part.frame = CGRect(x: -bleed, y: y0, width: x1 + bleed, height: y1 - y0) + mask.addSublayer(part) + } + func band(_ y0: CGFloat, _ y1: CGFloat) { + guard y1 > y0 else { return } + solid(fadeStart, y0, y1) + guard fade > 0 else { return } + let gradient = CAGradientLayer() + gradient.colors = [NSColor.black.cgColor, NSColor.black.withAlphaComponent(0).cgColor] + gradient.startPoint = CGPoint(x: 0, y: 0.5) + gradient.endPoint = CGPoint(x: 1, y: 0.5) + gradient.frame = CGRect(x: fadeStart, y: y0, width: fade, height: y1 - y0) + mask.addSublayer(gradient) + } + let top = layer.bounds.minY - bleed, bottom = layer.bounds.maxY + bleed + if let keep { + band(top, keep.rows.lowerBound) + band(keep.rows.upperBound, bottom) + solid(keep.edge, keep.rows.lowerBound, keep.rows.upperBound) + } else { + band(top, bottom) + } + return mask + } + + /// Bonsplit's action lane: 6 pt leading and 8 pt trailing padding, 22 pt + /// per button and 4 pt between them, all of up to five buttons shown; + /// past five it is capped near a quarter of the pane. + 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))) + } + + /// 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 new file mode 100644 index 000000000000..9a9efde4f9fe --- /dev/null +++ b/Sources/Sidebar/SidebarSlidePaneGlide.swift @@ -0,0 +1,475 @@ +import AppKit +import Bonsplit +import WebKit +import QuartzCore + +/// 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] = [] + /// Each pane's tab bar with the width it is held at for the slide. + private(set) var tabBarWidths: [(tabBar: BonsplitTabBarSlideWidthControlling, pane: NSView, held: CGFloat)] = [] + 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, + portalViews: [SidebarSlideStart.PortalView], + hidden: SidebarSlidePaneLayout, + docked: SidebarSlidePaneLayout, + sidebarWidth: CGFloat, + chrome: [ObjectIdentifier: [SidebarSlidePaneChrome.Band]], + tabFades: [ObjectIdentifier: SidebarSlidePaneChrome.TabFade], + tabRow: SidebarSlideTabRowCapture? + ) { + 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) } + + // 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. + 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) + } + for (id, hiddenRect) in hidden.panes { + guard let dockedRect = docked.panes[id], let pane = hidden.view(id) else { continue } + func find(_ view: NSView) -> BonsplitTabBarSlideWidthControlling? { + if let tabBar = view as? BonsplitTabBarSlideWidthControlling { return tabBar } + return view.subviews.lazy.compactMap(find).first + } + guard let tabBar = find(pane) else { continue } + let fade = tabFades[id] ?? SidebarSlidePaneChrome.TabFade() + // At the pane's own (hidden, the wider) width: the tabs and their + // scroll offset stay exactly as laid out, and nothing in them + // reaches the pane's moving edge unfaded. + tabBarWidths.append((tabBar, pane, hiddenRect.width)) + fadeTabRow(of: pane, tabBar: tabBar, fade: fade, trailing: Double(dockedRect.width - hiddenRect.width) / width) + } + // 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 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(hide) + } + var color: CGColor? + view.effectiveAppearance.performAsCurrentDrawingAppearance { color = view.dividerColor.cgColor } + let strip = CALayer() + strip.backgroundColor = color +#if DEBUG + if ProcessInfo.processInfo.environment["CMUX_SIDEBAR_SLIDE_STRIP_DEBUG"] != nil { + strip.backgroundColor = NSColor.systemRed.cgColor + SidebarNavigationTimings.record("slide.strip vertical=\(split.isVertical) hidden=\(split.rect) docked=\(dockedSplit.rect) gap=\(Self.divider(split))") + } +#endif + 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)) + } + } + // 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) chrome=\(chrome.values.map(\.count))") +#endif + // 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) } + } + } + + /// The tabs hold still for the slide (laid out once at the pane's own + /// width, `holdTabBars`), and are faded out before the pane's moving + /// trailing edge as Bonsplit fades them at rest, by masks riding that + /// edge on the slide's spring: no main-thread layout can lag it. The + /// selected tab's indicator is faded the same way; the bar's bottom + /// line ends at the lane, whose picture carries it on. + private func fadeTabRow(of pane: NSView, tabBar: BonsplitTabBarSlideWidthControlling, fade: SidebarSlidePaneChrome.TabFade, trailing: Double) { + let lane = tabBar.slideActionLaneWidth + let views = SidebarSlidePaneChrome.tabRowViews(in: pane) + for (view, isSelectionChrome) in views.scrollViews.map({ ($0, false) }) + views.selectionChromes.map({ ($0, true) }) { + guard let layer = view.layer, layer.mask == nil else { continue } + let edge = view.convertToLayer(view.convert(NSPoint(x: pane.bounds.maxX, y: 0), from: pane)).x + var keep: (rows: ClosedRange, edge: CGFloat)? + if isSelectionChrome { + let line = view.convertToLayer(NSRect(x: view.bounds.minX, y: view.isFlipped ? view.bounds.maxY - 1 : view.bounds.minY, width: view.bounds.width, height: 1)) + keep = (line.minY...line.maxY, edge - lane) + } + let mask = SidebarSlidePaneChrome.trailingFadeMask(for: layer, edge: edge, fade: fade.fade, occlusion: fade.occlusion, keep: keep) + layer.mask = mask + masked.append(layer) + animations.append(.init(layer: mask, keyPath: "transform.translation.x", factor: trailing)) + } + } + + /// 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 { + 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)) + } + 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 + 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)) + } + + /// 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 + } + + /// Holds every tab bar still for the slide at its held width (its lane + /// hidden; the lane's picture rides the edge), or back at its pane's + /// own width when done. + func holdTabBars(_ hold: Bool = true) { + for entry in tabBarWidths { + entry.tabBar.slideTabBarWidth = hold ? entry.held : nil + } + // Apply now, inside this transaction, not on a later pass: SwiftUI + // marks the hosting view for layout on its own schedule. + for entry in tabBarWidths { + entry.pane.needsLayout = true + entry.pane.layoutSubtreeIfNeeded() + } + } + + func tearDown(animationKey: String) { + holdTabBars(false) + animations.forEach { $0.layer.removeAnimation(forKey: $0.animationKey(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() + } + + /// 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: 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 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) + } + + /// 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: 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 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..7fc25e3696f6 --- /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 || SidebarSlideTabRowCapture.className(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 2bfd8ac02487..7fbbbb4ec8b9 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -21,16 +21,38 @@ import QuartzCore final class SidebarToggleAnimator: ObservableObject { private static let animationKey = "cmux.sidebarToggleSlide" + /// Whether any window's sidebar is sliding. Views whose layout steps with + /// their width (the browser toolbar's compact tools) hold their state + /// through a slide and change once it lands (`slideDidLand`), so nothing + /// pops in or out mid-motion. Main thread only. + nonisolated(unsafe) private(set) static var isSliding = false + static let slideDidLand = Notification.Name("cmux.sidebarToggleSlideDidLand") + private static var slidingAnimators: Set = [] + private weak var sidebarState: SidebarState? private weak var layout: SidebarLayoutModel? private var window: () -> NSWindow? = { nil } 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 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 } + /// 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. - private var session: SlideSession? + private var session: SidebarToggleSlideSession? + private var layoutObserver: NSObjectProtocol? + private var isRebuildPending = false /// Effects are being carried out; a press arriving meanwhile (the atomic /// commit runs the run loop once) waits its turn. private var isExecuting = false @@ -44,7 +66,10 @@ 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, + splitButtonCount: @escaping () -> Int ) { self.sidebarState = sidebarState self.layout = layout @@ -52,6 +77,9 @@ final class SidebarToggleAnimator: ObservableObject { self.canSlide = canSlide self.trailingStillWidth = trailingStillWidth 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 { @@ -105,9 +133,11 @@ final class SidebarToggleAnimator: ObservableObject { #if DEBUG let probe = SidebarToggleSlideProbe.begin(visible: visible, window: window, animator: self) #endif + markSliding(true) execute(machine.request(visible: visible, width: Double(layout.width), now: CACurrentMediaTime()), in: window) drainQueuedRequests(in: window) syncPendingVisibility() + if machine.slide == nil { markSliding(false) } #if DEBUG probe?.keypressDidFinish() #endif @@ -143,22 +173,26 @@ final class SidebarToggleAnimator: ObservableObject { } else { slide = nil } + pendingDocked = SidebarSlideStart.dockedLayout(in: window) commitAtomically(in: window) { layout?.docksSidebar = false + dockedLayoutWillCommit(false) } layers: { if let slide { addSlideAnimation(slide, in: window) } } - if let slide { machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) } + pendingDocked = nil + if let slide { machine.slideDidStart(generation: slide.generation, at: animationBegin()) } case let .animate(slide): CATransaction.begin() if slide.landsVisible { layout?.dockedPane?.setRowsOnScreen(true) } addSlideAnimation(slide, in: window) CATransaction.commit() CATransaction.flush() - machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) + machine.slideDidStart(generation: slide.generation, at: animationBegin()) case .commitShownLayout: commitAtomically(in: window) { layout?.docksSidebar = true + dockedLayoutWillCommit(true) commitVisibility(true) } layers: { removeSlideAnimations() @@ -223,8 +257,37 @@ 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 DEBUG + var laps: [(String, CFTimeInterval)] = [("start", CACurrentMediaTime())] + defer { + laps.append(("hold", CACurrentMediaTime())) + let parts = zip(laps, laps.dropFirst()).map { "\($1.0)=\(String(format: "%.1f", ($1.1 - $0.1) * 1000))" } + SidebarNavigationTimings.record("slide.build " + parts.joined(separator: " ")) + } +#endif + if session == nil, let views = Self.slidingViews(in: window), let layout { + let start = captureStart(in: window, docked: pendingDocked) + pendingDocked = nil +#if DEBUG + laps.append(("capture", CACurrentMediaTime())) +#endif + let panes = SidebarSlideStart.isRigid ? nil : paneLayouts(for: start, reference: views[0], width: layout.width, window: window) +#if DEBUG + laps.append(("layouts", CACurrentMediaTime())) +#endif + session = SidebarToggleSlideSession( + views: views, + trailingStillWidth: trailingStillWidth(), + sidebarWidth: layout.width, + titleGlide: layout.titlebarTitle?.glide(sidebarWidth: layout.width), + tabRow: start.tabRow, + chrome: start.chrome, + panes: panes + ) + watchLayout(in: window) +#if DEBUG + laps.append(("session", CACurrentMediaTime())) +#endif } guard let session, !session.movingLayers.isEmpty else { // Nothing to move: land now, so the press still takes effect. @@ -233,8 +296,10 @@ final class SidebarToggleAnimator: ObservableObject { return } let distance = slide.to - slide.from + let velocity = distance == 0 ? 0 : slide.velocity / distance + session.paneGlide?.holdTabBars() 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 +310,54 @@ 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: 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: glide.animationKey(Self.animationKey)) + } + } + + private func captureStart(in window: NSWindow, docked: SidebarSlidePaneLayout?) -> SidebarSlideStart { + SidebarSlideStart.capture(in: window, docked: docked, inset: tabBarInsetDelta(), sidebarWidth: layout?.width ?? 0, buttonCount: splitButtonCount()) } - private func slideSpring(from: Double, to: Double, velocity: Double, duration: Double) -> CASpringAnimation { + /// 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 = start.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, portalViews: SidebarSlideStart.portalViews(in: window), sidebarWidth: width) + } + + /// When Core Animation actually started the running spring (its + /// transaction's commit), so clock-driven work (the tab bars' widths, + /// retargets) follows the same curve frame for frame. + private func animationBegin() -> CFTimeInterval { + guard let layer = session?.movingLayers.first, let animation = layer.animation(forKey: Self.animationKey), + animation.beginTime > 0 else { return CACurrentMediaTime() } + let begin = layer.convertTime(animation.beginTime, to: nil) +#if DEBUG + SidebarToggleSlideProbe.current?.slideDidCommit(at: begin) +#endif + return begin + } + + 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 @@ -271,6 +376,40 @@ 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 is then rebuilt for the new layout and goes on from where + /// it is, with its speed, on the next turn (out of the layout pass, once + /// every split of the change has settled). + private func watchLayout(in window: NSWindow) { + layoutObserver = NotificationCenter.default.addObserver( + forName: NSSplitView.didResizeSubviewsNotification, object: nil, queue: nil + ) { [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 } + guard !self.isRebuildPending else { return } + self.isRebuildPending = true + DispatchQueue.main.async { [weak self] in self?.rebuildForNewLayout(generation: slide.generation) } + } + } + } + + private func rebuildForNewLayout(generation: Int) { + isRebuildPending = false + guard !isExecuting, machine.slide?.generation == generation, let window = window(), + let slide = machine.restart(now: CACurrentMediaTime()) else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + removeSlideAnimations() + addSlideAnimation(slide, in: window) + CATransaction.commit() + machine.slideDidStart(generation: slide.generation, at: animationBegin()) } /// Idempotent: a stale or repeated landing does nothing. @@ -279,7 +418,13 @@ final class SidebarToggleAnimator: ObservableObject { DispatchQueue.main.async { [weak self] in self?.land(generation: generation) } return } - guard let window = window() else { return } + guard let window = window() else { + // The window closed mid-slide: drop the slide so its layout + // observer goes with it. + removeSlideAnimations() + markSliding(false) + return + } let effects = machine.land(generation: generation) guard !effects.isEmpty else { return } #if DEBUG @@ -294,6 +439,15 @@ final class SidebarToggleAnimator: ObservableObject { // After the asserts: a queued press may start the next slide. drainQueuedRequests(in: window) syncPendingVisibility() + if machine.slide == nil { markSliding(false) } + } + + private func markSliding(_ sliding: Bool) { + let id = ObjectIdentifier(self) + guard sliding != Self.slidingAnimators.contains(id) else { return } + if sliding { Self.slidingAnimators.insert(id) } else { Self.slidingAnimators.remove(id) } + Self.isSliding = !Self.slidingAnimators.isEmpty + if !sliding { NotificationCenter.default.post(name: Self.slideDidLand, object: nil) } } private func commitVisibility(_ visible: Bool) { @@ -317,6 +471,7 @@ final class SidebarToggleAnimator: ObservableObject { if layout?.docksSidebar != visible { layout?.docksSidebar = visible } + markSliding(false) } private func syncPendingVisibility() { @@ -360,68 +515,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 { - let movingLayers: [CALayer] - let masks: [CALayer] - private let stillOverlay: NSView? - - 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 - } - 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/SidebarToggleSlideEdges.swift b/Sources/Sidebar/SidebarToggleSlideEdges.swift new file mode 100644 index 000000000000..d0c668b86a50 --- /dev/null +++ b/Sources/Sidebar/SidebarToggleSlideEdges.swift @@ -0,0 +1,120 @@ +import AppKit +import Bonsplit +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 + + 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, + 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 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) + 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. + 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 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 paneViews = layout.panes.sorted { ($0.value.minY, $0.value.minX) < ($1.value.minY, $1.value.minX) } + .compactMap { layout.view($0.key) } + let panes = paneViews.compactMap(span) + // Each pane's tab bar width as laid out (its slide width, else the + // pane's own): it must match the pane's visible width every frame. + let tabBars = paneViews.map { pane -> String in + func find(_ view: NSView) -> BonsplitTabBarSlideWidthControlling? { + if let tabBar = view as? BonsplitTabBarSlideWidthControlling { return tabBar } + return view.subviews.lazy.compactMap(find).first + } + let width = find(pane)?.slideTabBarWidth ?? pane.bounds.width + return String(format: "%.1f", width) + } + 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: ",")) tabbars=\(tabBars.joined(separator: ","))") + } +} +#endif diff --git a/Sources/Sidebar/SidebarToggleSlideMachine.swift b/Sources/Sidebar/SidebarToggleSlideMachine.swift index 1c17b8ff905b..9bb3f6e5deee 100644 --- a/Sources/Sidebar/SidebarToggleSlideMachine.swift +++ b/Sources/Sidebar/SidebarToggleSlideMachine.swift @@ -126,6 +126,17 @@ struct SidebarToggleSlideMachine: Equatable { return [.commitHiddenLayout, .animate(startSlide(from: width, to: 0, velocity: 0, visible: false, now: now))] } + /// The layout under a running slide changed (a split, a close): the + /// motion goes on from where it is to the same end, as a new slide, so + /// it can be rebuilt for the new layout. Nil when nothing moves. + mutating func restart(now: Double) -> Slide? { + guard let current = slide else { return nil } + let elapsed = now - current.begin + let from = spring.position(from: current.from, to: current.to, velocity: current.velocity, at: elapsed) + let velocity = spring.velocity(from: current.from, to: current.to, velocity: current.velocity, at: elapsed) + return startSlide(from: from, to: current.to, velocity: velocity, visible: current.landsVisible, now: now) + } + /// The motion with `generation` finished. Stale generations (a slide /// that was retargeted, or a repeated landing) do nothing. mutating func land(generation landed: Int) -> [Effect] { diff --git a/Sources/Sidebar/SidebarToggleSlideProbe.swift b/Sources/Sidebar/SidebarToggleSlideProbe.swift index 4d0afbc6b9d3..9b7bfa4df024 100644 --- a/Sources/Sidebar/SidebarToggleSlideProbe.swift +++ b/Sources/Sidebar/SidebarToggleSlideProbe.swift @@ -24,6 +24,10 @@ final class SidebarToggleSlideProbe: NSObject { private var link: CADisplayLink? private var last: (wall: CFTimeInterval, cpu: Double)? private var firstFrameMs: Double? + /// Keypress to the slide's Core Animation commit, and to the display + /// time of the first frame after it. + private var commitMs: Double? + private var firstTargetMs: Double? private var blankRows: Int? private var intervals: [Double] = [] private var cpu: [Double] = [] @@ -64,6 +68,8 @@ 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) + 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) @@ -95,19 +101,30 @@ final class SidebarToggleSlideProbe: NSObject { return probe } + /// The slide's spring began (its transaction committed) at `begin`. + func slideDidCommit(at begin: CFTimeInterval) { + if commitMs == nil { commitMs = (begin - startWall) * 1000 } + } + func keypressDidFinish() { keypressCPU = Self.threadCPU() - startCPU } 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) + SidebarToggleSlideEdges.dumpLayers(window: window) + } let now = (wall: CACurrentMediaTime(), cpu: Self.threadCPU()) if firstFrameMs == nil { firstFrameMs = (now.wall - startWall) * 1000 + firstTargetMs = (link.targetTimestamp - startWall) * 1000 if visible { blankRows = countBlankSidebarRows() } } else if let last { intervals.append((now.wall - last.wall) * 1000) @@ -168,6 +185,7 @@ final class SidebarToggleSlideProbe: NSObject { SidebarNavigationTimings.record( "nav.frames interaction=\(name) frames=\(intervals.count + 1) " + "keypressCpu=\(f(keypressCPU)) firstFrameMs=\(f(firstFrameMs ?? -1)) " + + "commitMs=\(f(commitMs ?? -1)) firstTargetMs=\(f(firstTargetMs ?? -1)) " + "landingCpu=\(f(landingCPU ?? -1)) landed=\(landingCPU == nil ? 0 : 1) " + "intervalAvg=\(f(average)) intervalMax=\(f(intervals.max() ?? 0)) dropped=\(dropped) period=\(f(period)) " + "movingCpuAvg=\(f(cpuAverage)) movingCpuMax=\(f(moving.max() ?? 0)) " + diff --git a/Sources/Sidebar/SidebarToggleSlideSession.swift b/Sources/Sidebar/SidebarToggleSlideSession.swift new file mode 100644 index 000000000000..578f0e626e5f --- /dev/null +++ b/Sources/Sidebar/SidebarToggleSlideSession.swift @@ -0,0 +1,139 @@ +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, +/// the root's layers that make up the right sidebar's column run the spring +/// backwards, so the live sidebar stays put on screen (a picture of it would +/// not match: `cacheDisplay` draws its text heavier than the screen does). +/// Everything else that could reach the column is masked off at its leading +/// edge, by masks that also run the spring backwards. The terminal reads as +/// sliding under the right sidebar, which never moves. +@MainActor +final class SidebarToggleSlideSession { + private(set) var movingLayers: [CALayer] + private(set) var masks: [CALayer] = [] + private(set) var glides: [SidebarSlideGlide.Layer] = [] + /// The right sidebar column's layers, held still against the root. + private var stillLayers: [SidebarSlideGlide.Layer] = [] + /// The root's layers masked off at the column's edge. + private var clipped: [CALayer] = [] + /// The right sidebar column's leading edge, in the content root's + /// coordinates (nil without a right sidebar). + private var stillEdge: CGFloat? + private var tabRowOverlay: NSView? + private var stillChrome: [NSView] = [] + private(set) var paneGlide: SidebarSlidePaneGlide? + + /// The panes' rects in both layouts, for per-pane motion. + struct Panes { + let hidden: SidebarSlidePaneLayout + let docked: SidebarSlidePaneLayout + let portalViews: [SidebarSlideStart.PortalView] + let sidebarWidth: CGFloat + } + + convenience init( + views: [NSView], + trailingStillWidth: CGFloat, + sidebarWidth: CGFloat, + titleGlide: SidebarSlideGlide.Layer?, + tabRow: SidebarSlideTabRowCapture?, + chrome: SidebarSlidePaneChrome?, + panes: Panes? + ) { + self.init(views: views, trailingStillWidth: trailingStillWidth, sidebarWidth: sidebarWidth) + glides = [titleGlide].compactMap { $0 } + stillLayers + 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, + portalViews: panes.portalViews, + hidden: panes.hidden, + docked: panes.docked, + sidebarWidth: panes.sidebarWidth, + chrome: chrome?.bands ?? [:], + tabFades: chrome?.tabFades ?? [:], + tabRow: tabRow + ), let overlay = glide.overlay, let layer = overlay.layer { + paneGlide = glide + movingLayers.append(layer) + clipAtStillEdge(overlay, reference: reference) + glides.append(contentsOf: glide.animations) + return + } else { + stillChrome = chrome?.makeStillOverlays(above: reference, in: container) ?? [] + } + } + // The tab row picture rides with the content root 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) + clipAtStillEdge(overlay.view, reference: reference) + glides.append(overlay.glide) + } + } + + private init(views: [NSView], trailingStillWidth: CGFloat, sidebarWidth: CGFloat) { + movingLayers = views.compactMap(\.layer) + guard trailingStillWidth > 0, + let reference = views.first, + let referenceLayer = reference.layer else { return } + let edge = reference.bounds.maxX - trailingStillWidth + stillEdge = edge + // The root's own layers split at the column's edge: the column's run + // the spring backwards (still and live), the rest that the slide can + // carry into it are clipped. A layer across the edge (the window's + // ground) keeps moving; it covers the column the whole way. + let reach = 2 * sidebarWidth + for layer in referenceLayer.sublayers ?? [] { + let frame = layer.frame + if frame.minX >= edge - 0.5 { + let base = (layer.value(forKeyPath: "transform.translation.x") as? Double) ?? 0 + stillLayers.append(.init(layer: layer, keyPath: "transform.translation.x", factor: -1, base: base)) + } else if frame.maxX <= edge + 0.5, frame.maxX > edge - reach, layer.mask == nil { + clip(layer, at: layer.convert(CGPoint(x: edge, y: 0), from: referenceLayer).x) + clipped.append(layer) + } +#if DEBUG + if frame.minX < edge - 0.5, frame.maxX > edge + 0.5, layer.contents != nil || layer.sublayers?.isEmpty == false { + SidebarNavigationTimings.record("slide.still across edge \(String(describing: type(of: layer))) \(frame)") + } +#endif + } + views.dropFirst().forEach { clipAtStillEdge($0, reference: reference) } + } + + /// Masks a moving view off at the right sidebar column's edge. The mask + /// runs the slide's spring backwards, so the edge stays put on screen. + private func clipAtStillEdge(_ view: NSView, reference: NSView) { + guard let edge = stillEdge, let layer = view.layer else { return } + clip(layer, at: view.convert(NSPoint(x: edge, y: 0), from: reference).x) + } + + private func clip(_ layer: CALayer, at x: CGFloat) { + let bleed: CGFloat = 10_000 + let mask = CALayer() + mask.backgroundColor = NSColor.black.cgColor + mask.frame = CGRect(x: -bleed, y: -bleed, width: x + bleed, height: layer.bounds.height + 2 * bleed) + layer.mask = mask + masks.append(mask) + } + + func tearDown(animationKey: String) { + for layer in movingLayers { + layer.removeAnimation(forKey: animationKey) + layer.mask = nil + } + glides.forEach { $0.layer.removeAnimation(forKey: $0.animationKey(animationKey)) } + paneGlide?.tearDown(animationKey: animationKey) + clipped.forEach { $0.mask = nil } + tabRowOverlay?.removeFromSuperview() + stillChrome.forEach { $0.removeFromSuperview() } + } +} diff --git a/Sources/WindowChromeMetrics.swift b/Sources/WindowChromeMetrics.swift index 621c99302555..fc037a3a307c 100644 --- a/Sources/WindowChromeMetrics.swift +++ b/Sources/WindowChromeMetrics.swift @@ -25,13 +25,6 @@ enum WindowChromeMetrics { enum WorkspaceCardMetrics { static let cornerRadius: CGFloat = 16 static let borderWidth: CGFloat = 1 - /// Pane edge to card edge where the card meets the window edge. - static let paneInset: CGFloat = 8 - /// The bottom leading corner sits under a pane that meets the sidebar, - /// so its curve has to end where the pane's square corner starts: a - /// continuous corner's curve runs about 1.53 radii along each edge, and - /// it must fit in the bottom inset. - static let bottomLeadingCornerRadius: CGFloat = paneInset / 1.53 } /// The card surface, filled with the terminal's own background colour. @@ -45,14 +38,14 @@ struct WorkspaceCardBackground: View { let fill: NSColor var body: some View { - // Rounded only where the card meets the glass: the leading corners. + // Rounded only where the card meets the glass: the top leading corner. // The trailing and bottom edges run flush to the window, so rounding // them would just notch the window's own frame. No stroke: the card // is one surface against the glass, separated by fill and rounding // alone. UnevenRoundedRectangle( topLeadingRadius: WorkspaceCardMetrics.cornerRadius, - bottomLeadingRadius: WorkspaceCardMetrics.bottomLeadingCornerRadius, + bottomLeadingRadius: 0, bottomTrailingRadius: 0, topTrailingRadius: 0, style: .continuous diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index df7fb35eb29d..7ddfb3da776d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3385,6 +3385,10 @@ 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 */; }; + 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 */; }; @@ -3393,8 +3397,10 @@ 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 */; }; 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 */; }; @@ -7875,6 +7881,10 @@ 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 = ""; }; + 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 = ""; }; @@ -7883,8 +7893,10 @@ 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 = ""; }; 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 = ""; }; @@ -10310,6 +10322,12 @@ 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */, 3FAC0DA415898D27426B2292 /* SidebarDockedPaneHost.swift */, 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */, + 0DF345819723AD8E68F23146 /* SidebarSlidePaneLayout.swift */, + FC1ECD71422729D5DB3A3D1C /* SidebarSlidePaneChrome.swift */, + 5009D8840B8255E315C5CB6D /* SidebarToggleSlideEdges.swift */, + 8C1C85B461C391B6E5CDB7B1 /* SidebarSlidePaneGlide.swift */, + 2902B955A8F8604A2E6174E4 /* SidebarToggleSlideSession.swift */, + 3C05AC6F464520A0505047A3 /* SidebarSlideGlide.swift */, 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */, 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */, A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */, @@ -16340,14 +16358,20 @@ 5A1180B9BE6B4395A220318A /* SidebarSearchFieldView.swift in Sources */, 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 */, 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 */, 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */, EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */, B290FAD92C810ACB2D9CD9C6 /* SidebarWorkspaceChecklistView.swift in Sources */, diff --git a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift index 0aee2523676d..fa3a50da3eab 100644 --- a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift +++ b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift @@ -275,3 +275,122 @@ 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) + } +} + +/// 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) + } +} + +/// A layout change mid-slide rebuilds the motion from where it is. +@Suite +struct SidebarToggleSlideMachineRestartTests { + @Test + func restartGoesOnFromThePresentedOffsetWithItsSpeed() throws { + var machine = SidebarToggleSlideMachine(docked: false) + _ = machine.request(visible: true, width: 240, now: 0) + let before = try #require(machine.slide) + let offset = try #require(machine.offset(at: 0.05)) + let restarted = try #require(machine.restart(now: 0.05)) + #expect(restarted.generation != before.generation) + #expect(abs(restarted.from - offset) < 1e-9) + #expect(restarted.to == before.to) + #expect(restarted.landsVisible == before.landsVisible) + let speed = machine.spring.velocity(from: before.from, to: before.to, velocity: before.velocity, at: 0.05) + #expect(abs(restarted.velocity - machine.spring.nonOvershootingVelocity(from: offset, to: 240, velocity: speed)) < 1e-9) + #expect(machine.land(generation: before.generation).isEmpty) + #expect(machine.land(generation: restarted.generation) == [.commitShownLayout]) + } + + @Test + func nothingToRestartAtRest() { + var machine = SidebarToggleSlideMachine(docked: true) + #expect(machine.restart(now: 1) == nil) + } +} diff --git a/vendor/bonsplit b/vendor/bonsplit index 2e21c479ba2f..d91c0003cc7b 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 2e21c479ba2fbd28c9806bfcac6a871196ecc142 +Subproject commit d91c0003cc7b512d491b15e5542b99383c666846