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
This commit is contained in:
Lawrence Chen 2026-02-04 03:04:45 -08:00 committed by GitHub
parent 600683cd7d
commit cc71e5797e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1548 additions and 108 deletions

File diff suppressed because it is too large Load diff

View file

@ -301,6 +301,61 @@ class GhosttyApp {
}
}
func reloadConfiguration(soft: Bool = false) {
guard let app else { return }
if soft, let config {
ghostty_app_update_config(app, config)
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
return
}
guard let newConfig = ghostty_config_new() else { return }
ghostty_config_load_default_files(newConfig)
ghostty_config_finalize(newConfig)
ghostty_app_update_config(app, newConfig)
updateDefaultBackground(from: newConfig)
DispatchQueue.main.async {
self.applyBackgroundToKeyWindow()
}
if let oldConfig = config {
ghostty_config_free(oldConfig)
}
config = newConfig
NotificationCenter.default.post(name: .ghosttyConfigDidReload, object: nil)
}
func reloadConfiguration(for surface: ghostty_surface_t, soft: Bool = false) {
if soft, let config {
ghostty_surface_update_config(surface, config)
return
}
guard let newConfig = ghostty_config_new() else { return }
ghostty_config_load_default_files(newConfig)
ghostty_config_finalize(newConfig)
ghostty_surface_update_config(surface, newConfig)
ghostty_config_free(newConfig)
}
func openConfigurationInTextEdit() {
#if os(macOS)
let path = ghosttyStringValue(ghostty_config_open_path())
guard !path.isEmpty else { return }
let fileURL = URL(fileURLWithPath: path)
let editorURL = URL(fileURLWithPath: "/System/Applications/TextEdit.app")
let configuration = NSWorkspace.OpenConfiguration()
NSWorkspace.shared.open([fileURL], withApplicationAt: editorURL, configuration: configuration)
#endif
}
private func ghosttyStringValue(_ value: ghostty_string_s) -> String {
defer { ghostty_string_free(value) }
guard let ptr = value.ptr, value.len > 0 else { return "" }
let rawPtr = UnsafeRawPointer(ptr).assumingMemoryBound(to: UInt8.self)
let buffer = UnsafeBufferPointer(start: rawPtr, count: Int(value.len))
return String(decoding: buffer, as: UTF8.self)
}
private func updateDefaultBackground(from config: ghostty_config_t?) {
guard let config else { return }
@ -389,6 +444,14 @@ class GhosttyApp {
return true
}
if action.tag == GHOSTTY_ACTION_RELOAD_CONFIG {
let soft = action.action.reload_config.soft
performOnMain {
GhosttyApp.shared.reloadConfiguration(soft: soft)
}
return true
}
if action.tag == GHOSTTY_ACTION_COLOR_CHANGE,
action.action.color_change.kind == GHOSTTY_ACTION_COLOR_KIND_BACKGROUND {
let change = action.action.color_change
@ -534,13 +597,15 @@ class GhosttyApp {
case GHOSTTY_ACTION_SET_TITLE:
let title = action.action.set_title.title
.flatMap { String(cString: $0) } ?? ""
if let tabId = surfaceView.tabId {
if let tabId = surfaceView.tabId,
let surfaceId = surfaceView.terminalSurface?.id {
DispatchQueue.main.async {
NotificationCenter.default.post(
name: .ghosttyDidSetTitle,
object: surfaceView,
userInfo: [
GhosttyNotificationKey.tabId: tabId,
GhosttyNotificationKey.surfaceId: surfaceId,
GhosttyNotificationKey.title: title,
]
)
@ -604,6 +669,16 @@ class GhosttyApp {
surfaceView.applyWindowBackgroundIfActive()
}
return true
case GHOSTTY_ACTION_RELOAD_CONFIG:
let soft = action.action.reload_config.soft
return performOnMain {
if let surface = surfaceView.terminalSurface?.surface {
GhosttyApp.shared.reloadConfiguration(for: surface, soft: soft)
} else {
GhosttyApp.shared.reloadConfiguration(soft: soft)
}
return true
}
case GHOSTTY_ACTION_KEY_SEQUENCE:
return performOnMain {
surfaceView.updateKeySequence(action.action.key_sequence)
@ -620,15 +695,33 @@ class GhosttyApp {
}
private func applyBackgroundToKeyWindow() {
guard let window = NSApp.keyWindow ?? NSApp.windows.first else { return }
let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity)
window.backgroundColor = color
window.isOpaque = color.alphaComponent >= 1.0
if backgroundLogEnabled {
logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))")
guard let window = activeMainWindow() else { return }
// Check if sidebar uses behindWindow blur - if so, keep window non-opaque
let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "withinWindow"
if sidebarBlendMode == "behindWindow" {
window.backgroundColor = .clear
window.isOpaque = false
if backgroundLogEnabled {
logBackground("applied transparent window for behindWindow blur")
}
} else {
let color = defaultBackgroundColor.withAlphaComponent(defaultBackgroundOpacity)
window.backgroundColor = color
window.isOpaque = color.alphaComponent >= 1.0
if backgroundLogEnabled {
logBackground("applied default window background color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))")
}
}
}
private func activeMainWindow() -> NSWindow? {
let keyWindow = NSApp.keyWindow
if keyWindow?.identifier?.rawValue == "cmux.main" {
return keyWindow
}
return NSApp.windows.first(where: { $0.identifier?.rawValue == "cmux.main" })
}
func logBackground(_ message: String) {
let line = "cmux bg: \(message)\n"
if let data = line.data(using: .utf8) {
@ -991,8 +1084,15 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations {
}
applySurfaceBackground()
let color = effectiveBackgroundColor()
window.backgroundColor = color
window.isOpaque = color.alphaComponent >= 1.0
// Check if sidebar uses behindWindow blur - if so, keep window non-opaque
let sidebarBlendMode = UserDefaults.standard.string(forKey: "sidebarBlendMode") ?? "withinWindow"
if sidebarBlendMode == "behindWindow" {
window.backgroundColor = .clear
window.isOpaque = false
} else {
window.backgroundColor = color
window.isOpaque = color.alphaComponent >= 1.0
}
if GhosttyApp.shared.backgroundLogEnabled {
GhosttyApp.shared.logBackground("applied window background tab=\(tabId?.uuidString ?? "unknown") color=\(color) opacity=\(String(format: "%.3f", color.alphaComponent))")
}
@ -1758,6 +1858,7 @@ enum GhosttyNotificationKey {
static let scrollbar = "ghostty.scrollbar"
static let cellSize = "ghostty.cellSize"
static let tabId = "ghostty.tabId"
static let surfaceId = "ghostty.surfaceId"
static let title = "ghostty.title"
}
@ -1765,6 +1866,7 @@ extension Notification.Name {
static let ghosttyDidUpdateScrollbar = Notification.Name("ghosttyDidUpdateScrollbar")
static let ghosttyDidUpdateCellSize = Notification.Name("ghosttyDidUpdateCellSize")
static let ghosttySearchFocus = Notification.Name("ghosttySearchFocus")
static let ghosttyConfigDidReload = Notification.Name("ghosttyConfigDidReload")
}
// MARK: - Scroll View Wrapper (Ghostty-style scrollbar)

View file

@ -1,4 +1,5 @@
import SwiftUI
import Foundation
struct TerminalSplitTreeView: View {
@ObservedObject var tab: Tab
@ -37,6 +38,9 @@ struct TerminalSplitTreeView: View {
.onAppear { tab.updateSplitViewSize(proxy.size) }
.onChange(of: proxy.size) { tab.updateSplitViewSize($0) }
})
.onReceive(NotificationCenter.default.publisher(for: .ghosttyConfigDidReload)) { _ in
config = GhosttyConfig.load()
}
}
}

View file

@ -5,20 +5,36 @@ import Foundation
class Tab: Identifiable, ObservableObject {
let id: UUID
@Published var title: String
@Published var customTitle: String?
@Published var isPinned: Bool = false
@Published var currentDirectory: String
@Published var splitTree: SplitTree<TerminalSurface>
@Published var focusedSurfaceId: UUID? {
didSet {
guard let focusedSurfaceId else { return }
AppDelegate.shared?.tabManager?.rememberFocusedSurface(tabId: id, surfaceId: focusedSurfaceId)
AppDelegate.shared?.tabManager?.focusedSurfaceTitleDidChange(tabId: id)
NotificationCenter.default.post(
name: .ghosttyDidFocusSurface,
object: nil,
userInfo: [
GhosttyNotificationKey.tabId: id,
GhosttyNotificationKey.surfaceId: focusedSurfaceId
]
)
}
}
@Published var surfaceDirectories: [UUID: String] = [:]
@Published var surfaceTitles: [UUID: String] = [:]
var splitViewSize: CGSize = .zero
private var processTitle: String
init(title: String = "Terminal", workingDirectory: String? = nil) {
self.id = UUID()
self.processTitle = title
self.title = title
self.customTitle = nil
let trimmedWorkingDirectory = workingDirectory?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let hasWorkingDirectory = !trimmedWorkingDirectory.isEmpty
self.currentDirectory = hasWorkingDirectory
@ -39,6 +55,28 @@ class Tab: Identifiable, ObservableObject {
return surface(for: focusedSurfaceId)
}
var hasCustomTitle: Bool {
let trimmed = customTitle?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !trimmed.isEmpty
}
func applyProcessTitle(_ title: String) {
processTitle = title
guard customTitle == nil else { return }
self.title = title
}
func setCustomTitle(_ title: String?) {
let trimmed = title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
customTitle = nil
self.title = processTitle
} else {
customTitle = trimmed
self.title = trimmed
}
}
func surface(for id: UUID) -> TerminalSurface? {
guard let node = splitTree.root?.find(id: id) else { return nil }
if case .leaf(let view) = node {
@ -305,8 +343,9 @@ class TabManager: ObservableObject {
) { [weak self] notification in
guard let self else { return }
guard let tabId = notification.userInfo?[GhosttyNotificationKey.tabId] as? UUID else { return }
guard let surfaceId = notification.userInfo?[GhosttyNotificationKey.surfaceId] as? UUID else { return }
guard let title = notification.userInfo?[GhosttyNotificationKey.title] as? String else { return }
self.updateTabTitle(tabId: tabId, title: title)
self.updateSurfaceTitle(tabId: tabId, surfaceId: surfaceId, title: title)
})
}
@ -383,6 +422,11 @@ class TabManager: ObservableObject {
let index = tabs.firstIndex(where: { $0.id == selectedTabId }) else {
return tabs.count
}
let selectedTab = tabs[index]
if selectedTab.isPinned {
let lastPinnedIndex = tabs.lastIndex(where: { $0.isPinned }) ?? -1
return min(lastPinnedIndex + 1, tabs.count)
}
return min(index + 1, tabs.count)
}
@ -403,7 +447,9 @@ class TabManager: ObservableObject {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
guard index != 0 else { return }
let tab = tabs.remove(at: index)
tabs.insert(tab, at: 0)
let pinnedCount = tabs.filter { $0.isPinned }.count
let insertIndex = tab.isPinned ? 0 : pinnedCount
tabs.insert(tab, at: insertIndex)
}
func moveTabsToTop(_ tabIds: Set<UUID>) {
@ -411,7 +457,43 @@ class TabManager: ObservableObject {
let selectedTabs = tabs.filter { tabIds.contains($0.id) }
guard !selectedTabs.isEmpty else { return }
let remainingTabs = tabs.filter { !tabIds.contains($0.id) }
tabs = selectedTabs + remainingTabs
let selectedPinned = selectedTabs.filter { $0.isPinned }
let selectedUnpinned = selectedTabs.filter { !$0.isPinned }
let remainingPinned = remainingTabs.filter { $0.isPinned }
let remainingUnpinned = remainingTabs.filter { !$0.isPinned }
tabs = selectedPinned + remainingPinned + selectedUnpinned + remainingUnpinned
}
func setCustomTitle(tabId: UUID, title: String?) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
tabs[index].setCustomTitle(title)
if selectedTabId == tabId {
updateWindowTitle(for: tabs[index])
}
}
func clearCustomTitle(tabId: UUID) {
setCustomTitle(tabId: tabId, title: nil)
}
func togglePin(tabId: UUID) {
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
let tab = tabs[index]
setPinned(tab, pinned: !tab.isPinned)
}
func setPinned(_ tab: Tab, pinned: Bool) {
guard tab.isPinned != pinned else { return }
tab.isPinned = pinned
reorderTabForPinnedState(tab)
}
private func reorderTabForPinnedState(_ tab: Tab) {
guard let index = tabs.firstIndex(where: { $0.id == tab.id }) else { return }
tabs.remove(at: index)
let pinnedCount = tabs.filter { $0.isPinned }.count
let insertIndex = min(pinnedCount, tabs.count)
tabs.insert(tab, at: insertIndex)
}
func updateSurfaceDirectory(tabId: UUID, surfaceId: UUID, directory: String) {
@ -570,17 +652,33 @@ class TabManager: ObservableObject {
notificationStore.markRead(forTabId: tabId, surfaceId: surfaceId)
}
private func updateTabTitle(tabId: UUID, title: String) {
private func updateSurfaceTitle(tabId: UUID, surfaceId: UUID, title: String) {
guard !title.isEmpty else { return }
guard let index = tabs.firstIndex(where: { $0.id == tabId }) else { return }
if tabs[index].title != title {
tabs[index].title = title
let tab = tabs[index]
// Store title per-surface
tab.surfaceTitles[surfaceId] = title
// Only update tab's display title if this surface is focused
if tab.focusedSurfaceId == surfaceId {
tab.applyProcessTitle(title)
if selectedTabId == tabId {
updateWindowTitle(for: tabs[index])
updateWindowTitle(for: tab)
}
}
}
func focusedSurfaceTitleDidChange(tabId: UUID) {
guard let tab = tabs.first(where: { $0.id == tabId }),
let focusedSurfaceId = tab.focusedSurfaceId,
let title = tab.surfaceTitles[focusedSurfaceId] else { return }
tab.applyProcessTitle(title)
if selectedTabId == tabId {
updateWindowTitle(for: tab)
}
}
private func updateWindowTitleForSelectedTab() {
guard let selectedTabId,
let tab = tabs.first(where: { $0.id == selectedTabId }) else {
@ -818,4 +916,5 @@ class TabManager: ObservableObject {
extension Notification.Name {
static let ghosttyDidSetTitle = Notification.Name("ghosttyDidSetTitle")
static let ghosttyDidFocusTab = Notification.Name("ghosttyDidFocusTab")
static let ghosttyDidFocusSurface = Notification.Name("ghosttyDidFocusSurface")
}

View file

@ -267,7 +267,7 @@ struct SwiftTermView: NSViewRepresentable {
func setTerminalTitle(source: LocalProcessTerminalView, title: String) {
DispatchQueue.main.async {
if !title.isEmpty {
self.tab.title = title
self.tab.applyProcessTitle(title)
}
}
}

View file

@ -204,15 +204,23 @@ private struct NotificationsAnchorView: NSViewRepresentable {
let onResolve: (NSView) -> Void
func makeNSView(context: Context) -> NSView {
let view = NSView(frame: .zero)
DispatchQueue.main.async {
let view = AnchorNSView()
view.onLayout = { [weak view] in
guard let view else { return }
onResolve(view)
}
return view
}
func updateNSView(_ nsView: NSView, context: Context) {
// Only need to resolve once in makeNSView - the view reference doesn't change
func updateNSView(_ nsView: NSView, context: Context) {}
}
private final class AnchorNSView: NSView {
var onLayout: (() -> Void)?
override func layout() {
super.layout()
onLayout?()
}
}
@ -225,8 +233,12 @@ private struct TitlebarControlButton<Content: View>: View {
var body: some View {
Button(action: action) {
content()
.frame(width: config.buttonSize, height: config.buttonSize)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.frame(width: config.buttonSize, height: config.buttonSize)
.contentShape(Rectangle())
.background(hoverBackground)
.onHover { isHovering = $0 }
}
@ -281,7 +293,7 @@ private struct TitlebarControlsView: View {
}
.frame(width: config.buttonSize, height: config.buttonSize)
}
.background(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 })
.overlay(NotificationsAnchorView { viewModel.notificationsAnchorView = $0 }.allowsHitTesting(false))
.accessibilityLabel("Notifications")
.help("Show notifications (Cmd+Shift+I)")
@ -420,7 +432,7 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
return
}
// Recreate content view each time to avoid stale observers when popover is hidden
notificationsPopover.contentViewController = NSHostingController(
let hostingController = NSHostingController(
rootView: NotificationsPopoverView(
notificationStore: notificationStore,
onDismiss: { [weak notificationsPopover] in
@ -428,9 +440,33 @@ final class TitlebarControlsAccessoryViewController: NSTitlebarAccessoryViewCont
}
)
)
let anchorView = viewModel.notificationsAnchorView ?? hostingView
hostingController.view.wantsLayer = true
hostingController.view.layer?.backgroundColor = .clear
notificationsPopover.contentViewController = hostingController
guard let window = view.window ?? hostingView.window ?? NSApp.keyWindow,
let contentView = window.contentView else {
return
}
// Force layout to ensure geometry is current.
contentView.layoutSubtreeIfNeeded()
if let anchorView = viewModel.notificationsAnchorView, anchorView.window != nil {
anchorView.superview?.layoutSubtreeIfNeeded()
let anchorRect = anchorView.convert(anchorView.bounds, to: contentView)
if !anchorRect.isEmpty {
notificationsPopover.animates = animated
notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY)
return
}
}
// Fallback: position near top-left of the window content.
let bounds = contentView.bounds
let anchorRect = NSRect(x: 12, y: bounds.maxY - 8, width: 1, height: 1)
notificationsPopover.animates = animated
notificationsPopover.show(relativeTo: anchorView.bounds, of: anchorView, preferredEdge: .maxY)
notificationsPopover.show(relativeTo: anchorRect, of: contentView, preferredEdge: .maxY)
}
private func makeNotificationsPopover() -> NSPopover {

View file

@ -106,7 +106,7 @@ struct cmuxApp: App {
updateSocketController()
}
}
.windowToolbarStyle(.automatic)
.windowStyle(.hiddenTitleBar)
Settings {
SettingsRootView()
}
@ -117,6 +117,13 @@ struct cmuxApp: App {
Button("About cmuxterm") {
showAboutPanel()
}
Button("Ghostty Settings…") {
GhosttyApp.shared.openConfigurationInTextEdit()
}
Button("Reload Configuration") {
GhosttyApp.shared.reloadConfiguration()
}
.keyboardShortcut("r", modifiers: [.command, .shift])
Divider()
Button("Check for Updates…") {
appDelegate.checkForUpdates(nil)
@ -161,6 +168,16 @@ struct cmuxApp: App {
Divider()
Button("Sidebar Debug…") {
SidebarDebugWindowController.shared.show()
}
Button("Background Debug…") {
BackgroundDebugWindowController.shared.show()
}
Divider()
Picker("Titlebar Controls Style", selection: $titlebarControlsStyle) {
ForEach(TitlebarControlsStyle.allCases) { style in
Text(style.menuTitle).tag(style.rawValue)
@ -394,6 +411,40 @@ private final class AboutWindowController: NSWindowController, NSWindowDelegate
}
}
private final class SidebarDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = SidebarDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 520),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Sidebar Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.sidebarDebug")
window.center()
window.contentView = NSHostingView(rootView: SidebarDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct AboutPanelView: View {
@Environment(\.openURL) private var openURL
@ -480,6 +531,297 @@ private struct AboutPanelView: View {
}
}
private struct SidebarDebugView: View {
@AppStorage("sidebarPreset") private var sidebarPreset = SidebarPresetOption.nativeSidebar.rawValue
@AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = 0.54
@AppStorage("sidebarTintHex") private var sidebarTintHex = "#101010"
@AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue
@AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue
@AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue
@AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0
@AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 0.79
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Sidebar Appearance")
.font(.headline)
GroupBox("Presets") {
Picker("Preset", selection: $sidebarPreset) {
ForEach(SidebarPresetOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
.onChange(of: sidebarPreset) { _ in
applyPreset()
}
.padding(.top, 2)
}
GroupBox("Blur") {
VStack(alignment: .leading, spacing: 8) {
Picker("Material", selection: $sidebarMaterial) {
ForEach(SidebarMaterialOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
Picker("Blending", selection: $sidebarBlendMode) {
ForEach(SidebarBlendModeOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
Picker("State", selection: $sidebarState) {
ForEach(SidebarStateOption.allCases) { option in
Text(option.title).tag(option.rawValue)
}
}
HStack(spacing: 8) {
Text("Strength")
Slider(value: $sidebarBlurOpacity, in: 0...1)
Text(String(format: "%.0f%%", sidebarBlurOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
GroupBox("Tint") {
VStack(alignment: .leading, spacing: 8) {
ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false)
HStack(spacing: 8) {
Text("Opacity")
Slider(value: $sidebarTintOpacity, in: 0...0.7)
Text(String(format: "%.0f%%", sidebarTintOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
GroupBox("Shape") {
HStack(spacing: 8) {
Text("Corner Radius")
Slider(value: $sidebarCornerRadius, in: 0...20)
Text(String(format: "%.0f", sidebarCornerRadius))
.font(.caption)
.frame(width: 32, alignment: .trailing)
}
.padding(.top, 2)
}
HStack(spacing: 12) {
Button("Reset Tint") {
sidebarTintOpacity = 0.62
sidebarTintHex = "#000000"
}
Button("Reset Blur") {
sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue
sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue
sidebarState = SidebarStateOption.active.rawValue
sidebarBlurOpacity = 0.98
}
Button("Reset Shape") {
sidebarCornerRadius = 0.0
}
}
Button("Copy Config") {
copySidebarConfig()
}
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
}
private var tintColorBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: sidebarTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
sidebarTintHex = nsColor.hexString()
}
)
}
private func copySidebarConfig() {
let payload = """
sidebarPreset=\(sidebarPreset)
sidebarMaterial=\(sidebarMaterial)
sidebarBlendMode=\(sidebarBlendMode)
sidebarState=\(sidebarState)
sidebarBlurOpacity=\(String(format: "%.2f", sidebarBlurOpacity))
sidebarTintHex=\(sidebarTintHex)
sidebarTintOpacity=\(String(format: "%.2f", sidebarTintOpacity))
sidebarCornerRadius=\(String(format: "%.1f", sidebarCornerRadius))
"""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
private func applyPreset() {
guard let preset = SidebarPresetOption(rawValue: sidebarPreset) else { return }
sidebarMaterial = preset.material.rawValue
sidebarBlendMode = preset.blendMode.rawValue
sidebarState = preset.state.rawValue
sidebarTintHex = preset.tintHex
sidebarTintOpacity = preset.tintOpacity
sidebarCornerRadius = preset.cornerRadius
sidebarBlurOpacity = preset.blurOpacity
}
}
// MARK: - Background Debug Window
private final class BackgroundDebugWindowController: NSWindowController, NSWindowDelegate {
static let shared = BackgroundDebugWindowController()
private init() {
let window = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 300),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
window.title = "Background Debug"
window.titleVisibility = .visible
window.titlebarAppearsTransparent = false
window.isMovableByWindowBackground = true
window.isReleasedWhenClosed = false
window.identifier = NSUserInterfaceItemIdentifier("cmux.backgroundDebug")
window.center()
window.contentView = NSHostingView(rootView: BackgroundDebugView())
AppDelegate.shared?.applyWindowDecorations(to: window)
super.init(window: window)
window.delegate = self
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func show() {
window?.center()
window?.makeKeyAndOrderFront(nil)
}
}
private struct BackgroundDebugView: View {
@AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000"
@AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.05
@AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow"
@AppStorage("bgGlassEnabled") private var bgGlassEnabled = true
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 14) {
Text("Window Background Glass")
.font(.headline)
GroupBox("Glass Effect") {
VStack(alignment: .leading, spacing: 8) {
Toggle("Enable Glass Effect", isOn: $bgGlassEnabled)
Picker("Material", selection: $bgGlassMaterial) {
Text("HUD Window").tag("hudWindow")
Text("Under Window").tag("underWindowBackground")
Text("Sidebar").tag("sidebar")
Text("Menu").tag("menu")
Text("Popover").tag("popover")
}
.disabled(!bgGlassEnabled)
}
.padding(.top, 2)
}
GroupBox("Tint") {
VStack(alignment: .leading, spacing: 8) {
ColorPicker("Tint Color", selection: tintColorBinding, supportsOpacity: false)
.disabled(!bgGlassEnabled)
HStack(spacing: 8) {
Text("Opacity")
Slider(value: $bgGlassTintOpacity, in: 0...0.8)
.disabled(!bgGlassEnabled)
Text(String(format: "%.0f%%", bgGlassTintOpacity * 100))
.font(.caption)
.frame(width: 44, alignment: .trailing)
}
}
.padding(.top, 2)
}
HStack(spacing: 12) {
Button("Reset") {
bgGlassTintHex = "#000000"
bgGlassTintOpacity = 0.05
bgGlassMaterial = "hudWindow"
bgGlassEnabled = true
updateWindowGlassTint()
}
Button("Copy Config") {
copyBgConfig()
}
}
Text("Tint changes apply live. Enable/disable requires reload.")
.font(.caption)
.foregroundColor(.secondary)
Spacer(minLength: 0)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.onChange(of: bgGlassTintHex) { _ in updateWindowGlassTint() }
.onChange(of: bgGlassTintOpacity) { _ in updateWindowGlassTint() }
}
private func updateWindowGlassTint() {
guard let window = NSApp.windows.first(where: { $0.identifier?.rawValue == "cmux.main" }) else { return }
let tintColor = (NSColor(hex: bgGlassTintHex) ?? .black).withAlphaComponent(bgGlassTintOpacity)
WindowGlassEffect.updateTint(to: window, color: tintColor)
}
private var tintColorBinding: Binding<Color> {
Binding(
get: {
Color(nsColor: NSColor(hex: bgGlassTintHex) ?? .black)
},
set: { newColor in
let nsColor = NSColor(newColor)
bgGlassTintHex = nsColor.hexString()
}
)
}
private func copyBgConfig() {
let payload = """
bgGlassEnabled=\(bgGlassEnabled)
bgGlassMaterial=\(bgGlassMaterial)
bgGlassTintHex=\(bgGlassTintHex)
bgGlassTintOpacity=\(String(format: "%.2f", bgGlassTintOpacity))
"""
let pasteboard = NSPasteboard.general
pasteboard.clearContents()
pasteboard.setString(payload, forType: .string)
}
}
private struct AboutPropertyRow: View {
private let label: String
private let text: String

View file

@ -203,7 +203,13 @@ if [[ -n "$TAG" && "$APP_NAME" != "$SEARCH_APP_NAME" ]]; then
APP_PATH="$TAG_APP_PATH"
fi
pkill -f "${APP_PATH}/Contents/MacOS/" || true
# Ensure any running instance is fully terminated, regardless of DerivedData path.
/usr/bin/osascript -e "tell application id \"${BUNDLE_ID}\" to quit" >/dev/null 2>&1 || true
sleep 0.3
pkill -f "/${BASE_APP_NAME}.app/Contents/MacOS/${BASE_APP_NAME}" || true
if [[ "${APP_NAME}" != "${BASE_APP_NAME}" ]]; then
pkill -f "/${APP_NAME}.app/Contents/MacOS/${APP_NAME}" || true
fi
sleep 0.3
CMUXD_SRC="$PWD/cmuxd/zig-out/bin/cmuxd"
if [[ -d "$PWD/cmuxd" ]]; then