Use workspace color for notification ring and selection bar (#664)

- Notification/focus flash uses workspace customColor (fallback: accent)
- Selection bar/indicator uses workspace customColor when set
- Flash color propagated through Panel.triggerFlash(color:) API
- Browser panel flash overlay uses workspace color
- Regression tests for flash color resolution

Fixes https://github.com/manaflow-ai/cmux/issues/557
This commit is contained in:
Lawrence Chen 2026-02-27 18:14:19 -08:00 committed by GitHub
parent 181574586e
commit 4bfe95d125
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 86 additions and 15 deletions

View file

@ -6437,6 +6437,10 @@ private struct TabItemView: View {
usesInvertedActiveForeground ? Color.white.opacity(0.8) : cmuxAccentColor()
}
private var activeSelectionColor: Color {
resolvedCustomTabColor ?? Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme))
}
private var shortcutHintEmphasis: Double {
usesInvertedActiveForeground ? 1.0 : 0.9
}
@ -6491,6 +6495,7 @@ private struct TabItemView: View {
.foregroundColor(activeSecondaryColor(0.8))
}
Text(tab.title)
.font(.system(size: 12.5, weight: titleFontWeight))
.foregroundColor(activePrimaryTextColor)
@ -6948,11 +6953,11 @@ private struct TabItemView: View {
private var backgroundColor: Color {
switch activeTabIndicatorStyle {
case .leftRail:
if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) }
if isActive { return activeSelectionColor }
if isMultiSelected { return cmuxAccentColor().opacity(0.25) }
return Color.clear
case .solidFill:
if isActive { return Color(nsColor: sidebarSelectedWorkspaceBackgroundNSColor(for: colorScheme)) }
if isActive { return activeSelectionColor }
if let custom = resolvedCustomTabColor {
if isMultiSelected { return custom.opacity(0.35) }
return custom.opacity(0.7)

View file

@ -3951,11 +3951,11 @@ final class GhosttySurfaceScrollView: NSView {
notificationRingOverlayView.layer?.masksToBounds = false
notificationRingOverlayView.autoresizingMask = [.width, .height]
notificationRingLayer.fillColor = NSColor.clear.cgColor
notificationRingLayer.strokeColor = NSColor.systemBlue.cgColor
notificationRingLayer.strokeColor = cmuxAccentNSColor().cgColor
notificationRingLayer.lineWidth = 2.5
notificationRingLayer.lineJoin = .round
notificationRingLayer.lineCap = .round
notificationRingLayer.shadowColor = NSColor.systemBlue.cgColor
notificationRingLayer.shadowColor = cmuxAccentNSColor().cgColor
notificationRingLayer.shadowOpacity = 0.35
notificationRingLayer.shadowRadius = 3
notificationRingLayer.shadowOffset = .zero
@ -3968,11 +3968,11 @@ final class GhosttySurfaceScrollView: NSView {
flashOverlayView.layer?.masksToBounds = false
flashOverlayView.autoresizingMask = [.width, .height]
flashLayer.fillColor = NSColor.clear.cgColor
flashLayer.strokeColor = NSColor.systemBlue.cgColor
flashLayer.strokeColor = cmuxAccentNSColor().cgColor
flashLayer.lineWidth = 3
flashLayer.lineJoin = .round
flashLayer.lineCap = .round
flashLayer.shadowColor = NSColor.systemBlue.cgColor
flashLayer.shadowColor = cmuxAccentNSColor().cgColor
flashLayer.shadowOpacity = 0.6
flashLayer.shadowRadius = 6
flashLayer.shadowOffset = .zero
@ -4145,6 +4145,27 @@ final class GhosttySurfaceScrollView: NSView {
surfaceView.onTriggerFlash = handler
}
private func resolvedWorkspaceFocusFlashColor() -> NSColor {
guard let tabId = surfaceView.tabId,
let app = AppDelegate.shared,
let manager = app.tabManagerFor(tabId: tabId) ?? app.tabManager,
let workspace = manager.tabs.first(where: { $0.id == tabId }) else {
return cmuxAccentNSColor()
}
return Workspace.resolvedFocusFlashColor(customColorHex: workspace.customColor)
}
private func setFocusFlashColor(_ color: NSColor?) {
let resolved = color ?? resolvedWorkspaceFocusFlashColor()
CATransaction.begin()
CATransaction.setDisableActions(true)
notificationRingLayer.strokeColor = resolved.cgColor
notificationRingLayer.shadowColor = resolved.cgColor
flashLayer.strokeColor = resolved.cgColor
flashLayer.shadowColor = resolved.cgColor
CATransaction.commit()
}
func setBackgroundColor(_ color: NSColor) {
guard let layer = backgroundView.layer else { return }
CATransaction.begin()
@ -4170,6 +4191,7 @@ final class GhosttySurfaceScrollView: NSView {
return
}
setFocusFlashColor(nil)
CATransaction.begin()
CATransaction.setDisableActions(true)
notificationRingOverlayView.isHidden = !visible
@ -4392,7 +4414,7 @@ final class GhosttySurfaceScrollView: NSView {
}
#endif
func triggerFlash() {
func triggerFlash(color: NSColor? = nil) {
DispatchQueue.main.async { [weak self] in
guard let self else { return }
#if DEBUG
@ -4400,6 +4422,7 @@ final class GhosttySurfaceScrollView: NSView {
Self.recordFlash(for: surfaceId)
}
#endif
self.setFocusFlashColor(color)
self.updateFlashPath()
self.flashLayer.removeAllAnimations()
self.flashLayer.opacity = 0

View file

@ -1313,6 +1313,7 @@ final class BrowserPanel: Panel, ObservableObject {
/// Increment to request a UI-only flash highlight (e.g. from a keyboard shortcut).
@Published private(set) var focusFlashToken: Int = 0
@Published private(set) var focusFlashColor: NSColor = cmuxAccentNSColor()
/// Sticky omnibar-focus intent. This survives view mount timing races and is
/// cleared only after BrowserPanelView acknowledges handling it.
@ -1520,7 +1521,8 @@ final class BrowserPanel: Panel, ObservableObject {
workspaceId = newWorkspaceId
}
func triggerFlash() {
func triggerFlash(color: NSColor? = nil) {
focusFlashColor = color ?? cmuxAccentNSColor()
focusFlashToken &+= 1
}

View file

@ -296,6 +296,10 @@ struct BrowserPanelView: View {
)
}
private var focusFlashColor: Color {
Color(nsColor: panel.focusFlashColor)
}
var body: some View {
VStack(spacing: 0) {
addressBar
@ -303,8 +307,8 @@ struct BrowserPanelView: View {
}
.overlay {
RoundedRectangle(cornerRadius: FocusFlashPattern.ringCornerRadius)
.stroke(cmuxAccentColor().opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: cmuxAccentColor().opacity(focusFlashOpacity * 0.35), radius: 10)
.stroke(focusFlashColor.opacity(focusFlashOpacity), lineWidth: 3)
.shadow(color: focusFlashColor.opacity(focusFlashOpacity * 0.35), radius: 10)
.padding(FocusFlashPattern.ringInset)
.allowsHitTesting(false)
}

View file

@ -1,5 +1,6 @@
import Foundation
import Combine
import AppKit
/// Type of panel content
public enum PanelType: String, Codable, Sendable {
@ -70,7 +71,8 @@ public protocol Panel: AnyObject, Identifiable, ObservableObject where ID == UUI
func unfocus()
/// Trigger a focus flash animation for this panel.
func triggerFlash()
/// - Parameter color: Optional override color for this flash.
func triggerFlash(color: NSColor?)
}
/// Extension providing default implementations

View file

@ -166,8 +166,8 @@ final class TerminalPanel: Panel, ObservableObject {
surface.needsConfirmClose()
}
func triggerFlash() {
hostedView.triggerFlash()
func triggerFlash(color: NSColor? = nil) {
hostedView.triggerFlash(color: color)
}
func applyWindowBackgroundIfActive() {

View file

@ -2934,8 +2934,23 @@ final class Workspace: Identifiable, ObservableObject {
// MARK: - Flash/Notification Support
nonisolated static func resolvedFocusFlashColor(
customColorHex: String?,
fallback: NSColor = cmuxAccentNSColor()
) -> NSColor {
guard let normalizedHex = customColorHex.flatMap(WorkspaceTabColorSettings.normalizedHex),
let color = NSColor(hex: normalizedHex) else {
return fallback
}
return color
}
private var focusFlashColor: NSColor {
Self.resolvedFocusFlashColor(customColorHex: customColor)
}
func triggerFocusFlash(panelId: UUID) {
panels[panelId]?.triggerFlash()
panels[panelId]?.triggerFlash(color: focusFlashColor)
}
func triggerNotificationFocusFlash(
@ -2951,7 +2966,7 @@ final class Workspace: Identifiable, ObservableObject {
if requiresSplit && !isSplit {
return
}
terminalPanel.triggerFlash()
terminalPanel.triggerFlash(color: focusFlashColor)
}
func triggerDebugFlash(panelId: UUID) {

View file

@ -2848,6 +2848,26 @@ final class WorkspaceTabColorSettingsTests: XCTestCase {
XCTAssertNotEqual(rendered.hexString(), originalHex)
XCTAssertGreaterThan(rendered.luminance, base.luminance)
}
func testWorkspaceFocusFlashColorUsesCustomHexWhenValid() {
let fallback = NSColor(hex: "#112233")!
let resolved = Workspace.resolvedFocusFlashColor(
customColorHex: " c0392b ",
fallback: fallback
)
XCTAssertEqual(resolved.hexString(), "#C0392B")
}
func testWorkspaceFocusFlashColorFallsBackWhenHexIsInvalid() {
let fallback = NSColor(hex: "#112233")!
let resolved = Workspace.resolvedFocusFlashColor(
customColorHex: "not-a-color",
fallback: fallback
)
XCTAssertEqual(resolved.hexString(), "#112233")
}
}
final class WorkspaceAutoReorderSettingsTests: XCTestCase {