diff --git a/Macterm/App/Preferences.swift b/Macterm/App/Preferences.swift index 5deb467..0f60ad0 100644 --- a/Macterm/App/Preferences.swift +++ b/Macterm/App/Preferences.swift @@ -92,6 +92,32 @@ enum WindowGlassStyle: String, CaseIterable, Identifiable { } } +/// How a hidden sidebar appears while the pointer rests at the window's +/// leading edge. The normal pinned sidebar always remains a native split-view +/// column; this only controls the temporary hover peek. +enum SidebarPeekStyle: String, CaseIterable, Identifiable { + case resizeTerminal = "resize_content" + case overlayTerminal = "overlay_on_hover" + + var id: String { rawValue } + + var displayName: String { + switch self { + case .resizeTerminal: "Resize terminal" + case .overlayTerminal: "Overlay terminal" + } + } + + var explanation: String { + switch self { + case .resizeTerminal: + "Slides the native sidebar column out and temporarily resizes the terminal." + case .overlayTerminal: + "Shows a floating Liquid Glass sidebar over the terminal without changing its size." + } + } +} + /// Single observable source of truth for UserDefaults-backed preferences. /// /// Macterm only stores app-shaped state here (window opacity/blur, quick @@ -132,6 +158,12 @@ final class Preferences { didSet { defaults.set(paneDimOpacity, forKey: Keys.paneDimOpacity) } } + /// Presentation used by `peekSidebarWhenHidden`. The pinned sidebar is + /// always the native split-view column. + var sidebarPeekStyle: SidebarPeekStyle { + didSet { defaults.set(sidebarPeekStyle.rawValue, forKey: Keys.sidebarPeekStyle) } + } + // MARK: - Sidebar icons var projectIconSymbol: String { @@ -543,6 +575,8 @@ final class Preferences { paneDimOpacity = Self.clampPaneDimOpacity( (defaults.object(forKey: Keys.paneDimOpacity) as? Double) ?? 0.2 ) + sidebarPeekStyle = (defaults.string(forKey: Keys.sidebarPeekStyle)) + .flatMap(SidebarPeekStyle.init(rawValue:)) ?? .resizeTerminal windowOpacity = (defaults.object(forKey: Keys.windowOpacity) as? Double) ?? 1.0 windowBlurRadius = defaults.integer(forKey: Keys.windowBlurRadius) windowGlassEnabled = defaults.object(forKey: Keys.windowGlassEnabled) as? Bool ?? false @@ -659,6 +693,7 @@ final class Preferences { static let autoTiling = "macterm.autoTiling.enabled" static let terminalScrollSpeed = "macterm.terminal.scrollSpeed" static let paneDimOpacity = "macterm.pane.dimOpacity" + static let sidebarPeekStyle = "macterm.sidebar.presentation" static let windowOpacity = "macterm.window.opacity" static let windowBlurRadius = "macterm.window.blurRadius" static let windowGlassEnabled = "macterm.window.glassEnabled" diff --git a/Macterm/Settings/SettingsView.swift b/Macterm/Settings/SettingsView.swift index 6c18955..3c855b7 100644 --- a/Macterm/Settings/SettingsView.swift +++ b/Macterm/Settings/SettingsView.swift @@ -645,6 +645,8 @@ private struct AppearanceSettings: View { private var paneDimOpacity: Double = Preferences.shared.paneDimOpacity @State private var adaptiveTerminalChrome: Bool = Preferences.shared.adaptiveTerminalChromeEnabled + @State + private var sidebarPeekStyle: SidebarPeekStyle = Preferences.shared.sidebarPeekStyle /// Inverted view of `Preferences.hideTitleBar`: the control reads as /// "Show toolbar" (on by default), the preference stores the hide. @State @@ -722,6 +724,25 @@ private struct AppearanceSettings: View { } Section("Sidebar") { + Toggle("Peek sidebar when hidden", isOn: $peekSidebarWhenHidden) + .onChange(of: peekSidebarWhenHidden) { _, v in Preferences.shared.peekSidebarWhenHidden = v } + Text("Shows the hidden sidebar while the pointer rests at the window's left edge.") + .settingsCaption() + + Group { + Picker("Peek style", selection: $sidebarPeekStyle) { + ForEach(SidebarPeekStyle.allCases) { style in + Text(style.displayName).tag(style) + } + } + .onChange(of: sidebarPeekStyle) { _, style in + Preferences.shared.sidebarPeekStyle = style + } + Text(sidebarPeekStyle.explanation) + .settingsCaption() + } + .disabled(!peekSidebarWhenHidden) + Picker("Project icon", selection: $projectIconSymbol) { ForEach(Preferences.projectIconChoices, id: \.self) { name in iconPickerLabel(name).tag(name) @@ -761,11 +782,6 @@ private struct AppearanceSettings: View { .onChange(of: showNewProjectButton) { _, v in Preferences.shared.showNewProjectButton = v } Text("When hidden, create projects via the command palette or context menu.") .settingsCaption() - - Toggle("Peek sidebar when hidden", isOn: $peekSidebarWhenHidden) - .onChange(of: peekSidebarWhenHidden) { _, v in Preferences.shared.peekSidebarWhenHidden = v } - Text("Slides the hidden sidebar out while the pointer rests at the window's left edge.") - .settingsCaption() } Section("Toolbar") { diff --git a/Macterm/Views/MainWindow.swift b/Macterm/Views/MainWindow.swift index 4c39399..22a3276 100644 --- a/Macterm/Views/MainWindow.swift +++ b/Macterm/Views/MainWindow.swift @@ -15,10 +15,12 @@ struct MainWindow: View { private var detailWidth: CGFloat = .infinity @State private var preferences = Preferences.shared + @State + private var windowCornerRadius: CGFloat? /// The sidebar is temporarily out because the pointer is at the leading - /// edge, while the user's toggle state still says hidden. Peeking drives - /// the same `columnVisibility` path the shortcut uses — never AppKit's - /// broken overlay reveal, disabled in `WindowAppearance`. + /// edge while the user's toggle state still says hidden. Resize peeks use + /// `columnVisibility`; overlay peeks leave that column hidden and mount a + /// separate glass surface. @State private var isPeeking = false /// Set when the shortcut hides the sidebar with the pointer still over it, @@ -58,6 +60,12 @@ struct MainWindow: View { /// event. Cleared when the pointer leaves the window. @State private var lastHoverPoint: CGPoint? + /// Invalidates a pending overlay dismissal whenever the pointer returns. + /// A generation token avoids retaining a task handle in view state. + @State + private var overlayDismissGeneration = 0 + @State + private var isResizingOverlay = false /// Conservative bound on the column expand/collapse animation, including a /// margin — deferring the opposite transition slightly long is invisible, @@ -65,9 +73,14 @@ struct MainWindow: View { /// NavigationSplitView's stored width metric. private let peekAnimationDuration: TimeInterval = 0.4 - /// Width of the hover strip at the leading edge that pops the hidden - /// sidebar out — a little wider than AppKit's own edge-hover band. - private let peekStripWidth: CGFloat = 12 + /// A generous activation band makes the overlay easy to acquire without + /// requiring pixel-perfect contact with the window edge. + private let peekStripWidth: CGFloat = 24 + private let overlayDismissDelay: Duration = .milliseconds(440) + + private var usesOverlayPeek: Bool { + preferences.sidebarPeekStyle == .overlayTerminal + } var body: some View { // Derive bindings to the @Observable AppState via @Bindable (the @@ -156,8 +169,20 @@ struct MainWindow: View { } } } + .overlay(alignment: .leading) { + if isPeeking, usesOverlayPeek, !appState.sidebarVisible { + SidebarOverlayPanel( + width: sidebarWidth, + chromeHidden: chromeHidden, + windowCornerRadius: windowCornerRadius, + onResize: { recordSidebarWidth($0) }, + onResizeStateChanged: { handleOverlayResizeState($0) } + ) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } .toolbar(chromeHidden ? .hidden : .visible, for: .windowToolbar) - .background(WindowStyler(hideTitle: chromeHidden)) + .background(WindowStyler(hideTitle: chromeHidden, windowCornerRadius: $windowCornerRadius)) .overlay { if appState.isCommandPaletteVisible { CommandPaletteOverlay() @@ -183,16 +208,15 @@ struct MainWindow: View { } .onChange(of: appState.sidebarVisible) { _, visible in if visible { - // A peek promoted to pinned (toolbar button, shortcut while - // peeked): the column is already out, just drop the peek flag. + cancelOverlayDismiss() isPeeking = false - } else if isPeeking { + } else if isPeeking, !usesOverlayPeek { // Hidden by shortcut while peeked out under the pointer: don't // let the very next hover event pop it straight back open. isPeeking = false suppressPeekUntilExit = true } - if !visible { + if !visible, !usesOverlayPeek { // A shortcut hide collapses the column just like a peek's // retraction; a peek starting into that animation would // reverse it mid-flight (see `beginPeek`). @@ -209,6 +233,18 @@ struct MainWindow: View { // A peek is the exception: the column shows while the user's // toggle state stays hidden. if isPeeking { + if usesOverlayPeek { + // The overlay never changes the split-view column. If the + // toolbar button opens that column, promote the temporary + // peek to the one pinned native sidebar and remove the + // overlay immediately. + if visibility != .detailOnly { + cancelOverlayDismiss() + isPeeking = false + appState.sidebarVisible = true + } + return + } // The toolbar button honors the sidebar's real configuration // (hidden), not the peeked column it happens to see — so its // collapse means "show": pin the sidebar instead of letting @@ -225,6 +261,16 @@ struct MainWindow: View { appState.sidebarVisible = visible } } + .onChange(of: preferences.sidebarPeekStyle) { _, style in + guard isPeeking, !appState.sidebarVisible else { return } + cancelOverlayDismiss() + withAnimation(.easeOut(duration: 0.16)) { + columnVisibility = style == .resizeTerminal ? .automatic : .detailOnly + } + } + .onChange(of: preferences.peekSidebarWhenHidden) { _, enabled in + if !enabled, isPeeking { endPeek() } + } .onChange(of: appState.isCommandPaletteVisible) { _, visible in guard !visible else { return } // Run a post-dismiss action if one was registered, otherwise return @@ -251,15 +297,21 @@ struct MainWindow: View { preferences.sidebarWidth = rounded } - /// Hover-peek for the hidden sidebar: pointer in the leading-edge strip - /// slides it out at its remembered width; pointer off the sidebar (or out - /// of the window) slides it back in. Runs through `columnVisibility`, the - /// shortcut's path, so the titlebar lays out as for a pinned sidebar. + /// Hover-peek for the hidden sidebar. The resize style uses the native + /// split-view column; the overlay style leaves that column hidden and + /// draws a separate glass panel over the terminal. private func handleSidebarPeekHover(_ phase: HoverPhase) { switch phase { case let .active(point): lastHoverPoint = point - guard !appState.sidebarVisible else { return } + if isResizingOverlay { + cancelOverlayDismiss() + return + } + guard !appState.sidebarVisible else { + cancelOverlayDismiss() + return + } // Toggleable in Settings → Appearance → Sidebar. Checked here, not // at the modifier, so flipping it off mid-peek still retracts. guard preferences.peekSidebarWhenHidden else { @@ -272,17 +324,28 @@ struct MainWindow: View { } if !isPeeking, point.x <= peekStripWidth { beginPeek() - } else if isPeeking, point.x > sidebarWidth + 8 { - endPeek() } else if isPeeking { - // Pointer back over the sidebar: cancel a deferred retraction. - deferredUnpeek = false + if point.x <= sidebarWidth + 24 { + deferredUnpeek = false + cancelOverlayDismiss() + } else if usesOverlayPeek { + scheduleOverlayDismiss() + } else { + endPeek() + } } case .ended: - // The pointer left the window entirely. + // A brief overshoot beyond the window's left edge is common while + // acquiring the panel. Overlay mode gets a grace period. lastHoverPoint = nil deferredPeek = false - if isPeeking { endPeek() } + if isPeeking, !isResizingOverlay { + if usesOverlayPeek { + scheduleOverlayDismiss() + } else { + endPeek() + } + } suppressPeekUntilExit = false } } @@ -292,6 +355,10 @@ struct MainWindow: View { /// re-checks against the pointer's last known position (it may be parked /// in the strip, generating no further events to retry on). private func beginPeek() { + if usesOverlayPeek { + expandPeek() + return + } let remaining = peekCollapseSettleTime.timeIntervalSinceNow guard remaining <= 0 else { guard !deferredPeek else { return } @@ -312,8 +379,13 @@ struct MainWindow: View { } private func expandPeek() { - isPeeking = true deferredPeek = false + if usesOverlayPeek { + cancelOverlayDismiss() + withAnimation(.easeOut(duration: 0.16)) { isPeeking = true } + return + } + isPeeking = true peekExpandSettleTime = Date().addingTimeInterval(peekAnimationDuration) withAnimation { columnVisibility = .automatic } } @@ -324,6 +396,10 @@ struct MainWindow: View { /// `ideal` can override a stored metric), so a too-quick exit defers the /// retraction until the expand has settled. private func endPeek() { + if usesOverlayPeek { + collapsePeek() + return + } let remaining = peekExpandSettleTime.timeIntervalSinceNow guard remaining <= 0 else { guard !deferredUnpeek else { return } @@ -339,12 +415,45 @@ struct MainWindow: View { } private func collapsePeek() { + if usesOverlayPeek { + cancelOverlayDismiss() + deferredUnpeek = false + withAnimation(.easeOut(duration: 0.16)) { isPeeking = false } + return + } isPeeking = false deferredUnpeek = false peekCollapseSettleTime = Date().addingTimeInterval(peekAnimationDuration) withAnimation { columnVisibility = .detailOnly } } + private func scheduleOverlayDismiss() { + guard usesOverlayPeek, isPeeking, !isResizingOverlay else { return } + overlayDismissGeneration += 1 + let generation = overlayDismissGeneration + Task { @MainActor in + try? await Task.sleep(for: overlayDismissDelay) + guard generation == overlayDismissGeneration, + usesOverlayPeek, isPeeking, + !appState.sidebarVisible, !isResizingOverlay + else { return } + collapsePeek() + } + } + + private func cancelOverlayDismiss() { + overlayDismissGeneration += 1 + } + + private func handleOverlayResizeState(_ isResizing: Bool) { + isResizingOverlay = isResizing + if isResizing { + cancelOverlayDismiss() + } else if lastHoverPoint.map({ $0.x > sidebarWidth + 24 }) ?? true { + scheduleOverlayDismiss() + } + } + private var activeProject: Project? { guard let pid = appState.activeProjectID else { return nil } return projectStore.projects.first { $0.id == pid } @@ -601,9 +710,11 @@ private struct WindowStyler: NSViewRepresentable { /// (`.toolbar(.hidden, for: .windowToolbar)` in `MainWindow`); the title /// text is an `NSWindow` property, so it's applied here. var hideTitle: Bool = false + @Binding + var windowCornerRadius: CGFloat? func makeCoordinator() -> Coordinator { - Coordinator() + Coordinator(windowCornerRadius: $windowCornerRadius) } func makeNSView(context: Context) -> NSView { @@ -637,6 +748,7 @@ private struct WindowStyler: NSViewRepresentable { window.styleMask.insert(.fullSizeContentView) window.titleVisibility = hideTitle ? .hidden : .visible WindowAppearance.sync(window: window) + coordinator.syncWindowCornerRadius(window: window) coordinator.observe(window: window) // Intercept the close button to hide instead of close, // preserving terminal surfaces and running processes. @@ -644,7 +756,7 @@ private struct WindowStyler: NSViewRepresentable { } } - func updateNSView(_ view: NSView, context _: Context) { + func updateNSView(_ view: NSView, context: Context) { // Follow live setting flips. Async because SwiftUI forbids window // mutation from inside the update pass. let hide = hideTitle @@ -652,12 +764,23 @@ private struct WindowStyler: NSViewRepresentable { guard let window = view.window else { return } window.titleVisibility = hide ? .hidden : .visible WindowAppearance.syncTitleBarHidden(window: window) + context.coordinator.syncWindowCornerRadius(window: window) } } final class Coordinator: NSObject, NSWindowDelegate { nonisolated(unsafe) private var observer: Any? weak var swiftuiDelegate: (any NSWindowDelegate)? + private var windowCornerRadius: Binding + + init(windowCornerRadius: Binding) { + self.windowCornerRadius = windowCornerRadius + } + + @MainActor + func syncWindowCornerRadius(window: NSWindow) { + windowCornerRadius.wrappedValue = WindowAppearance.windowCornerRadius(window) + } @MainActor func observe(window: NSWindow) { @@ -680,6 +803,7 @@ private struct WindowStyler: NSViewRepresentable { func windowDidBecomeMain(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } WindowAppearance.sync(window: window) + syncWindowCornerRadius(window: window) swiftuiDelegate?.windowDidBecomeMain?(notification) } @@ -700,12 +824,14 @@ private struct WindowStyler: NSViewRepresentable { func windowDidEnterFullScreen(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } WindowAppearance.sync(window: window) + syncWindowCornerRadius(window: window) swiftuiDelegate?.windowDidEnterFullScreen?(notification) } func windowDidExitFullScreen(_ notification: Notification) { guard let window = notification.object as? NSWindow else { return } WindowAppearance.sync(window: window) + syncWindowCornerRadius(window: window) swiftuiDelegate?.windowDidExitFullScreen?(notification) } diff --git a/Macterm/Views/SidebarOverlay.swift b/Macterm/Views/SidebarOverlay.swift new file mode 100644 index 0000000..2fbf1e6 --- /dev/null +++ b/Macterm/Views/SidebarOverlay.swift @@ -0,0 +1,178 @@ +import AppKit +import SwiftUI + +/// The overlay peek is a separate visual surface, never a second split-view +/// column. Its inset exposes the terminal around a rounded Liquid Glass panel +/// while the sidebar content keeps the titlebar's safe area. +struct SidebarOverlayPanel: View { + let width: CGFloat + let chromeHidden: Bool + let windowCornerRadius: CGFloat? + let onResize: (CGFloat) -> Void + let onResizeStateChanged: (Bool) -> Void + + private var cornerRadius: CGFloat { + SidebarOverlayMetrics.cornerRadius( + windowCornerRadius: windowCornerRadius, + inset: SidebarOverlayMetrics.panelInset + ) + } + + var body: some View { + SidebarContent() + .safeAreaPadding(.top, chromeHidden ? SidebarOverlayMetrics.panelInset : 0) + .safeAreaPadding(.bottom, SidebarOverlayMetrics.panelInset) + .frame(width: width) + .frame(maxHeight: .infinity) + .background { + SidebarOverlayBackground(cornerRadius: cornerRadius) + .padding(.vertical, SidebarOverlayMetrics.panelInset) + .ignoresSafeArea(.container, edges: .vertical) + } + .overlay(alignment: .trailing) { + SidebarResizeBand( + widthAtDragStart: { width }, + onResize: onResize, + onResizeStateChanged: onResizeStateChanged + ) + .frame(width: SplitDividerMetrics.bandThickness) + } + .padding(.leading, SidebarOverlayMetrics.panelInset) + } +} + +/// Geometry shared by the overlay panel and its resize band. The corner-radius +/// formula is the concentric rounded-rectangle rule: an edge inset by `d` +/// receives radius `outerRadius - d`. Reading the outer radius from NSWindow +/// lets macOS define the proportions for every OS/window style. +@MainActor +enum SidebarOverlayMetrics { + static let panelInset: CGFloat = 4 + + static func cornerRadius(windowCornerRadius: CGFloat?, inset: CGFloat) -> CGFloat { + guard let windowCornerRadius, windowCornerRadius > 0 else { return 0 } + return max(windowCornerRadius - inset, 0) + } + + static func resizedWidth(start: CGFloat, delta: CGFloat) -> CGFloat { + let range = Preferences.sidebarWidthRange + return min(max(start + delta, CGFloat(range.lowerBound)), CGFloat(range.upperBound)) + } +} + +private struct SidebarOverlayBackground: View { + let cornerRadius: CGFloat + + var body: some View { + if #available(macOS 26.0, *) { + Color.clear + .glassEffect(in: .rect(cornerRadius: cornerRadius)) + .shadow(color: MactermTheme.border, radius: max(cornerRadius, 1), x: SidebarOverlayMetrics.panelInset) + } else { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(.regularMaterial) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(MactermTheme.border, lineWidth: 1) + } + .shadow(color: MactermTheme.border, radius: max(cornerRadius, 1), x: SidebarOverlayMetrics.panelInset) + } + } +} + +/// AppKit drag band on the panel's trailing edge. It owns the resize cursor and +/// keeps receiving drag events after the pointer leaves the narrow hit area, +/// which a SwiftUI gesture layered over a terminal NSView cannot guarantee. +private struct SidebarResizeBand: NSViewRepresentable { + let widthAtDragStart: () -> CGFloat + let onResize: (CGFloat) -> Void + let onResizeStateChanged: (Bool) -> Void + + func makeNSView(context _: Context) -> BandView { + let view = BandView() + configure(view) + return view + } + + func updateNSView(_ view: BandView, context _: Context) { + configure(view) + } + + private func configure(_ view: BandView) { + view.widthAtDragStart = widthAtDragStart + view.onResize = onResize + view.onResizeStateChanged = onResizeStateChanged + } + + final class BandView: NSView { + var widthAtDragStart: () -> CGFloat = { CGFloat(Preferences.defaultSidebarWidth) } + var onResize: (CGFloat) -> Void = { _ in } + var onResizeStateChanged: (Bool) -> Void = { _ in } + + private var dragOriginX: CGFloat? + private var startWidth: CGFloat = .init(Preferences.defaultSidebarWidth) + private var isTransparentToHitTest = false + + override var mouseDownCanMoveWindow: Bool { false } + + override func acceptsFirstMouse(for _: NSEvent?) -> Bool { + true + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + trackingAreas.forEach { removeTrackingArea($0) } + addTrackingArea(NSTrackingArea( + rect: .zero, + options: [.cursorUpdate, .activeInActiveApp, .inVisibleRect], + owner: self, + userInfo: nil + )) + } + + override func cursorUpdate(with _: NSEvent) { + NSCursor.resizeLeftRight.set() + } + + override func mouseDown(with event: NSEvent) { + dragOriginX = event.locationInWindow.x + startWidth = widthAtDragStart() + onResizeStateChanged(true) + } + + override func mouseDragged(with event: NSEvent) { + guard let dragOriginX else { return } + NSCursor.resizeLeftRight.set() + onResize(SidebarOverlayMetrics.resizedWidth( + start: startWidth, + delta: event.locationInWindow.x - dragOriginX + )) + } + + override func mouseUp(with _: NSEvent) { + dragOriginX = nil + onResizeStateChanged(false) + } + + override func hitTest(_ point: NSPoint) -> NSView? { + isTransparentToHitTest ? nil : super.hitTest(point) + } + + private func viewBeneath(_ event: NSEvent) -> NSView? { + isTransparentToHitTest = true + defer { isTransparentToHitTest = false } + let target = window?.contentView?.hitTest(event.locationInWindow) + return target === self ? nil : target + } + + override func scrollWheel(with event: NSEvent) { + guard let target = viewBeneath(event) else { return super.scrollWheel(with: event) } + target.scrollWheel(with: event) + } + + override func rightMouseDown(with event: NSEvent) { + guard let target = viewBeneath(event) else { return super.rightMouseDown(with: event) } + target.rightMouseDown(with: event) + } + } +} diff --git a/Macterm/Views/WindowAppearance.swift b/Macterm/Views/WindowAppearance.swift index f41b3f9..eb22c34 100644 --- a/Macterm/Views/WindowAppearance.swift +++ b/Macterm/Views/WindowAppearance.swift @@ -539,9 +539,16 @@ enum WindowAppearance { /// The window's private corner radius, so the glass clips to the same /// rounded corners as the window. Falls back to nil (square) if the SPI /// is unavailable. - private static func windowCornerRadius(_ window: NSWindow) -> CGFloat? { - guard window.responds(to: Selector(("_cornerRadius"))) else { return nil } - return window.value(forKey: "_cornerRadius") as? CGFloat + static func windowCornerRadius(_ window: NSWindow) -> CGFloat? { + if window.responds(to: Selector(("_cornerRadius"))), + let radius = window.value(forKey: "_cornerRadius") as? CGFloat + { + return radius + } + // Older AppKit builds can expose the applied corner only on the theme + // frame's backing layer. It is still system-owned geometry, not a + // guessed OS-version constant. + return window.contentView?.superview?.layer?.cornerRadius } /// Apply the Hide Title Bar option (#226) to the window: hide the titlebar diff --git a/MactermTests/Views/SidebarOverlayMetricsTests.swift b/MactermTests/Views/SidebarOverlayMetricsTests.swift new file mode 100644 index 0000000..69122a6 --- /dev/null +++ b/MactermTests/Views/SidebarOverlayMetricsTests.swift @@ -0,0 +1,26 @@ +import CoreGraphics +@testable import Macterm +import Testing + +@MainActor +struct SidebarOverlayMetricsTests { + @Test + func inset_corner_radius_stays_concentric_with_window() { + #expect(SidebarOverlayMetrics.cornerRadius(windowCornerRadius: 24, inset: 4) == 20) + #expect(SidebarOverlayMetrics.cornerRadius(windowCornerRadius: 8, inset: 4) == 4) + } + + @Test + func missing_or_small_window_radius_never_produces_negative_radius() { + #expect(SidebarOverlayMetrics.cornerRadius(windowCornerRadius: nil, inset: 4) == 0) + #expect(SidebarOverlayMetrics.cornerRadius(windowCornerRadius: 2, inset: 4) == 0) + } + + @Test + func resize_width_uses_native_sidebar_bounds() { + let range = Preferences.sidebarWidthRange + #expect(SidebarOverlayMetrics.resizedWidth(start: 180, delta: 25) == 205) + #expect(SidebarOverlayMetrics.resizedWidth(start: 180, delta: -200) == CGFloat(range.lowerBound)) + #expect(SidebarOverlayMetrics.resizedWidth(start: 180, delta: 200) == CGFloat(range.upperBound)) + } +} diff --git a/website/docs/pages/20-configuration.md b/website/docs/pages/20-configuration.md index cfd71b1..1ec3826 100644 --- a/website/docs/pages/20-configuration.md +++ b/website/docs/pages/20-configuration.md @@ -10,6 +10,6 @@ description: Point Macterm at your Ghostty config and manage Macterm-specific se Macterm reads your `~/.config/ghostty/config` on launch — themes, fonts, palettes, keybinds, and everything else Ghostty supports work the same here. If your config lives elsewhere, set the path in **Settings → General → Ghostty Config**. -Macterm-specific settings — window opacity, blur style, quick-terminal size, and hotkeys — live in **Macterm → Settings**. A few Ghostty keys are overridden because Macterm owns that chrome: `background-opacity` and `background-blur` are forced to `0` (use Settings instead), and titlebar, window-decoration, split-divider, and quick-terminal settings are ignored. +Macterm-specific settings — window opacity, blur style, sidebar behavior, quick-terminal size, and hotkeys — live in **Macterm → Settings**. When hidden-sidebar peek is enabled, the sidebar can either slide out as a normal split-view column or appear as a rounded Liquid Glass overlay without resizing the terminal. A few Ghostty keys are overridden because Macterm owns that chrome: `background-opacity` and `background-blur` are forced to `0` (use Settings instead), and titlebar, window-decoration, split-divider, and quick-terminal settings are ignored. > The `ssh-env` and `ssh-terminfo` shell-integration features work out of the box — Macterm serves them natively (via `macterm ssh`), no Ghostty.app needed. The `path` feature is disabled: Macterm ships no `ghostty` CLI to put on your PATH (the bundled `macterm` CLI is already there).