diff --git a/RemuxApp/Sources/App/RemuxAppChrome.swift b/RemuxApp/Sources/App/RemuxAppChrome.swift index e95221ea..2aaca7e9 100644 --- a/RemuxApp/Sources/App/RemuxAppChrome.swift +++ b/RemuxApp/Sources/App/RemuxAppChrome.swift @@ -12,6 +12,22 @@ enum RemuxAppPalette { typealias LibraryHomePalette = RemuxAppPalette +/// The app's accent, the same in every terminal theme: a theme sets only the terminal's colours and whether +/// the chrome around it is light or dark. +enum RemuxAccent { + static let fill = Color.accentColor + /// Text and glyphs drawn on `fill`. + static let onFill = Color.white + + static let toolbarButtonActiveFill = fill.opacity(0.16) + static let selectedFill = fill.opacity(0.18) + static let selectedStroke = fill.opacity(0.76) + + /// `fill` for UIKit: the app's named accent colour, or the system blue `Color.accentColor` is without one. + static let uiFill = UIColor(named: "AccentColor") ?? .systemBlue + static let uiOnFill = UIColor.white +} + extension View { func remuxAppListRowSurface() -> some View { listRowBackground(RemuxAppPalette.rowSurface) diff --git a/RemuxApp/Sources/App/RootView.swift b/RemuxApp/Sources/App/RootView.swift index cefdbebf..8646b107 100644 --- a/RemuxApp/Sources/App/RootView.swift +++ b/RemuxApp/Sources/App/RootView.swift @@ -238,10 +238,7 @@ private struct RemuxWorkspaceShell: View { } ) .remuxSheetPresentationBackground() - .ghosttyTerminalChromePresentation( - model.terminalSettings.theme.terminalChromeColorScheme, - chromeStyle: model.terminalSettings.theme.terminalChromeStyle - ) + .ghosttyTerminalChromePresentation(model.terminalSettings.theme.terminalChromeColorScheme) .tailscaleSSHCheckAlert( request: $tailscaleSSHCheckRequest, isActive: true @@ -382,9 +379,6 @@ private struct RemuxWorkspaceShell: View { } } .remuxSheetPresentationBackground() - .ghosttyTerminalChromePresentation( - chromeStyle: model.terminalSettings.theme.terminalChromeStyle - ) .alert( setupSubmissionIssueTitle(setup.submissionIssue), isPresented: setupSubmissionIssueIsPresented(setup.submissionIssue), diff --git a/RemuxApp/Sources/App/SessionAgentsList.swift b/RemuxApp/Sources/App/SessionAgentsList.swift index 0a4b3dbe..8d7ca541 100644 --- a/RemuxApp/Sources/App/SessionAgentsList.swift +++ b/RemuxApp/Sources/App/SessionAgentsList.swift @@ -147,8 +147,6 @@ struct SessionAgentsProjection: Equatable { /// an agent that appears, or comes back to the filter, is added after the rest and keeps its place from then /// on. Herdr's client sorts again at once. struct SessionAgentsList: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - let projection: SessionAgentsProjection @Binding var filter: SessionAgentsFilter let onOpen: (SessionAgentsProjection.Session, HerdrAgentPresentation.Agent) -> Void @@ -208,11 +206,11 @@ struct SessionAgentsList: View { Text("\(option.title) \(count)") .font(.system(size: 13, weight: .semibold)) .monospacedDigit() - .foregroundStyle(isSelected ? chromeStyle.accentForeground : TerminalSelectionSheetPalette.primary) + .foregroundStyle(isSelected ? RemuxAccent.onFill : TerminalSelectionSheetPalette.primary) .padding(.horizontal, 12) .padding(.vertical, 6) .background( - isSelected ? chromeStyle.accent : TerminalSelectionSheetPalette.controlFill, + isSelected ? RemuxAccent.fill : TerminalSelectionSheetPalette.controlFill, in: Capsule() ) .contentShape(Capsule()) diff --git a/RemuxApp/Sources/App/SessionSwitcherView.swift b/RemuxApp/Sources/App/SessionSwitcherView.swift index 6eeb504f..8a02feb1 100644 --- a/RemuxApp/Sources/App/SessionSwitcherView.swift +++ b/RemuxApp/Sources/App/SessionSwitcherView.swift @@ -172,7 +172,6 @@ struct SessionSwitcherView: View { } @Environment(\.dismiss) private var dismiss - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle let projection: SessionSwitcherProjection let servers: [SavedServer] @@ -471,8 +470,7 @@ struct SessionSwitcherView: View { ActiveSessionSwitcherRow( session: session, disclosure: disclosure, - agentStatus: herdrAgents(session.id)?.status, - chromeStyle: chromeStyle + agentStatus: herdrAgents(session.id)?.status ) } .buttonStyle(.plain) @@ -509,8 +507,7 @@ struct SessionSwitcherView: View { workspace: workspace, isCurrent: isCurrent, agentStatus: agentStatus, - isStale: isStale, - chromeStyle: chromeStyle + isStale: isStale ) } .buttonStyle(.plain) @@ -573,7 +570,7 @@ struct SessionSwitcherView: View { } label: { Label("New Workspace", systemImage: "plus") .font(.body) - .foregroundStyle(chromeStyle.accent) + .foregroundStyle(RemuxAccent.fill) .padding(.leading, HerdrWorkspaceSwitcherRow.leadingInset) .frame(maxWidth: .infinity, minHeight: 44, alignment: .leading) .contentShape(Rectangle()) @@ -771,7 +768,6 @@ private struct ActiveSessionSwitcherRow: View { let disclosure: Bool? /// The session's most urgent agent, for a session that reports agents. let agentStatus: TerminalAgentStatus? - let chromeStyle: GhosttyTerminalChromeStyle var body: some View { HStack(spacing: 12) { @@ -813,7 +809,7 @@ private struct ActiveSessionSwitcherRow: View { if session.showsCheckmark(disclosure: disclosure) { Image(systemName: "checkmark") .font(.body.weight(.semibold)) - .foregroundStyle(chromeStyle.accent) + .foregroundStyle(RemuxAccent.fill) .accessibilityHidden(true) } @@ -855,7 +851,6 @@ private struct HerdrWorkspaceSwitcherRow: View { let agentStatus: TerminalAgentStatus? /// The session isn't connected, so the agents' state may be out of date. let isStale: Bool - let chromeStyle: GhosttyTerminalChromeStyle private var tabCount: String { workspace.tabs.count == 1 ? "1 tab" : "\(workspace.tabs.count) tabs" @@ -891,7 +886,7 @@ private struct HerdrWorkspaceSwitcherRow: View { if isCurrent { Image(systemName: "checkmark") .font(.body.weight(.semibold)) - .foregroundStyle(chromeStyle.accent) + .foregroundStyle(RemuxAccent.fill) .accessibilityHidden(true) } } diff --git a/RemuxApp/Sources/Ghostty/GhosttyAttachmentPreviewSheet.swift b/RemuxApp/Sources/Ghostty/GhosttyAttachmentPreviewSheet.swift index 798a982d..38e3f403 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyAttachmentPreviewSheet.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyAttachmentPreviewSheet.swift @@ -3,7 +3,6 @@ import UIKit struct GhosttyAttachmentPreviewSheet: View { @Environment(\.dismiss) private var dismiss - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle @State private var selectedAttachmentID: UUID? @State private var markupRequest: GhosttyAttachmentMarkupRequest? @State private var preparingMarkupAttachmentID: UUID? @@ -120,8 +119,7 @@ struct GhosttyAttachmentPreviewSheet: View { } .buttonStyle( GhosttyAttachmentPreviewChipButtonStyle( - isSelected: isSelected(attachment), - chromeStyle: chromeStyle + isSelected: isSelected(attachment) ) ) .id(attachment.id) @@ -233,7 +231,7 @@ struct GhosttyAttachmentPreviewSheet: View { HStack(spacing: 8) { Image(systemName: "globe") .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(chromeStyle.accent) + .foregroundStyle(RemuxAccent.fill) .accessibilityHidden(true) Text(linkTitle(for: url)) @@ -259,11 +257,11 @@ struct GhosttyAttachmentPreviewSheet: View { .padding(.horizontal, 18) .frame(height: 40) .frame(maxWidth: .infinity) - .foregroundStyle(chromeStyle.accent) - .background(chromeStyle.selectedFill, in: Capsule()) + .foregroundStyle(RemuxAccent.fill) + .background(RemuxAccent.selectedFill, in: Capsule()) .overlay { Capsule() - .strokeBorder(chromeStyle.accent.opacity(0.18), lineWidth: 0.75) + .strokeBorder(RemuxAccent.fill.opacity(0.18), lineWidth: 0.75) } } } @@ -289,7 +287,7 @@ struct GhosttyAttachmentPreviewSheet: View { .textInputAutocapitalization(.never) .autocorrectionDisabled() .scrollContentBackground(.hidden) - .tint(chromeStyle.accent) + .tint(RemuxAccent.fill) .padding(.horizontal, 4) .padding(.vertical, 2) .frame(maxWidth: .infinity, maxHeight: .infinity) diff --git a/RemuxApp/Sources/Ghostty/GhosttyAttachmentPreviewStyle.swift b/RemuxApp/Sources/Ghostty/GhosttyAttachmentPreviewStyle.swift index c10eba14..a1918386 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyAttachmentPreviewStyle.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyAttachmentPreviewStyle.swift @@ -22,15 +22,13 @@ struct GhosttyAttachmentPreviewCloseButtonStyle: ButtonStyle { struct GhosttyAttachmentPreviewChipButtonStyle: ButtonStyle { let isSelected: Bool - let chromeStyle: GhosttyTerminalChromeStyle func makeBody(configuration: Configuration) -> some View { configuration.label - .foregroundStyle(isSelected ? chromeStyle.accent : GhosttySheetPalette.primary) + .foregroundStyle(isSelected ? RemuxAccent.fill : GhosttySheetPalette.primary) .ghosttyAttachmentPreviewChipSurface( isSelected: isSelected, - isPressed: configuration.isPressed, - chromeStyle: chromeStyle + isPressed: configuration.isPressed ) .scaleEffect(configuration.isPressed ? 0.98 : 1) .animation(.easeOut(duration: 0.12), value: configuration.isPressed) @@ -80,16 +78,14 @@ extension View { func ghosttyAttachmentPreviewChipSurface( isSelected: Bool, - isPressed: Bool, - chromeStyle: GhosttyTerminalChromeStyle + isPressed: Bool ) -> some View { let shape = Capsule() let fill = attachmentPreviewChipFill( isSelected: isSelected, - isPressed: isPressed, - chromeStyle: chromeStyle + isPressed: isPressed ) - let stroke = isSelected ? GhosttySheetPalette.selectedStroke(chromeStyle) : GhosttyAttachmentPreviewStyle.controlStroke + let stroke = isSelected ? GhosttySheetPalette.selectedStroke : GhosttyAttachmentPreviewStyle.controlStroke return self .background(fill, in: shape) @@ -101,11 +97,10 @@ extension View { private func attachmentPreviewChipFill( isSelected: Bool, - isPressed: Bool, - chromeStyle: GhosttyTerminalChromeStyle + isPressed: Bool ) -> Color { if isSelected { - return chromeStyle.selectedFill + return RemuxAccent.selectedFill } return isPressed diff --git a/RemuxApp/Sources/Ghostty/GhosttyComposeBar.swift b/RemuxApp/Sources/Ghostty/GhosttyComposeBar.swift index 4228cba3..4c533093 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyComposeBar.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyComposeBar.swift @@ -211,7 +211,6 @@ private struct GhosttyComposerInputLayout: Layout { } struct GhosttyComposeBar: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle @AppStorage("terminal.composer.keyboardDismissHintShown") private var hasShownKeyboardDismissHint = false @State private var isShowingKeyboardDismissHint = false @@ -442,7 +441,7 @@ struct GhosttyComposeBar: View { ZStack(alignment: .topLeading) { GhosttyComposerTextView( text: $composer.draft, - caretColor: UIColor(chromeStyle.accent), + caretColor: RemuxAccent.uiFill, allowsUserEdits: submissionState.allowsComposerInput && !dictationPhase.isActive, wantsFirstResponder: wantsKeyboardFocus, diff --git a/RemuxApp/Sources/Ghostty/GhosttyIOSurfaceFrame.swift b/RemuxApp/Sources/Ghostty/GhosttyIOSurfaceFrame.swift index 201fabbd..2c61fe6e 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyIOSurfaceFrame.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyIOSurfaceFrame.swift @@ -121,49 +121,9 @@ struct GhosttyIOSurfaceFrame: Sendable { ) } - static func sourceRect( - width: Int, - height: Int, - centeredOn anchor: CGRect, - maxWidth: UInt32, - maxHeight: UInt32 - ) -> CGRect? { - guard width > 0, height > 0, - maxWidth > 0, maxHeight > 0, - anchor.origin.x.isFinite, anchor.origin.y.isFinite, - anchor.width.isFinite, anchor.height.isFinite, - anchor.width > 0, anchor.height > 0 - else { return nil } - - let bounds = CGRect(x: 0, y: 0, width: width, height: height) - guard anchor.intersects(bounds) else { return nil } - - let aspectRatio = Double(maxWidth) / Double(maxHeight) - var cropWidth = min(Double(width), Double(maxWidth)) - var cropHeight = min(Double(height), Double(maxHeight)) - if cropWidth / cropHeight > aspectRatio { - cropWidth = cropHeight * aspectRatio - } else { - cropHeight = cropWidth / aspectRatio - } - - let integralWidth = max(1, min(width, Int(cropWidth.rounded(.down)))) - let integralHeight = max(1, min(height, Int(cropHeight.rounded(.down)))) - let x = min( - max(0, Int((anchor.midX - CGFloat(integralWidth) / 2).rounded(.down))), - width - integralWidth - ) - let y = min( - max(0, Int((anchor.midY - CGFloat(integralHeight) / 2).rounded(.down))), - height - integralHeight - ) - return CGRect(x: x, y: y, width: integralWidth, height: integralHeight) - } - - func image(maxWidth: UInt32, maxHeight: UInt32) throws -> CGImage { - guard maxWidth > 0, maxHeight > 0, - let provider = CGDataProvider(data: bytes as CFData), - let source = CGImage( + func image() throws -> CGImage { + guard let provider = CGDataProvider(data: bytes as CFData), + let image = CGImage( width: width, height: height, bitsPerComponent: 8, @@ -173,35 +133,10 @@ struct GhosttyIOSurfaceFrame: Sendable { bitmapInfo: Self.bitmapInfo, provider: provider, decode: nil, - shouldInterpolate: false, + shouldInterpolate: true, intent: .defaultIntent ) else { throw ReadError.imageCreationFailed } - - let scale = min( - 1, - min( - Double(maxWidth) / Double(source.width), - Double(maxHeight) / Double(source.height) - ) - ) - guard scale < 1 else { return source } - - let targetWidth = max(1, Int((Double(source.width) * scale).rounded(.down))) - let targetHeight = max(1, Int((Double(source.height) * scale).rounded(.down))) - let targetBytesPerRow = targetWidth * 4 - guard let context = CGContext( - data: nil, - width: targetWidth, - height: targetHeight, - bitsPerComponent: 8, - bytesPerRow: targetBytesPerRow, - space: Self.colorSpace, - bitmapInfo: Self.bitmapInfo.rawValue - ) else { throw ReadError.imageCreationFailed } - context.interpolationQuality = .medium - context.draw(source, in: CGRect(x: 0, y: 0, width: targetWidth, height: targetHeight)) - guard let image = context.makeImage() else { throw ReadError.imageCreationFailed } return image } diff --git a/RemuxApp/Sources/Ghostty/GhosttyKeyboardChrome.swift b/RemuxApp/Sources/Ghostty/GhosttyKeyboardChrome.swift index 507e8907..72d998f6 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyKeyboardChrome.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyKeyboardChrome.swift @@ -72,62 +72,6 @@ struct GhosttyBottomChromeReservation: Equatable { } } -struct GhosttyTerminalChromeStyle { - let accent: Color - let accentForeground: Color - - var toolbarButtonActiveFill: Color { - accent.opacity(0.16) - } - - var selectedFill: Color { - accent.opacity(0.18) - } - - var selectedStroke: Color { - accent.opacity(0.76) - } - - static let ghosttyDefault = GhosttyTerminalChromeStyle( - accent: Color(uiColor: .ghosttyDefaultTerminalChromeAccent), - accentForeground: Color(uiColor: .terminalChromeAccentForegroundForDarkAccent) - ) - - static let catppuccinMocha = GhosttyTerminalChromeStyle( - accent: Color(uiColor: .catppuccinMochaTerminalChromeAccent), - accentForeground: Color(uiColor: .terminalChromeAccentForegroundForDarkAccent) - ) - - static let catppuccinLatte = GhosttyTerminalChromeStyle( - accent: Color(uiColor: .catppuccinLatteTerminalChromeAccent), - accentForeground: Color(uiColor: .terminalChromeAccentForegroundForLightAccent) - ) -} - -private struct GhosttyTerminalChromeStyleKey: EnvironmentKey { - static let defaultValue = GhosttyTerminalChromeStyle.ghosttyDefault -} - -extension EnvironmentValues { - var ghosttyTerminalChromeStyle: GhosttyTerminalChromeStyle { - get { self[GhosttyTerminalChromeStyleKey.self] } - set { self[GhosttyTerminalChromeStyleKey.self] = newValue } - } -} - -extension TerminalTheme { - var terminalChromeStyle: GhosttyTerminalChromeStyle { - switch self { - case .ghosttyDefault: - .ghosttyDefault - case .remuxDark: - .catppuccinMocha - case .remuxLight: - .catppuccinLatte - } - } -} - enum GhosttyKeyboardChromeAnimation { static let composerTransition = Animation.spring( response: 0.34, @@ -137,8 +81,6 @@ enum GhosttyKeyboardChromeAnimation { } struct GhosttyKeyboardChrome: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - let keyboardMode: GhosttyKeyboardChromeMode let isEnabled: Bool let isInteractionLocked: Bool @@ -212,7 +154,6 @@ struct GhosttyKeyboardChrome: View { GhosttyKeyboardChromeDockButton( systemName: "rectangle.stack", badge: nil, - chromeStyle: chromeStyle, width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight, accessibilityLabel: "Sessions", @@ -226,7 +167,6 @@ struct GhosttyKeyboardChrome: View { GhosttyKeyboardChromeDockButton( systemName: "rectangle.on.rectangle", badge: windowBadge, - chromeStyle: chromeStyle, width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight, accessibilityLabel: windowAccessibilityLabel, @@ -240,7 +180,6 @@ struct GhosttyKeyboardChrome: View { GhosttyKeyboardChromeDockButton( systemName: "square.split.2x1", badge: paneBadge, - chromeStyle: chromeStyle, width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight, accessibilityLabel: paneAccessibilityLabel, @@ -280,7 +219,6 @@ struct GhosttyKeyboardChrome: View { GhosttyKeyboardChromeDockButton( systemName: "house", badge: nil, - chromeStyle: chromeStyle, width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight, accessibilityLabel: "Home", @@ -301,7 +239,6 @@ struct GhosttyKeyboardChrome: View { GhosttyKeyboardChromeDockButton( systemName: "square.and.pencil", badge: nil, - chromeStyle: chromeStyle, width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight, accessibilityLabel: "Open composer", @@ -317,7 +254,6 @@ struct GhosttyKeyboardChrome: View { GhosttyKeyboardChromeDockButton( systemName: "keyboard", badge: nil, - chromeStyle: chromeStyle, width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight, accessibilityLabel: keyboardMode == .hidden ? "Show keyboard controls" : "Hide keyboard controls", @@ -352,7 +288,6 @@ struct GhosttyKeyboardChrome: View { accessibilityHint: accessibilityHint, accessibilityIdentifier: accessibilityIdentifier, accessibilityValue: accessibilityValue, - chromeStyle: chromeStyle, fontSize: 12, width: dockButtonWidth, height: GhosttyKeyboardChromeSizing.dockButtonHeight, @@ -443,7 +378,6 @@ struct GhosttyKeyboardChromeNavigation: Equatable { private struct GhosttyKeyboardChromeDockButton: View { let systemName: String let badge: String? - let chromeStyle: GhosttyTerminalChromeStyle let width: CGFloat let height: CGFloat let accessibilityLabel: String @@ -469,7 +403,6 @@ private struct GhosttyKeyboardChromeDockButton: View { .buttonStyle(GhosttyChromeDockButtonStyle( isActive: isActive, isEnabled: isEnabled, - chromeStyle: chromeStyle, width: width, height: height )) @@ -486,10 +419,10 @@ private struct GhosttyKeyboardChromeDockButton: View { .font(.system(size: 8, weight: .semibold).monospacedDigit()) .lineLimit(1) .minimumScaleFactor(0.75) - .foregroundStyle(chromeStyle.accentForeground) + .foregroundStyle(RemuxAccent.onFill) .frame(minWidth: 12.5, minHeight: 12.5) .padding(.horizontal, horizontalPadding) - .background(chromeStyle.accent.opacity(0.88), in: Capsule()) + .background(RemuxAccent.fill.opacity(0.88), in: Capsule()) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) .padding(.top, 4) .padding(.trailing, 4) @@ -502,7 +435,6 @@ private struct GhosttyKeyboardKeyButton: View { let accessibilityHint: String? let accessibilityIdentifier: String let accessibilityValue: String? - let chromeStyle: GhosttyTerminalChromeStyle var fontSize: CGFloat = 13 var width: CGFloat = GhosttyKeyboardChromeSizing.dockButtonWidth var height: CGFloat = GhosttyKeyboardChromeSizing.dockButtonHeight @@ -518,13 +450,12 @@ private struct GhosttyKeyboardKeyButton: View { .font(.system(size: fontSize, weight: .semibold)) .lineLimit(1) .minimumScaleFactor(0.8) - .foregroundStyle(isActive ? chromeStyle.accent : GhosttyPhoneChromePalette.chromeForeground) + .foregroundStyle(isActive ? RemuxAccent.fill : GhosttyPhoneChromePalette.chromeForeground) .frame(width: width, height: height) .ghosttyToolbarButtonSurface( isActive: isActive, isPressed: isPressed, - isEnabled: isEnabled, - chromeStyle: chromeStyle + isEnabled: isEnabled ) .scaleEffect(isPressed && isEnabled ? 0.96 : 1) // Dimmed as one image, so a disabled button's symbol doesn't show through its badge or surface. @@ -591,7 +522,6 @@ private struct GhosttyChromePressBody: View { private struct GhosttyChromeDockButtonStyle: ButtonStyle { let isActive: Bool let isEnabled: Bool - let chromeStyle: GhosttyTerminalChromeStyle let width: CGFloat let height: CGFloat @@ -602,13 +532,12 @@ private struct GhosttyChromeDockButtonStyle: ButtonStyle { onPressDown: { Haptic.chromeControlPress() } ) { configuration.label - .foregroundStyle(isActive ? chromeStyle.accent : GhosttyPhoneChromePalette.chromeForeground) + .foregroundStyle(isActive ? RemuxAccent.fill : GhosttyPhoneChromePalette.chromeForeground) .frame(width: width, height: height) .ghosttyToolbarButtonSurface( isActive: isActive, isPressed: configuration.isPressed, - isEnabled: isEnabled, - chromeStyle: chromeStyle + isEnabled: isEnabled ) .contentShape(Rectangle()) } @@ -655,19 +584,18 @@ private extension View { func ghosttyToolbarButtonSurface( isActive: Bool, isPressed: Bool, - isEnabled: Bool, - chromeStyle: GhosttyTerminalChromeStyle + isEnabled: Bool ) -> some View { let shape = Circle() if #available(iOS 26.0, *) { self - .background(isActive ? chromeStyle.toolbarButtonActiveFill : Color.clear, in: shape) + .background(isActive ? RemuxAccent.toolbarButtonActiveFill : Color.clear, in: shape) .overlay { shape.fill(isPressed && isEnabled ? GhosttyPhoneChromePalette.toolbarButtonPressedFill : Color.clear) } } else { - let activeFill = isActive ? chromeStyle.toolbarButtonActiveFill : Color.clear + let activeFill = isActive ? RemuxAccent.toolbarButtonActiveFill : Color.clear let pressedFill = isPressed && isEnabled ? GhosttyPhoneChromePalette.toolbarButtonPressedFill : Color.clear self @@ -707,29 +635,8 @@ enum GhosttyPhoneChromePalette { ) } -private extension UIColor { - static let ghosttyDefaultTerminalChromeAccent = UIColor(red: 0.38, green: 0.69, blue: 0.94, alpha: 1.0) - static let catppuccinMochaTerminalChromeAccent = UIColor(red: 0.54, green: 0.71, blue: 0.98, alpha: 1.0) - static let catppuccinLatteTerminalChromeAccent = UIColor(red: 0.12, green: 0.40, blue: 0.96, alpha: 1.0) - static let terminalChromeAccentForegroundForDarkAccent = UIColor.black.withAlphaComponent(0.74) - static let terminalChromeAccentForegroundForLightAccent = UIColor.white -} - extension View { - @ViewBuilder - func ghosttyTerminalChromePresentation( - _ colorScheme: ColorScheme? = nil, - chromeStyle: GhosttyTerminalChromeStyle? = nil - ) -> some View { - if let colorScheme, let chromeStyle { - preferredColorScheme(colorScheme) - .environment(\.ghosttyTerminalChromeStyle, chromeStyle) - } else if let colorScheme { - preferredColorScheme(colorScheme) - } else if let chromeStyle { - environment(\.ghosttyTerminalChromeStyle, chromeStyle) - } else { - self - } + func ghosttyTerminalChromePresentation(_ colorScheme: ColorScheme) -> some View { + preferredColorScheme(colorScheme) } } diff --git a/RemuxApp/Sources/Ghostty/GhosttyKeyboardCursorTrackpadHUD.swift b/RemuxApp/Sources/Ghostty/GhosttyKeyboardCursorTrackpadHUD.swift index 743108ce..e365b7c6 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyKeyboardCursorTrackpadHUD.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyKeyboardCursorTrackpadHUD.swift @@ -3,8 +3,6 @@ import SwiftUI /// Displays either the stable committed rate or the complete shape currently /// being armed. The repeat scheduler remains driven only by `committedTier`. struct GhosttyKeyboardCursorTrackpadHUD: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - let state: GhosttyKeyboardCursorTrackpad.FeedbackState private let cornerRadius: CGFloat = 14 @@ -130,7 +128,7 @@ struct GhosttyKeyboardCursorTrackpadHUD: View { Image(systemName: symbol) .foregroundStyle(Color.white.opacity(0.42)) Image(systemName: symbol) - .foregroundStyle(chromeStyle.accent) + .foregroundStyle(RemuxAccent.fill) .mask { GeometryReader { proxy in Rectangle() diff --git a/RemuxApp/Sources/Ghostty/GhosttyPanePreviewSession.swift b/RemuxApp/Sources/Ghostty/GhosttyPanePreviewSession.swift index a1eb0c46..08388bb8 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyPanePreviewSession.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyPanePreviewSession.swift @@ -1,21 +1,41 @@ -import CoreGraphics -import Foundation +import UIKit -/// Picker-scoped preview images and one sequential capture task. Native -/// renderers and terminals stay owned by the session behind PreviewClient. +/// Picker-scoped window previews, keyed by each window's focused pane, and one +/// sequential capture task. Native renderers and terminals stay owned by the +/// session behind PreviewClient. @MainActor final class GhosttyPanePreviewSession: ObservableObject { + /// The size a preview is drawn at, in device pixels. struct PixelBudget: Equatable, Sendable { let width: UInt32 let height: UInt32 } + /// A window's preview, in points: its panes' pixels, each image with the scale it is drawn at, + /// where the window has them, and the lines between panes. + struct Preview: Sendable { + struct Pane: Sendable { + let image: UIImage + let origin: CGPoint + } + + struct Line: Sendable { + let start: CGPoint + let end: CGPoint + } + + let size: CGSize + let panes: [Pane] + let separators: [Line] + } + struct PreviewClient { - let capture: @MainActor (UUID, PixelBudget) async -> CGImage? + /// The preview, for the budget, of the window a pane is the focused one of. + let capture: @MainActor (UUID, PixelBudget) async -> Preview? let cancelCapture: @MainActor (UUID) -> Void init( - capture: @escaping @MainActor (UUID, PixelBudget) async -> CGImage?, + capture: @escaping @MainActor (UUID, PixelBudget) async -> Preview?, cancelCapture: @escaping @MainActor (UUID) -> Void = { _ in } ) { self.capture = capture @@ -25,35 +45,37 @@ final class GhosttyPanePreviewSession: ObservableObject { enum PreviewState { case pending - case ready(CGImage) + case ready(Preview) case failed } let id = UUID() @Published private(set) var imagesByPaneID: [UUID: PreviewState] = [:] - private let pixelBudget: PixelBudget private let client: PreviewClient private var trackedLeafIDs: [UUID] + /// Nil until the picker says what size it draws previews at. + private var pixelBudget: PixelBudget? private var refreshTask: Task? - private var didStartRefreshing = false private var cancelled = false private var activeCaptureLeafID: UUID? private var activeCaptureIsValid = false init( leafIDs: [UUID], - pixelBudget: PixelBudget, client: PreviewClient ) { - self.pixelBudget = pixelBudget self.client = client trackedLeafIDs = Self.unique(leafIDs) } - func startRefreshing() { - guard !didStartRefreshing, !cancelled else { return } - didStartRefreshing = true + /// Captures the previews for the size the picker draws them at. Another size, as after a rotation, + /// captures them again: a preview covers exactly the area it was captured for. + func present(pixelBudget: PixelBudget) { + guard !cancelled, pixelBudget != self.pixelBudget else { return } + self.pixelBudget = pixelBudget + cancelActiveCapture() + imagesByPaneID.removeAll() startRefreshIfNeeded() } @@ -65,7 +87,7 @@ final class GhosttyPanePreviewSession: ObservableObject { imagesByPaneID.removeValue(forKey: removed) } trackedLeafIDs = next - guard didStartRefreshing, !cancelled else { return } + guard pixelBudget != nil, !cancelled else { return } if let activeCaptureLeafID, !nextSet.contains(activeCaptureLeafID) { cancelActiveCapture() } @@ -81,21 +103,17 @@ final class GhosttyPanePreviewSession: ObservableObject { } private func startRefreshIfNeeded() { - guard refreshTask == nil, - didStartRefreshing, - !cancelled, - nextCaptureLeafID != nil - else { return } + guard refreshTask == nil, !cancelled, nextCaptureLeafID != nil else { return } refreshTask = Task { @MainActor [weak self] in guard let self else { return } defer { refreshTask = nil } - while let leafID = nextCaptureLeafID { + while let pixelBudget, let leafID = nextCaptureLeafID { guard !Task.isCancelled, !cancelled else { return } imagesByPaneID[leafID] = .pending activeCaptureLeafID = leafID activeCaptureIsValid = true - let image = await client.capture(leafID, pixelBudget) + let preview = await client.capture(leafID, pixelBudget) let captureIsValid = activeCaptureLeafID == leafID && activeCaptureIsValid if activeCaptureLeafID == leafID { @@ -106,8 +124,8 @@ final class GhosttyPanePreviewSession: ObservableObject { guard captureIsValid, trackedLeafIDs.contains(leafID) else { continue } - if let image { - imagesByPaneID[leafID] = .ready(image) + if let preview { + imagesByPaneID[leafID] = .ready(preview) } else { imagesByPaneID[leafID] = .failed } @@ -122,7 +140,8 @@ final class GhosttyPanePreviewSession: ObservableObject { } private var nextCaptureLeafID: UUID? { - trackedLeafIDs.first { imagesByPaneID[$0] == nil } + guard pixelBudget != nil else { return nil } + return trackedLeafIDs.first { imagesByPaneID[$0] == nil } } private static func unique(_ leafIDs: [UUID]) -> [UUID] { diff --git a/RemuxApp/Sources/Ghostty/GhosttyShortcutSurfacePalette.swift b/RemuxApp/Sources/Ghostty/GhosttyShortcutSurfacePalette.swift index 24ab084d..943acd95 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyShortcutSurfacePalette.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyShortcutSurfacePalette.swift @@ -7,13 +7,10 @@ enum GhosttyShortcutSurfacePalette { static let embeddedFill = Color(uiColor: .secondarySystemFill) static let embeddedPressedFill = Color(uiColor: .tertiarySystemFill) + static let embeddedSelectedFill = RemuxAccent.selectedFill static let cornerRadiusLarge: CGFloat = 22 static let cornerRadiusMedium: CGFloat = 14 - - static func embeddedSelectedFill(_ chromeStyle: GhosttyTerminalChromeStyle) -> Color { - chromeStyle.selectedFill - } } enum GhosttyShortcutTypography { diff --git a/RemuxApp/Sources/Ghostty/GhosttySingleViewportView.swift b/RemuxApp/Sources/Ghostty/GhosttySingleViewportView.swift index 371d3f93..a73283c4 100644 --- a/RemuxApp/Sources/Ghostty/GhosttySingleViewportView.swift +++ b/RemuxApp/Sources/Ghostty/GhosttySingleViewportView.swift @@ -70,19 +70,13 @@ private struct GhosttyCompositeViewportRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> GhosttyCompositeViewportContainerView { let view = GhosttyCompositeViewportContainerView() view.backgroundColor = terminalTheme.terminalBackgroundUIColor - view.updateSelectionHandleStyle( - terminalTheme.terminalChromeStyle, - separatorColor: terminalTheme.terminalCompositeSeparatorUIColor - ) + view.updateSeparatorColor(terminalTheme.terminalCompositeSeparatorUIColor) return view } func updateUIView(_ view: GhosttyCompositeViewportContainerView, context: Context) { view.backgroundColor = terminalTheme.terminalBackgroundUIColor - view.updateSelectionHandleStyle( - terminalTheme.terminalChromeStyle, - separatorColor: terminalTheme.terminalCompositeSeparatorUIColor - ) + view.updateSeparatorColor(terminalTheme.terminalCompositeSeparatorUIColor) view.update( projection: projection, surfaceLookup: surfaceLookup, @@ -119,7 +113,6 @@ private final class GhosttyCompositeViewportContainerView: UIView, private let linesView = GhosttyPaneLinesUIView() /// The backend's popup, in front of the panes while it shows, and behind the selection's handles. private let popupView = GhosttyTerminalPopupFrameView() - private var focusedSeparatorColor = UIColor.clear private var separatorColor = UIColor.clear private var trackpadDriver: GhosttyKeyboardCursorTrackpadDriver? @@ -233,22 +226,9 @@ private final class GhosttyCompositeViewportContainerView: UIView, return handle.hitTest(convert(point, to: handle), with: event) } - func updateSelectionHandleStyle( - _ style: GhosttyTerminalChromeStyle, - separatorColor: UIColor - ) { - let fillColor = UIColor(style.accent) - let borderColor = UIColor(style.accentForeground).cgColor - let styleChanged = !focusedSeparatorColor.isEqual(fillColor) - || !self.separatorColor.isEqual(separatorColor) - || startSelectionHandle.layer.borderColor != borderColor - guard styleChanged else { return } - focusedSeparatorColor = fillColor + func updateSeparatorColor(_ separatorColor: UIColor) { + guard !self.separatorColor.isEqual(separatorColor) else { return } self.separatorColor = separatorColor - startSelectionHandle.backgroundColor = fillColor - endSelectionHandle.backgroundColor = fillColor - startSelectionHandle.layer.borderColor = borderColor - endSelectionHandle.layer.borderColor = borderColor flushLayoutIfPossible() } @@ -415,7 +395,7 @@ private final class GhosttyCompositeViewportContainerView: UIView, let startedAt = GhosttyRuntimeTrace.perfEnabled ? GhosttyRuntimeTrace.nowNanos() : nil - let inks = GhosttyPaneLinesUIView.Inks(separator: separatorColor, focus: focusedSeparatorColor) + let inks = GhosttyPaneLinesUIView.Inks(separator: separatorColor, focus: RemuxAccent.uiFill) guard let grid = projection.windowGrid, let cell = projection.cellMetrics, let layout = GhosttyCompositeViewportLayout( @@ -1091,6 +1071,8 @@ private final class GhosttySelectionHandleView: UIView { init(endpoint: ghostty_terminal_surface_selection_endpoint_e) { self.endpoint = endpoint super.init(frame: CGRect(x: 0, y: 0, width: 18, height: 18)) + backgroundColor = RemuxAccent.uiFill + layer.borderColor = RemuxAccent.uiOnFill.cgColor layer.borderWidth = 1 layer.cornerRadius = 9 } @@ -1130,7 +1112,7 @@ private extension GhosttySurfacePanGesture.WindowNavigationDirection { /// animation, so lines drawn into the area's own layer would stretch over the panes, which are laid out without /// animation. The canvas keeps its place through the animation, and this view's geometry never animates. final class GhosttyPaneLinesUIView: UIView { - /// The colours of the inks the backend leaves to the theme. + /// The colours of the inks the backend leaves to the app: the theme's separator and the app's accent. struct Inks: Equatable { var separator: UIColor var focus: UIColor diff --git a/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift b/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift index f3e60d4d..33a0439c 100644 --- a/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift +++ b/RemuxApp/Sources/Ghostty/GhosttySurfaceScreen.swift @@ -483,7 +483,7 @@ struct GhosttySurfaceScreen: View { onShowSessions: onShowSessions, onShowLibrary: onShowLibrary, onShowWindows: { - showWindows(availableSize: screenProxy.size) + showWindows() }, onShowPanes: showPanes, onOpenComposer: openComposer, @@ -584,6 +584,12 @@ struct GhosttySurfaceScreen: View { paneTopologySize: paneTopologySize, maximumContentHeight: maximumContentHeight ) + let gridPresentation = selectionSheetGridPresentation( + sheet, + windowProjection: windowProjection, + windowLayout: windowLayout, + contentHeight: contentHeight + ) selectionSheetContent( sheet, windowProjection: windowProjection, @@ -592,19 +598,20 @@ struct GhosttySurfaceScreen: View { contentHeight: contentHeight ) .presentationDetents( - [.height(TerminalSelectionSheetLayout.sheetHeight( - contentHeight: contentHeight, - navigationBarHeight: navigationBarHeight, - headerHeight: headerHeight - ))] + selectionSheetDetents( + fitted: .height(TerminalSelectionSheetLayout.sheetHeight( + contentHeight: contentHeight, + navigationBarHeight: navigationBarHeight, + headerHeight: headerHeight + )), + gridPresentation: gridPresentation + ) ) - .presentationContentInteraction(.scrolls) - .presentationDragIndicator(.hidden) + // A sheet that can be pulled taller grows before its grid scrolls. + .presentationContentInteraction(gridPresentation == .fitted ? .scrolls : .automatic) + .presentationDragIndicator(gridPresentation == .fitted ? .hidden : .visible) .remuxSheetPresentationBackground() - .ghosttyTerminalChromePresentation( - presentation.terminalTheme.terminalChromeColorScheme, - chromeStyle: presentation.terminalTheme.terminalChromeStyle - ) + .ghosttyTerminalChromePresentation(presentation.terminalTheme.terminalChromeColorScheme) } .ghosttyCloseConfirmationDialog(selectionSheet == nil ? askedCloseQuestion : nil) .sheet(isPresented: $isShortcutsSettingsPresented) { @@ -612,10 +619,7 @@ struct GhosttySurfaceScreen: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) .remuxSheetPresentationBackground() - .ghosttyTerminalChromePresentation( - presentation.terminalTheme.terminalChromeColorScheme, - chromeStyle: presentation.terminalTheme.terminalChromeStyle - ) + .ghosttyTerminalChromePresentation(presentation.terminalTheme.terminalChromeColorScheme) } .sheet(item: $shortcutEditorRequest) { request in ShortcutEditorSheet(request: request) { shortcut, favorite in @@ -629,10 +633,7 @@ struct GhosttySurfaceScreen: View { .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .remuxSheetPresentationBackground() - .ghosttyTerminalChromePresentation( - presentation.terminalTheme.terminalChromeColorScheme, - chromeStyle: presentation.terminalTheme.terminalChromeStyle - ) + .ghosttyTerminalChromePresentation(presentation.terminalTheme.terminalChromeColorScheme) } .sheet(item: $attachmentPreviewRequest) { request in GhosttyAttachmentPreviewSheet( @@ -642,10 +643,7 @@ struct GhosttySurfaceScreen: View { .presentationDetents([.large]) .presentationDragIndicator(.visible) .remuxSheetPresentationBackground() - .ghosttyTerminalChromePresentation( - presentation.terminalTheme.terminalChromeColorScheme, - chromeStyle: presentation.terminalTheme.terminalChromeStyle - ) + .ghosttyTerminalChromePresentation(presentation.terminalTheme.terminalChromeColorScheme) } .photosPicker( isPresented: $isAttachmentPhotosPickerPresented, @@ -731,7 +729,6 @@ struct GhosttySurfaceScreen: View { } #endif } - .environment(\.ghosttyTerminalChromeStyle, presentation.terminalTheme.terminalChromeStyle) .onAppear { GhosttyRuntimeTrace.flowEvent( sessionOpenFlowID, @@ -1566,32 +1563,14 @@ struct GhosttySurfaceScreen: View { return consumesDraft || sendTerminalKeyEvent(event) } - private func showWindows(availableSize: CGSize) { + private func showWindows() { guard !composer.isSubmitting, model.terminalScreenPresentationProjection.viewport.popup == nil else { return } model.claimActiveTmuxViewportIfNeeded() guard let projection = model.windowSheetPresentationProjection() else { return } GhosttyRuntimeTrace.flowEventIfActive("tmux.newWindow", event: "ui.showWindows") - let layout = PanePreviewLayout.windowMetrics(availableSize: availableSize) - let session = makeWindowPreviewSession( - leafIDs: projection.previewLeafIDs, - layout: layout - ) shownWindowGroupID = nil - applySelectionSheetPresentation(.windows(session)) - session?.startRefreshing() - } - - private func makeWindowPreviewSession( - leafIDs: [UUID], - layout: PanePreviewLayout.Metrics - ) -> GhosttyPanePreviewSession? { - let dimensions = PanePreviewLayout.windowPhysicalPixelBudget( - metrics: layout, - scale: displayScale - ) - return model.makePanePreviewSession( - leafIDs: leafIDs, - pixelBudget: .init(width: dimensions.width, height: dimensions.height) + applySelectionSheetPresentation( + .windows(model.makePanePreviewSession(leafIDs: projection.previewLeafIDs)) ) } @@ -2328,6 +2307,33 @@ struct GhosttySurfaceScreen: View { } } + /// How the sheet opens: only a grid of windows taller than its fitted height opens another way. + private func selectionSheetGridPresentation( + _ sheet: GhosttySurfaceSelectionSheet, + windowProjection: GhosttyWindowSelectionSheetRenderProjection?, + windowLayout: PanePreviewLayout.Metrics, + contentHeight: CGFloat + ) -> PanePreviewLayout.GridPresentation { + guard case .windows(let session) = sheet, session != nil, let windowProjection else { return .fitted } + return PanePreviewLayout.gridPresentation( + itemCount: windowProjection.windows.count, + currentIndex: windowProjection.windows.firstIndex(where: \.isSelected), + metrics: windowLayout, + fittedHeight: contentHeight + ) + } + + private func selectionSheetDetents( + fitted: PresentationDetent, + gridPresentation: PanePreviewLayout.GridPresentation + ) -> Set { + switch gridPresentation { + case .fitted: [fitted] + case .fittedOrFull: [fitted, .large] + case .full: [.large] + } + } + @ViewBuilder private func selectionSheetContent( _ sheet: GhosttySurfaceSelectionSheet, @@ -2344,6 +2350,8 @@ struct GhosttySurfaceScreen: View { projection: windowProjection, topLevelNoun: model.topLevelNoun, sessionName: presentation.sessionName, + terminalBackground: Color(uiColor: presentation.terminalTheme.terminalBackgroundUIColor), + terminalSeparator: Color(uiColor: presentation.terminalTheme.terminalCompositeSeparatorUIColor), layout: windowLayout, contentHeight: contentHeight, commandFailureMessage: selectionSheetCommandFailureMessage, @@ -2694,12 +2702,7 @@ extension TerminalTheme { } var terminalChromeColorScheme: ColorScheme { - switch self { - case .remuxLight: - .light - case .ghosttyDefault, .remuxDark: - .dark - } + isLight ? .light : .dark } var terminalKeyboardAppearance: UIKeyboardAppearance { diff --git a/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift b/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift index 5883afbe..2c242796 100644 --- a/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift +++ b/RemuxApp/Sources/Ghostty/GhosttySurfaceSelectionSheet.swift @@ -29,12 +29,16 @@ struct GhosttyWindowSelectionSheet: View { @State private var removal = GhosttyTileRemoval() @State private var renamePrompt: TerminalRenamePrompt? @State private var renameName = "" + @State private var keepsCurrentWindowInView = true /// The windows' previews; nil lists the windows as rows. let session: GhosttyPanePreviewSession? let projection: GhosttyWindowSelectionSheetRenderProjection let topLevelNoun: GhosttyTopLevelNoun let sessionName: String + /// The terminal theme's background, which a preview sits on. + let terminalBackground: Color + let terminalSeparator: Color let layout: PanePreviewLayout.Metrics let contentHeight: CGFloat let commandFailureMessage: String? @@ -61,21 +65,41 @@ struct GhosttyWindowSelectionSheet: View { } content: { Group { if let session { - ScrollView(showsIndicators: false) { - GhosttyWindowPreviewGrid( - session: session, - projection: projection, - topLevelNoun: topLevelNoun, - layout: layout, - onSelect: select, - onClose: close + // The grid takes the sheet's height, which a sheet with more windows than it + // shows can be pulled taller. + ScrollViewReader { scroll in + ScrollView(showsIndicators: false) { + GhosttyWindowPreviewGrid( + session: session, + projection: projection, + topLevelNoun: topLevelNoun, + terminalBackground: terminalBackground, + terminalSeparator: terminalSeparator, + layout: layout, + onSelect: select, + onClose: close + ) + } + .contentMargins( + .horizontal, + TerminalSelectionSheetLayout.horizontalContentPadding, + for: .scrollContent ) + .terminalSelectionSheetScrollFades() + // The current window stays in view as the sheet takes its height, until + // the grid is scrolled by hand. + .onScrollPhaseChange { _, phase in + if phase == .interacting { keepsCurrentWindowInView = false } + } + .onScrollGeometryChange(for: CGFloat.self) { geometry in + geometry.containerSize.height + } action: { _, _ in + guard keepsCurrentWindowInView, + let current = projection.windows.first(where: \.isSelected) + else { return } + scroll.scrollTo(current.id, anchor: .bottom) + } } - .contentMargins( - .horizontal, - TerminalSelectionSheetLayout.horizontalContentPadding, - for: .scrollContent - ) } else { GhosttyWindowRowList( windows: projection.windows, @@ -84,9 +108,9 @@ struct GhosttyWindowSelectionSheet: View { onClose: close, onRename: rename ) + .frame(height: contentHeight) } } - .frame(height: contentHeight) .accessibilityIdentifier("terminal.windows.scroll") } actions: { TerminalSelectionSheetActionButton( @@ -167,7 +191,6 @@ struct GhosttyWindowSelectionSheet: View { struct GhosttyPaneSelectionSheet: View { @Environment(\.dismiss) private var dismiss - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle @State private var removal = GhosttyTileRemoval() @State private var renamePrompt: TerminalRenamePrompt? @State private var renameName = "" @@ -222,8 +245,7 @@ struct GhosttyPaneSelectionSheet: View { isOn: Binding( get: { projection.isServerZoomed }, set: { zoomed in onSetZoomed(zoomed) } - ), - accent: chromeStyle.accent + ) ) } } @@ -268,7 +290,6 @@ struct GhosttyPaneSelectionSheet: View { panes: projection.panes, selectedPaneID: projection.selectedPaneID, size: topologySize, - accent: chromeStyle.selectedStroke, onSelect: { paneID in Haptic.selection() removal.end() @@ -308,11 +329,13 @@ struct GhosttyPaneSelectionSheet: View { /// The windows as a grid of previews of their focused panes. A long press on a tile offers its removal. private struct GhosttyWindowPreviewGrid: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle + @Environment(\.displayScale) private var displayScale @ObservedObject var session: GhosttyPanePreviewSession let projection: GhosttyWindowSelectionSheetRenderProjection let topLevelNoun: GhosttyTopLevelNoun + let terminalBackground: Color + let terminalSeparator: Color let layout: PanePreviewLayout.Metrics let onSelect: (GhosttyWindowSelectionSheetRenderProjection.Window) -> Void let onClose: (GhosttyWindowSelectionSheetRenderProjection.Window) -> Void @@ -324,7 +347,7 @@ private struct GhosttyWindowPreviewGrid: View { count: layout.columnCount ), alignment: .center, - spacing: layout.gridSpacing + spacing: layout.rowSpacing ) { ForEach(projection.windows) { window in Button { @@ -341,7 +364,8 @@ private struct GhosttyWindowPreviewGrid: View { previewState: window.focusedPreviewPaneID.flatMap { pane in projection.previewLeafIDs.contains(pane) ? session.imagesByPaneID[pane] : .failed }, - chromeStyle: chromeStyle, + terminalBackground: terminalBackground, + terminalSeparator: terminalSeparator, layout: layout ) } @@ -363,11 +387,20 @@ private struct GhosttyWindowPreviewGrid: View { .frame(maxWidth: .infinity, alignment: .top) .onAppear { session.reconcile(leafIDs: projection.previewLeafIDs) + session.present(pixelBudget: pixelBudget) GhosttyRuntimeTrace.perf("panePreview.presentation activate kind=windows") } .onChange(of: projection.previewLeafIDs) { _, newValue in session.reconcile(leafIDs: newValue) } + .onChange(of: pixelBudget) { _, newValue in + session.present(pixelBudget: newValue) + } + } + + /// The size the tiles draw their previews at, which is the size the previews are captured at. + private var pixelBudget: GhosttyPanePreviewSession.PixelBudget { + PanePreviewLayout.windowPhysicalPixelBudget(metrics: layout, scale: displayScale) } } @@ -640,21 +673,8 @@ struct GhosttySelectionSheetFailureBanner: View { } } -private struct GhosttyRenderedPreviewSurface: View { - let image: CGImage - let size: CGSize - - var body: some View { - Image(decorative: image, scale: PanePreviewLayout.currentScale()) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: size.width, height: size.height) - .background(Color.black.opacity(0.30)) - .clipped() - } - -} - +/// A window as a picture of its focused pane over a caption: its number, its agent's state, its name +/// and, when it is split, how many panes it has. private struct GhosttyWindowSelectionTile: View { let topLevelNoun: GhosttyTopLevelNoun let displayIndex: Int @@ -664,76 +684,89 @@ private struct GhosttyWindowSelectionTile: View { let isSelected: Bool let agentStatus: TerminalAgentStatus? let previewState: GhosttyPanePreviewSession.PreviewState? - let chromeStyle: GhosttyTerminalChromeStyle + let terminalBackground: Color + let terminalSeparator: Color let layout: PanePreviewLayout.Metrics + private static let pictureShape = RoundedRectangle(cornerRadius: 12, style: .continuous) + var body: some View { - ZStack(alignment: .bottomLeading) { - previewSurface - - metadata - .padding(.horizontal, 8) - .padding(.vertical, 5) - .background( - Color.black.opacity(0.78), - in: RoundedRectangle(cornerRadius: 7, style: .continuous) - ) - .padding(8) + VStack(spacing: PanePreviewLayout.pictureToCaptionSpacing) { + picture + caption } .frame( width: layout.tilePointSize.width, height: layout.tilePointSize.height, - alignment: .topLeading + alignment: .top ) - .terminalSelectionTileChrome(isSelected: isSelected, chromeStyle: chromeStyle) - .overlay(alignment: .topLeading) { - GhosttyPreviewIndexBadge( - displayIndex: displayIndex, - isSelected: isSelected, - chromeStyle: chromeStyle - ) - .padding(8) - } + .contentShape(Self.pictureShape) .accessibilityElement(children: .ignore) .accessibilityLabel(accessibilityLabel) .accessibilityValue(previewState.accessibilityValue) .accessibilityAddTraits(isSelected ? [.isSelected, .isButton] : .isButton) } - @ViewBuilder - private var previewSurface: some View { - switch previewState { - case .ready(let image): - GhosttyRenderedPreviewSurface( - image: image, - size: layout.tilePointSize + private var picture: some View { + ZStack(alignment: .topLeading) { + terminalBackground + + switch previewState { + case .ready(let preview): + GhosttyWindowPreviewPicture(preview: preview, separator: terminalSeparator) + .padding(PanePreviewLayout.pictureInset) + .transition(.opacity) + + case .failed: + Image(systemName: "apple.terminal") + .font(.system(size: 22, weight: .regular)) + .foregroundStyle(TerminalSelectionSheetPalette.tertiary) + .frame(maxWidth: .infinity, maxHeight: .infinity) + + case .pending, .none: + EmptyView() + } + } + .frame( + width: layout.picturePointSize.width, + height: layout.picturePointSize.height, + alignment: .topLeading + ) + .clipShape(Self.pictureShape) + .overlay { + Self.pictureShape.strokeBorder( + isSelected ? RemuxAccent.fill : TerminalSelectionSheetPalette.stroke, + lineWidth: isSelected ? 2 : 1 ) - - case .pending, .none, .failed: - Color.black.opacity(0.30) - .frame(width: layout.tilePointSize.width, height: layout.tilePointSize.height) } + .animation(.easeOut(duration: 0.18), value: previewState.isReady) } - private var metadata: some View { - HStack(spacing: 4) { + private var caption: some View { + HStack(spacing: 6) { + GhosttyWindowIndexBadge(displayIndex: displayIndex) + if let agentStatus { TerminalAgentDot(status: agentStatus) - // The label sits on a dark plate in either appearance. - .environment(\.colorScheme, .dark) } + // A long name keeps both its ends, where window names differ. Text(displayName.isEmpty ? "\(topLevelNoun.singular) \(displayIndex)" : displayName) - .fontWeight(.semibold) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .truncationMode(.middle) - Text("ยท") + if paneCount > 1 { + Text("\(paneCount) panes") + .font(.system(size: 12)) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + .layoutPriority(1) + } - Text("\(paneCount) \(paneCount == 1 ? "pane" : "panes")") + Spacer(minLength: 0) } - .font(.system(size: 11)) - .foregroundStyle(Color.white.opacity(0.94)) .lineLimit(1) - .truncationMode(.tail) + .frame(height: PanePreviewLayout.captionHeight) } private var accessibilityLabel: String { @@ -748,37 +781,55 @@ private struct GhosttyWindowSelectionTile: View { } } -private struct GhosttyPreviewIndexBadge: View { +/// A window's number, as tmux counts it, filled for the current window. +/// A window's preview as the window has it: each pane's own pixels, scaled once, as they are drawn, +/// and the lines between panes. +private struct GhosttyWindowPreviewPicture: View { + @Environment(\.displayScale) private var displayScale + + let preview: GhosttyPanePreviewSession.Preview + let separator: Color + + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(preview.panes.indices, id: \.self) { index in + Image(uiImage: preview.panes[index].image) + .offset(x: preview.panes[index].origin.x, y: preview.panes[index].origin.y) + } + Path { path in + for line in preview.separators { + path.move(to: line.start) + path.addLine(to: line.end) + } + } + .stroke(separator, lineWidth: 1 / max(displayScale, 1)) + } + .frame(width: preview.size.width, height: preview.size.height, alignment: .topLeading) + } +} + +/// A window's number. The ring around the picture alone marks the current window. +private struct GhosttyWindowIndexBadge: View { let displayIndex: Int - let isSelected: Bool - let chromeStyle: GhosttyTerminalChromeStyle var body: some View { Text("\(displayIndex)") .font(.system(size: 12, weight: .bold, design: .rounded)) .monospacedDigit() - .foregroundStyle( - isSelected - ? chromeStyle.accentForeground - : TerminalSelectionSheetPalette.primary - ) - .frame(width: 28, height: 28) + .foregroundStyle(TerminalSelectionSheetPalette.primary) + .padding(.horizontal, 5) + .frame(minWidth: PanePreviewLayout.captionHeight) + .frame(height: PanePreviewLayout.captionHeight) .background( - isSelected - ? chromeStyle.accent - : Color(uiColor: .secondarySystemBackground).opacity(0.84), - in: RoundedRectangle(cornerRadius: 8, style: .continuous) + TerminalSelectionSheetPalette.controlFill, + in: RoundedRectangle(cornerRadius: 6, style: .continuous) ) - .overlay { - if !isSelected { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(TerminalSelectionSheetPalette.stroke, lineWidth: 0.75) - } - } } } private struct GhosttyPaneTopologyDiagram: View { + @Environment(\.colorScheme) private var colorScheme + static let contentInset: CGFloat = 6 private static let outerCornerRadius: CGFloat = 12 private static let tileInset: CGFloat = 2 @@ -786,7 +837,6 @@ private struct GhosttyPaneTopologyDiagram: View { let panes: [GhosttyPaneSelectionSheetRenderProjection.Pane] let selectedPaneID: UUID? let size: CGSize - let accent: Color let onSelect: (UUID) -> Void let onClose: (GhosttyPaneSelectionSheetRenderProjection.Pane) -> Void let onRename: (GhosttyPaneSelectionSheetRenderProjection.Pane) -> Void @@ -826,16 +876,23 @@ private struct GhosttyPaneTopologyDiagram: View { .frame(width: Self.contentSize(for: size).width, height: Self.contentSize(for: size).height) .padding(Self.contentInset) .frame(width: size.width, height: size.height) - .background( - Color.black.opacity(0.18), - in: RoundedRectangle(cornerRadius: Self.outerCornerRadius, style: .continuous) - ) + .background(wellFill, in: RoundedRectangle(cornerRadius: Self.outerCornerRadius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: Self.outerCornerRadius, style: .continuous) .strokeBorder(TerminalSelectionSheetPalette.stroke, lineWidth: 1) } } + /// The panes sit lifted from a well: in the light look as in the dark one, a pane is lighter than + /// what it sits in. + private var wellFill: Color { + colorScheme == .dark ? Color.black.opacity(0.18) : Color.black.opacity(0.06) + } + + private var paneFill: Color { + colorScheme == .dark ? TerminalSelectionSheetPalette.row.opacity(0.84) : Color.white.opacity(0.6) + } + private func paneTile( for pane: GhosttyPaneSelectionSheetRenderProjection.Pane, size: CGSize @@ -890,15 +947,14 @@ private struct GhosttyPaneTopologyDiagram: View { } .frame(width: size.width, height: size.height) .background( - selected - ? accent.opacity(0.24) - : TerminalSelectionSheetPalette.row.opacity(0.84), + selected ? RemuxAccent.selectedFill : .clear, in: RoundedRectangle(cornerRadius: radius, style: .continuous) ) + .background(paneFill, in: RoundedRectangle(cornerRadius: radius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: radius, style: .continuous) .strokeBorder( - selected ? accent : TerminalSelectionSheetPalette.stroke, + selected ? RemuxAccent.fill : TerminalSelectionSheetPalette.stroke, lineWidth: selected ? 2 : 1 ) } @@ -916,22 +972,22 @@ private struct GhosttyPaneTopologyDiagram: View { for pane: GhosttyPaneSelectionSheetRenderProjection.Pane, size: CGSize ) -> some View { - VStack(spacing: 2) { - Text(directoryName(pane)) + // A pane's folder over its program; a pane without a folder is named by its program alone. + let hasFolder = !pane.currentPath.isEmpty + return VStack(spacing: 2) { + Text(hasFolder ? directoryName(pane) : commandName(for: pane)) .font(.system(size: 13, weight: .semibold)) - .foregroundStyle( - pane.id == selectedPaneID - ? accent - : TerminalSelectionSheetPalette.primary - ) + .foregroundStyle(TerminalSelectionSheetPalette.primary) .lineLimit(1) .truncationMode(.middle) - Text(commandName(for: pane)) - .font(.system(size: 11, weight: .medium)) - .foregroundStyle(TerminalSelectionSheetPalette.secondary) - .lineLimit(1) - .truncationMode(.tail) + if hasFolder { + Text(commandName(for: pane)) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(TerminalSelectionSheetPalette.secondary) + .lineLimit(1) + .truncationMode(.tail) + } if let agent = pane.agent { TerminalAgentStateLine(state: agent) @@ -1005,7 +1061,6 @@ private struct GhosttyPaneSheetActionButtonStyle: ButtonStyle { private struct GhosttyPaneSheetZoomControl: View { @Binding var isOn: Bool - let accent: Color var body: some View { Toggle(isOn: $isOn) { @@ -1016,7 +1071,7 @@ private struct GhosttyPaneSheetZoomControl: View { } .toggleStyle(.switch) .controlSize(.small) - .tint(accent) + .tint(RemuxAccent.fill) .padding(.horizontal, 11) .frame(height: TerminalSelectionSheetLayout.actionBarHeight) .fixedSize(horizontal: true, vertical: false) @@ -1036,6 +1091,11 @@ private struct GhosttyPaneSheetControlDivider: View { } private extension Optional where Wrapped == GhosttyPanePreviewSession.PreviewState { + var isReady: Bool { + if case .ready = self { return true } + return false + } + var accessibilityValue: String { switch self { case .ready: diff --git a/RemuxApp/Sources/Ghostty/GhosttySurfaceStatusOverlay.swift b/RemuxApp/Sources/Ghostty/GhosttySurfaceStatusOverlay.swift index 8422e43a..1f3c92f8 100644 --- a/RemuxApp/Sources/Ghostty/GhosttySurfaceStatusOverlay.swift +++ b/RemuxApp/Sources/Ghostty/GhosttySurfaceStatusOverlay.swift @@ -1,8 +1,6 @@ import SwiftUI struct GhosttySurfaceStatusOverlay: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - let projection: GhosttyTerminalStatusOverlayProjection let loadingTitle: String let sessionName: String @@ -170,7 +168,6 @@ struct GhosttySurfaceStatusOverlay: View { title: action.title, systemName: action.systemImage, accessibilityIdentifier: action.accessibilityIdentifier, - chromeStyle: chromeStyle, isPrimary: action.isPrimary, action: handler(for: action.kind) ) @@ -204,7 +201,6 @@ private struct GhosttyRepairActionButton: View { let title: String let systemName: String let accessibilityIdentifier: String - let chromeStyle: GhosttyTerminalChromeStyle var isPrimary = false let action: () -> Void @@ -222,7 +218,7 @@ private struct GhosttyRepairActionButton: View { .lineLimit(1) .minimumScaleFactor(0.82) } - .foregroundStyle(isPrimary ? chromeStyle.accent : GhosttySheetPalette.primary) + .foregroundStyle(isPrimary ? RemuxAccent.fill : GhosttySheetPalette.primary) .padding(.horizontal, 16) .frame(height: 38) } diff --git a/RemuxApp/Sources/Ghostty/GhosttyTerminalPresentationProjector.swift b/RemuxApp/Sources/Ghostty/GhosttyTerminalPresentationProjector.swift index e5c9049f..cdde3142 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyTerminalPresentationProjector.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyTerminalPresentationProjector.swift @@ -242,7 +242,7 @@ struct GhosttyPaneLine: Equatable, Sendable { enum Ink: Equatable, Sendable { /// The theme's separator colour. case separator - /// The theme's accent, which marks the focused pane. + /// The app's accent, which marks the focused pane. case focus case color(red: UInt8, green: UInt8, blue: UInt8) } diff --git a/RemuxApp/Sources/Ghostty/GhosttyTerminalScreenModeling.swift b/RemuxApp/Sources/Ghostty/GhosttyTerminalScreenModeling.swift index a33677fe..c1f3ca6f 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyTerminalScreenModeling.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyTerminalScreenModeling.swift @@ -288,10 +288,7 @@ protocol GhosttyTerminalSelectionModeling: ObservableObject { /// The window sheet's previews of `leafIDs`; nil for a backend whose sheet lists its windows as rows /// saying what each holds. - func makePanePreviewSession( - leafIDs: [UUID], - pixelBudget: GhosttyPanePreviewSession.PixelBudget - ) -> GhosttyPanePreviewSession? + func makePanePreviewSession(leafIDs: [UUID]) -> GhosttyPanePreviewSession? // MARK: Selection sheets diff --git a/RemuxApp/Sources/Ghostty/GhosttyWindowRowList.swift b/RemuxApp/Sources/Ghostty/GhosttyWindowRowList.swift index 2ad46271..062dcef6 100644 --- a/RemuxApp/Sources/Ghostty/GhosttyWindowRowList.swift +++ b/RemuxApp/Sources/Ghostty/GhosttyWindowRowList.swift @@ -76,8 +76,6 @@ struct GhosttyWindowRowList: View { } private struct GhosttyWindowRow: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - let window: GhosttyWindowSelectionSheetRenderProjection.Window let topLevelNoun: GhosttyTopLevelNoun @@ -104,7 +102,7 @@ private struct GhosttyWindowRow: View { if window.isSelected { Image(systemName: "checkmark") .font(.body.weight(.semibold)) - .foregroundStyle(chromeStyle.accent) + .foregroundStyle(RemuxAccent.fill) .accessibilityHidden(true) } } diff --git a/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift b/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift index 5f7ca886..3d90dbdd 100644 --- a/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift +++ b/RemuxApp/Sources/Ghostty/PanePreviewLayout.swift @@ -1,24 +1,35 @@ import CoreGraphics -import UIKit +import Foundation /// Single source of truth for window-preview geometry and capture budgets. /// -/// Capture resolves once when the picker opens. Rendering and sheet sizing use -/// the live available size, so rotation updates the grid without recapturing -/// the existing 4:3 preview images. +/// A tile is a picture of its window over a caption. A preview holds the +/// panes' own pixels for the area the picture shows, with the scale they are +/// drawn at, so it is scaled once, as it is drawn, and a tile of another size +/// is captured again. enum PanePreviewLayout { struct Metrics: Equatable { let columnCount: Int let tilePointSize: CGSize - /// Terminal image size inside the card, before display scaling. - let capturePointSize: CGSize + /// The tile's picture: its terminal background, with the preview inside the insets. + let picturePointSize: CGSize let gridSpacing: CGFloat + /// Wider than the gap under a picture, so a caption reads as its own picture's. + let rowSpacing: CGFloat + + /// The preview image's size inside the picture, before display scaling. + var capturePointSize: CGSize { + CGSize( + width: max(1, picturePointSize.width - PanePreviewLayout.pictureInset * 2), + height: max(1, picturePointSize.height - PanePreviewLayout.pictureInset * 2) + ) + } func gridHeight(itemCount: Int) -> CGFloat { guard itemCount > 0 else { return 0 } let rows = (itemCount + columnCount - 1) / columnCount return CGFloat(rows) * tilePointSize.height - + CGFloat(rows - 1) * gridSpacing + + CGFloat(rows - 1) * rowSpacing } } @@ -38,7 +49,7 @@ enum PanePreviewLayout { guard budget > 0 else { return 0 } let tile = metrics.tilePointSize.height - let spacing = metrics.gridSpacing + let spacing = metrics.rowSpacing let peek = tile * 0.5 func completedHeight(rows: Int) -> CGFloat { @@ -55,20 +66,46 @@ enum PanePreviewLayout { return min(heightWithPeek, fullHeight, budget) } - private static let defaultPreviewAspectRatio: CGFloat = 4.0 / 3.0 - private static let previewCaptureHorizontalInset: CGFloat = 8 + /// How a sheet of windows opens. + enum GridPresentation: Equatable { + /// The whole grid shows at the fitted height. + case fitted + /// The fitted height shows the current window and part of the grid; the sheet can be pulled + /// to full height for more. + case fittedOrFull + /// The fitted height would not show the current window, so the sheet has the full height. + case full + } + + static func gridPresentation( + itemCount: Int, + currentIndex: Int?, + metrics: Metrics, + fittedHeight: CGFloat + ) -> GridPresentation { + guard metrics.gridHeight(itemCount: itemCount) > fittedHeight else { return .fitted } + let rowPitch = metrics.tilePointSize.height + metrics.rowSpacing + let wholeRows = Int((fittedHeight + metrics.rowSpacing) / rowPitch) + guard let currentIndex, metrics.columnCount > 0 else { return .fittedOrFull } + return currentIndex / metrics.columnCount < wholeRows ? .fittedOrFull : .full + } + + static let captionHeight: CGFloat = 20 + static let pictureToCaptionSpacing: CGFloat = 6 + /// The picture's own padding, as a terminal has. + static let pictureInset: CGFloat = 6 + /// The smallest a preview draws its pane's pixels, so text stays readable in a tile narrow for its + /// window; such a preview shows the window's leading part. + private static let minimumPreviewZoom: CGFloat = 0.4 + private static let rowsBelowCursor = 3 + private static let portraitCardHeightRatio: CGFloat = 1.10 private static let landscapeCardHeightRatio: CGFloat = 3.0 / 4.0 private static let portraitColumnCount: Int = 2 private static let landscapeColumnCount: Int = 3 private static let windowGridSpacing: CGFloat = 10 - - @MainActor - static func currentScale() -> CGFloat { - let scale = UIScreen.main.scale - return scale.isFinite && scale > 0 ? scale : 1 - } + private static let windowRowSpacing: CGFloat = 16 static func windowMetrics( availableSize: CGSize @@ -86,30 +123,173 @@ enum PanePreviewLayout { 1, floor((contentWidth - totalGridSpacing) / CGFloat(columnCount)) ) - let captureWidth = max(1, tileWidth - previewCaptureHorizontalInset * 2) - let captureHeight = ceil(captureWidth / defaultPreviewAspectRatio) let tileHeight = ceil(tileWidth * cardHeightRatio) return .init( columnCount: columnCount, tilePointSize: CGSize(width: tileWidth, height: tileHeight), - capturePointSize: CGSize(width: captureWidth, height: captureHeight), - gridSpacing: windowGridSpacing + picturePointSize: CGSize( + width: tileWidth, + height: max(1, tileHeight - captionHeight - pictureToCaptionSpacing) + ), + gridSpacing: windowGridSpacing, + rowSpacing: windowRowSpacing ) } static func windowPhysicalPixelBudget( metrics: Metrics, scale: CGFloat - ) -> (width: UInt32, height: UInt32) { + ) -> GhosttyPanePreviewSession.PixelBudget { let safeScale = max(scale, 1) - let widthPx = (metrics.capturePointSize.width * safeScale).rounded(.up) - let heightPx = (metrics.capturePointSize.height * safeScale).rounded(.up) - return ( - clampUInt32(widthPx), - clampUInt32(heightPx) + return .init( + width: clampUInt32((metrics.capturePointSize.width * safeScale).rounded(.down)), + height: clampUInt32((metrics.capturePointSize.height * safeScale).rounded(.down)) ) } + /// How large a preview draws its window's pixels: the window's whole width fits the preview, so + /// every preview of a session draws text at one size. `windowWidth` is in the panes' pixels. + static func previewZoom( + windowWidth: Int, + budget: GhosttyPanePreviewSession.PixelBudget + ) -> CGFloat { + guard windowWidth > 0 else { return 1 } + return min(1, max(minimumPreviewZoom, CGFloat(budget.width) / CGFloat(windowWidth))) + } + + /// The part of a window its preview shows, in the window's pixels: from the left edge, where + /// lines start, in whole rows ending a few below the cursor's, which hold a program's footer when + /// it has one and are empty under a shell's prompt. Fewer rows there, or above, show the window's + /// last, or first, rows. + /// + /// `cursorRowTop` is the top of the cursor's row; nil shows the window's last rows, which then may + /// start part-way through a row. + static func previewSourceRect( + frameWidth: Int, + frameHeight: Int, + cellHeight: Int, + cursorRowTop: CGFloat?, + budget: GhosttyPanePreviewSession.PixelBudget, + zoom: CGFloat + ) -> CGRect? { + guard frameWidth > 0, frameHeight > 0, budget.width > 0, budget.height > 0, + zoom.isFinite, zoom > 0 + else { return nil } + + let width = min(frameWidth, Int((CGFloat(budget.width) / zoom).rounded(.up))) + let maximumHeight = max(1, Int((CGFloat(budget.height) / zoom).rounded(.down))) + guard cellHeight > 0, cellHeight <= maximumHeight, + let cursorRowTop, cursorRowTop.isFinite, + cursorRowTop >= 0, cursorRowTop < CGFloat(frameHeight) + else { + let height = min(frameHeight, maximumHeight) + return CGRect(x: 0, y: frameHeight - height, width: width, height: height) + } + + let cursorTop = Int(cursorRowTop.rounded()) + let rows = maximumHeight / cellHeight + let firstRowTop = cursorTop % cellHeight + let lastRowBottom = cursorTop + (frameHeight - cursorTop) / cellHeight * cellHeight + let bottom = min(lastRowBottom, cursorTop + (1 + rowsBelowCursor) * cellHeight) + let top = max(firstRowTop, bottom - rows * cellHeight) + return CGRect(x: 0, y: top, width: width, height: min(rows * cellHeight, lastRowBottom - top)) + } + + /// A window's pane, in the window's cells. + struct WindowPane: Equatable { + let id: ID + let frame: GhosttyTerminalGridRect + } + + /// What a window's preview shows of it, in the panes' pixels: each pane's part of the area the + /// preview covers, and the lines between panes. + struct WindowPreviewPlan: Equatable { + struct Part: Equatable { + let id: ID + /// In the pane's own frame. + let sourceRect: CGRect + /// Where the part goes in the preview. + let origin: CGPoint + } + + struct Line: Equatable { + let start: CGPoint + let end: CGPoint + } + + let size: CGSize + let parts: [Part] + let separators: [Line] + } + + /// The preview of a window: the rows `previewSourceRect` picks for the focused pane's cursor, + /// with every pane that has a part in them where the window has it. `cursorRowTop` is in the + /// focused pane's pixels. + static func windowPreviewPlan( + grid: GhosttyTerminalGridSize, + panes: [WindowPane], + focusedPaneID: ID, + cellWidth: Int, + cellHeight: Int, + cursorRowTop: CGFloat?, + budget: GhosttyPanePreviewSession.PixelBudget, + zoom: CGFloat + ) -> WindowPreviewPlan? { + guard cellWidth > 0, cellHeight > 0, + let focused = panes.first(where: { $0.id == focusedPaneID }), + let shown = previewSourceRect( + frameWidth: Int(grid.columns) * cellWidth, + frameHeight: Int(grid.rows) * cellHeight, + cellHeight: cellHeight, + cursorRowTop: cursorRowTop.map { CGFloat(Int(focused.frame.y) * cellHeight) + $0 }, + budget: budget, + zoom: zoom + ) + else { return nil } + + let cell = CGSize(width: cellWidth, height: cellHeight) + let parts = panes.compactMap { pane -> WindowPreviewPlan.Part? in + let frame = CGRect( + x: CGFloat(pane.frame.x) * cell.width, + y: CGFloat(pane.frame.y) * cell.height, + width: CGFloat(pane.frame.columns) * cell.width, + height: CGFloat(pane.frame.rows) * cell.height + ) + let part = frame.intersection(shown) + guard !part.isNull, !part.isEmpty else { return nil } + return .init( + id: pane.id, + sourceRect: part.offsetBy(dx: -frame.minX, dy: -frame.minY), + origin: CGPoint(x: part.minX - shown.minX, y: part.minY - shown.minY) + ) + } + let separators = GhosttyPaneSeparatorLayout.segments( + for: panes.map { .init(id: UUID(), frame: $0.frame) } + ).compactMap { segment -> WindowPreviewPlan.Line? in + switch segment.orientation { + case .vertical: + let x = segment.position * cell.width + let top = max(segment.start * cell.height, shown.minY) + let bottom = min(segment.end * cell.height, shown.maxY) + guard x > shown.minX, x < shown.maxX, top < bottom else { return nil } + return .init( + start: CGPoint(x: x - shown.minX, y: top - shown.minY), + end: CGPoint(x: x - shown.minX, y: bottom - shown.minY) + ) + case .horizontal: + let y = segment.position * cell.height + let left = max(segment.start * cell.width, shown.minX) + let right = min(segment.end * cell.width, shown.maxX) + guard y > shown.minY, y < shown.maxY, left < right else { return nil } + return .init( + start: CGPoint(x: left - shown.minX, y: y - shown.minY), + end: CGPoint(x: right - shown.minX, y: y - shown.minY) + ) + } + } + return .init(size: shown.size, parts: parts, separators: separators) + } + private static func clampUInt32(_ value: CGFloat) -> UInt32 { guard value.isFinite, value > 0 else { return 1 } let clamped = min(value, CGFloat(UInt32.max)) diff --git a/RemuxApp/Sources/Ghostty/ShortcutsSettingsSheet.swift b/RemuxApp/Sources/Ghostty/ShortcutsSettingsSheet.swift index c71adb3c..4eee5dff 100644 --- a/RemuxApp/Sources/Ghostty/ShortcutsSettingsSheet.swift +++ b/RemuxApp/Sources/Ghostty/ShortcutsSettingsSheet.swift @@ -120,7 +120,6 @@ struct ShortcutsSettingsView: View { .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .remuxSheetPresentationBackground() - .ghosttyTerminalChromePresentation() } .sheet(item: $editorRequest) { request in ShortcutEditorSheet(request: request) { shortcut, favorite in @@ -134,7 +133,6 @@ struct ShortcutsSettingsView: View { .presentationDetents([.medium, .large]) .presentationDragIndicator(.visible) .remuxSheetPresentationBackground() - .ghosttyTerminalChromePresentation() } } @@ -633,8 +631,6 @@ private struct ShortcutCollectionEditorIconSummaryRow: View { } private struct ShortcutCollectionIconSuggestionButton: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - let icon: ShortcutCollectionIcon let isSelected: Bool let action: () -> Void @@ -645,7 +641,7 @@ private struct ShortcutCollectionIconSuggestionButton: View { action() } label: { ShortcutCollectionIconView(icon: icon) - .foregroundStyle(isSelected ? chromeStyle.accent : GhosttySheetPalette.secondary) + .foregroundStyle(isSelected ? RemuxAccent.fill : GhosttySheetPalette.secondary) .frame(width: 32, height: 38) .contentShape(Rectangle()) } @@ -987,8 +983,6 @@ private struct ShortcutEditorSection: View { } private struct ShortcutEditorTextFieldRow: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - private let title: String private let text: Binding private let axis: Axis @@ -1003,7 +997,7 @@ private struct ShortcutEditorTextFieldRow: View { TextField(title, text: text, axis: axis) .font(GhosttyShortcutTypography.rowText) .foregroundStyle(GhosttySheetPalette.primary) - .tint(chromeStyle.accent) + .tint(RemuxAccent.fill) .textFieldStyle(.plain) .padding(.horizontal, 18) .frame(minHeight: 56, alignment: .center) @@ -1036,8 +1030,6 @@ private struct ShortcutEditorValueRow: View { } private struct ShortcutEditorModePicker: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - @Binding var selection: ShortcutEditorMode var body: some View { @@ -1052,7 +1044,7 @@ private struct ShortcutEditorModePicker: View { .frame(maxWidth: .infinity, minHeight: 32) .background( selection == mode - ? GhosttyShortcutSurfacePalette.embeddedSelectedFill(chromeStyle) + ? GhosttyShortcutSurfacePalette.embeddedSelectedFill : Color.clear, in: Capsule() ) @@ -1073,8 +1065,6 @@ private struct ShortcutEditorModePicker: View { } private struct ShortcutEditorToggleRow: View { - @Environment(\.ghosttyTerminalChromeStyle) private var chromeStyle - let title: String let isOn: Binding @@ -1087,7 +1077,7 @@ private struct ShortcutEditorToggleRow: View { Toggle(title, isOn: isOn) .font(GhosttyShortcutTypography.rowText) .foregroundStyle(GhosttySheetPalette.primary) - .tint(chromeStyle.accent) + .tint(RemuxAccent.fill) .padding(.horizontal, 18) .frame(minHeight: 56, alignment: .center) } diff --git a/RemuxApp/Sources/Ghostty/TerminalPaneRenderer.swift b/RemuxApp/Sources/Ghostty/TerminalPaneRenderer.swift index 0fc85577..ffb9370f 100644 --- a/RemuxApp/Sources/Ghostty/TerminalPaneRenderer.swift +++ b/RemuxApp/Sources/Ghostty/TerminalPaneRenderer.swift @@ -206,16 +206,25 @@ final class TerminalPaneRenderer { } private final class FramePublicationWait: @unchecked Sendable { - let budget: GhosttyPanePreviewSession.PixelBudget + let sourceRect: CGRect var observation: NSKeyValueObservation? var continuation: CheckedContinuation? var timeoutTask: Task? - init(budget: GhosttyPanePreviewSession.PixelBudget) { - self.budget = budget + init(sourceRect: CGRect) { + self.sourceRect = sourceRect } } + /// What a preview's layout needs of the pane, in the pane's pixels. + struct PickerPreviewGeometry { + let cellWidth: Int + let cellHeight: Int + /// The top of the cursor's row; nil without a cursor. + let cursorRowTop: CGFloat? + let contentScale: CGFloat + } + private final class PreviewRelay: @unchecked Sendable { weak var renderer: TerminalPaneRenderer? } @@ -631,11 +640,26 @@ final class TerminalPaneRenderer { cancelFramePublicationWait() } - func capturePickerPreview( + var pickerPreviewGeometry: PickerPreviewGeometry? { + guard lifecycle == .active, let renderer else { return nil } + let size = renderer.control.currentSize() + guard size.cell_width_px > 0, size.cell_height_px > 0 else { return nil } + let scale = CGFloat(canonicalViewportMetrics.contentScale) + return PickerPreviewGeometry( + cellWidth: Int(size.cell_width_px), + cellHeight: Int(size.cell_height_px), + cursorRowTop: renderer.control.cursorGeometry().map { $0.minY * scale }, + contentScale: scale + ) + } + + /// The pixels of `sourceRect` in the pane's frame: the frame on screen, or the next one a pane + /// that isn't on screen publishes. + func capturePickerFrame( columns: UInt32, rows: UInt32, - budget: GhosttyPanePreviewSession.PixelBudget - ) async -> CGImage? { + sourceRect: CGRect + ) async -> GhosttyIOSurfaceFrame? { guard lifecycle == .active, framePublicationWait == nil, replacementCompletion == nil, rendererIsAvailable, presentationFailure == nil, @@ -649,35 +673,10 @@ final class TerminalPaneRenderer { || isViewportSized(current) else { return nil } - let frame: GhosttyIOSurfaceFrame if presented { - guard let dimensions = GhosttyIOSurfaceFrame.dimensions(in: rendererLayer), - let sourceRect = previewSourceRect( - width: dimensions.width, - height: dimensions.height, - budget: budget - ) - else { return nil } - guard let published = try? GhosttyIOSurfaceFrame.read( - from: rendererLayer, - sourceRect: sourceRect - ) else { return nil } - frame = published - } else { - guard let published = await matchingPublication( - on: rendererLayer, budget: budget - ) - else { return nil } - frame = published - } - - guard let image = await makePreviewImage( - from: frame, - budget: budget - ) else { - return nil + return try? GhosttyIOSurfaceFrame.read(from: rendererLayer, sourceRect: sourceRect) } - return image + return await matchingPublication(on: rendererLayer, sourceRect: sourceRect) } func reportRendererFailure() { rendererDidFail() } @@ -718,11 +717,9 @@ final class TerminalPaneRenderer { private func currentRendererSnapshot() -> CGImage? { guard let layer = GhosttyIOSurfaceFrame.rendererLayer(in: view.layer), - let frame = try? GhosttyIOSurfaceFrame.read(from: layer), - let width = UInt32(exactly: frame.width), - let height = UInt32(exactly: frame.height) + let frame = try? GhosttyIOSurfaceFrame.read(from: layer) else { return nil } - return try? frame.image(maxWidth: width, maxHeight: height) + return try? frame.image() } private func applyPresentationActivity() { @@ -798,7 +795,7 @@ final class TerminalPaneRenderer { private func matchingPublication( on layer: CALayer, - budget: GhosttyPanePreviewSession.PixelBudget + sourceRect: CGRect ) async -> GhosttyIOSurfaceFrame? { // Drain stale display invalidation before observing the next renderer // publication. @@ -808,7 +805,7 @@ final class TerminalPaneRenderer { continuation.resume(returning: nil) return } - let wait = FramePublicationWait(budget: budget) + let wait = FramePublicationWait(sourceRect: sourceRect) let layerReference = LayerReference(layer) let relay = previewRelay wait.continuation = continuation @@ -841,18 +838,12 @@ final class TerminalPaneRenderer { layer: CALayer ) { guard framePublicationWait === wait, - let dimensions = GhosttyIOSurfaceFrame.dimensions(in: layer) + GhosttyIOSurfaceFrame.dimensions(in: layer) != nil else { return } - guard let sourceRect = previewSourceRect( - width: dimensions.width, height: dimensions.height, budget: wait.budget - ) else { - finishFramePublicationWait(wait, publication: nil) - return - } let frame: GhosttyIOSurfaceFrame do { - frame = try GhosttyIOSurfaceFrame.read(from: layer, sourceRect: sourceRect) + frame = try GhosttyIOSurfaceFrame.read(from: layer, sourceRect: wait.sourceRect) } catch { GhosttyRuntimeTrace.diagnostics( "terminalPane.frameRead failed \(diagnosticName) error=\(String(describing: error))" @@ -883,61 +874,6 @@ final class TerminalPaneRenderer { finishFramePublicationWait(wait, publication: nil) } - private func makePreviewImage( - from frame: GhosttyIOSurfaceFrame, - budget: GhosttyPanePreviewSession.PixelBudget - ) async -> CGImage? { - let diagnosticName = diagnosticName - return await Task.detached(priority: .userInitiated) { - do { - return try frame.image( - maxWidth: budget.width, - maxHeight: budget.height - ) - } catch { - GhosttyRuntimeTrace.diagnostics( - "terminalPane.previewRead failed \(diagnosticName) error=\(String(describing: error))" - ) - return nil - } - }.value - } - - private func previewSourceRect( - width: Int, - height: Int, - budget: GhosttyPanePreviewSession.PixelBudget - ) -> CGRect? { - let viewportAnchor = CGRect(x: 0, y: 0, width: width, height: height) - let cropAnchor: CGRect - if let cursor = renderer?.control.cursorGeometry() { - let scale = CGFloat(canonicalViewportMetrics.contentScale) - cropAnchor = CGRect( - x: cursor.minX * scale, - y: cursor.minY * scale, - width: cursor.width * scale, - height: cursor.height * scale - ) - } else { - cropAnchor = viewportAnchor - } - let cropWidth = UInt32(clamping: UInt64(budget.width) * 2) - let cropHeight = UInt32(clamping: UInt64(budget.height) * 2) - return GhosttyIOSurfaceFrame.sourceRect( - width: width, - height: height, - centeredOn: cropAnchor, - maxWidth: cropWidth, - maxHeight: cropHeight - ) ?? GhosttyIOSurfaceFrame.sourceRect( - width: width, - height: height, - centeredOn: viewportAnchor, - maxWidth: cropWidth, - maxHeight: cropHeight - ) - } - private func isViewportSized(_ size: ghostty_surface_size_s) -> Bool { size.width_px == canonicalViewportMetrics.pixelWidth && size.height_px == canonicalViewportMetrics.pixelHeight diff --git a/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift b/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift index a662d8c8..e8dce89b 100644 --- a/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift +++ b/RemuxApp/Sources/Ghostty/TerminalSelectionSheetStyle.swift @@ -8,12 +8,9 @@ enum TerminalSelectionSheetPalette { static let controlPressedFill = Color.primary.opacity(0.08) static let controlGroupGlassTint = Color.primary.opacity(0.035) static let primary = Color.primary.opacity(0.92) - static let secondary = Color.secondary.opacity(0.78) + static let secondary = Color.secondary static let tertiary = Color.secondary.opacity(0.56) - - static func selectedStroke(_ chromeStyle: GhosttyTerminalChromeStyle) -> Color { - chromeStyle.selectedStroke - } + static let selectedStroke = RemuxAccent.selectedStroke } /// Selector-sheet layout below the system navigation bar. The sheet's height @@ -123,10 +120,11 @@ struct TerminalSelectionSheetContent TerminalSelectionSheetLayout.horizontalContentPadding ) + // Content that scrolls takes the height its sheet has; other content has its own. content - .frame(maxWidth: .infinity, alignment: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - .frame(maxWidth: .infinity, alignment: .top) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) actions .padding( @@ -184,7 +182,55 @@ struct TerminalSelectionSheetActionButton: View { } } +/// Fades a scroll view's content out at an edge it continues past, so a cut row reads as more to +/// scroll to rather than as an edge. +private struct TerminalSelectionSheetScrollFades: ViewModifier { + private struct Edges: Equatable { + var top = false + var bottom = false + } + + private static let length: CGFloat = 20 + + @State private var continues = Edges() + + func body(content: Content) -> some View { + content + .onScrollGeometryChange(for: Edges.self) { geometry in + Edges( + top: geometry.contentOffset.y + geometry.contentInsets.top > 1, + bottom: geometry.contentOffset.y + geometry.containerSize.height + - geometry.contentInsets.bottom < geometry.contentSize.height - 1 + ) + } action: { _, edges in + continues = edges + } + .mask { + VStack(spacing: 0) { + LinearGradient( + colors: [continues.top ? .clear : .black, .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: Self.length) + Color.black + LinearGradient( + colors: [.black, continues.bottom ? .clear : .black], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: Self.length) + } + } + .animation(.easeOut(duration: 0.15), value: continues) + } +} + extension View { + func terminalSelectionSheetScrollFades() -> some View { + modifier(TerminalSelectionSheetScrollFades()) + } + @ViewBuilder func terminalSelectionSheetControlGroupSurface() -> some View { let shape = Capsule() @@ -216,24 +262,4 @@ extension View { } } } - - func terminalSelectionTileChrome( - isSelected: Bool, - chromeStyle: GhosttyTerminalChromeStyle - ) -> some View { - let shape = RoundedRectangle(cornerRadius: 12, style: .continuous) - return background(TerminalSelectionSheetPalette.row) - .clipShape(shape) - .overlay { - shape.strokeBorder( - isSelected - ? TerminalSelectionSheetPalette.selectedStroke(chromeStyle) - : TerminalSelectionSheetPalette.stroke, - lineWidth: isSelected ? 1.25 : 1 - ) - } - // Clipping only hides what is drawn past the tile, such as a preview filling it, which would - // otherwise take the touches meant for the tile beside it. - .contentShape(shape) - } } diff --git a/RemuxApp/Sources/Herdr/HerdrTerminalScreenAdapter.swift b/RemuxApp/Sources/Herdr/HerdrTerminalScreenAdapter.swift index c41cd3d0..d810f11e 100644 --- a/RemuxApp/Sources/Herdr/HerdrTerminalScreenAdapter.swift +++ b/RemuxApp/Sources/Herdr/HerdrTerminalScreenAdapter.swift @@ -742,10 +742,7 @@ extension HerdrTerminalScreenAdapter: GhosttyTerminalScreenModeling { var topLevelNoun: GhosttyTopLevelNoun { .tab } /// Herdr's Tabs sheet lists its tabs as rows, with no previews. - func makePanePreviewSession( - leafIDs: [UUID], - pixelBudget: GhosttyPanePreviewSession.PixelBudget - ) -> GhosttyPanePreviewSession? { + func makePanePreviewSession(leafIDs: [UUID]) -> GhosttyPanePreviewSession? { nil } diff --git a/RemuxApp/Sources/Preview/TerminalPreviewView.swift b/RemuxApp/Sources/Preview/TerminalPreviewView.swift index b5ce3fbd..999f9508 100644 --- a/RemuxApp/Sources/Preview/TerminalPreviewView.swift +++ b/RemuxApp/Sources/Preview/TerminalPreviewView.swift @@ -45,7 +45,6 @@ struct TerminalPreviewView: View { .toolbarBackground(.visible, for: .navigationBar) } .tint(.primary) - .environment(\.ghosttyTerminalChromeStyle, terminalTheme.terminalChromeStyle) } @ViewBuilder @@ -89,7 +88,7 @@ struct TerminalPreviewView: View { .multilineTextAlignment(.center) Button("Retry", action: session.restart) .buttonStyle(.borderedProminent) - .tint(terminalTheme.terminalChromeStyle.accent) + .tint(RemuxAccent.fill) .accessibilityIdentifier("terminal.preview.retry") } .padding(28) diff --git a/RemuxApp/Sources/Tmux/TmuxTerminalScreenAdapter.swift b/RemuxApp/Sources/Tmux/TmuxTerminalScreenAdapter.swift index 890ff812..121ff36d 100644 --- a/RemuxApp/Sources/Tmux/TmuxTerminalScreenAdapter.swift +++ b/RemuxApp/Sources/Tmux/TmuxTerminalScreenAdapter.swift @@ -541,32 +541,19 @@ extension TmuxTerminalScreenAdapter: GhosttyTerminalScreenModeling { viewportStabilityHandler?(stable) } - func makePanePreviewSession( - leafIDs: [UUID], - pixelBudget: GhosttyPanePreviewSession.PixelBudget - ) -> GhosttyPanePreviewSession? { + func makePanePreviewSession(leafIDs: [UUID]) -> GhosttyPanePreviewSession? { GhosttyPanePreviewSession( leafIDs: leafIDs, - pixelBudget: pixelBudget, client: GhosttyPanePreviewSession.PreviewClient( capture: { [weak self] leafID, budget in guard let self, let session = self.session, - let paneID = self.identities.paneID(for: leafID), - let pane = self.latestTopology?.panes.first(where: { $0.id == paneID }) + let paneID = self.identities.paneID(for: leafID) else { return nil } - return await session.capturePickerPreview( - paneID: paneID, - columns: pane.width, - rows: pane.height, - budget: budget - ) + return await session.capturePickerPreview(paneID: paneID, budget: budget) }, - cancelCapture: { [weak self] leafID in - guard let self, - let paneID = self.identities.paneID(for: leafID) - else { return } - self.session?.cancelPickerPreview(paneID: paneID) + cancelCapture: { [weak self] _ in + self?.session?.cancelPickerPreview() } ) ) diff --git a/RemuxApp/Sources/Tmux/TmuxTerminalSession.swift b/RemuxApp/Sources/Tmux/TmuxTerminalSession.swift index 830e3bab..0eac07c0 100644 --- a/RemuxApp/Sources/Tmux/TmuxTerminalSession.swift +++ b/RemuxApp/Sources/Tmux/TmuxTerminalSession.swift @@ -1,6 +1,6 @@ -import CoreGraphics import Foundation import GhosttyKit +import UIKit /// MainActor owner of one control attachment and one retained renderer surface /// for every live pane. Active-window visibility and focus are projections of @@ -36,6 +36,9 @@ final class TmuxTerminalSession: ObservableObject { private let createPaneSurface: PaneSurfaceCreator @Published private(set) var surfacesByPaneID: [TmuxPaneID: TmuxPaneSurface] = [:] + /// Previews are captured one at a time: the generation tells a capture it was ended, and the + /// awaited pane is the one whose frame it waits for. + private var pickerPreviewCapture: (generation: Int, awaitedPaneID: TmuxPaneID?) = (0, nil) private var pendingTerminalsByPaneID: [ TmuxPaneID: TmuxSessionController.RetainedPaneTerminal ] = [:] @@ -436,32 +439,139 @@ final class TmuxTerminalSession: ObservableObject { // MARK: Composite presentation + /// Before a pane's window is shown: a picker frame still pending must not complete into the + /// window's presentation. func prepareForPaneSelection(paneID: TmuxPaneID) { guard !isShutDown else { return } - surfacesByPaneID[paneID]?.renderer.cancelPickerCaptureForPresentation() + cancelPickerPreview() } + /// The preview of the window `paneID` is the active pane of: the window's rows around that pane's + /// cursor, with every pane that has a part in them. The other panes are best-effort; without the + /// active one there is no preview. A capture ends the one before it. func capturePickerPreview( paneID: TmuxPaneID, - columns: UInt32, - rows: UInt32, budget: GhosttyPanePreviewSession.PixelBudget - ) async -> CGImage? { + ) async -> GhosttyPanePreviewSession.Preview? { + cancelPickerPreview() + let generation = pickerPreviewCapture.generation + // A window whose panes change while they are captured is captured once more, as it then is. + for _ in 0..<2 { + guard let window = pickerPreviewWindow(activePaneID: paneID) else { return nil } + let preview = await capturePickerPreview( + of: window, activePaneID: paneID, budget: budget, generation: generation + ) + guard generation == pickerPreviewCapture.generation else { return nil } + if pickerPreviewWindow(activePaneID: paneID) == window { return preview } + } + return nil + } + + /// Ends the running preview capture, whichever pane's frame it waits for. + func cancelPickerPreview() { + pickerPreviewCapture.generation += 1 + guard let paneID = pickerPreviewCapture.awaitedPaneID else { return } + pickerPreviewCapture.awaitedPaneID = nil + surfacesByPaneID[paneID]?.renderer.cancelPickerCaptureForPresentation() + } + + private struct PickerPreviewWindow: Equatable { + let grid: GhosttyTerminalGridSize + let panes: [PanePreviewLayout.WindowPane] + } + + /// The window `paneID` is the active pane of, as its preview shows it. + private func pickerPreviewWindow(activePaneID paneID: TmuxPaneID) -> PickerPreviewWindow? { guard !isShutDown, state == .ready, livePaneIDs.contains(paneID), - let surface = surfacesByPaneID[paneID], - !surface.renderer.isClosing + let topology, + let active = topology.panes.first(where: { $0.id == paneID }), + let window = topology.windows.first(where: { $0.id == active.windowID }) else { return nil } - return await surface.renderer.capturePickerPreview( - columns: columns, - rows: rows, + // A zoomed window shows its active pane alone, at the window's size. + let panes: [PanePreviewLayout.WindowPane] = window.zoomed && window.activePaneID == paneID + ? [.init(id: paneID, frame: .init(x: 0, y: 0, columns: window.width, rows: window.height))] + : topology.panes.filter { $0.windowID == window.id }.map { + .init(id: $0.id, frame: .init(x: $0.x, y: $0.y, columns: $0.width, rows: $0.height)) + } + return PickerPreviewWindow(grid: .init(columns: window.width, rows: window.height), panes: panes) + } + + private func capturePickerPreview( + of window: PickerPreviewWindow, + activePaneID paneID: TmuxPaneID, + budget: GhosttyPanePreviewSession.PixelBudget, + generation: Int + ) async -> GhosttyPanePreviewSession.Preview? { + guard let geometry = surfacesByPaneID[paneID]?.renderer.pickerPreviewGeometry else { return nil } + let zoom = PanePreviewLayout.previewZoom( + windowWidth: Int(window.grid.columns) * geometry.cellWidth, budget: budget ) - } + guard let plan = PanePreviewLayout.windowPreviewPlan( + grid: window.grid, + panes: window.panes, + focusedPaneID: paneID, + cellWidth: geometry.cellWidth, + cellHeight: geometry.cellHeight, + cursorRowTop: geometry.cursorRowTop, + budget: budget, + zoom: zoom + ) else { return nil } + + var frames: [(frame: GhosttyIOSurfaceFrame, origin: CGPoint)] = [] + for part in plan.parts { + guard !isShutDown, state == .ready else { return nil } + var frame: GhosttyIOSurfaceFrame? + if let pane = window.panes.first(where: { $0.id == part.id }), + let surface = surfacesByPaneID[part.id], + !surface.renderer.isClosing { + pickerPreviewCapture.awaitedPaneID = part.id + frame = await surface.renderer.capturePickerFrame( + columns: pane.frame.columns, + rows: pane.frame.rows, + sourceRect: part.sourceRect + ) + guard generation == pickerPreviewCapture.generation else { return nil } + pickerPreviewCapture.awaitedPaneID = nil + } + if let frame { + frames.append((frame, part.origin)) + } else if part.id == paneID { + return nil + } + } - func cancelPickerPreview(paneID: TmuxPaneID) { - surfacesByPaneID[paneID]?.renderer.cancelPickerCaptureForPresentation() + // The pixels are drawn `zoom` times their size. + let imageScale = geometry.contentScale / zoom + func points(_ point: CGPoint) -> CGPoint { + CGPoint(x: point.x / imageScale, y: point.y / imageScale) + } + let size = CGSize(width: plan.size.width / imageScale, height: plan.size.height / imageScale) + let separators = plan.separators.map { + GhosttyPanePreviewSession.Preview.Line(start: points($0.start), end: points($0.end)) + } + let placed = frames.map { (frame: $0.frame, origin: points($0.origin)) } + return await Task.detached(priority: .userInitiated) { + do { + return GhosttyPanePreviewSession.Preview( + size: size, + panes: try placed.map { + .init( + image: UIImage(cgImage: try $0.frame.image(), scale: imageScale, orientation: .up), + origin: $0.origin + ) + }, + separators: separators + ) + } catch { + GhosttyRuntimeTrace.diagnostics( + "tmux.windowPreview failed pane=\(paneID) error=\(String(describing: error))" + ) + return nil + } + }.value } private func reconcilePresentationActivity() { diff --git a/RemuxAppTests/GhosttyKitRuntimeTests.swift b/RemuxAppTests/GhosttyKitRuntimeTests.swift index 210ba893..7f6c9af9 100644 --- a/RemuxAppTests/GhosttyKitRuntimeTests.swift +++ b/RemuxAppTests/GhosttyKitRuntimeTests.swift @@ -114,7 +114,7 @@ final class GhosttyKitRuntimeTests: XCTestCase { _ = fixture.freeSurface() let image = try await Task.detached { - try frame.image(maxWidth: 160, maxHeight: 120) + try frame.image() }.value XCTAssertEqual(frame.bytes, originalBytes) XCTAssertGreaterThan(image.width, 0) @@ -167,51 +167,6 @@ final class GhosttyKitRuntimeTests: XCTestCase { XCTAssertEqual(frame.bytes.count, 4_800) } - func testPreviewSourceRectCentersOnCursorAndClampsToFrameEdges() throws { - XCTAssertEqual( - GhosttyIOSurfaceFrame.sourceRect( - width: 1_000, - height: 800, - centeredOn: CGRect(x: 495, y: 395, width: 10, height: 10), - maxWidth: 400, - maxHeight: 300 - ), - CGRect(x: 300, y: 250, width: 400, height: 300) - ) - XCTAssertEqual( - GhosttyIOSurfaceFrame.sourceRect( - width: 1_000, - height: 800, - centeredOn: CGRect(x: 0, y: 0, width: 10, height: 10), - maxWidth: 400, - maxHeight: 300 - ), - CGRect(x: 0, y: 0, width: 400, height: 300) - ) - XCTAssertEqual( - GhosttyIOSurfaceFrame.sourceRect( - width: 1_000, - height: 800, - centeredOn: CGRect(x: 990, y: 790, width: 10, height: 10), - maxWidth: 400, - maxHeight: 300 - ), - CGRect(x: 600, y: 500, width: 400, height: 300) - ) - } - - func testPreviewSourceRectRejectsCursorOutsideFrame() { - XCTAssertNil( - GhosttyIOSurfaceFrame.sourceRect( - width: 100, - height: 80, - centeredOn: CGRect(x: 120, y: 10, width: 8, height: 16), - maxWidth: 40, - maxHeight: 30 - ) - ) - } - func testLiveConfigUpdatePreservesSurfaceAndTerminalOSCBackgroundOverride() async throws { let fixture = try NativeTerminalSurfaceFixture( settings: TerminalSettings(fontSize: 11, theme: .remuxDark) diff --git a/RemuxAppTests/GhosttyPaneLinesViewTests.swift b/RemuxAppTests/GhosttyPaneLinesViewTests.swift index c21227bd..d8506f3b 100644 --- a/RemuxAppTests/GhosttyPaneLinesViewTests.swift +++ b/RemuxAppTests/GhosttyPaneLinesViewTests.swift @@ -104,20 +104,20 @@ final class GhosttyPaneLinesViewTests: XCTestCase { XCTAssertEqual(lines.layer.animationKeys() ?? [], [], "the frame's lines' geometry animated") } - /// A new theme reaches the lines: the area's theme sets the inks, and the lines are drawn again in the new - /// theme's separator and accent. + /// A new theme reaches the lines: the area's theme sets the separator, and the lines are drawn again in the new + /// theme's separator and the app's accent. func testANewThemeDrawsThePanesLinesInItsInks() throws { - let (first, second) = try XCTUnwrap(Self.themesOfOtherInks()) + let (first, second) = try XCTUnwrap(Self.themesOfOtherSeparators()) let projection = twoPanes(focusingLeft: true) let (window, host) = show(viewport(projection, theme: first)) defer { hide(window) } let lines = try XCTUnwrap(descendant(of: host.view, named: "GhosttyPaneLinesUIView"), "no view of the lines") - assertLines(lines, left: Self.focus(first), right: Self.separator(first)) + assertLines(lines, left: Self.focus, right: Self.separator(first)) host.rootView = viewport(projection, theme: second) settle(host) - assertLines(lines, left: Self.focus(second), right: Self.separator(second)) + assertLines(lines, left: Self.focus, right: Self.separator(second)) } /// The focus moving to another pane changes only the lines, no pane's frame: the lines are drawn again, the @@ -127,12 +127,12 @@ final class GhosttyPaneLinesViewTests: XCTestCase { let (window, host) = show(viewport(twoPanes(focusingLeft: true), theme: theme)) defer { hide(window) } let lines = try XCTUnwrap(descendant(of: host.view, named: "GhosttyPaneLinesUIView"), "no view of the lines") - assertLines(lines, left: Self.focus(theme), right: Self.separator(theme)) + assertLines(lines, left: Self.focus, right: Self.separator(theme)) host.rootView = viewport(twoPanes(focusingLeft: false), theme: theme) settle(host) - assertLines(lines, left: Self.separator(theme), right: Self.focus(theme)) + assertLines(lines, left: Self.separator(theme), right: Self.focus) } /// Two panes side by side, the left one's left side and the right one's right side lined, in the accent on @@ -189,18 +189,15 @@ final class GhosttyPaneLinesViewTests: XCTestCase { theme.terminalCompositeSeparatorUIColor } - private static func focus(_ theme: TerminalTheme) -> UIColor { - UIColor(theme.terminalChromeStyle.accent) - } + private static let focus = RemuxAccent.uiFill - /// Two themes whose separators and accents both differ. - private static func themesOfOtherInks() -> (TerminalTheme, TerminalTheme)? { + /// Two themes whose separators differ, neither of them the accent. + private static func themesOfOtherSeparators() -> (TerminalTheme, TerminalTheme)? { for first in TerminalTheme.allCases { for second in TerminalTheme.allCases where second != first { if sRGB(of: separator(first)) != sRGB(of: separator(second)), - sRGB(of: focus(first)) != sRGB(of: focus(second)), - sRGB(of: focus(first)) != sRGB(of: separator(second)), - sRGB(of: separator(first)) != sRGB(of: focus(second)) { + sRGB(of: focus) != sRGB(of: separator(first)), + sRGB(of: focus) != sRGB(of: separator(second)) { return (first, second) } } diff --git a/RemuxAppTests/GhosttyPanePreviewSessionTests.swift b/RemuxAppTests/GhosttyPanePreviewSessionTests.swift index e5861ed6..ace5aa40 100644 --- a/RemuxAppTests/GhosttyPanePreviewSessionTests.swift +++ b/RemuxAppTests/GhosttyPanePreviewSessionTests.swift @@ -1,4 +1,4 @@ -import CoreGraphics +import UIKit import XCTest @testable import Remux @@ -11,7 +11,6 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { let harness = CaptureHarness() let session = GhosttyPanePreviewSession( leafIDs: [first, second], - pixelBudget: .init(width: 640, height: 480), client: .init( capture: { await harness.capture(paneID: $0, budget: $1) @@ -20,7 +19,7 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { ) ) - session.startRefreshing() + session.present(pixelBudget: .init(width: 640, height: 480)) try await waitUntil { harness.requests.count == 1 } XCTAssertEqual(harness.requests.map(\.paneID), [first]) @@ -53,7 +52,6 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { let harness = CaptureHarness() let session = GhosttyPanePreviewSession( leafIDs: [paneID], - pixelBudget: .init(width: 320, height: 240), client: .init( capture: { await harness.capture(paneID: $0, budget: $1) @@ -62,7 +60,7 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { ) ) - session.startRefreshing() + session.present(pixelBudget: .init(width: 320, height: 240)) try await waitUntil { harness.requests.count == 1 } harness.resolve(paneID: paneID, with: firstPreview) try await waitUntil { @@ -90,7 +88,6 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { let harness = CaptureHarness() let session = GhosttyPanePreviewSession( leafIDs: [active, removed], - pixelBudget: .init(width: 320, height: 240), client: .init( capture: { await harness.capture(paneID: $0, budget: $1) @@ -99,7 +96,7 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { ) ) - session.startRefreshing() + session.present(pixelBudget: .init(width: 320, height: 240)) try await waitUntil { harness.requests.map(\.paneID) == [active] } session.reconcile(leafIDs: [active]) @@ -120,7 +117,6 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { let harness = CaptureHarness() let session = GhosttyPanePreviewSession( leafIDs: [paneID], - pixelBudget: .init(width: 320, height: 240), client: .init( capture: { await harness.capture(paneID: $0, budget: $1) @@ -129,7 +125,7 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { ) ) - session.startRefreshing() + session.present(pixelBudget: .init(width: 320, height: 240)) try await waitUntil { harness.requests.count == 1 } harness.resolve(paneID: paneID, with: nil) try await waitUntil { @@ -143,7 +139,6 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { let harness = CaptureHarness() let session = GhosttyPanePreviewSession( leafIDs: [paneID], - pixelBudget: .init(width: 320, height: 240), client: .init( capture: { await harness.capture(paneID: $0, budget: $1) @@ -152,7 +147,7 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { ) ) - session.startRefreshing() + session.present(pixelBudget: .init(width: 320, height: 240)) try await waitUntil { harness.requests.count == 1 } session.cancelAll() @@ -170,7 +165,6 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { let harness = CaptureHarness() let session = GhosttyPanePreviewSession( leafIDs: [removed], - pixelBudget: .init(width: 320, height: 240), client: .init( capture: { await harness.capture(paneID: $0, budget: $1) @@ -179,7 +173,7 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { ) ) - session.startRefreshing() + session.present(pixelBudget: .init(width: 320, height: 240)) try await waitUntil { harness.requests.map(\.paneID) == [removed] } session.reconcile(leafIDs: [current]) @@ -207,7 +201,6 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { let harness = CaptureHarness() let session = GhosttyPanePreviewSession( leafIDs: [paneID, paneID, paneID], - pixelBudget: .init(width: 320, height: 240), client: .init( capture: { await harness.capture(paneID: $0, budget: $1) @@ -216,7 +209,7 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { ) ) - session.startRefreshing() + session.present(pixelBudget: .init(width: 320, height: 240)) try await waitUntil { harness.requests.count == 1 } XCTAssertEqual(harness.requests.map(\.paneID), [paneID]) harness.resolve(paneID: paneID, with: nil) @@ -231,7 +224,6 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { let harness = CaptureHarness() let session = GhosttyPanePreviewSession( leafIDs: [paneID], - pixelBudget: .init(width: 304, height: 228), client: .init( capture: { await harness.capture(paneID: $0, budget: $1) @@ -239,7 +231,7 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { ) ) - session.startRefreshing() + session.present(pixelBudget: .init(width: 304, height: 228)) try await waitUntil { harness.requests.count == 1 } XCTAssertEqual( @@ -253,7 +245,81 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { } } - private func preview(marker: UInt32) throws -> CGImage { + func testAnotherSizeCapturesEveryPreviewAgainAtThatSize() async throws { + let first = UUID() + let second = UUID() + let harness = CaptureHarness() + let session = GhosttyPanePreviewSession( + leafIDs: [first, second], + client: .init( + capture: { + await harness.capture(paneID: $0, budget: $1) + }, + cancelCapture: { harness.cancel(paneID: $0) } + ) + ) + let portrait = GhosttyPanePreviewSession.PixelBudget(width: 320, height: 240) + let landscape = GhosttyPanePreviewSession.PixelBudget(width: 480, height: 200) + + session.present(pixelBudget: portrait) + try await waitUntil { harness.requests.count == 1 } + harness.resolve(paneID: first, with: try preview(marker: 1)) + try await waitUntil { harness.requests.count == 2 } + + // The second pane's capture is still out when the size changes. + session.present(pixelBudget: landscape) + XCTAssertEqual(harness.cancelledPaneIDs, [second]) + XCTAssertNil(session.imagesByPaneID[first]) + harness.resolve(paneID: second, with: try preview(marker: 2)) + + try await waitUntil { harness.requests.count == 3 } + let firstLandscape = try preview(marker: 3) + harness.resolve(paneID: first, with: firstLandscape) + try await waitUntil { harness.requests.count == 4 } + let secondLandscape = try preview(marker: 4) + harness.resolve(paneID: second, with: secondLandscape) + try await waitUntil { + if case .ready? = session.imagesByPaneID[second] { return true } + return false + } + + XCTAssertEqual( + harness.requests, + [ + .init(paneID: first, budget: portrait), + .init(paneID: second, budget: portrait), + .init(paneID: first, budget: landscape), + .init(paneID: second, budget: landscape), + ] + ) + assertReady(session.imagesByPaneID[first], image: firstLandscape) + assertReady(session.imagesByPaneID[second], image: secondLandscape) + } + + func testTheSameSizeAgainKeepsThePreviews() async throws { + let paneID = UUID() + let harness = CaptureHarness() + let session = GhosttyPanePreviewSession( + leafIDs: [paneID], + client: .init(capture: { await harness.capture(paneID: $0, budget: $1) }) + ) + let budget = GhosttyPanePreviewSession.PixelBudget(width: 320, height: 240) + let image = try preview(marker: 1) + + session.present(pixelBudget: budget) + try await waitUntil { harness.requests.count == 1 } + harness.resolve(paneID: paneID, with: image) + try await waitUntil { + if case .ready? = session.imagesByPaneID[paneID] { return true } + return false + } + session.present(pixelBudget: budget) + + assertReady(session.imagesByPaneID[paneID], image: image) + XCTAssertEqual(harness.requests.count, 1) + } + + private func preview(marker: UInt32) throws -> GhosttyPanePreviewSession.Preview { let colorSpace = CGColorSpaceCreateDeviceRGB() let context = try XCTUnwrap(CGContext( data: nil, @@ -271,7 +337,8 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { alpha: 1 ) context.fill(CGRect(x: 0, y: 0, width: 2, height: 2)) - return try XCTUnwrap(context.makeImage()) + let image = UIImage(cgImage: try XCTUnwrap(context.makeImage())) + return .init(size: image.size, panes: [.init(image: image, origin: .zero)], separators: []) } private func assertPending( @@ -286,14 +353,14 @@ final class GhosttyPanePreviewSessionTests: XCTestCase { private func assertReady( _ state: GhosttyPanePreviewSession.PreviewState?, - image: CGImage, + image: GhosttyPanePreviewSession.Preview, file: StaticString = #filePath, line: UInt = #line ) { guard case .ready(let renderedImage)? = state else { return XCTFail("expected ready preview", file: file, line: line) } - XCTAssertTrue(renderedImage === image, file: file, line: line) + XCTAssertTrue(renderedImage.panes.first?.image === image.panes.first?.image, file: file, line: line) } private func waitUntil( @@ -320,13 +387,13 @@ private final class CaptureHarness { private(set) var cancelledPaneIDs: [UUID] = [] private(set) var completedPaneIDs: [UUID] = [] private var continuations: [ - UUID: CheckedContinuation + UUID: CheckedContinuation ] = [:] func capture( paneID: UUID, budget: GhosttyPanePreviewSession.PixelBudget - ) async -> CGImage? { + ) async -> GhosttyPanePreviewSession.Preview? { requests.append(.init(paneID: paneID, budget: budget)) let result = await withCheckedContinuation { continuations[paneID] = $0 } completedPaneIDs.append(paneID) @@ -339,7 +406,7 @@ private final class CaptureHarness { func resolve( paneID: UUID, - with result: CGImage? + with result: GhosttyPanePreviewSession.Preview? ) { continuations.removeValue(forKey: paneID)?.resume(returning: result) } diff --git a/RemuxAppTests/PanePreviewLayoutTests.swift b/RemuxAppTests/PanePreviewLayoutTests.swift index e796f5e7..f1c1980a 100644 --- a/RemuxAppTests/PanePreviewLayoutTests.swift +++ b/RemuxAppTests/PanePreviewLayoutTests.swift @@ -10,7 +10,8 @@ final class PanePreviewLayoutTests: XCTestCase { XCTAssertEqual(metrics.columnCount, 2) XCTAssertEqual(metrics.tilePointSize, CGSize(width: 175, height: 193)) - XCTAssertEqual(metrics.capturePointSize, CGSize(width: 159, height: 120)) + XCTAssertEqual(metrics.picturePointSize, CGSize(width: 175, height: 167)) + XCTAssertEqual(metrics.capturePointSize, CGSize(width: 163, height: 155)) } func testLandscapeWindowGridUsesThreeColumnFourByThreeCards() { @@ -20,7 +21,8 @@ final class PanePreviewLayoutTests: XCTestCase { XCTAssertEqual(metrics.columnCount, 3) XCTAssertEqual(metrics.tilePointSize, CGSize(width: 266, height: 200)) - XCTAssertEqual(metrics.capturePointSize, CGSize(width: 250, height: 188)) + XCTAssertEqual(metrics.picturePointSize, CGSize(width: 266, height: 174)) + XCTAssertEqual(metrics.capturePointSize, CGSize(width: 254, height: 162)) } func testWindowPhysicalPixelBudgetUsesResolvedMetrics() { @@ -32,55 +34,187 @@ final class PanePreviewLayoutTests: XCTestCase { scale: 3 ) - XCTAssertEqual(budget.width, 477) - XCTAssertEqual(budget.height, 360) + XCTAssertEqual(budget, .init(width: 489, height: 465)) } - func testLandscapeWindowGridUsesAvailableContentBudget() { - let availableHeight: CGFloat = 393 - let navigationBarHeight: CGFloat = 44 - let maximumContentHeight = TerminalSelectionSheetLayout.maximumContentHeight( - availableHeight: availableHeight, - navigationBarHeight: navigationBarHeight + func testPreviewFitsItsWindowsWidth() { + let budget = GhosttyPanePreviewSession.PixelBudget(width: 489, height: 465) + + // A 50-column window of 24-pixel cells. + XCTAssertEqual(PanePreviewLayout.previewZoom(windowWidth: 1_200, budget: budget), 489.0 / 1_200) + // A window too wide for readable text shows its leading part, and a narrow one isn't enlarged. + XCTAssertEqual(PanePreviewLayout.previewZoom(windowWidth: 4_800, budget: budget), 0.4) + XCTAssertEqual(PanePreviewLayout.previewZoom(windowWidth: 240, budget: budget), 1) + } + + // A 1200 x 2190 frame of 53-pixel rows under a 6-pixel top padding, and a 480 x 424 preview at + // two fifths: the frame's whole width and 20 whole rows. + private func sourceRect(cursorRow: Int?, frameWidth: Int = 1_200, frameHeight: Int = 2_190) -> CGRect? { + PanePreviewLayout.previewSourceRect( + frameWidth: frameWidth, + frameHeight: frameHeight, + cellHeight: 53, + cursorRowTop: cursorRow.map { CGFloat(6 + $0 * 53) }, + budget: .init(width: 480, height: 424), + zoom: 0.4 ) - let windowHeight = PanePreviewLayout.gridIdealHeight( - itemCount: 100, - metrics: PanePreviewLayout.windowMetrics( - availableSize: CGSize(width: 852, height: availableHeight) - ), - maximumContentHeight: maximumContentHeight + } + + func testPreviewEndsAtThePanesLastRowForACursorNearIt() { + // 41 rows fit the frame; the last 20 are rows 21 to 40. + let lastRows = CGRect(x: 0, y: 6 + 21 * 53, width: 1_200, height: 20 * 53) + + XCTAssertEqual(sourceRect(cursorRow: 40), lastRows) + XCTAssertEqual(sourceRect(cursorRow: 37), lastRows) + } + + func testPreviewEndsThreeRowsBelowACursorHigherUp() { + // Rows 14 to 33, and rows 4 to 23. + XCTAssertEqual( + sourceRect(cursorRow: 30), + CGRect(x: 0, y: 6 + 14 * 53, width: 1_200, height: 20 * 53) ) XCTAssertEqual( - maximumContentHeight + TerminalSelectionSheetLayout.fixedChromeHeight( - navigationBarHeight: navigationBarHeight - ), - availableHeight + sourceRect(cursorRow: 20), + CGRect(x: 0, y: 6 + 4 * 53, width: 1_200, height: 20 * 53) ) - XCTAssertEqual(windowHeight, maximumContentHeight) + } + + func testPreviewStartsAtTheFirstRowForACursorNearTheTop() { XCTAssertEqual( - TerminalSelectionSheetLayout.sheetHeight( - contentHeight: windowHeight, - navigationBarHeight: navigationBarHeight - ), - availableHeight + sourceRect(cursorRow: 3), + CGRect(x: 0, y: 6, width: 1_200, height: 20 * 53) ) } - func testWindowGridPreservesItsPartialNextRowAffordanceWithinTheBudget() { - let metrics = PanePreviewLayout.windowMetrics( - availableSize: CGSize(width: 393, height: 852) + func testPreviewOfAPaneShorterThanItShowsEveryRow() { + // Five rows fit a 280-pixel frame. + XCTAssertEqual( + sourceRect(cursorRow: 4, frameHeight: 280), + CGRect(x: 0, y: 6, width: 1_200, height: 5 * 53) + ) + } + + func testPreviewOfASplitPaneIsAsWideAsThePane() { + XCTAssertEqual(sourceRect(cursorRow: 40, frameWidth: 590)?.width, 590) + } + + func testPreviewOfAWindowWiderThanItShowsTheLeadingPart() { + XCTAssertEqual(sourceRect(cursorRow: 40, frameWidth: 4_800)?.width, 1_200) + } + + func testPreviewWithoutACursorShowsTheEndOfTheFrame() { + XCTAssertEqual( + sourceRect(cursorRow: nil), + CGRect(x: 0, y: 2_190 - 1_060, width: 1_200, height: 1_060) + ) + } + + // MARK: A window's preview + + /// A 50 by 41 window of 24 by 53 pixel cells, whose preview shows 21 rows. + private func windowPlan( + panes: [PanePreviewLayout.WindowPane], + focused: Int, + cursorRow: Int + ) -> PanePreviewLayout.WindowPreviewPlan? { + PanePreviewLayout.windowPreviewPlan( + grid: .init(columns: 50, rows: 41), + panes: panes, + focusedPaneID: focused, + cellWidth: 24, + cellHeight: 53, + cursorRowTop: CGFloat(cursorRow * 53), + budget: .init(width: 504, height: 483), + zoom: 0.42 ) - let expectedHeight = metrics.tilePointSize.height - + metrics.gridSpacing - + metrics.tilePointSize.height * 0.5 - let height = PanePreviewLayout.gridIdealHeight( - itemCount: 5, + } + + func testPreviewOfPanesSideBySideShowsBothWithTheLineBetweenThem() { + let plan = windowPlan( + panes: [ + .init(id: 1, frame: .init(x: 0, y: 0, columns: 25, rows: 41)), + .init(id: 2, frame: .init(x: 26, y: 0, columns: 24, rows: 41)), + ], + focused: 2, + cursorRow: 40 + ) + XCTAssertEqual(plan?.size, CGSize(width: 1_200, height: 21 * 53)) + XCTAssertEqual(plan?.parts, [ + .init(id: 1, sourceRect: CGRect(x: 0, y: 20 * 53, width: 600, height: 21 * 53), origin: .zero), + .init( + id: 2, + sourceRect: CGRect(x: 0, y: 20 * 53, width: 576, height: 21 * 53), + origin: CGPoint(x: 624, y: 0) + ), + ]) + XCTAssertEqual(plan?.separators, [ + .init(start: CGPoint(x: 612, y: 0), end: CGPoint(x: 612, y: 21 * 53)), + ]) + } + + func testPreviewOfStackedPanesFollowsTheFocusedPanesCursorInTheWindow() { + let panes: [PanePreviewLayout.WindowPane] = [ + .init(id: 1, frame: .init(x: 0, y: 0, columns: 50, rows: 28)), + .init(id: 2, frame: .init(x: 0, y: 29, columns: 50, rows: 12)), + ] + // The lower pane's last row is the window's: the preview ends there, and starts in the upper pane. + let lower = windowPlan(panes: panes, focused: 2, cursorRow: 11) + XCTAssertEqual(lower?.parts, [ + .init(id: 1, sourceRect: CGRect(x: 0, y: 20 * 53, width: 1_200, height: 8 * 53), origin: .zero), + .init( + id: 2, + sourceRect: CGRect(x: 0, y: 0, width: 1_200, height: 12 * 53), + origin: CGPoint(x: 0, y: 9 * 53) + ), + ]) + XCTAssertEqual(lower?.separators, [ + .init(start: CGPoint(x: 0, y: 8.5 * 53), end: CGPoint(x: 1_200, y: 8.5 * 53)), + ]) + + // A cursor near the window's top shows its first rows, which the lower pane has no part in. + let upper = windowPlan(panes: panes, focused: 1, cursorRow: 5) + XCTAssertEqual(upper?.parts, [ + .init(id: 1, sourceRect: CGRect(x: 0, y: 0, width: 1_200, height: 21 * 53), origin: .zero), + ]) + XCTAssertEqual(upper?.separators, []) + } + + func testPreviewOfAWindowWithoutItsFocusedPaneIsNil() { + XCTAssertNil(windowPlan( + panes: [.init(id: 1, frame: .init(x: 0, y: 0, columns: 50, rows: 41))], + focused: 2, + cursorRow: 40 + )) + } + + // MARK: How a sheet of windows opens + + private func gridPresentation(itemCount: Int, currentIndex: Int?) -> PanePreviewLayout.GridPresentation { + let metrics = PanePreviewLayout.windowMetrics(availableSize: CGSize(width: 402, height: 874)) + // Two rows and half of a third. + let fittedHeight = metrics.gridHeight(itemCount: 4) + metrics.rowSpacing + metrics.tilePointSize.height / 2 + return PanePreviewLayout.gridPresentation( + itemCount: itemCount, + currentIndex: currentIndex, metrics: metrics, - maximumContentHeight: 320 + fittedHeight: fittedHeight ) + } - XCTAssertEqual(height, expectedHeight) - XCTAssertLessThanOrEqual(height, 320) + func testASheetThatShowsEveryWindowOpensFitted() { + XCTAssertEqual(gridPresentation(itemCount: 4, currentIndex: 3), .fitted) } + func testASheetWithMoreWindowsThanItShowsCanBePulledToFullHeight() { + XCTAssertEqual(gridPresentation(itemCount: 10, currentIndex: 0), .fittedOrFull) + XCTAssertEqual(gridPresentation(itemCount: 10, currentIndex: 3), .fittedOrFull) + XCTAssertEqual(gridPresentation(itemCount: 10, currentIndex: nil), .fittedOrFull) + } + + func testASheetThatWouldNotShowTheCurrentWindowOpensAtFullHeight() { + // The third row is only half in view at the fitted height. + XCTAssertEqual(gridPresentation(itemCount: 10, currentIndex: 4), .full) + XCTAssertEqual(gridPresentation(itemCount: 10, currentIndex: 9), .full) + } } diff --git a/RemuxAppTests/TmuxTerminalSessionShutdownDrainTests.swift b/RemuxAppTests/TmuxTerminalSessionShutdownDrainTests.swift index c6cc7e11..6b416079 100644 --- a/RemuxAppTests/TmuxTerminalSessionShutdownDrainTests.swift +++ b/RemuxAppTests/TmuxTerminalSessionShutdownDrainTests.swift @@ -358,8 +358,8 @@ final class TmuxTerminalSessionShutdownDrainTests: XCTestCase { fixture.surface.renderer.setPresented(false) let managed = try XCTUnwrap(fixture.surface.renderer.managedSurface) XCTAssertFalse(managed.rendererIsAvailable) - let preview = await fixture.surface.renderer.capturePickerPreview( - columns: 83, rows: 44, budget: .init(width: 200, height: 120) + let preview = await fixture.surface.renderer.capturePickerFrame( + columns: 83, rows: 44, sourceRect: CGRect(x: 0, y: 0, width: 200, height: 120) ) XCTAssertNotNil(preview) XCTAssertEqual(fixture.surface.renderer.presentation, .pending)