cmux/Sources/Splits/TerminalSplitTreeView.swift
Lawrence Chen cc71e5797e
Add sidebar blur effect with withinWindow blending (#9)
* Add sidebar blur effect with withinWindow blending

- Add NSVisualEffectView-based blur backdrop for sidebar
- Support withinWindow blending mode to blur terminal content behind sidebar
- Auto-switch to overlay layout when withinWindow mode is selected
- Add sidebar debug panel with material, blending, tint, and opacity controls
- Add preset options (HUD Glass, Popover Glass, etc.)
- Default to HUD Glass preset with withinWindow blur

* Simplify tab close button visibility and remove hover background

Show close button only on hover instead of when active/multi-selected.
Remove the hover background color from tabs for cleaner appearance.

* Add config reload support with notification system

- Add reloadConfiguration() methods for app-wide and per-surface reload
- Handle GHOSTTY_ACTION_RELOAD_CONFIG action from Ghostty
- Add ghosttyConfigDidReload notification for views to react
- TerminalSplitTreeView reloads GhosttyConfig on notification
- Add openConfigurationInTextEdit() helper
- Fix activeMainWindow() to correctly find main window

* Add custom tab titles and pinned tabs support

- Add customTitle and isPinned properties to Tab
- Separate process title from custom title with applyProcessTitle()
- Add setCustomTitle()/clearCustomTitle() for user-defined tab names
- Add togglePin()/setPinned() with automatic reordering
- Pinned tabs stay at the top, new tabs insert after pinned section
- moveTabToTop/moveTabsToTop respect pinned tab ordering

* Add --panel option to new-split command

- CLI: Parse --panel <id|index> option for new-split
- Controller: Resolve panel argument to split specific surface
- Return new panel UUID on successful split creation

* Fix notifications popover positioning with layout-aware anchor

- Use AnchorNSView with layout callback for reliable positioning
- Force layout before showing popover to ensure current geometry
- Convert anchor bounds to window content view coordinates
- Add fallback positioning near top-left when anchor unavailable
- Fix button hit testing with explicit frame and contentShape

* Improve app termination in reload script

Use osascript to gracefully quit by bundle ID before pkill fallback.
Add more robust pkill patterns to catch instances from any DerivedData path.

* Add sidebar blur effect with live-adjustable glass settings

- Add WindowGlassEffect for window-level NSGlassEffectView (macOS 26+)
- Add SidebarBackdrop with configurable material, blend mode, tint, and opacity
- Add Sidebar Debug panel (Debug menu) for live adjustment of sidebar appearance
- Add Background Debug panel for window glass tint settings
- Support both behindWindow and withinWindow blur modes
- Live tint updates without requiring window reload

* Align titlebar text to left edge of content area
2026-02-04 03:04:45 -08:00

183 lines
6.6 KiB
Swift

import SwiftUI
import Foundation
struct TerminalSplitTreeView: View {
@ObservedObject var tab: Tab
let isTabActive: Bool
@State private var config = GhosttyConfig.load()
@EnvironmentObject var notificationStore: TerminalNotificationStore
var body: some View {
let appearance = SplitAppearance(
dividerColor: Color(nsColor: config.resolvedSplitDividerColor),
unfocusedOverlayColor: Color(nsColor: config.unfocusedSplitOverlayFill),
unfocusedOverlayOpacity: config.unfocusedSplitOverlayOpacity
)
Group {
if let node = tab.splitTree.zoomed ?? tab.splitTree.root {
TerminalSplitSubtreeView(
node: node,
isRoot: node == tab.splitTree.root,
isSplit: tab.splitTree.isSplit,
isTabActive: isTabActive,
focusedSurfaceId: tab.focusedSurfaceId,
appearance: appearance,
tabId: tab.id,
notificationStore: notificationStore,
onFocus: { tab.focusSurface($0) },
onTriggerFlash: { tab.triggerDebugFlash(surfaceId: $0) },
onResize: { tab.updateSplitRatio(node: $0, ratio: $1) },
onEqualize: { tab.equalizeSplits() }
)
.id(node.structuralIdentity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(GeometryReader { proxy in
Color.clear
.onAppear { tab.updateSplitViewSize(proxy.size) }
.onChange(of: proxy.size) { tab.updateSplitViewSize($0) }
})
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
config = GhosttyConfig.load()
}
}
}
fileprivate struct TerminalSplitSubtreeView: View {
let node: SplitTree<TerminalSurface>.Node
let isRoot: Bool
let isSplit: Bool
let isTabActive: Bool
let focusedSurfaceId: UUID?
let appearance: SplitAppearance
let tabId: UUID
let notificationStore: TerminalNotificationStore
let onFocus: (UUID) -> Void
let onTriggerFlash: (UUID) -> Void
let onResize: (SplitTree<TerminalSurface>.Node, Double) -> Void
let onEqualize: () -> Void
var body: some View {
switch node {
case .leaf(let surface):
let isFocused = isTabActive && focusedSurfaceId == surface.id
TerminalSurfaceView(
surface: surface,
isFocused: isFocused,
isSplit: isSplit,
appearance: appearance,
tabId: tabId,
notificationStore: notificationStore,
onFocus: { onFocus(surface.id) },
onTriggerFlash: { onTriggerFlash(surface.id) }
)
case .split(let split):
let splitViewDirection: SplitViewDirection = switch split.direction {
case .horizontal: .horizontal
case .vertical: .vertical
}
SplitView(
splitViewDirection,
.init(get: {
CGFloat(split.ratio)
}, set: {
onResize(node, Double($0))
}),
dividerColor: appearance.dividerColor,
resizeIncrements: .init(width: 1, height: 1),
left: {
TerminalSplitSubtreeView(
node: split.left,
isRoot: false,
isSplit: isSplit,
isTabActive: isTabActive,
focusedSurfaceId: focusedSurfaceId,
appearance: appearance,
tabId: tabId,
notificationStore: notificationStore,
onFocus: onFocus,
onTriggerFlash: onTriggerFlash,
onResize: onResize,
onEqualize: onEqualize
)
},
right: {
TerminalSplitSubtreeView(
node: split.right,
isRoot: false,
isSplit: isSplit,
isTabActive: isTabActive,
focusedSurfaceId: focusedSurfaceId,
appearance: appearance,
tabId: tabId,
notificationStore: notificationStore,
onFocus: onFocus,
onTriggerFlash: onTriggerFlash,
onResize: onResize,
onEqualize: onEqualize
)
},
onEqualize: {
onEqualize()
}
)
}
}
}
private struct SplitAppearance {
let dividerColor: Color
let unfocusedOverlayColor: Color
let unfocusedOverlayOpacity: Double
}
private struct TerminalSurfaceView: View {
@ObservedObject var surface: TerminalSurface
let isFocused: Bool
let isSplit: Bool
let appearance: SplitAppearance
let tabId: UUID
let notificationStore: TerminalNotificationStore
let onFocus: () -> Void
let onTriggerFlash: () -> Void
var body: some View {
ZStack(alignment: .topLeading) {
GhosttyTerminalView(
terminalSurface: surface,
isActive: isFocused,
onFocus: { _ in onFocus() },
onTriggerFlash: onTriggerFlash
)
.background(Color.clear)
if isSplit && !isFocused && appearance.unfocusedOverlayOpacity > 0 {
Rectangle()
.fill(appearance.unfocusedOverlayColor)
.opacity(appearance.unfocusedOverlayOpacity)
.allowsHitTesting(false)
}
if notificationStore.hasUnreadNotification(forTabId: tabId, surfaceId: surface.id) {
Rectangle()
.stroke(Color(nsColor: .systemBlue), lineWidth: 2.5)
.shadow(color: Color(nsColor: .systemBlue).opacity(0.35), radius: 3)
.padding(2)
.allowsHitTesting(false)
}
if let searchState = surface.searchState {
SurfaceSearchOverlay(
surface: surface,
searchState: searchState,
onClose: {
surface.searchState = nil
surface.hostedView.moveFocus()
}
)
}
}
}
}