From 711fa03db9e5b5467e2badabf20d167c93cdb816 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 10:51:01 +1200 Subject: [PATCH 01/69] lift fuzzy matcher to CmuxFoundation, add sidebar filter engine the command palette's fuzzy matcher was the right scorer for a sidebar workspace filter, but two domain packages cannot depend on each other, so it moves down to CmuxFoundation. CommandPaletteFuzzyMatcher stays as a typealias, so no palette call site changes and its reference-scoring tests still pin the same numbers. the filter itself is pure value logic in CmuxSidebar: a prepared index built once per corpus revision, scored per keystroke. fields are tried in priority order and the first hit wins, so a row matching on its title never pays to score its branch, directory and ports (worst-case keystroke 35.9ms -> 10.5ms at 500 all-matching workspaces; 2.8ms on a realistic 200-workspace corpus). rows are filtered, never reordered - a sidebar is spatial and re-sorting by score mid-type moves rows under the pointer. ranking rides along separately for return-to-jump. --- .../CommandPaletteSearchOrchestrator.swift | 1 + .../Search/CommandPaletteFuzzyMatcher.swift | 1093 +---------------- .../CommandPaletteSearchCorpusEntry.swift | 1 + .../Search/CommandPaletteSearchEngine.swift | 1 + .../Search/CommandPaletteSearchWordText.swift | 1 + .../CommandPaletteNucleoSearchIndex.swift | 1 + .../CmuxFoundation/Search/FuzzyMatcher.swift | 1091 ++++++++++++++++ .../Filter/SidebarFilterCandidate.swift | 37 + .../Filter/SidebarFilterCandidateField.swift | 39 + .../Filter/SidebarFilterField.swift | 70 ++ .../Filter/SidebarFilterGroup.swift | 30 + .../Filter/SidebarFilterIndex.swift | 152 +++ .../Filter/SidebarFilterMatch.swift | 93 ++ .../Filter/SidebarFilterOutcome.swift | 81 ++ .../Filter/SidebarFilterQuery.swift | 74 ++ .../SidebarFilterHighlightTests.swift | 80 ++ .../SidebarFilterIndexTests.swift | 223 ++++ .../SidebarFilterQueryTests.swift | 65 + .../SidebarFilterScaleTests.swift | 143 +++ 19 files changed, 2191 insertions(+), 1085 deletions(-) create mode 100644 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Search/FuzzyMatcher.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidate.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidateField.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterField.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterGroup.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterIndex.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterMatch.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterOutcome.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterQuery.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightTests.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterIndexTests.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterQueryTests.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterScaleTests.swift diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift index 853821ff5723..36fb0055fa12 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift @@ -1,3 +1,4 @@ +public import CmuxFoundation public import Foundation /// Orchestrates one palette search across both engines: prefers the nucleo diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift index 8ee5a91ae390..5bfaced186c8 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift @@ -1,1087 +1,10 @@ -import Foundation +public import CmuxFoundation -/// The Swift fuzzy matcher behind command-palette ranking: token scoring over -/// word segments with exact/prefix/contains/initialism/stitched-prefix and -/// single-edit fallbacks. Pure logic; scores are deterministic for a given -/// query/candidate pair. +/// The command palette's name for the shared ``FuzzyMatcher``. /// -/// A matcher is a value object bound to one prepared query: construct it with -/// `init(query:)` (or an already-prepared query) and score candidates through -/// the instance methods. The preparation/normalization building blocks remain -/// `static` factories on the value types they produce, per the foundation -/// helper conventions. -public struct CommandPaletteFuzzyMatcher { - /// The prepared query this matcher scores candidates against. - public let preparedQuery: PreparedQuery - - /// Prepares `query` for matching. - public init(query: String) { self.preparedQuery = Self.preparedQuery(query) } - - /// Binds an already-prepared query. - public init(preparedQuery: PreparedQuery) { self.preparedQuery = preparedQuery } - - /// Scores `candidate` against this matcher's query; nil when it does not - /// match. - public func score(candidate: String) -> Int? { - Self.score(query: preparedQuery.normalizedText, candidate: candidate) - } - - /// Scores `preparedCandidate` against this matcher's query. - public func score(preparedCandidate: PreparedCandidateText) -> Int? { - Self.score(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) - } - - /// Candidate character indices matched by this matcher's query. - public func matchCharacterIndices(in candidate: String) -> Set { - Self.matchCharacterIndices(preparedQuery: preparedQuery, candidate: candidate) - } - - /// Candidate character indices matched against a prepared candidate. - public func matchCharacterIndices(in preparedCandidate: PreparedCandidateText) -> Set { - Self.matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) - } - - private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - - /// Half-open `[start, end)` character range of one word in a candidate. - public struct WordSegment: Hashable, Sendable { - /// Index of the first character of the word. - public let start: Int - /// Index one past the last character of the word. - public let end: Int - } - - /// 128-bit presence mask of ASCII scalars used to cheaply prune - /// candidates that cannot contain a token's characters. - public struct ASCIIScalarMask: Equatable, Sendable { - /// Bits for scalars 0-63. - public let low: UInt64 - /// Bits for scalars 64-127. - public let high: UInt64 - - /// Creates a mask from raw bit halves. - public init(low: UInt64, high: UInt64) { - self.low = low - self.high = high - } - - /// Builds the mask from the ASCII scalars of `text`. - public init(_ text: String) { - var low: UInt64 = 0 - var high: UInt64 = 0 - for scalar in text.unicodeScalars where scalar.isASCII { - let value = Int(scalar.value) - if value < 64 { - low |= UInt64(1) << UInt64(value) - } else { - high |= UInt64(1) << UInt64(value - 64) - } - } - self.low = low - self.high = high - } - - /// Number of scalars present here but absent from `candidate`. - public func missingBitCount(from candidate: ASCIIScalarMask) -> Int { - (low & ~candidate.low).nonzeroBitCount + (high & ~candidate.high).nonzeroBitCount - } - } - - /// One normalized query token with precomputed characters, ASCII mask, - /// and score bounds. - public struct PreparedToken: Equatable, Sendable { - /// The normalized token text. - public let normalizedText: String - /// The token's characters in order. - public let characters: [Character] - /// ASCII presence mask for fast pruning. - public let asciiMask: ASCIIScalarMask - /// Whether single-edit (typo) fallback matching applies (length >= 4). - public let allowsSingleEdit: Bool - /// Whether the token contains a word-boundary character. - public let containsTokenBoundaryCharacter: Bool - /// Maximum achievable score for this token. - public let scoreUpperBound: Int - /// Maximum achievable score excluding an exact whole-text match. - public let scoreUpperBoundWithoutExactMatch: Int - - /// Prepares a normalized token for matching. - public init(_ normalizedText: String) { - self.normalizedText = normalizedText - self.characters = Array(normalizedText) - self.asciiMask = ASCIIScalarMask(normalizedText) - self.allowsSingleEdit = characters.count >= 4 - self.containsTokenBoundaryCharacter = characters.contains { - CommandPaletteFuzzyMatcher.tokenBoundaryChars.contains($0) - } - self.scoreUpperBound = max(8000, 3500 + (characters.count * 300)) - self.scoreUpperBoundWithoutExactMatch = max(6799, 3500 + (characters.count * 300)) - } - - /// Fast pre-check: whether `candidate` could possibly match this token - /// within the allowed edit budget. - public func couldMatch(_ candidate: PreparedCandidateText) -> Bool { - let missingCharacters = asciiMask.missingBitCount(from: candidate.asciiMask) - return missingCharacters <= (allowsSingleEdit ? 1 : 0) - } - } - - /// One normalized candidate string with precomputed characters, word - /// segments, and ASCII mask. - public struct PreparedCandidateText: Sendable { - /// The normalized candidate text. - public let normalizedText: String - /// The candidate's characters in order. - public let characters: [Character] - /// Word segments split on token-boundary characters. - public let wordSegments: [WordSegment] - /// ASCII presence mask for fast pruning. - public let asciiMask: ASCIIScalarMask - - /// Prepares a normalized candidate for matching. - public init(normalizedText: String) { - self.normalizedText = normalizedText - self.characters = Array(normalizedText) - self.wordSegments = CommandPaletteFuzzyMatcher.wordSegments(characters) - self.asciiMask = ASCIIScalarMask(normalizedText) - } - } - - private enum SingleEditWordPrefixEditKind { - case candidateExtraCharacter - case tokenExtraCharacter - case substitutedCharacter - case transposedCharacters - - var basePenalty: Int { - switch self { - case .candidateExtraCharacter: - return 0 - case .tokenExtraCharacter: - return 240 - case .transposedCharacters: - return 24 - case .substitutedCharacter: - return 40 - } - } - } - - private struct SingleEditWordPrefixMatch { - let matchedIndices: Set - let segmentStart: Int - let segmentLength: Int - let prefixLength: Int - let editPosition: Int - let editKind: SingleEditWordPrefixEditKind - } - - /// A normalized query split into prepared tokens. - public struct PreparedQuery { - /// The full normalized query text. - public let normalizedText: String - /// Normalized query tokens joined by single spaces. - public let normalizedTokenText: String - /// The prepared tokens, in query order. - public let tokens: [PreparedToken] - - /// Whether the query has no tokens. - public var isEmpty: Bool { - tokens.isEmpty - } - } - - /// Normalizes and tokenizes `query` for matching. - public static func preparedQuery(_ query: String) -> PreparedQuery { - let normalizedQuery = normalizeForSearch(query) - let tokens = normalizedQuery - .split(separator: " ") - .map(String.init) - .filter { !$0.isEmpty } - .map(PreparedToken.init) - return PreparedQuery( - normalizedText: normalizedQuery, - normalizedTokenText: tokens.map(\.normalizedText).joined(separator: " "), - tokens: tokens - ) - } - - /// Canonical search normalization: trim, diacritic-fold, case-fold. - public static func normalizeForSearch(_ text: String) -> String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - - /// Normalizes and prepares `candidate`, or nil when it normalizes to empty. - public static func prepareCandidateText(_ candidate: String) -> PreparedCandidateText? { - let normalizedCandidate = normalizeForSearch(candidate) - guard !normalizedCandidate.isEmpty else { return nil } - return PreparedCandidateText(normalizedText: normalizedCandidate) - } - - /// Prepares an already-normalized candidate, or nil when empty. - public static func prepareNormalizedCandidateText(_ normalizedCandidate: String) -> PreparedCandidateText? { - guard !normalizedCandidate.isEmpty else { return nil } - return PreparedCandidateText(normalizedText: normalizedCandidate) - } - - /// Scores `query` against a single candidate; nil when it does not match. - public static func score(query: String, candidate: String) -> Int? { - score(query: query, candidates: [candidate]) - } - - /// Scores `query` against multiple candidate texts; nil when any token fails. - public static func score(query: String, candidates: [String]) -> Int? { - let preparedQuery = preparedQuery(query) - var normalizedCandidates: [String] = [] - normalizedCandidates.reserveCapacity(candidates.count) - for candidate in candidates { - let normalizedCandidate = normalizeForSearch(candidate) - guard !normalizedCandidate.isEmpty else { continue } - normalizedCandidates.append(normalizedCandidate) - } - return score( - preparedQuery: preparedQuery, - normalizedCandidates: normalizedCandidates - ) - } - - /// Scores a prepared query against normalized candidate texts. - public static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { - score( - preparedQuery: preparedQuery, - preparedCandidates: normalizedCandidates.compactMap(prepareNormalizedCandidateText), - exactCandidateTexts: Set(normalizedCandidates) - ) - } - - /// Scores a prepared query against prepared candidates. - public static func score(preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText]) -> Int? { - score( - preparedQuery: preparedQuery, - preparedCandidates: preparedCandidates, - exactCandidateTexts: nil - ) - } - - /// Scores a prepared query against one prepared candidate. - public static func score(preparedQuery: PreparedQuery, preparedCandidate: PreparedCandidateText) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - - var totalScore = 0 - for token in preparedQuery.tokens { - guard token.couldMatch(preparedCandidate) else { return nil } - guard let tokenScore = scoreToken(token, in: preparedCandidate) else { return nil } - totalScore += tokenScore - } - return totalScore - } - - /// Full scoring entry point with exact-text and prefix-score fast paths. - public static func score( - preparedQuery: PreparedQuery, - preparedCandidates: [PreparedCandidateText], - exactCandidateTexts: Set?, - wholeCandidatePrefixScoreByToken: [String: Int]? = nil - ) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - guard !preparedCandidates.isEmpty else { return nil } - - var totalScore = 0 - for token in preparedQuery.tokens { - let hasExactCandidateText = exactCandidateTexts?.contains(token.normalizedText) == true - if token.scoreUpperBound == 8000, hasExactCandidateText { - totalScore += 8000 - continue - } - if exactCandidateTexts != nil, - !hasExactCandidateText, - let prefixScore = wholeCandidatePrefixScoreByToken?[token.normalizedText] - ?? bestWholeCandidatePrefixScore(token: token, preparedCandidates: preparedCandidates), - prefixScore >= token.scoreUpperBoundWithoutExactMatch { - totalScore += prefixScore - continue - } - - var bestTokenScore: Int? - for candidate in preparedCandidates { - guard token.couldMatch(candidate) else { continue } - guard let candidateScore = scoreToken(token, in: candidate) else { continue } - bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) - if bestTokenScore ?? 0 >= token.scoreUpperBound { - break - } - } - guard let bestTokenScore else { return nil } - totalScore += bestTokenScore - } - return totalScore - } - - private static func bestWholeCandidatePrefixScore( - token: PreparedToken, - preparedCandidates: [PreparedCandidateText] - ) -> Int? { - var bestScore: Int? - for candidate in preparedCandidates where candidate.normalizedText.hasPrefix(token.normalizedText) { - let score = 6800 - max(0, candidate.characters.count - token.characters.count) - bestScore = max(bestScore ?? score, score) - } - return bestScore - } - - /// Precomputes best whole-candidate prefix scores keyed by prefix text. - public static func wholeCandidatePrefixScoreByToken( - preparedCandidates: [PreparedCandidateText], - maxPrefixLength: Int = 16 - ) -> [String: Int] { - var scores: [String: Int] = [:] - for candidate in preparedCandidates { - let prefixLimit = min(candidate.characters.count, maxPrefixLength) - guard prefixLimit > 0 else { continue } - - for prefixLength in 1...prefixLimit { - let prefix = String(candidate.characters.prefix(prefixLength)) - let score = 6800 - max(0, candidate.characters.count - prefixLength) - if score > (scores[prefix] ?? Int.min) { - scores[prefix] = score - } - } - } - return scores - } - - /// Candidate character indices matched by `query`, for highlight rendering. - public static func matchCharacterIndices(query: String, candidate: String) -> Set { - matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) - } - - /// Candidate character indices matched by a prepared query. - public static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { - guard !preparedQuery.isEmpty else { return [] } - - guard let preparedCandidate = prepareCandidateText(candidate) else { return [] } - return matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) - } - - /// Candidate character indices matched by a prepared query against a - /// prepared candidate. - public static func matchCharacterIndices( - preparedQuery: PreparedQuery, - preparedCandidate: PreparedCandidateText - ) -> Set { - guard !preparedQuery.isEmpty else { return [] } - - let loweredCandidate = preparedCandidate.normalizedText - let candidateChars = preparedCandidate.characters - var matched: Set = [] - - for token in preparedQuery.tokens { - guard token.couldMatch(preparedCandidate) else { continue } - - if token.normalizedText == loweredCandidate { - matched.formUnion(0.. Bool { - guard !token.normalizedText.isEmpty else { return true } - - let candidateText = candidate.normalizedText - if token.normalizedText == candidateText { - return true - } - if candidateText.hasPrefix(token.normalizedText) { - return true - } - if candidateText.range(of: token.normalizedText) != nil { - return true - } - - guard !token.containsTokenBoundaryCharacter else { - return token.characters.count <= 3 && subsequenceScore(token: token, candidate: candidate) != nil - } - - if bestWordScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if initialismScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if stitchedWordPrefixScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if token.characters.count <= 3, subsequenceScore(token: token, candidate: candidate) != nil { - return true - } - return false - } - - /// Whether any token only matches these candidates via the single-edit - /// (typo) word-prefix fallback. - public static func usesSingleEditWordPrefix( - preparedQuery: PreparedQuery, - preparedCandidates: [PreparedCandidateText] - ) -> Bool { - for token in preparedQuery.tokens where token.allowsSingleEdit && !token.containsTokenBoundaryCharacter { - for candidate in preparedCandidates { - guard !tokenCanMatchWithoutSingleEdit(token, preparedCandidate: candidate) else { continue } - if singleEditWordPrefixMatch( - tokenChars: token.characters, - candidateChars: candidate.characters, - segments: candidate.wordSegments - ) != nil { - return true - } - } - } - return false - } - - private static func scoreToken(_ token: PreparedToken, in candidate: PreparedCandidateText) -> Int? { - guard !token.normalizedText.isEmpty else { return 0 } - - let candidateText = candidate.normalizedText - let candidateChars = candidate.characters - let tokenChars = token.characters - guard tokenChars.count <= candidateChars.count else { return nil } - - if token.normalizedText == candidateText { - return 8000 - } - if candidateText.hasPrefix(token.normalizedText) { - return 6800 - max(0, candidateChars.count - tokenChars.count) - } - - var bestScore: Int? - if !token.containsTokenBoundaryCharacter { - if let wordScore = bestWordScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? wordScore, wordScore) - } - if let singleEditPrefixScore = singleEditWordPrefixScore( - tokenChars: tokenChars, - candidate: candidate - ) { - bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) - } - } - - if let range = candidateText.range(of: token.normalizedText) { - let distance = candidateText.distance(from: candidateText.startIndex, to: range.lowerBound) - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) - let boundaryBoost: Int = { - guard distance > 0 else { return 220 } - let prior = candidateChars[distance - 1] - return tokenBoundaryChars.contains(prior) ? 180 : 0 - }() - let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty - bestScore = max(bestScore ?? containsScore, containsScore) - } - - if !token.containsTokenBoundaryCharacter { - if let initialismScore = initialismScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? initialismScore, initialismScore) - } - - if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? stitchedScore, stitchedScore) - } - } - - if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { - bestScore = max(bestScore ?? subsequence, subsequence) - } - - guard let bestScore else { return nil } - return max(1, bestScore) - } - - private static func bestWordScore( - tokenChars: [Character], - candidate: PreparedCandidateText - ) -> Int? { - guard !tokenChars.isEmpty else { return nil } - - let candidateChars = candidate.characters - var best: Int? - for segment in candidate.wordSegments { - let wordLength = segment.end - segment.start - guard tokenChars.count <= wordLength else { continue } - - var matchesPrefix = true - for offset in 0.. Int? { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidate.characters, - segments: candidate.wordSegments - ) else { - return nil - } - return singleEditWordPrefixScore(match: match, candidateLength: candidate.characters.count) - } - - private static func singleEditWordPrefixScore( - match: SingleEditWordPrefixMatch, - candidateLength: Int - ) -> Int { - let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 - let distancePenalty = match.segmentStart * 8 - let trailingPenalty = max(0, candidateLength - match.segmentLength) - let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 - return 5000 - - match.editKind.basePenalty - - distancePenalty - - lengthPenalty - - trailingPenalty - - editPositionPenalty - } - - private static func initialismScore(tokenChars: [Character], candidate: PreparedCandidateText) -> Int? { - guard !tokenChars.isEmpty else { return nil } - let candidateChars = candidate.characters - let segments = candidate.wordSegments - guard tokenChars.count <= segments.count else { return nil } - - var matchedStarts: [Int] = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matchedStarts.append(segment.start) - found = true - break - } - } - if !found { return nil } - } - - let firstStart = matchedStarts.first ?? 0 - let skippedWords = max(0, segments.count - tokenChars.count) - return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) - } - - private static func tokenPrefixMatches( - tokenChars: [Character], - tokenStart: Int, - length: Int, - candidateChars: [Character], - candidateStart: Int - ) -> Bool { - guard length >= 0 else { return false } - guard tokenStart + length <= tokenChars.count else { return false } - guard candidateStart + length <= candidateChars.count else { return false } - guard length > 0 else { return true } - - for offset in 0.. Int? { - guard tokenChars.count >= 4 else { return nil } - let candidateChars = candidate.characters - let segments = candidate.wordSegments - guard segments.count >= 2 else { return nil } - - struct StitchState: Hashable { - let tokenIndex: Int - let wordIndex: Int - let usedWords: Int - } - - var memo: [StitchState: Int?] = [:] - - func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { - if tokenIndex == tokenChars.count { - return usedWords >= 2 ? 0 : nil - } - guard wordIndex < segments.count else { return nil } - - let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) - if let cached = memo[state] { - return cached - } - - var best: Int? - let remainingChars = tokenChars.count - tokenIndex - for segmentIndex in wordIndex.. 0 else { continue } - - let skippedWords = max(0, segmentIndex - wordIndex) - let skipPenalty = skippedWords * 120 - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - guard let suffixScore = dfs( - tokenIndex: tokenIndex + chunkLength, - wordIndex: segmentIndex + 1, - usedWords: min(2, usedWords + 1) - ) else { - continue - } - - let chunkCoverage = chunkLength * 220 - let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 - let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 - let distancePenalty = segment.start * 4 - let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty - let totalScore = suffixScore + chunkScore - best = max(best ?? totalScore, totalScore) - } - } - - memo[state] = best - return best - } - - guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) - return 3500 + stitchedScore - lengthPenalty - } - - private static func stitchedWordPrefixMatchIndices( - token: PreparedToken, - candidate: PreparedCandidateText - ) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters - guard tokenChars.count >= 4 else { return nil } - - let segments = candidate.wordSegments - guard segments.count >= 2 else { return nil } - - var tokenIndex = 0 - var nextWordIndex = 0 - var usedWords = 0 - var matchedIndices: Set = [] - - while tokenIndex < tokenChars.count { - let remainingChars = tokenChars.count - tokenIndex - var foundMatch = false - - for segmentIndex in nextWordIndex.. 0 else { continue } - - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - - matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) - tokenIndex += chunkLength - nextWordIndex = segmentIndex + 1 - usedWords += 1 - foundMatch = true - break - } - - if foundMatch { break } - } - - if !foundMatch { return nil } - } - - guard usedWords >= 2 else { return nil } - return matchedIndices - } - - private static func singleEditWordPrefixMatch( - token: String, - candidate: String - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: Array(token), - candidateChars: Array(candidate) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character] - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segments: wordSegments(candidateChars) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segments: [WordSegment] - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - var bestMatch: SingleEditWordPrefixMatch? - var bestScore: Int? - - for segment in segments { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segment: segment - ) else { - continue - } - - let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - if let bestScore, score <= bestScore { - continue - } - bestScore = score - bestMatch = match - } - - return bestMatch - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segment: WordSegment - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - let segmentLength = segment.end - segment.start - guard segmentLength + 1 >= tokenChars.count else { return nil } - - let exactPrefixLength = min(tokenChars.count, segmentLength) - var mismatchOffset = 0 - while mismatchOffset < exactPrefixLength, - candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] - { - mismatchOffset += 1 - } - - if mismatchOffset == tokenChars.count { - let prefixLength = tokenChars.count + 1 - guard segmentLength >= prefixLength else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + tokenChars.count, - editKind: .candidateExtraCharacter - ) - } - - if mismatchOffset == segmentLength { - let prefixLength = tokenChars.count - 1 - guard prefixLength > 0 else { return nil } - guard tokenChars.count == segmentLength + 1 else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + prefixLength)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + prefixLength, - editKind: .tokenExtraCharacter - ) - } - - let mismatchCandidateIndex = segment.start + mismatchOffset - - if segmentLength >= tokenChars.count + 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset, - length: tokenChars.count - mismatchOffset, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count + 1, - editPosition: mismatchCandidateIndex, - editKind: .candidateExtraCharacter - ) - } - - if tokenChars.count >= 2, - segmentLength >= tokenChars.count - 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count - 1, - editPosition: mismatchCandidateIndex, - editKind: .tokenExtraCharacter - ) - } - - if segmentLength >= tokenChars.count, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .substitutedCharacter - ) - } - - if segmentLength >= tokenChars.count, - mismatchOffset + 1 < tokenChars.count, - mismatchCandidateIndex + 1 < segment.end, - tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], - tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 2, - length: tokenChars.count - mismatchOffset - 2, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 2 - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .transposedCharacters - ) - } - - return nil - } - - private static func wordSegments(_ candidateChars: [Character]) -> [WordSegment] { - var segments: [WordSegment] = [] - var index = 0 - - while index < candidateChars.count { - while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - guard index < candidateChars.count else { break } - let start = index - while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - segments.append(WordSegment(start: start, end: index)) - } - - return segments - } - - private static func subsequenceScore(token: PreparedToken, candidate: PreparedCandidateText) -> Int? { - let tokenChars = token.characters - let candidateChars = candidate.characters - guard tokenChars.count <= candidateChars.count else { return nil } - - var searchIndex = 0 - var previousMatch = -1 - var consecutiveRun = 0 - var score = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchedIndex = foundIndex else { return nil } - - score += 90 - if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { - score += 140 - } - if matchedIndex == previousMatch + 1 { - consecutiveRun += 1 - score += min(200, consecutiveRun * 45) - } else { - consecutiveRun = 0 - score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) - } - - previousMatch = matchedIndex - searchIndex = matchedIndex + 1 - } - - score -= max(0, candidateChars.count - tokenChars.count) - return max(1, score) - } - - private static func subsequenceMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters - guard tokenChars.count <= candidateChars.count else { return nil } - - var indices: Set = [] - var searchIndex = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchIndex = foundIndex else { return nil } - indices.insert(matchIndex) - searchIndex = matchIndex + 1 - } - - return indices - } - - private static func initialismMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters - guard !tokenChars.isEmpty else { return nil } - - let segments = candidate.wordSegments - guard tokenChars.count <= segments.count else { return nil } - - var matched: Set = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matched.insert(segment.start) - found = true - break - } - } - if !found { return nil } - } - - return matched - } -} +/// The matcher moved down to `CmuxFoundation` when the sidebar workspace +/// filter needed the same scoring: two domain packages may not depend on each +/// other, so the shared primitive lifts to the common lower package. This +/// alias keeps every palette call site (and its reference-scoring tests) +/// spelled as before. +public typealias CommandPaletteFuzzyMatcher = FuzzyMatcher diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift index 5ed290f136a6..b45b1fa71e5c 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift @@ -1,3 +1,4 @@ +public import CmuxFoundation import Foundation /// One searchable palette entry: a payload plus precomputed normalized diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift index e2e5e8aa8fda..fcb2e6ca25b3 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift @@ -1,3 +1,4 @@ +public import CmuxFoundation import Foundation /// Pure Swift ranking engine over a prepared corpus: scores entries with diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchWordText.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchWordText.swift index f0e35ffabef8..5fc7d00f0fb0 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchWordText.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchWordText.swift @@ -1,3 +1,4 @@ +public import CmuxFoundation import Foundation func commandPaletteNormalizedSearchWordText( diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift index 08483aaba540..131864e7a5de 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift @@ -1,3 +1,4 @@ +public import CmuxFoundation import Foundation // Sendable is safe here because the Swift payload entries are immutable, the diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Search/FuzzyMatcher.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Search/FuzzyMatcher.swift new file mode 100644 index 000000000000..44975498d376 --- /dev/null +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Search/FuzzyMatcher.swift @@ -0,0 +1,1091 @@ +import Foundation + +/// The shared Swift fuzzy matcher: token scoring over word segments with +/// exact/prefix/contains/initialism/stitched-prefix and single-edit +/// fallbacks. Pure logic; scores are deterministic for a given +/// query/candidate pair. +/// +/// Lives in `CmuxFoundation` because more than one domain ranks text against +/// a typed query - command-palette results and the sidebar workspace filter +/// both score through it, and neither may depend on the other. +/// +/// A matcher is a value object bound to one prepared query: construct it with +/// `init(query:)` (or an already-prepared query) and score candidates through +/// the instance methods. The preparation/normalization building blocks remain +/// `static` factories on the value types they produce, per the foundation +/// helper conventions. +public struct FuzzyMatcher { + /// The prepared query this matcher scores candidates against. + public let preparedQuery: PreparedQuery + + /// Prepares `query` for matching. + public init(query: String) { self.preparedQuery = Self.preparedQuery(query) } + + /// Binds an already-prepared query. + public init(preparedQuery: PreparedQuery) { self.preparedQuery = preparedQuery } + + /// Scores `candidate` against this matcher's query; nil when it does not + /// match. + public func score(candidate: String) -> Int? { + Self.score(query: preparedQuery.normalizedText, candidate: candidate) + } + + /// Scores `preparedCandidate` against this matcher's query. + public func score(preparedCandidate: PreparedCandidateText) -> Int? { + Self.score(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) + } + + /// Candidate character indices matched by this matcher's query. + public func matchCharacterIndices(in candidate: String) -> Set { + Self.matchCharacterIndices(preparedQuery: preparedQuery, candidate: candidate) + } + + /// Candidate character indices matched against a prepared candidate. + public func matchCharacterIndices(in preparedCandidate: PreparedCandidateText) -> Set { + Self.matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) + } + + private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + + /// Half-open `[start, end)` character range of one word in a candidate. + public struct WordSegment: Hashable, Sendable { + /// Index of the first character of the word. + public let start: Int + /// Index one past the last character of the word. + public let end: Int + } + + /// 128-bit presence mask of ASCII scalars used to cheaply prune + /// candidates that cannot contain a token's characters. + public struct ASCIIScalarMask: Equatable, Sendable { + /// Bits for scalars 0-63. + public let low: UInt64 + /// Bits for scalars 64-127. + public let high: UInt64 + + /// Creates a mask from raw bit halves. + public init(low: UInt64, high: UInt64) { + self.low = low + self.high = high + } + + /// Builds the mask from the ASCII scalars of `text`. + public init(_ text: String) { + var low: UInt64 = 0 + var high: UInt64 = 0 + for scalar in text.unicodeScalars where scalar.isASCII { + let value = Int(scalar.value) + if value < 64 { + low |= UInt64(1) << UInt64(value) + } else { + high |= UInt64(1) << UInt64(value - 64) + } + } + self.low = low + self.high = high + } + + /// Number of scalars present here but absent from `candidate`. + public func missingBitCount(from candidate: ASCIIScalarMask) -> Int { + (low & ~candidate.low).nonzeroBitCount + (high & ~candidate.high).nonzeroBitCount + } + } + + /// One normalized query token with precomputed characters, ASCII mask, + /// and score bounds. + public struct PreparedToken: Equatable, Sendable { + /// The normalized token text. + public let normalizedText: String + /// The token's characters in order. + public let characters: [Character] + /// ASCII presence mask for fast pruning. + public let asciiMask: ASCIIScalarMask + /// Whether single-edit (typo) fallback matching applies (length >= 4). + public let allowsSingleEdit: Bool + /// Whether the token contains a word-boundary character. + public let containsTokenBoundaryCharacter: Bool + /// Maximum achievable score for this token. + public let scoreUpperBound: Int + /// Maximum achievable score excluding an exact whole-text match. + public let scoreUpperBoundWithoutExactMatch: Int + + /// Prepares a normalized token for matching. + public init(_ normalizedText: String) { + self.normalizedText = normalizedText + self.characters = Array(normalizedText) + self.asciiMask = ASCIIScalarMask(normalizedText) + self.allowsSingleEdit = characters.count >= 4 + self.containsTokenBoundaryCharacter = characters.contains { + FuzzyMatcher.tokenBoundaryChars.contains($0) + } + self.scoreUpperBound = max(8000, 3500 + (characters.count * 300)) + self.scoreUpperBoundWithoutExactMatch = max(6799, 3500 + (characters.count * 300)) + } + + /// Fast pre-check: whether `candidate` could possibly match this token + /// within the allowed edit budget. + public func couldMatch(_ candidate: PreparedCandidateText) -> Bool { + let missingCharacters = asciiMask.missingBitCount(from: candidate.asciiMask) + return missingCharacters <= (allowsSingleEdit ? 1 : 0) + } + } + + /// One normalized candidate string with precomputed characters, word + /// segments, and ASCII mask. + public struct PreparedCandidateText: Sendable { + /// The normalized candidate text. + public let normalizedText: String + /// The candidate's characters in order. + public let characters: [Character] + /// Word segments split on token-boundary characters. + public let wordSegments: [WordSegment] + /// ASCII presence mask for fast pruning. + public let asciiMask: ASCIIScalarMask + + /// Prepares a normalized candidate for matching. + public init(normalizedText: String) { + self.normalizedText = normalizedText + self.characters = Array(normalizedText) + self.wordSegments = FuzzyMatcher.wordSegments(characters) + self.asciiMask = ASCIIScalarMask(normalizedText) + } + } + + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 240 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { + let matchedIndices: Set + let segmentStart: Int + let segmentLength: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind + } + + /// A normalized query split into prepared tokens. + public struct PreparedQuery { + /// The full normalized query text. + public let normalizedText: String + /// Normalized query tokens joined by single spaces. + public let normalizedTokenText: String + /// The prepared tokens, in query order. + public let tokens: [PreparedToken] + + /// Whether the query has no tokens. + public var isEmpty: Bool { + tokens.isEmpty + } + } + + /// Normalizes and tokenizes `query` for matching. + public static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + let tokens = normalizedQuery + .split(separator: " ") + .map(String.init) + .filter { !$0.isEmpty } + .map(PreparedToken.init) + return PreparedQuery( + normalizedText: normalizedQuery, + normalizedTokenText: tokens.map(\.normalizedText).joined(separator: " "), + tokens: tokens + ) + } + + /// Canonical search normalization: trim, diacritic-fold, case-fold. + public static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + /// Normalizes and prepares `candidate`, or nil when it normalizes to empty. + public static func prepareCandidateText(_ candidate: String) -> PreparedCandidateText? { + let normalizedCandidate = normalizeForSearch(candidate) + guard !normalizedCandidate.isEmpty else { return nil } + return PreparedCandidateText(normalizedText: normalizedCandidate) + } + + /// Prepares an already-normalized candidate, or nil when empty. + public static func prepareNormalizedCandidateText(_ normalizedCandidate: String) -> PreparedCandidateText? { + guard !normalizedCandidate.isEmpty else { return nil } + return PreparedCandidateText(normalizedText: normalizedCandidate) + } + + /// Scores `query` against a single candidate; nil when it does not match. + public static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + /// Scores `query` against multiple candidate texts; nil when any token fails. + public static func score(query: String, candidates: [String]) -> Int? { + let preparedQuery = preparedQuery(query) + var normalizedCandidates: [String] = [] + normalizedCandidates.reserveCapacity(candidates.count) + for candidate in candidates { + let normalizedCandidate = normalizeForSearch(candidate) + guard !normalizedCandidate.isEmpty else { continue } + normalizedCandidates.append(normalizedCandidate) + } + return score( + preparedQuery: preparedQuery, + normalizedCandidates: normalizedCandidates + ) + } + + /// Scores a prepared query against normalized candidate texts. + public static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + score( + preparedQuery: preparedQuery, + preparedCandidates: normalizedCandidates.compactMap(prepareNormalizedCandidateText), + exactCandidateTexts: Set(normalizedCandidates) + ) + } + + /// Scores a prepared query against prepared candidates. + public static func score(preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText]) -> Int? { + score( + preparedQuery: preparedQuery, + preparedCandidates: preparedCandidates, + exactCandidateTexts: nil + ) + } + + /// Scores a prepared query against one prepared candidate. + public static func score(preparedQuery: PreparedQuery, preparedCandidate: PreparedCandidateText) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + + var totalScore = 0 + for token in preparedQuery.tokens { + guard token.couldMatch(preparedCandidate) else { return nil } + guard let tokenScore = scoreToken(token, in: preparedCandidate) else { return nil } + totalScore += tokenScore + } + return totalScore + } + + /// Full scoring entry point with exact-text and prefix-score fast paths. + public static func score( + preparedQuery: PreparedQuery, + preparedCandidates: [PreparedCandidateText], + exactCandidateTexts: Set?, + wholeCandidatePrefixScoreByToken: [String: Int]? = nil + ) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + guard !preparedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in preparedQuery.tokens { + let hasExactCandidateText = exactCandidateTexts?.contains(token.normalizedText) == true + if token.scoreUpperBound == 8000, hasExactCandidateText { + totalScore += 8000 + continue + } + if exactCandidateTexts != nil, + !hasExactCandidateText, + let prefixScore = wholeCandidatePrefixScoreByToken?[token.normalizedText] + ?? bestWholeCandidatePrefixScore(token: token, preparedCandidates: preparedCandidates), + prefixScore >= token.scoreUpperBoundWithoutExactMatch { + totalScore += prefixScore + continue + } + + var bestTokenScore: Int? + for candidate in preparedCandidates { + guard token.couldMatch(candidate) else { continue } + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + if bestTokenScore ?? 0 >= token.scoreUpperBound { + break + } + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + private static func bestWholeCandidatePrefixScore( + token: PreparedToken, + preparedCandidates: [PreparedCandidateText] + ) -> Int? { + var bestScore: Int? + for candidate in preparedCandidates where candidate.normalizedText.hasPrefix(token.normalizedText) { + let score = 6800 - max(0, candidate.characters.count - token.characters.count) + bestScore = max(bestScore ?? score, score) + } + return bestScore + } + + /// Precomputes best whole-candidate prefix scores keyed by prefix text. + public static func wholeCandidatePrefixScoreByToken( + preparedCandidates: [PreparedCandidateText], + maxPrefixLength: Int = 16 + ) -> [String: Int] { + var scores: [String: Int] = [:] + for candidate in preparedCandidates { + let prefixLimit = min(candidate.characters.count, maxPrefixLength) + guard prefixLimit > 0 else { continue } + + for prefixLength in 1...prefixLimit { + let prefix = String(candidate.characters.prefix(prefixLength)) + let score = 6800 - max(0, candidate.characters.count - prefixLength) + if score > (scores[prefix] ?? Int.min) { + scores[prefix] = score + } + } + } + return scores + } + + /// Candidate character indices matched by `query`, for highlight rendering. + public static func matchCharacterIndices(query: String, candidate: String) -> Set { + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } + + /// Candidate character indices matched by a prepared query. + public static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + guard !preparedQuery.isEmpty else { return [] } + + guard let preparedCandidate = prepareCandidateText(candidate) else { return [] } + return matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) + } + + /// Candidate character indices matched by a prepared query against a + /// prepared candidate. + public static func matchCharacterIndices( + preparedQuery: PreparedQuery, + preparedCandidate: PreparedCandidateText + ) -> Set { + guard !preparedQuery.isEmpty else { return [] } + + let loweredCandidate = preparedCandidate.normalizedText + let candidateChars = preparedCandidate.characters + var matched: Set = [] + + for token in preparedQuery.tokens { + guard token.couldMatch(preparedCandidate) else { continue } + + if token.normalizedText == loweredCandidate { + matched.formUnion(0.. Bool { + guard !token.normalizedText.isEmpty else { return true } + + let candidateText = candidate.normalizedText + if token.normalizedText == candidateText { + return true + } + if candidateText.hasPrefix(token.normalizedText) { + return true + } + if candidateText.range(of: token.normalizedText) != nil { + return true + } + + guard !token.containsTokenBoundaryCharacter else { + return token.characters.count <= 3 && subsequenceScore(token: token, candidate: candidate) != nil + } + + if bestWordScore(tokenChars: token.characters, candidate: candidate) != nil { + return true + } + if initialismScore(tokenChars: token.characters, candidate: candidate) != nil { + return true + } + if stitchedWordPrefixScore(tokenChars: token.characters, candidate: candidate) != nil { + return true + } + if token.characters.count <= 3, subsequenceScore(token: token, candidate: candidate) != nil { + return true + } + return false + } + + /// Whether any token only matches these candidates via the single-edit + /// (typo) word-prefix fallback. + public static func usesSingleEditWordPrefix( + preparedQuery: PreparedQuery, + preparedCandidates: [PreparedCandidateText] + ) -> Bool { + for token in preparedQuery.tokens where token.allowsSingleEdit && !token.containsTokenBoundaryCharacter { + for candidate in preparedCandidates { + guard !tokenCanMatchWithoutSingleEdit(token, preparedCandidate: candidate) else { continue } + if singleEditWordPrefixMatch( + tokenChars: token.characters, + candidateChars: candidate.characters, + segments: candidate.wordSegments + ) != nil { + return true + } + } + } + return false + } + + private static func scoreToken(_ token: PreparedToken, in candidate: PreparedCandidateText) -> Int? { + guard !token.normalizedText.isEmpty else { return 0 } + + let candidateText = candidate.normalizedText + let candidateChars = candidate.characters + let tokenChars = token.characters + guard tokenChars.count <= candidateChars.count else { return nil } + + if token.normalizedText == candidateText { + return 8000 + } + if candidateText.hasPrefix(token.normalizedText) { + return 6800 - max(0, candidateChars.count - tokenChars.count) + } + + var bestScore: Int? + if !token.containsTokenBoundaryCharacter { + if let wordScore = bestWordScore(tokenChars: tokenChars, candidate: candidate) { + bestScore = max(bestScore ?? wordScore, wordScore) + } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidate: candidate + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) + } + } + + if let range = candidateText.range(of: token.normalizedText) { + let distance = candidateText.distance(from: candidateText.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if !token.containsTokenBoundaryCharacter { + if let initialismScore = initialismScore(tokenChars: tokenChars, candidate: candidate) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidate: candidate) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidate: PreparedCandidateText + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + let candidateChars = candidate.characters + var best: Int? + for segment in candidate.wordSegments { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0.. Int? { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidate.characters, + segments: candidate.wordSegments + ) else { + return nil + } + return singleEditWordPrefixScore(match: match, candidateLength: candidate.characters.count) + } + + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty + } + + private static func initialismScore(tokenChars: [Character], candidate: PreparedCandidateText) -> Int? { + guard !tokenChars.isEmpty else { return nil } + let candidateChars = candidate.characters + let segments = candidate.wordSegments + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length >= 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } + + for offset in 0.. Int? { + guard tokenChars.count >= 4 else { return nil } + let candidateChars = candidate.characters + let segments = candidate.wordSegments + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex.. 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices( + token: PreparedToken, + candidate: PreparedCandidateText + ) -> Set? { + let tokenChars = token.characters + let candidateChars = candidate.characters + guard tokenChars.count >= 4 else { return nil } + + let segments = candidate.wordSegments + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex.. 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func singleEditWordPrefixMatch( + token: String, + candidate: String + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segments: wordSegments(candidateChars) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segments: [WordSegment] + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleEditWordPrefixMatch? + var bestScore: Int? + + for segment in segments { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: WordSegment + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength + 1 >= tokenChars.count else { return nil } + + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 + } + + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) + } + + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil + } + + private static func wordSegments(_ candidateChars: [Character]) -> [WordSegment] { + var segments: [WordSegment] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append(WordSegment(start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: PreparedToken, candidate: PreparedCandidateText) -> Int? { + let tokenChars = token.characters + let candidateChars = candidate.characters + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { + let tokenChars = token.characters + let candidateChars = candidate.characters + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { + let tokenChars = token.characters + let candidateChars = candidate.characters + guard !tokenChars.isEmpty else { return nil } + + let segments = candidate.wordSegments + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidate.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidate.swift new file mode 100644 index 000000000000..adc81b9f5381 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidate.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// One workspace's searchable text, prepared for repeated matching. +/// +/// Built once per corpus revision by the owner of the workspace list and +/// re-scored on every keystroke. Carries only value types: no `Workspace` +/// reference crosses into the filter, so scoring stays pure and testable. +public struct SidebarFilterCandidate: Sendable, Identifiable { + /// The workspace this candidate represents. + public let id: UUID + /// The group the workspace belongs to, if any. + public let groupId: UUID? + /// Whether this workspace is its group's anchor (rendered as the header). + public let isGroupAnchor: Bool + /// Every prepared field this workspace can be matched on. + public let fields: [SidebarFilterCandidateField] + + /// Creates a candidate for one workspace. + /// + /// - Parameters: + /// - id: The workspace id. + /// - groupId: The workspace's group, or nil when ungrouped. + /// - isGroupAnchor: Whether the workspace renders as its group's header. + /// - fields: Prepared searchable fields; empty fields should be omitted + /// by the caller rather than passed as empty strings. + public init( + id: UUID, + groupId: UUID? = nil, + isGroupAnchor: Bool = false, + fields: [SidebarFilterCandidateField] + ) { + self.id = id + self.groupId = groupId + self.isGroupAnchor = isGroupAnchor + self.fields = fields + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidateField.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidateField.swift new file mode 100644 index 000000000000..5603aa821ec2 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidateField.swift @@ -0,0 +1,39 @@ +public import CmuxFoundation + +/// One searchable field of one sidebar row, prepared for repeated matching. +/// +/// Preparation (normalize, split into characters and word segments, build the +/// ASCII mask) is the expensive half of fuzzy matching, and it depends only on +/// the candidate - not on the query. Building these once per corpus revision +/// and reusing them across keystrokes is what keeps per-character filtering off +/// the main thread's critical path. +public struct SidebarFilterCandidateField: Sendable { + /// Which row field this text came from. + public let field: SidebarFilterField + /// The text as the row displays it, used to map match indices back to UI. + public let displayText: String + /// The normalized, pre-segmented form the matcher scores against. + public let prepared: FuzzyMatcher.PreparedCandidateText + /// Whether match offsets in normalized coordinates also address + /// `displayText`. + /// + /// Search normalization case-folds and strips diacritics, which can change + /// a string's character count. When it does, a matched offset no longer + /// points at the same character in the displayed string, so the engine + /// drops that field's highlight data rather than underlining the wrong + /// characters. Scoring is unaffected either way. + public let isDisplayIndexAligned: Bool + + /// Prepares `displayText` for matching as `field`. + /// + /// - Parameters: + /// - field: The row field this text is displayed in. + /// - displayText: The text exactly as the row renders it. + public init(field: SidebarFilterField, displayText: String) { + self.field = field + self.displayText = displayText + let normalizedText = FuzzyMatcher.normalizeForSearch(displayText) + self.prepared = FuzzyMatcher.PreparedCandidateText(normalizedText: normalizedText) + self.isDisplayIndexAligned = normalizedText.count == displayText.count + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterField.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterField.swift new file mode 100644 index 000000000000..b3f35a28e78c --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterField.swift @@ -0,0 +1,70 @@ +/// One searchable text field on a sidebar workspace row. +/// +/// The sidebar filter scores a query against the fields a row can display, so +/// typing a branch name finds a workspace whose title says nothing about the +/// branch. Fields are tried in ``matchPriority`` order and the first hit wins. +public enum SidebarFilterField: String, CaseIterable, Sendable, Hashable { + /// The workspace's displayed title (custom or auto-derived). + case title + /// The workspace's git branch. + case branch + /// The workspace's working directory path. + case directory + /// The name of the group the workspace belongs to. + case group + /// The linked pull request's number and title. + case pullRequest + /// A port the workspace is listening on. + case port + + /// Priority of this field when a row matches on more than one. + /// + /// Fields are scored in descending priority and the first hit wins, so a + /// row that matches on its title never pays to score its directory. That + /// is both faster and more predictable than taking the maximum score + /// across fields: "the title matched" always beats "the path happened to + /// contain those letters", regardless of how the fuzzy scores compare. + /// + /// Ranking between rows is lexicographic on `(matchPriority, fuzzyScore)`. + public var matchPriority: Int { + switch self { + case .title: + return 60 + case .branch: + return 50 + case .directory: + return 40 + case .group: + return 30 + case .pullRequest: + return 20 + case .port: + return 10 + } + } + + /// Every field in the order the filter scores them. + public static var scoringOrder: [SidebarFilterField] { + allCases.sorted { $0.matchPriority > $1.matchPriority } + } + + /// The single-character prefix that restricts a query to this field, if any. + /// + /// Typing `@main` searches branches only; `#infra` searches group names; + /// `:3000` searches ports; `/repos` searches directories. Titles have no + /// sigil because an unprefixed query already searches every field. + public var querySigil: Character? { + switch self { + case .branch: + return "@" + case .group: + return "#" + case .port: + return ":" + case .directory: + return "/" + case .title, .pullRequest: + return nil + } + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterGroup.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterGroup.swift new file mode 100644 index 000000000000..945412b162db --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterGroup.swift @@ -0,0 +1,30 @@ +public import CmuxFoundation +public import Foundation + +/// One workspace group's searchable name plus the membership the filter needs +/// to decide whether the group's header survives filtering. +public struct SidebarFilterGroup: Sendable, Identifiable { + /// The group id. + public let id: UUID + /// The workspace rendered as this group's header row. + public let anchorWorkspaceId: UUID + /// The group's display name. + public let name: String + /// The normalized, pre-segmented group name. + public let prepared: FuzzyMatcher.PreparedCandidateText + + /// Creates a searchable group. + /// + /// - Parameters: + /// - id: The group id. + /// - anchorWorkspaceId: The workspace whose row is the group header. + /// - name: The group's display name. + public init(id: UUID, anchorWorkspaceId: UUID, name: String) { + self.id = id + self.anchorWorkspaceId = anchorWorkspaceId + self.name = name + self.prepared = FuzzyMatcher.PreparedCandidateText( + normalizedText: FuzzyMatcher.normalizeForSearch(name) + ) + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterIndex.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterIndex.swift new file mode 100644 index 000000000000..472edff4f515 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterIndex.swift @@ -0,0 +1,152 @@ +public import CmuxFoundation +public import Foundation + +/// The prepared corpus the sidebar filter scores against. +/// +/// Built once per corpus revision (a workspace added, renamed, moved group, or +/// its branch/directory changed) and re-scored on every keystroke. Holding the +/// index across keystrokes is the whole performance story: preparation is +/// O(text) and scoring is O(query), so typing costs only the latter. +/// +/// ```swift +/// let index = SidebarFilterIndex(candidates: candidates, groups: groups) +/// let outcome = index.outcome(for: SidebarFilterQuery("@drag")) +/// ``` +public struct SidebarFilterIndex: Sendable { + /// Every workspace's prepared searchable text, in sidebar order. + public let candidates: [SidebarFilterCandidate] + /// Every group's prepared name, keyed for membership lookups. + public let groupsById: [UUID: SidebarFilterGroup] + + /// Builds an index over a workspace list and its groups. + /// + /// - Parameters: + /// - candidates: Prepared workspaces in the order the sidebar renders + /// them; the order is preserved in the outcome. + /// - groups: Prepared groups; order is irrelevant. + public init(candidates: [SidebarFilterCandidate], groups: [SidebarFilterGroup]) { + self.candidates = candidates + self.groupsById = Dictionary(uniqueKeysWithValues: groups.map { ($0.id, $0) }) + } + + /// Scores every candidate against `query`. + /// + /// A group survives when its own name matches (which keeps every member + /// with it, so a group is browsable by name) or when any member matches + /// (which keeps the header for context and forces the group open). + /// + /// Cost is one fuzzy score per candidate in the common case: scoring stops + /// at the highest-priority field that matches, and the matcher's ASCII mask + /// prunes non-matching candidates before any token work. + /// + /// - Parameter query: The parsed filter query. + /// - Returns: The rows to show, the groups to force open, and per-row match + /// detail. Returns ``SidebarFilterOutcome/inactive`` for an empty query. + public func outcome(for query: SidebarFilterQuery) -> SidebarFilterOutcome { + guard let matcher = query.makeMatcher() else { return .inactive } + let scoredFields = Set(query.fields) + + var matchedGroupIds: Set = [] + if scoredFields.contains(.group) { + for group in groupsById.values + where matcher.score(preparedCandidate: group.prepared) != nil { + matchedGroupIds.insert(group.id) + } + } + + var matchesByWorkspaceId: [UUID: SidebarFilterMatch] = [:] + var orderedMatchWorkspaceIds: [UUID] = [] + var visibleWorkspaceIds: Set = [] + var expandedGroupIds: Set = [] + var bestMatch: SidebarFilterMatch? + + for candidate in candidates { + let inMatchedGroup = candidate.groupId.map(matchedGroupIds.contains) ?? false + let match = self.match(candidate: candidate, matcher: matcher, fields: scoredFields) + + if let match { + matchesByWorkspaceId[candidate.id] = match + orderedMatchWorkspaceIds.append(candidate.id) + if bestMatch.map(match.outranks) ?? true { + bestMatch = match + } + } + + guard match != nil || inMatchedGroup else { continue } + visibleWorkspaceIds.insert(candidate.id) + guard let groupId = candidate.groupId, let group = groupsById[groupId] else { continue } + // Keep the header so a surviving member is not stranded under a + // group that filtered itself away. + visibleWorkspaceIds.insert(group.anchorWorkspaceId) + // Open the group only when something *inside* it needs reaching. An + // anchor matching on its own title is the header itself, and + // force-opening a group because the user typed its header's name + // would fight the collapse they chose. + if !candidate.isGroupAnchor { + expandedGroupIds.insert(groupId) + } + } + + // A group whose name matched stays visible even with no members, so + // typing a group name never blanks the sidebar mid-word, and it opens: + // asking for a group by name is asking to see what is in it. + for groupId in matchedGroupIds { + guard let group = groupsById[groupId] else { continue } + visibleWorkspaceIds.insert(group.anchorWorkspaceId) + expandedGroupIds.insert(groupId) + } + + return SidebarFilterOutcome( + isFiltering: true, + visibleWorkspaceIds: visibleWorkspaceIds, + expandedGroupIds: expandedGroupIds, + matchesByWorkspaceId: matchesByWorkspaceId, + orderedMatchWorkspaceIds: orderedMatchWorkspaceIds, + bestMatchWorkspaceId: bestMatch?.workspaceId + ) + } + + /// Scores one candidate, stopping at the highest-priority field that hits. + /// + /// Fields are visited in ``SidebarFilterField/matchPriority`` order, so a + /// row whose title matches never pays to score its branch, directory, and + /// path. Only the winning field's highlight offsets are computed; nothing + /// downstream can render a highlight on a field it is not showing as the + /// match. + private func match( + candidate: SidebarFilterCandidate, + matcher: FuzzyMatcher, + fields: Set + ) -> SidebarFilterMatch? { + for field in SidebarFilterField.scoringOrder where fields.contains(field) { + var bestScore: Int? + var bestCandidateField: SidebarFilterCandidateField? + // A row can carry several values for one field (two listening + // ports, say); keep the strongest. + for candidateField in candidate.fields where candidateField.field == field { + guard let score = matcher.score(preparedCandidate: candidateField.prepared) else { + continue + } + if bestScore.map({ score > $0 }) ?? true { + bestScore = score + bestCandidateField = candidateField + } + } + guard let bestScore, let bestCandidateField else { continue } + var matchedIndicesByField: [SidebarFilterField: Set] = [:] + if bestCandidateField.isDisplayIndexAligned { + let indices = matcher.matchCharacterIndices(in: bestCandidateField.prepared) + if !indices.isEmpty { + matchedIndicesByField[field] = indices + } + } + return SidebarFilterMatch( + workspaceId: candidate.id, + score: bestScore, + field: field, + matchedIndicesByField: matchedIndicesByField + ) + } + return nil + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterMatch.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterMatch.swift new file mode 100644 index 000000000000..06f0db7ccb75 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterMatch.swift @@ -0,0 +1,93 @@ +public import Foundation + +/// The result of scoring one sidebar row against a filter query. +/// +/// Carries both the ranking score and the character positions that matched, so +/// the row can highlight exactly the characters the user typed instead of just +/// appearing in a filtered list. +public struct SidebarFilterMatch: Sendable, Equatable { + /// The workspace this match belongs to. + public let workspaceId: UUID + /// The fuzzy score of the winning field, unmixed with field priority. + public let score: Int + /// The highest-priority field that matched. + public let field: SidebarFilterField + /// Matched character offsets per field. + /// + /// Only fields whose normalization is index-aligned with their display text + /// appear here (see ``SidebarFilterCandidateField/isDisplayIndexAligned``), + /// so every offset addresses the displayed string directly. + public let matchedIndicesByField: [SidebarFilterField: Set] + + /// Creates a match. + /// + /// - Parameters: + /// - workspaceId: The matched workspace. + /// - score: The winning field's fuzzy score. + /// - field: The highest-priority field that matched. + /// - matchedIndicesByField: Matched offsets for every field that hit. + public init( + workspaceId: UUID, + score: Int, + field: SidebarFilterField, + matchedIndicesByField: [SidebarFilterField: Set] + ) { + self.workspaceId = workspaceId + self.score = score + self.field = field + self.matchedIndicesByField = matchedIndicesByField + } + + /// Whether this match ranks above `other`. + /// + /// Lexicographic on `(field priority, fuzzy score)`: which field matched + /// dominates, and the fuzzy score only separates rows that matched on the + /// same field. Comparing raw scores across fields would let a long path + /// coincidentally out-score a direct title hit. + /// + /// - Parameter other: The match to compare against. + /// - Returns: `true` when this match should win. + public func outranks(_ other: SidebarFilterMatch) -> Bool { + if field.matchPriority != other.field.matchPriority { + return field.matchPriority > other.field.matchPriority + } + return score > other.score + } + + /// Contiguous highlight ranges for `field` against `displayText`. + /// + /// Fields whose normalization changed their character count carry no + /// offsets at all, so the only guard here is a bounds check against the + /// string actually being drawn. + /// + /// - Parameters: + /// - field: The field being rendered. + /// - displayText: The exact string the row draws. + /// - Returns: Ascending, non-overlapping character ranges to highlight. + public func highlightRanges( + for field: SidebarFilterField, + in displayText: String + ) -> [Range] { + guard let indices = matchedIndicesByField[field], !indices.isEmpty else { return [] } + let displayCount = displayText.count + guard indices.allSatisfy({ $0 >= 0 && $0 < displayCount }) else { return [] } + var ranges: [Range] = [] + var runStart: Int? + var previous: Int? + for index in indices.sorted() { + if let last = previous, index == last + 1 { + previous = index + continue + } + if let start = runStart, let last = previous { + ranges.append(start..<(last + 1)) + } + runStart = index + previous = index + } + if let start = runStart, let last = previous { + ranges.append(start..<(last + 1)) + } + return ranges + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterOutcome.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterOutcome.swift new file mode 100644 index 000000000000..a62943017dc1 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterOutcome.swift @@ -0,0 +1,81 @@ +public import Foundation + +/// Everything the sidebar needs to render one filter query's result. +/// +/// The outcome deliberately does not reorder rows. A sidebar is a spatial +/// surface: users reach for a workspace by where it sits, and re-sorting by +/// score on every keystroke destroys that. Rows are hidden, never moved, and +/// ``bestMatchWorkspaceId`` carries the ranking separately so Return can still +/// jump to the strongest hit. +public struct SidebarFilterOutcome: Sendable, Equatable { + /// Whether a query was active at all. + /// + /// `false` means the filter is off and every row renders untouched; callers + /// use this to skip filtering work entirely rather than comparing sets. + public let isFiltering: Bool + /// Workspaces whose rows survive the filter, including group anchors kept + /// only to give a surviving member its header. + public let visibleWorkspaceIds: Set + /// Groups that must render expanded so their matching members are reachable. + /// + /// Transient: the filter never writes a group's persisted collapse state, + /// so clearing the query restores exactly the shape the user had. + public let expandedGroupIds: Set + /// Match detail per workspace, for highlighting. + public let matchesByWorkspaceId: [UUID: SidebarFilterMatch] + /// Matching workspaces in model order, for arrow-key navigation. + public let orderedMatchWorkspaceIds: [UUID] + /// The highest-scoring workspace, for Return. + public let bestMatchWorkspaceId: UUID? + + /// The outcome for an inactive filter: everything visible, nothing matched. + public static let inactive = SidebarFilterOutcome( + isFiltering: false, + visibleWorkspaceIds: [], + expandedGroupIds: [], + matchesByWorkspaceId: [:], + orderedMatchWorkspaceIds: [], + bestMatchWorkspaceId: nil + ) + + /// Whether the query matched nothing, so the sidebar should show its empty + /// state instead of a blank list. + public var isEmptyResult: Bool { + isFiltering && orderedMatchWorkspaceIds.isEmpty + } + + /// Whether the row for `workspaceId` should render. + /// + /// - Parameter workspaceId: The workspace whose row is being placed. + /// - Returns: `true` when the filter is off or the workspace survived it. + public func includesWorkspace(_ workspaceId: UUID) -> Bool { + guard isFiltering else { return true } + return visibleWorkspaceIds.contains(workspaceId) + } + + /// Whether `groupId` should render expanded regardless of its stored + /// collapse state. + /// + /// - Parameter groupId: The group being placed. + /// - Returns: `true` only while a filter is forcing the group open. + public func forcesGroupExpanded(_ groupId: UUID) -> Bool { + isFiltering && expandedGroupIds.contains(groupId) + } + + /// Creates an outcome. + public init( + isFiltering: Bool, + visibleWorkspaceIds: Set, + expandedGroupIds: Set, + matchesByWorkspaceId: [UUID: SidebarFilterMatch], + orderedMatchWorkspaceIds: [UUID], + bestMatchWorkspaceId: UUID? + ) { + self.isFiltering = isFiltering + self.visibleWorkspaceIds = visibleWorkspaceIds + self.expandedGroupIds = expandedGroupIds + self.matchesByWorkspaceId = matchesByWorkspaceId + self.orderedMatchWorkspaceIds = orderedMatchWorkspaceIds + self.bestMatchWorkspaceId = bestMatchWorkspaceId + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterQuery.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterQuery.swift new file mode 100644 index 000000000000..16a2988aaa19 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterQuery.swift @@ -0,0 +1,74 @@ +public import CmuxFoundation + +/// A sidebar filter query: the raw text the user typed, split into the text to +/// match and the optional field it was restricted to. +/// +/// A leading sigil scopes the search to one field (`@` branch, `#` group, `:` +/// port, `/` directory); everything else searches every field. The sigil is +/// stripped from ``searchText`` so `/repos` matches the directory text +/// `~/repos/cmux` rather than requiring a literal leading slash. +/// +/// ```swift +/// let query = SidebarFilterQuery("@fix-drag") +/// query.restrictedField // .branch +/// query.searchText // "fix-drag" +/// ``` +/// +/// A directory query keeps its slash when the user typed a rooted path, since +/// `/Users/me` reads as a path fragment rather than a scoped search. +public struct SidebarFilterQuery: Sendable, Equatable { + /// The unmodified text the user typed. + public let rawText: String + /// The text to fuzzy-match, with any scoping sigil removed. + public let searchText: String + /// The field this query was scoped to, or nil to search every field. + public let restrictedField: SidebarFilterField? + + /// Whether this query selects everything (nothing to match against). + public var isEmpty: Bool { + searchText.isEmpty + } + + /// The fields this query should be scored against, in declaration order. + public var fields: [SidebarFilterField] { + guard let restrictedField else { return SidebarFilterField.allCases } + return [restrictedField] + } + + /// Parses `rawText` into a scoped query. + /// + /// - Parameter rawText: Text straight from the filter field. + public init(_ rawText: String) { + self.rawText = rawText + let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) + guard let sigil = trimmed.first, + let field = SidebarFilterField.allCases.first(where: { $0.querySigil == sigil }), + Self.sigilScopesQuery(sigil: sigil, trimmed: trimmed) else { + self.searchText = trimmed + self.restrictedField = nil + return + } + self.searchText = String(trimmed.dropFirst()) + .trimmingCharacters(in: .whitespacesAndNewlines) + self.restrictedField = field + } + + /// Whether a leading sigil should be read as a scope rather than as part of + /// the search text. + /// + /// `/Users/me/src` is a path the user is searching for, not a directory + /// scope followed by `Users/me/src` - a rooted path keeps its slash. Every + /// other sigil always scopes. + private static func sigilScopesQuery(sigil: Character, trimmed: String) -> Bool { + guard sigil == "/" else { return true } + return !trimmed.dropFirst().contains("/") + } + + /// The fuzzy matcher for this query, or nil when the query is empty. + public func makeMatcher() -> FuzzyMatcher? { + guard !isEmpty else { return nil } + let matcher = FuzzyMatcher(query: searchText) + guard !matcher.preparedQuery.isEmpty else { return nil } + return matcher + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightTests.swift new file mode 100644 index 000000000000..19332d5ee75f --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightTests.swift @@ -0,0 +1,80 @@ +import Foundation +import Testing +@testable import CmuxSidebar + +@Suite("SidebarFilterMatch highlighting") +struct SidebarFilterHighlightTests { + private func match(indices: Set, field: SidebarFilterField = .title) -> SidebarFilterMatch { + SidebarFilterMatch( + workspaceId: UUID(), + score: 1, + field: field, + matchedIndicesByField: [field: indices] + ) + } + + @Test func adjacentOffsetsCollapseIntoOneRange() { + let ranges = match(indices: [0, 1, 2]).highlightRanges(for: .title, in: "cmux-web") + #expect(ranges == [0..<3]) + } + + @Test func gapsSplitIntoSeparateRanges() { + let ranges = match(indices: [0, 1, 5, 6, 7]).highlightRanges(for: .title, in: "cmux-web") + #expect(ranges == [0..<2, 5..<8]) + } + + @Test func singleOffsetsBecomeSingleCharacterRanges() { + let ranges = match(indices: [0, 2, 4]).highlightRanges(for: .title, in: "cmux-web") + #expect(ranges == [0..<1, 2..<3, 4..<5]) + } + + @Test func rangesAreAscendingRegardlessOfSetIteration() { + // Set iteration order is unspecified; the ranges must not be. + let ranges = match(indices: [7, 1, 4, 0, 5]).highlightRanges(for: .title, in: "cmux-web") + #expect(ranges == [0..<2, 4..<6, 7..<8]) + } + + @Test func fieldWithoutMatchesHighlightsNothing() { + let ranges = match(indices: [0, 1]).highlightRanges(for: .branch, in: "main") + #expect(ranges.isEmpty) + } + + @Test func offsetsPastTheDisplayStringHighlightNothing() { + // Defence in depth against a stale match outliving a renamed row: + // highlighting out of bounds would trap in the row's text layout. + let ranges = match(indices: [0, 1, 99]).highlightRanges(for: .title, in: "cmux") + #expect(ranges.isEmpty) + } + + @Test func nonLengthPreservingNormalizationCarriesNoHighlights() { + // Diacritic folding can change the character count, which invalidates + // the offsets against the displayed string. The field still scores; it + // just renders unhighlighted rather than underlining the wrong glyphs. + let field = SidebarFilterCandidateField(field: .title, displayText: "café") + if !field.isDisplayIndexAligned { + let index = SidebarFilterIndex( + candidates: [SidebarFilterCandidate(id: UUID(), fields: [field])], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("cafe")) + let match = outcome.matchesByWorkspaceId.values.first + #expect(match != nil) + #expect(match?.matchedIndicesByField.isEmpty == true) + } + } + + @Test func alignedAsciiFieldKeepsItsHighlights() { + let id = UUID() + let index = SidebarFilterIndex( + candidates: [SidebarFilterCandidate( + id: id, + fields: [SidebarFilterCandidateField(field: .title, displayText: "cmux-web")] + )], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("cmux")) + let ranges = outcome.matchesByWorkspaceId[id]? + .highlightRanges(for: .title, in: "cmux-web") + #expect(ranges == [0..<4]) + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterIndexTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterIndexTests.swift new file mode 100644 index 000000000000..c9712bd46bfa --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterIndexTests.swift @@ -0,0 +1,223 @@ +import Foundation +import Testing +@testable import CmuxSidebar + +@Suite("SidebarFilterIndex") +struct SidebarFilterIndexTests { + // MARK: - Fixtures + + private static func workspace( + _ id: UUID, + title: String, + branch: String? = nil, + directory: String? = nil, + pullRequest: String? = nil, + ports: [String] = [], + groupId: UUID? = nil, + isGroupAnchor: Bool = false + ) -> SidebarFilterCandidate { + var fields: [SidebarFilterCandidateField] = [ + SidebarFilterCandidateField(field: .title, displayText: title) + ] + if let branch { + fields.append(SidebarFilterCandidateField(field: .branch, displayText: branch)) + } + if let directory { + fields.append(SidebarFilterCandidateField(field: .directory, displayText: directory)) + } + if let pullRequest { + fields.append(SidebarFilterCandidateField(field: .pullRequest, displayText: pullRequest)) + } + for port in ports { + fields.append(SidebarFilterCandidateField(field: .port, displayText: port)) + } + return SidebarFilterCandidate( + id: id, + groupId: groupId, + isGroupAnchor: isGroupAnchor, + fields: fields + ) + } + + // MARK: - Passthrough + + @Test func emptyQueryReturnsInactiveOutcome() { + let index = SidebarFilterIndex( + candidates: [Self.workspace(UUID(), title: "cmux")], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery(" ")) + #expect(outcome == .inactive) + #expect(!outcome.isFiltering) + #expect(!outcome.isEmptyResult) + } + + @Test func inactiveOutcomeIncludesEveryWorkspace() { + // Passthrough must not depend on the visible set being populated: an + // inactive filter carries no ids at all and still shows every row. + #expect(SidebarFilterOutcome.inactive.includesWorkspace(UUID())) + #expect(!SidebarFilterOutcome.inactive.forcesGroupExpanded(UUID())) + } + + // MARK: - Field matching + + @Test func matchesOnBranchWhenTitleSaysNothingAboutIt() { + let id = UUID() + let index = SidebarFilterIndex( + candidates: [Self.workspace(id, title: "api", branch: "fix-drag-failsafe")], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("failsafe")) + #expect(outcome.orderedMatchWorkspaceIds == [id]) + #expect(outcome.matchesByWorkspaceId[id]?.field == .branch) + } + + @Test func matchesOnDirectory() { + let id = UUID() + let index = SidebarFilterIndex( + candidates: [Self.workspace(id, title: "api", directory: "~/repos/manaflow")], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("manaflow")) + #expect(outcome.matchesByWorkspaceId[id]?.field == .directory) + } + + @Test func titleOutranksDirectoryForTheSameText() { + let titled = UUID() + let pathed = UUID() + let index = SidebarFilterIndex( + candidates: [ + Self.workspace(pathed, title: "unrelated", directory: "~/src/ghostty"), + Self.workspace(titled, title: "ghostty"), + ], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("ghostty")) + #expect(outcome.bestMatchWorkspaceId == titled) + } + + @Test func scopedQueryIgnoresOtherFields() { + let branchHit = UUID() + let titleHit = UUID() + let index = SidebarFilterIndex( + candidates: [ + Self.workspace(titleHit, title: "main"), + Self.workspace(branchHit, title: "worker", branch: "main"), + ], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("@main")) + #expect(outcome.orderedMatchWorkspaceIds == [branchHit]) + #expect(outcome.matchesByWorkspaceId[titleHit] == nil) + } + + @Test func portQueryFindsAListeningWorkspace() { + let id = UUID() + let index = SidebarFilterIndex( + candidates: [Self.workspace(id, title: "web", ports: ["3000", "9229"])], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery(":9229")) + #expect(outcome.orderedMatchWorkspaceIds == [id]) + } + + // MARK: - Ordering + + @Test func rowsKeepModelOrderRatherThanScoreOrder() { + // A sidebar is spatial. Re-sorting by score on every keystroke would + // move rows under the pointer mid-type, so the outcome preserves the + // order the sidebar renders in and exposes ranking separately. + let weak = UUID() + let strong = UUID() + let index = SidebarFilterIndex( + candidates: [ + Self.workspace(weak, title: "unrelated", directory: "~/src/cmux"), + Self.workspace(strong, title: "cmux"), + ], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("cmux")) + #expect(outcome.orderedMatchWorkspaceIds == [weak, strong]) + #expect(outcome.bestMatchWorkspaceId == strong) + } + + // MARK: - Groups + + @Test func matchingMemberKeepsItsHeaderAndForcesTheGroupOpen() { + let groupId = UUID() + let anchor = UUID() + let member = UUID() + let index = SidebarFilterIndex( + candidates: [ + Self.workspace(anchor, title: "infra", groupId: groupId, isGroupAnchor: true), + Self.workspace(member, title: "deploy-runner", groupId: groupId), + ], + groups: [SidebarFilterGroup(id: groupId, anchorWorkspaceId: anchor, name: "infra")] + ) + let outcome = index.outcome(for: SidebarFilterQuery("runner")) + #expect(outcome.visibleWorkspaceIds == [anchor, member]) + #expect(outcome.expandedGroupIds == [groupId]) + // The header is context, not a hit. + #expect(outcome.orderedMatchWorkspaceIds == [member]) + } + + @Test func matchingGroupNameKeepsEveryMemberAndOpensTheGroup() { + let groupId = UUID() + let anchor = UUID() + let first = UUID() + let second = UUID() + let index = SidebarFilterIndex( + candidates: [ + Self.workspace(anchor, title: "anchor", groupId: groupId, isGroupAnchor: true), + Self.workspace(first, title: "alpha", groupId: groupId), + Self.workspace(second, title: "beta", groupId: groupId), + ], + groups: [SidebarFilterGroup(id: groupId, anchorWorkspaceId: anchor, name: "infra")] + ) + let outcome = index.outcome(for: SidebarFilterQuery("#infra")) + #expect(outcome.visibleWorkspaceIds == [anchor, first, second]) + #expect(outcome.expandedGroupIds == [groupId]) + } + + @Test func anchorMatchingOnItsOwnTitleDoesNotForceTheGroupOpen() { + // The anchor *is* the header. Typing its name is not a request to + // expand the group the user deliberately collapsed. + let groupId = UUID() + let anchor = UUID() + let member = UUID() + let index = SidebarFilterIndex( + candidates: [ + Self.workspace(anchor, title: "zephyr", groupId: groupId, isGroupAnchor: true), + Self.workspace(member, title: "unrelated", groupId: groupId), + ], + groups: [SidebarFilterGroup(id: groupId, anchorWorkspaceId: anchor, name: "other")] + ) + let outcome = index.outcome(for: SidebarFilterQuery("zephyr")) + #expect(outcome.orderedMatchWorkspaceIds == [anchor]) + #expect(outcome.expandedGroupIds.isEmpty) + } + + @Test func ungroupedWorkspacesNeverProduceGroupExpansion() { + let id = UUID() + let index = SidebarFilterIndex( + candidates: [Self.workspace(id, title: "solo")], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("solo")) + #expect(outcome.expandedGroupIds.isEmpty) + } + + // MARK: - Empty results + + @Test func unmatchedQueryReportsAnEmptyResultRatherThanPassthrough() { + let index = SidebarFilterIndex( + candidates: [Self.workspace(UUID(), title: "cmux")], + groups: [] + ) + let outcome = index.outcome(for: SidebarFilterQuery("zzzzqqq")) + #expect(outcome.isFiltering) + #expect(outcome.isEmptyResult) + #expect(outcome.visibleWorkspaceIds.isEmpty) + #expect(outcome.bestMatchWorkspaceId == nil) + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterQueryTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterQueryTests.swift new file mode 100644 index 000000000000..01c7382d58f7 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterQueryTests.swift @@ -0,0 +1,65 @@ +import Foundation +import Testing +@testable import CmuxSidebar + +@Suite("SidebarFilterQuery") +struct SidebarFilterQueryTests { + @Test func blankQueryIsEmptyAndBuildsNoMatcher() { + for raw in ["", " ", "\n\t"] { + let query = SidebarFilterQuery(raw) + #expect(query.isEmpty) + #expect(query.makeMatcher() == nil) + } + } + + @Test func unprefixedQuerySearchesEveryField() { + let query = SidebarFilterQuery("cmux") + #expect(query.restrictedField == nil) + #expect(query.searchText == "cmux") + #expect(Set(query.fields) == Set(SidebarFilterField.allCases)) + } + + @Test(arguments: [ + ("@fix-drag", SidebarFilterField.branch, "fix-drag"), + ("#infra", SidebarFilterField.group, "infra"), + (":3000", SidebarFilterField.port, "3000"), + ]) + func sigilScopesQueryToOneField( + raw: String, + expectedField: SidebarFilterField, + expectedText: String + ) { + let query = SidebarFilterQuery(raw) + #expect(query.restrictedField == expectedField) + #expect(query.searchText == expectedText) + #expect(query.fields == [expectedField]) + } + + @Test func bareSlashScopesToDirectory() { + let query = SidebarFilterQuery("/repos") + #expect(query.restrictedField == .directory) + #expect(query.searchText == "repos") + } + + @Test func rootedPathKeepsItsSlashAndSearchesEveryField() { + // A user typing an absolute path is searching for that path, not + // scoping a search to directories. Stripping the leading slash here + // would make `/Users/me` fail to match `/Users/me`. + let query = SidebarFilterQuery("/Users/me/src") + #expect(query.restrictedField == nil) + #expect(query.searchText == "/Users/me/src") + } + + @Test func sigilAloneMatchesNothingRatherThanEverything() { + let query = SidebarFilterQuery("@") + #expect(query.restrictedField == .branch) + #expect(query.isEmpty) + #expect(query.makeMatcher() == nil) + } + + @Test func surroundingWhitespaceIsTrimmed() { + let query = SidebarFilterQuery(" @ main ") + #expect(query.restrictedField == .branch) + #expect(query.searchText == "main") + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterScaleTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterScaleTests.swift new file mode 100644 index 000000000000..7df78eb77c95 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterScaleTests.swift @@ -0,0 +1,143 @@ +import Foundation +import Testing +@testable import CmuxSidebar + +/// Scale coverage for the sidebar filter's two performance claims: +/// +/// 1. Candidate preparation is paid once per corpus revision, so a keystroke +/// costs only scoring. A regression that rebuilds the index per keystroke +/// passes every behavioural suite and only shows up here. +/// 2. Scoring stops at the highest-priority field that matches, so a row whose +/// title matches never pays to score its branch, directory, and ports. +/// +/// Bounds are deliberately loose. These exist to fail on an order-of-magnitude +/// regression, not to pin a number to one machine's clock. +@Suite("SidebarFilterIndex scale") +struct SidebarFilterScaleTests { + private static func makeIndex( + workspaceCount: Int, + matchingEvery: Int + ) -> SidebarFilterIndex { + let groupCount = max(1, workspaceCount / 25) + let groups = (0.. SidebarFilterCandidate in + let group = groups[index % groupCount] + let stem = index % matchingEvery == 0 ? "manaflow" : "zephyr" + return SidebarFilterCandidate( + id: UUID(), + groupId: group.id, + isGroupAnchor: false, + fields: [ + SidebarFilterCandidateField( + field: .title, + displayText: "workspace-\(index)-\(stem)" + ), + SidebarFilterCandidateField( + field: .branch, + displayText: "feature/sidebar-rail-\(index)" + ), + SidebarFilterCandidateField( + field: .directory, + displayText: "~/repos/\(stem)/cmux/pkg-\(index)" + ), + SidebarFilterCandidateField(field: .port, displayText: "\(3000 + index)"), + ] + ) + } + return SidebarFilterIndex(candidates: candidates, groups: groups) + } + + /// Types `query` one character at a time and returns the worst and total + /// wall time, the way a filter field drives the index. + private static func measureTyping( + index: SidebarFilterIndex, + query: String + ) -> (worstMs: Double, totalMs: Double, matches: Int) { + var worstMs = 0.0 + var totalMs = 0.0 + var matches = 0 + for length in 1...query.count { + let prefix = String(query.prefix(length)) + let start = DispatchTime.now().uptimeNanoseconds + let outcome = index.outcome(for: SidebarFilterQuery(prefix)) + let elapsedMs = Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 + worstMs = max(worstMs, elapsedMs) + totalMs += elapsedMs + matches = outcome.orderedMatchWorkspaceIds.count + } + return (worstMs, totalMs, matches) + } + + @Test func typingStaysWellInsideAFrameOnARealisticCorpus() { + // 200 workspaces is already far past what a sidebar shows; one in + // twelve matching is a normal narrowing search. + let index = Self.makeIndex(workspaceCount: 200, matchingEvery: 12) + let result = Self.measureTyping(index: index, query: "manaflow") + print( + "BENCH sidebar-filter realistic workspaces=200" + + " worstKeystroke=\(String(format: "%.2f", result.worstMs))ms" + + " typing=\(String(format: "%.2f", result.totalMs))ms" + + " matches=\(result.matches)" + ) + #expect(result.matches > 0) + // One 60Hz frame is 16.6ms; a realistic corpus must not come close. + #expect(result.worstMs < 8.0) + } + + @Test func preparationIsPaidOncePerCorpusRevisionNotPerKeystroke() { + let query = "manaflow" + let prepareStart = DispatchTime.now().uptimeNanoseconds + let index = Self.makeIndex(workspaceCount: 500, matchingEvery: 1) + let prepareMs = Double(DispatchTime.now().uptimeNanoseconds - prepareStart) / 1_000_000 + + let result = Self.measureTyping(index: index, query: query) + let averageKeystrokeMs = result.totalMs / Double(query.count) + + print( + "BENCH sidebar-filter adversarial workspaces=500 allMatching" + + " prepare=\(String(format: "%.2f", prepareMs))ms" + + " worstKeystroke=\(String(format: "%.2f", result.worstMs))ms" + + " avgKeystroke=\(String(format: "%.2f", averageKeystrokeMs))ms" + ) + + #expect(result.matches == 500) + // Every one of 500 rows matching on its title is not a real corpus; it + // is the worst case the scorer can be handed. Even here a keystroke + // stays inside a few frames, and human typing leaves >60ms between + // them. The bound is a tripwire for a per-keystroke re-preparation + // regression, which would cost `prepare` on top of every character. + #expect(result.worstMs < prepareMs * 4) + #expect(result.worstMs < 40.0) + } + + @Test func aMissPrunesCheaplyThroughTheASCIIMask() { + let index = Self.makeIndex(workspaceCount: 500, matchingEvery: 1) + let start = DispatchTime.now().uptimeNanoseconds + let outcome = index.outcome(for: SidebarFilterQuery("qqqzzzxxx")) + let elapsedMs = Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 + print("BENCH sidebar-filter miss workspaces=500 elapsed=\(String(format: "%.2f", elapsedMs))ms") + #expect(outcome.isEmptyResult) + #expect(elapsedMs < 16.0) + } + + @Test func aTitleHitNeverPaysToScoreTheRemainingFields() { + // Same corpus, same query text, scoped to the lowest-priority field so + // every higher-priority field is skipped outright. If early exit ever + // regresses into scoring every field, the unscoped run stops being + // comparable to this one. + let index = Self.makeIndex(workspaceCount: 500, matchingEvery: 1) + let outcome = index.outcome(for: SidebarFilterQuery("manaflow")) + #expect(outcome.matchesByWorkspaceId.values.allSatisfy { $0.field == .title }) + // The directory also contains "manaflow"; it must not appear as the + // match, and it must not carry highlight offsets it never earned. + #expect(outcome.matchesByWorkspaceId.values.allSatisfy { + $0.matchedIndicesByField[.directory] == nil + }) + } +} From 165c71a057da58214b77283aee62bb83a07b58a0 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 11:10:43 +1200 Subject: [PATCH 02/69] add sidebar peek state machine hover-reveal for the collapsed sidebar, as a pure (state, event) -> effects machine with no timers, views or clock inside it. the timing policy is a value type so settings configures a policy rather than reaching into the machine. the rules that decide whether a hover panel is bearable are all here and all tested: dwell before revealing so crossing the edge en route to a terminal never flashes the sidebar; grace before dismissing so the pointer can travel the diagonal from edge strip into panel; a drag skips dwell because it has already declared intent; and a hold set (pointer inside, context menu open, keyboard focus, drag in flight) refuses dismissal, which is what stops a context menu from dismissing the panel it was opened from. 21 tests, including stale timers firing in the wrong phase and a hold taken between the grace timer being scheduled and firing. --- .../CmuxSidebar/Peek/SidebarPeekEffect.swift | 14 ++ .../CmuxSidebar/Peek/SidebarPeekEvent.swift | 35 +++ .../CmuxSidebar/Peek/SidebarPeekHold.swift | 33 +++ .../CmuxSidebar/Peek/SidebarPeekMachine.swift | 138 ++++++++++ .../CmuxSidebar/Peek/SidebarPeekPhase.swift | 32 +++ .../CmuxSidebar/Peek/SidebarPeekPolicy.swift | 61 +++++ .../CmuxSidebar/Peek/SidebarPeekState.swift | 46 ++++ .../SidebarPeekMachineTests.swift | 236 ++++++++++++++++++ 8 files changed, 595 insertions(+) create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekEffect.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekEvent.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekHold.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekMachine.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPhase.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPolicy.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekState.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekMachineTests.swift diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekEffect.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekEffect.swift new file mode 100644 index 000000000000..24be46837159 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekEffect.swift @@ -0,0 +1,14 @@ +/// A timer the peek machine wants started or stopped. +/// +/// Returned by the machine instead of being scheduled inside it, so the state +/// transitions stay pure and the timing policy stays testable without a clock. +public enum SidebarPeekEffect: Sendable, Hashable { + /// Start (or restart) the dwell timer before revealing. + case startDwellTimer + /// Stop the dwell timer. + case cancelDwellTimer + /// Start (or restart) the grace timer before dismissing. + case startGraceTimer + /// Stop the grace timer. + case cancelGraceTimer +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekEvent.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekEvent.swift new file mode 100644 index 000000000000..4c4426aa92a2 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekEvent.swift @@ -0,0 +1,35 @@ +/// Everything that can move the peek state machine. +/// +/// Deliberately exhaustive and value-typed: the machine is pure, so every +/// interaction the panel supports has to arrive as one of these rather than as +/// a side effect somewhere in a view. +public enum SidebarPeekEvent: Sendable, Hashable { + /// The pointer entered the reveal strip along the window's leading edge. + case pointerEnteredEdge + /// The pointer left the reveal strip. + case pointerExitedEdge + /// The dwell timer finished; the user rested at the edge long enough. + case dwellElapsed + /// The grace timer finished with no hold left to keep the panel open. + case graceElapsed + /// A hold was acquired (pointer entered the panel, a menu opened, a field + /// took focus, a drag started). + case holdAcquired(SidebarPeekHold) + /// A hold was released. + case holdReleased(SidebarPeekHold) + /// A drag carrying content crossed the reveal strip. + /// + /// Distinct from a plain pointer entry because a drag skips dwell: someone + /// dragging a file or a workspace toward a hidden sidebar has already + /// declared intent, and making them hover through a delay while holding a + /// drag is the worst version of this interaction. + case dragEnteredEdge + /// The user activated a workspace from the panel. + case workspaceActivated + /// The user pressed Escape. + case escapePressed + /// The sidebar was docked open, so peek has nothing left to do. + case sidebarDocked + /// The sidebar was collapsed, arming peek. + case sidebarCollapsed +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekHold.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekHold.swift new file mode 100644 index 000000000000..b5a83bbef4a9 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekHold.swift @@ -0,0 +1,33 @@ +/// Reasons the peek panel must stay open regardless of where the pointer is. +/// +/// Pointer-driven panels are usually ruined by the cases where the pointer is +/// legitimately somewhere else: a context menu the user opened from a row, a +/// text field they are typing into, a drag they are carrying. Each of those +/// registers a hold, and dismissal is refused while any hold is set. +public struct SidebarPeekHold: OptionSet, Sendable, Hashable { + public let rawValue: Int + + /// Creates a hold set from its raw bits. + public init(rawValue: Int) { + self.rawValue = rawValue + } + + /// The pointer is inside the panel. + public static let pointerInsidePanel = SidebarPeekHold(rawValue: 1 << 0) + /// A context menu opened from the panel is showing. + /// + /// The menu is its own window, so the pointer leaves the panel the moment + /// the menu appears. Without this hold the panel dismisses out from under + /// the menu the user just opened. + public static let contextMenuOpen = SidebarPeekHold(rawValue: 1 << 1) + /// A text field in the panel has keyboard focus, such as the filter field + /// or an inline rename. + public static let keyboardFocusInside = SidebarPeekHold(rawValue: 1 << 2) + /// A drag is in flight over the panel. + public static let dragInFlight = SidebarPeekHold(rawValue: 1 << 3) + + /// Whether any hold is set. + public var isHolding: Bool { + !isEmpty + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekMachine.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekMachine.swift new file mode 100644 index 000000000000..499fcf6d6b8c --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekMachine.swift @@ -0,0 +1,138 @@ +/// The pure state machine behind the collapsed sidebar's hover-reveal. +/// +/// Holds no timers, no views, and no clock: it maps `(state, event)` to a new +/// state plus the timers its owner should start or cancel. Every timing and +/// dismissal rule that makes a hover panel bearable lives here and is unit +/// tested without a run loop. +/// +/// ```swift +/// var state = SidebarPeekState.idle +/// let machine = SidebarPeekMachine(policy: .default) +/// let effects = machine.apply(.pointerEnteredEdge, to: &state) +/// // effects == [.startDwellTimer] +/// ``` +public struct SidebarPeekMachine: Sendable { + /// The timing and behaviour policy this machine enforces. + public let policy: SidebarPeekPolicy + + /// Creates a machine bound to `policy`. + /// + /// - Parameter policy: Timings and behaviour flags to enforce. + public init(policy: SidebarPeekPolicy = .default) { + self.policy = policy + } + + /// Applies `event` to `state`, returning the timers to start or cancel. + /// + /// - Parameters: + /// - event: The interaction that occurred. + /// - state: The state to advance, mutated in place. + /// - Returns: Timer effects for the caller to perform, in order. + @discardableResult + public func apply( + _ event: SidebarPeekEvent, + to state: inout SidebarPeekState + ) -> [SidebarPeekEffect] { + switch event { + case .sidebarDocked: + // A docked sidebar makes peek meaningless. Drop every hold too, so + // a menu that was open over the panel cannot strand the machine in + // a held state after the panel is gone. + state.setPhase(.idle) + state.clearHolds() + return [.cancelDwellTimer, .cancelGraceTimer] + + case .sidebarCollapsed: + state.setPhase(.idle) + state.clearHolds() + return [.cancelDwellTimer, .cancelGraceTimer] + + case .pointerEnteredEdge: + guard policy.isEnabled else { return [] } + switch state.phase { + case .idle: + state.setPhase(.arming) + return [.startDwellTimer] + case .dismissing: + // Back at the edge before grace ran out: keep the panel and + // stop the countdown rather than dismissing and re-revealing. + state.setPhase(.peeking) + return [.cancelGraceTimer] + case .arming, .peeking: + return [] + } + + case .dragEnteredEdge: + guard policy.isEnabled else { return [] } + // A drag has already declared intent, so it skips dwell entirely. + state.setPhase(.peeking) + state.insert(.dragInFlight) + return [.cancelDwellTimer, .cancelGraceTimer] + + case .pointerExitedEdge: + switch state.phase { + case .arming: + // Left before dwell finished: this was a pass-through, not a + // request. Nothing was shown, so nothing needs dismissing. + state.setPhase(.idle) + return [.cancelDwellTimer] + case .peeking: + return beginDismissalIfUnheld(&state) + case .idle, .dismissing: + return [] + } + + case .dwellElapsed: + guard state.phase == .arming else { return [] } + state.setPhase(.peeking) + return [] + + case .graceElapsed: + guard state.phase == .dismissing else { return [] } + // A hold acquired after the timer started but before it fired must + // still win; the phase check alone would not catch that. + guard !state.holds.isHolding else { + state.setPhase(.peeking) + return [.cancelGraceTimer] + } + state.setPhase(.idle) + return [] + + case .holdAcquired(let hold): + state.insert(hold) + guard state.phase != .idle else { return [] } + state.setPhase(.peeking) + return [.cancelDwellTimer, .cancelGraceTimer] + + case .holdReleased(let hold): + state.remove(hold) + guard state.phase == .peeking else { return [] } + return beginDismissalIfUnheld(&state) + + case .workspaceActivated: + guard policy.dismissesOnWorkspaceActivation else { return [] } + guard state.phase.presentsPanel else { return [] } + // Deliberate: no grace period. The user said where they were going, + // so the panel gets out of the way immediately instead of hovering + // over the content they just asked for. + state.setPhase(.idle) + state.clearHolds() + return [.cancelDwellTimer, .cancelGraceTimer] + + case .escapePressed: + guard state.phase != .idle else { return [] } + state.setPhase(.idle) + state.clearHolds() + return [.cancelDwellTimer, .cancelGraceTimer] + } + } + + /// Starts dismissal, unless something is still holding the panel open. + private func beginDismissalIfUnheld( + _ state: inout SidebarPeekState + ) -> [SidebarPeekEffect] { + guard !state.holds.isHolding else { return [] } + state.setPhase(.dismissing) + return [.startGraceTimer] + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPhase.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPhase.swift new file mode 100644 index 000000000000..b43b6514fde6 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPhase.swift @@ -0,0 +1,32 @@ +/// Where the collapsed sidebar's hover-reveal currently sits. +/// +/// Peek exists so a collapsed sidebar is still reachable without a keystroke: +/// the pointer rests at the window's leading edge and the workspace list floats +/// in over the content, the way Arc and Aside reveal a hidden rail. +public enum SidebarPeekPhase: String, Sendable, Hashable, CaseIterable { + /// Collapsed and idle. Nothing is drawn and no timer is running. + case idle + /// The pointer is in the edge zone and the dwell timer is running. + /// + /// Dwell is what keeps peek from being a nuisance. Without it, every + /// pointer trip across the leading edge on the way to a terminal flashes + /// the sidebar open. + case arming + /// The panel is presented. + case peeking + /// The panel is presented and the grace timer is running toward dismissal. + /// + /// Grace absorbs the gap between leaving the edge strip and entering the + /// panel, and it forgives a pointer that wobbles off the panel's edge. + case dismissing + + /// Whether the floating panel is on screen in this phase. + public var presentsPanel: Bool { + switch self { + case .idle, .arming: + return false + case .peeking, .dismissing: + return true + } + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPolicy.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPolicy.swift new file mode 100644 index 000000000000..260ae376d182 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPolicy.swift @@ -0,0 +1,61 @@ +public import Foundation + +/// The tunable behaviour of the peek interaction. +/// +/// Every value here is a real preference someone will want to change: how +/// eager the reveal is, how forgiving the dismissal is, and whether picking a +/// workspace closes the panel. Keeping them in one value type means the +/// settings surface configures a policy rather than reaching into the machine. +public struct SidebarPeekPolicy: Sendable, Hashable { + /// How long the pointer must rest in the edge strip before revealing. + public let dwell: Duration + /// How long the panel survives after the last hold is released. + public let grace: Duration + /// Width of the pointer-sensitive strip along the window's leading edge. + public let edgeWidth: Double + /// Whether activating a workspace dismisses the panel. + /// + /// On by default: peek is a reach-in gesture, and staying open after the + /// user has said where they wanted to go leaves the panel covering the + /// content they just asked for. + public let dismissesOnWorkspaceActivation: Bool + /// Whether peek is enabled at all. + public let isEnabled: Bool + + /// The shipped defaults. + /// + /// 180ms of dwell is long enough that crossing the edge on the way + /// somewhere else does not trigger a reveal, and short enough that a + /// deliberate rest feels immediate. 260ms of grace comfortably covers the + /// diagonal from the edge strip into the panel. + public static let `default` = SidebarPeekPolicy( + dwell: .milliseconds(180), + grace: .milliseconds(260), + edgeWidth: 6, + dismissesOnWorkspaceActivation: true, + isEnabled: true + ) + + /// Creates a policy. + /// + /// - Parameters: + /// - dwell: Rest time at the edge before revealing. + /// - grace: Delay between the last hold releasing and dismissal. + /// - edgeWidth: Width in points of the leading-edge reveal strip. + /// - dismissesOnWorkspaceActivation: Whether picking a workspace closes + /// the panel. + /// - isEnabled: Whether hover-reveal runs at all. + public init( + dwell: Duration, + grace: Duration, + edgeWidth: Double, + dismissesOnWorkspaceActivation: Bool, + isEnabled: Bool + ) { + self.dwell = dwell + self.grace = grace + self.edgeWidth = edgeWidth + self.dismissesOnWorkspaceActivation = dismissesOnWorkspaceActivation + self.isEnabled = isEnabled + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekState.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekState.swift new file mode 100644 index 000000000000..7dd113b51329 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekState.swift @@ -0,0 +1,46 @@ +/// The peek machine's full state: which phase it is in and what is holding it +/// open. +public struct SidebarPeekState: Sendable, Hashable { + /// The current phase. + public private(set) var phase: SidebarPeekPhase + /// Reasons the panel must stay open regardless of pointer position. + public private(set) var holds: SidebarPeekHold + + /// Whether the floating panel should be on screen. + public var presentsPanel: Bool { + phase.presentsPanel + } + + /// The idle starting state. + public static let idle = SidebarPeekState(phase: .idle, holds: []) + + /// Creates a state. + /// + /// - Parameters: + /// - phase: The phase to start in. + /// - holds: Holds already in effect. + public init(phase: SidebarPeekPhase, holds: SidebarPeekHold) { + self.phase = phase + self.holds = holds + } + + /// Replaces the phase. + mutating func setPhase(_ phase: SidebarPeekPhase) { + self.phase = phase + } + + /// Adds a hold. + mutating func insert(_ hold: SidebarPeekHold) { + holds.insert(hold) + } + + /// Removes a hold. + mutating func remove(_ hold: SidebarPeekHold) { + holds.remove(hold) + } + + /// Clears every hold, used when peek shuts down entirely. + mutating func clearHolds() { + holds = [] + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekMachineTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekMachineTests.swift new file mode 100644 index 000000000000..dde8e01d1137 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekMachineTests.swift @@ -0,0 +1,236 @@ +import Foundation +import Testing +@testable import CmuxSidebar + +@Suite("SidebarPeekMachine") +struct SidebarPeekMachineTests { + private let machine = SidebarPeekMachine(policy: .default) + + private func armedAndRevealed() -> SidebarPeekState { + var state = SidebarPeekState.idle + machine.apply(.pointerEnteredEdge, to: &state) + machine.apply(.dwellElapsed, to: &state) + return state + } + + // MARK: - Reveal + + @Test func restingAtTheEdgeArmsDwellBeforeRevealing() { + var state = SidebarPeekState.idle + let effects = machine.apply(.pointerEnteredEdge, to: &state) + #expect(state.phase == .arming) + #expect(!state.presentsPanel) + #expect(effects == [.startDwellTimer]) + } + + @Test func dwellElapsingRevealsThePanel() { + var state = SidebarPeekState.idle + machine.apply(.pointerEnteredEdge, to: &state) + let effects = machine.apply(.dwellElapsed, to: &state) + #expect(state.phase == .peeking) + #expect(state.presentsPanel) + #expect(effects.isEmpty) + } + + @Test func crossingTheEdgeWithoutRestingNeverReveals() { + // The whole point of dwell: a pointer travelling to a terminal passes + // through the edge strip constantly and must not flash the sidebar. + var state = SidebarPeekState.idle + machine.apply(.pointerEnteredEdge, to: &state) + let effects = machine.apply(.pointerExitedEdge, to: &state) + #expect(state.phase == .idle) + #expect(!state.presentsPanel) + #expect(effects == [.cancelDwellTimer]) + } + + @Test func aDragSkipsDwellEntirely() { + var state = SidebarPeekState.idle + let effects = machine.apply(.dragEnteredEdge, to: &state) + #expect(state.phase == .peeking) + #expect(state.holds.contains(.dragInFlight)) + #expect(effects.contains(.cancelDwellTimer)) + } + + @Test func disabledPolicyNeverReveals() { + let disabled = SidebarPeekMachine(policy: SidebarPeekPolicy( + dwell: .milliseconds(180), + grace: .milliseconds(260), + edgeWidth: 6, + dismissesOnWorkspaceActivation: true, + isEnabled: false + )) + var state = SidebarPeekState.idle + #expect(disabled.apply(.pointerEnteredEdge, to: &state).isEmpty) + #expect(disabled.apply(.dragEnteredEdge, to: &state).isEmpty) + #expect(state.phase == .idle) + } + + // MARK: - Dismissal + + @Test func leavingTheEdgeStartsGraceRatherThanDismissingOutright() { + var state = armedAndRevealed() + let effects = machine.apply(.pointerExitedEdge, to: &state) + #expect(state.phase == .dismissing) + // Still drawn: grace is what lets the pointer travel the diagonal from + // the edge strip into the panel without the panel vanishing en route. + #expect(state.presentsPanel) + #expect(effects == [.startGraceTimer]) + } + + @Test func returningToTheEdgeDuringGraceCancelsDismissal() { + var state = armedAndRevealed() + machine.apply(.pointerExitedEdge, to: &state) + let effects = machine.apply(.pointerEnteredEdge, to: &state) + #expect(state.phase == .peeking) + #expect(effects == [.cancelGraceTimer]) + } + + @Test func graceElapsingWithNoHoldDismisses() { + var state = armedAndRevealed() + machine.apply(.pointerExitedEdge, to: &state) + machine.apply(.graceElapsed, to: &state) + #expect(state.phase == .idle) + #expect(!state.presentsPanel) + } + + @Test func enteringThePanelDuringGraceKeepsItOpen() { + var state = armedAndRevealed() + machine.apply(.pointerExitedEdge, to: &state) + let effects = machine.apply(.holdAcquired(.pointerInsidePanel), to: &state) + #expect(state.phase == .peeking) + #expect(effects.contains(.cancelGraceTimer)) + } + + // MARK: - Holds + + @Test(arguments: [ + SidebarPeekHold.contextMenuOpen, + SidebarPeekHold.keyboardFocusInside, + SidebarPeekHold.dragInFlight, + ]) + func aHoldRefusesDismissalWhileThePointerIsElsewhere(hold: SidebarPeekHold) { + var state = armedAndRevealed() + machine.apply(.holdAcquired(hold), to: &state) + let effects = machine.apply(.pointerExitedEdge, to: &state) + #expect(state.phase == .peeking) + #expect(effects.isEmpty) + } + + @Test func openingAContextMenuSurvivesThePointerLeavingThePanel() { + // The classic hover-panel bug: a context menu is its own window, so + // opening one moves the pointer out of the panel and the panel + // dismisses under the menu the user just opened. + var state = armedAndRevealed() + machine.apply(.holdAcquired(.pointerInsidePanel), to: &state) + machine.apply(.holdAcquired(.contextMenuOpen), to: &state) + machine.apply(.holdReleased(.pointerInsidePanel), to: &state) + #expect(state.phase == .peeking) + } + + @Test func releasingTheLastHoldStartsGrace() { + var state = armedAndRevealed() + machine.apply(.holdAcquired(.pointerInsidePanel), to: &state) + machine.apply(.holdAcquired(.contextMenuOpen), to: &state) + #expect(machine.apply(.holdReleased(.contextMenuOpen), to: &state).isEmpty) + let effects = machine.apply(.holdReleased(.pointerInsidePanel), to: &state) + #expect(state.phase == .dismissing) + #expect(effects == [.startGraceTimer]) + } + + @Test func aHoldAcquiredMidGraceWinsOverTheTimerThatAlreadyFired() { + // The grace timer cannot be un-fired once scheduled, so a hold taken + // between scheduling and firing has to be re-checked at fire time. + var state = armedAndRevealed() + machine.apply(.pointerExitedEdge, to: &state) + state.insert(.keyboardFocusInside) + let effects = machine.apply(.graceElapsed, to: &state) + #expect(state.phase == .peeking) + #expect(effects == [.cancelGraceTimer]) + } + + @Test func typingInTheFilterKeepsThePanelOpenWithThePointerAway() { + var state = armedAndRevealed() + machine.apply(.holdAcquired(.keyboardFocusInside), to: &state) + machine.apply(.pointerExitedEdge, to: &state) + machine.apply(.graceElapsed, to: &state) + #expect(state.presentsPanel) + } + + // MARK: - Explicit exits + + @Test func activatingAWorkspaceDismissesImmediatelyWithoutGrace() { + var state = armedAndRevealed() + machine.apply(.holdAcquired(.pointerInsidePanel), to: &state) + let effects = machine.apply(.workspaceActivated, to: &state) + #expect(state.phase == .idle) + #expect(state.holds.isEmpty) + #expect(!effects.contains(.startGraceTimer)) + } + + @Test func activationCanBeConfiguredToLeaveThePanelOpen() { + let sticky = SidebarPeekMachine(policy: SidebarPeekPolicy( + dwell: .milliseconds(180), + grace: .milliseconds(260), + edgeWidth: 6, + dismissesOnWorkspaceActivation: false, + isEnabled: true + )) + var state = SidebarPeekState.idle + sticky.apply(.pointerEnteredEdge, to: &state) + sticky.apply(.dwellElapsed, to: &state) + sticky.apply(.workspaceActivated, to: &state) + #expect(state.phase == .peeking) + } + + @Test func escapeDismissesThroughEveryHold() { + var state = armedAndRevealed() + machine.apply(.holdAcquired(.pointerInsidePanel), to: &state) + machine.apply(.holdAcquired(.contextMenuOpen), to: &state) + machine.apply(.escapePressed, to: &state) + #expect(state.phase == .idle) + #expect(state.holds.isEmpty) + } + + @Test func dockingTheSidebarClearsEveryHoldSoPeekCannotStrand() { + var state = armedAndRevealed() + machine.apply(.holdAcquired(.contextMenuOpen), to: &state) + let effects = machine.apply(.sidebarDocked, to: &state) + #expect(state.phase == .idle) + #expect(state.holds.isEmpty) + #expect(effects.contains(.cancelDwellTimer)) + #expect(effects.contains(.cancelGraceTimer)) + } + + // MARK: - Robustness + + @Test func staleTimerEventsInTheWrongPhaseAreIgnored() { + // Timers fire from outside the machine and can outlive the phase that + // scheduled them; every one has to be a no-op rather than a jump. + var state = SidebarPeekState.idle + #expect(machine.apply(.dwellElapsed, to: &state).isEmpty) + #expect(state.phase == .idle) + #expect(machine.apply(.graceElapsed, to: &state).isEmpty) + #expect(state.phase == .idle) + + var peeking = armedAndRevealed() + #expect(machine.apply(.dwellElapsed, to: &peeking).isEmpty) + #expect(peeking.phase == .peeking) + } + + @Test func repeatedEdgeEntriesDoNotRestartDwell() { + var state = SidebarPeekState.idle + machine.apply(.pointerEnteredEdge, to: &state) + // A jittering pointer re-entering the strip must not push the reveal + // further away by restarting the countdown each time. + let effects = machine.apply(.pointerEnteredEdge, to: &state) + #expect(effects.isEmpty) + #expect(state.phase == .arming) + } + + @Test func everyPhaseAgreesWithItsPanelVisibility() { + for phase in SidebarPeekPhase.allCases { + let state = SidebarPeekState(phase: phase, holds: []) + #expect(state.presentsPanel == phase.presentsPanel) + } + } +} From c5936c30bbd75653e65d181b8ab159369df73ab4 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 11:24:59 +1200 Subject: [PATCH 03/69] add sidebar filter UI and an offscreen render gallery the filter chrome is split from its text input so the same frame hosts a live TextField in the app and a plain Text anywhere the field is drawn without being editable. match emphasis is a tinted pill behind the run rather than coloured text: recolouring would put a second blue beside the selection fill, which is already the accent, and the two would compete. the tint is scheme-aware because the accent needs more alpha on near-black than on near-white to read the same. a miss is reported by the match count reaching 0 and by an empty state that names the query, with no colour change anywhere. the earlier orange ring introduced a hue the sidebar palette does not have. CmuxSidebarGallery renders these to PNG offscreen via ImageRenderer, no window and no screen-recording permission, in seconds rather than the ~15 minutes a full app build costs. it is deliberately not wired into cmux.xcodeproj. it immediately paid for itself by catching a literal \(queryText) in the empty state and a .link button that renders AppKit's link blue next to the accent. --- .gitignore | 5 +- .../UI/SidebarFilterEmptyStateView.swift | 88 +++++++++++ .../Filter/UI/SidebarFilterFieldChrome.swift | 117 +++++++++++++++ .../Filter/UI/SidebarFilterFieldModel.swift | 56 +++++++ .../Filter/UI/SidebarFilterFieldView.swift | 63 ++++++++ .../UI/SidebarFilterHighlightStyle.swift | 84 +++++++++++ .../UI/SidebarFilterHighlightedText.swift | 70 +++++++++ .../Filter/UI/SidebarFilterMetrics.swift | 37 +++++ .../Filter/UI/SidebarFilterScopeLabel.swift | 25 ++++ .../SidebarFilterHighlightedTextTests.swift | 71 +++++++++ .../macOS/CmuxSidebarGallery/Package.resolved | 15 ++ .../macOS/CmuxSidebarGallery/Package.swift | 29 ++++ .../CmuxSidebarGallery/GalleryCatalog.swift | 137 ++++++++++++++++++ .../CmuxSidebarGallery/GalleryMockRow.swift | 67 +++++++++ .../CmuxSidebarGallery/GalleryRenderer.swift | 60 ++++++++ .../CmuxSidebarGallery/GalleryScene.swift | 14 ++ .../Sources/CmuxSidebarGallery/main.swift | 31 ++++ 17 files changed, 966 insertions(+), 3 deletions(-) create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterEmptyStateView.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldChrome.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldModel.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldView.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightStyle.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightedText.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterMetrics.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterScopeLabel.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightedTextTests.swift create mode 100644 Packages/macOS/CmuxSidebarGallery/Package.resolved create mode 100644 Packages/macOS/CmuxSidebarGallery/Package.swift create mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift create mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryMockRow.swift create mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryRenderer.swift create mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryScene.swift create mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/main.swift diff --git a/.gitignore b/.gitignore index bd653d7aad8e..6cfd6cb80770 100644 --- a/.gitignore +++ b/.gitignore @@ -19,7 +19,6 @@ ios/Config/AppStoreConnect.local.plist .swiftpm/ .ci-source-packages/ .ci-reload-derived-data/ -.glaeda/ # GhosttyKit binary (built from ghostty submodule via scripts/setup.sh) GhosttyKit.xcframework @@ -80,5 +79,5 @@ tmux-*.log /testbox-benchmark/ /.cmux-scratch/ -# Locally downloaded prebuilt client for Packages/Shared/CmuxTerminalClient -Packages/Shared/CmuxTerminalClient/CmuxTerminalClient.xcframework/ +# CmuxSidebarGallery render output (dev-only, see Packages/macOS/CmuxSidebarGallery) +.gallery/ diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterEmptyStateView.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterEmptyStateView.swift new file mode 100644 index 000000000000..a5edbe13c34f --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterEmptyStateView.swift @@ -0,0 +1,88 @@ +public import SwiftUI + +/// What the workspace list shows when a filter matches nothing. +/// +/// An empty list is ambiguous: it reads the same as "you have no workspaces". +/// This says which query came up empty and offers the way out, so clearing the +/// filter never requires remembering a shortcut. +public struct SidebarFilterEmptyStateView: View { + /// The query that matched nothing. + public let queryText: String + /// The field the query was scoped to, if any. + public let scopeField: SidebarFilterField? + /// The sidebar's accent color. + public let accent: Color + /// Geometry, scaled to the sidebar's font size. + public let metrics: SidebarFilterMetrics + /// Clears the filter. + public let onClear: () -> Void + + /// Creates the empty state. + /// + /// - Parameters: + /// - queryText: The text that matched nothing. + /// - scopeField: The field the query was scoped to, if any. + /// - accent: The sidebar accent color. + /// - metrics: Geometry for the current font scale. + /// - onClear: Called when the user chooses to clear the filter. + public init( + queryText: String, + scopeField: SidebarFilterField? = nil, + accent: Color, + metrics: SidebarFilterMetrics = SidebarFilterMetrics(), + onClear: @escaping () -> Void + ) { + self.queryText = queryText + self.scopeField = scopeField + self.accent = accent + self.metrics = metrics + self.onClear = onClear + } + + public var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(headline) + .font(.system(size: 11 * metrics.fontScale)) + .foregroundStyle(.secondary) + .lineLimit(2) + + // Plain rather than `.link`: the link style paints AppKit's link + // blue, which is a different blue from the sidebar accent sitting + // right above it. + Button(action: onClear) { + Text(String( + localized: "sidebar.filter.empty.clear", + defaultValue: "Clear filter" + )) + .font(.system(size: 10.5 * metrics.fontScale, weight: .medium)) + .foregroundStyle(accent) + } + .buttonStyle(.plain) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, metrics.horizontalInset + metrics.fieldPadding) + .padding(.vertical, 10) + .accessibilityIdentifier("SidebarFilterEmptyState") + } + + /// The query as it appears in the message, quoted so a query made of + /// spaces or punctuation is still visible. + private var quoted: String { + "\u{201C}\(queryText)\u{201D}" + } + + /// The message, naming the scope when the query was scoped so a fruitless + /// `@` search does not look like the workspace simply is not there. + private var headline: String { + guard let scopeField else { + return String( + localized: "sidebar.filter.empty.unscoped", + defaultValue: "No workspace matches \(quoted)" + ) + } + return String( + localized: "sidebar.filter.empty.scoped", + defaultValue: "No \(SidebarFilterScopeLabel.text(for: scopeField)) matches \(quoted)" + ) + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldChrome.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldChrome.swift new file mode 100644 index 000000000000..25d4da222c79 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldChrome.swift @@ -0,0 +1,117 @@ +public import SwiftUI + +/// The filter field's frame: leading glyph, scope chip, match count, clear +/// button, and the rounded fill behind them. +/// +/// The text input is injected rather than owned, so the same chrome hosts the +/// live `TextField` in the app and a plain `Text` anywhere the field has to be +/// drawn without being editable (the settings preview, the render gallery). +/// Keeping the chrome input-agnostic also means the surrounding layout is +/// exercised by everything that draws it, not only by the interactive path. +public struct SidebarFilterFieldChrome: View { + /// Everything the chrome draws from. + public let model: SidebarFilterFieldModel + /// Geometry, scaled to the sidebar's font size. + public let metrics: SidebarFilterMetrics + /// The sidebar's accent color. + public let accent: Color + /// Whether to draw the focused accent ring. + public let isFocused: Bool + /// Invoked when the user clears the field. + public let onCancel: () -> Void + /// The text input to host. + @ViewBuilder public let input: () -> Input + + /// Creates the chrome around `input`. + /// + /// - Parameters: + /// - model: The render snapshot. + /// - metrics: Geometry for the current font scale. + /// - accent: The sidebar accent color. + /// - isFocused: Whether to draw the focus ring. + /// - onCancel: Called when the clear button is pressed. + /// - input: The text input, editable or otherwise. + public init( + model: SidebarFilterFieldModel, + metrics: SidebarFilterMetrics = SidebarFilterMetrics(), + accent: Color, + isFocused: Bool, + onCancel: @escaping () -> Void, + @ViewBuilder input: @escaping () -> Input + ) { + self.model = model + self.metrics = metrics + self.accent = accent + self.isFocused = isFocused + self.onCancel = onCancel + self.input = input + } + + public var body: some View { + HStack(spacing: metrics.itemSpacing) { + Image(systemName: "magnifyingglass") + .font(.system(size: metrics.accessoryFontSize, weight: .medium)) + .foregroundStyle(.secondary) + + if let scopeField = model.scopeField { + scopeChip(scopeField) + } + + input() + .frame(maxWidth: .infinity, alignment: .leading) + + if model.showsMatchCount { + // Zero is shown, not hidden. It is the calmest possible "no + // matches" signal: the number the user is already watching + // simply reaches 0, with no colour change anywhere. + Text(verbatim: "\(model.matchCount)") + .font(.system(size: metrics.accessoryFontSize, weight: .medium)) + .monospacedDigit() + .foregroundStyle(model.isEmptyResult ? AnyShapeStyle(.tertiary) : AnyShapeStyle(.secondary)) + } + + if model.hasQuery { + Button(action: onCancel) { + Image(systemName: "xmark.circle.fill") + .font(.system(size: metrics.accessoryFontSize)) + .foregroundStyle(.tertiary) + } + .buttonStyle(.plain) + .accessibilityLabel(String( + localized: "sidebar.filter.clear", + defaultValue: "Clear filter" + )) + } + } + .padding(.horizontal, metrics.fieldPadding) + .frame(height: metrics.fieldHeight) + .background(background) + .padding(.horizontal, metrics.horizontalInset) + } + + private func scopeChip(_ field: SidebarFilterField) -> some View { + Text(SidebarFilterScopeLabel.text(for: field)) + .font(.system(size: metrics.scopeFontSize, weight: .semibold)) + .foregroundStyle(accent) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + RoundedRectangle(cornerRadius: 3, style: .continuous) + .fill(accent.opacity(0.15)) + ) + .fixedSize() + } + + /// Focus reads as a slightly brighter fill and a soft accent ring rather + /// than a hard outline. The sidebar is a resting surface that happens to + /// contain a field; a system-weight focus border makes the field shout + /// over the workspace list it belongs to. + private var background: some View { + RoundedRectangle(cornerRadius: metrics.cornerRadius, style: .continuous) + .fill(Color.primary.opacity(isFocused ? 0.09 : 0.06)) + .overlay( + RoundedRectangle(cornerRadius: metrics.cornerRadius, style: .continuous) + .strokeBorder(accent.opacity(isFocused ? 0.3 : 0), lineWidth: 1) + ) + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldModel.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldModel.swift new file mode 100644 index 000000000000..798c676ee064 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldModel.swift @@ -0,0 +1,56 @@ +public import Foundation + +/// Immutable render input for the sidebar's filter field. +/// +/// A value snapshot, not a store: the field sits above a virtualized list, and +/// the sidebar's snapshot-boundary rule keeps observable references out of +/// everything in that subtree. +public struct SidebarFilterFieldModel: Sendable, Equatable { + /// The text currently in the field. + public let queryText: String + /// How many workspaces match. + public let matchCount: Int + /// How many workspaces exist in total. + public let totalCount: Int + /// The field the query is scoped to by a sigil, if any. + public let scopeField: SidebarFilterField? + + /// Whether the field has anything in it. + public var hasQuery: Bool { + !queryText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + /// Whether a real query matched nothing. + public var isEmptyResult: Bool { + hasQuery && matchCount == 0 + } + + /// Whether to show the match count. + /// + /// Hidden when the query matches everything, because "42 of 42" is noise; + /// the count earns its place once it is narrowing something. Zero counts + /// as narrowing: watching the number fall to 0 is how the field reports a + /// miss, with no colour change needed anywhere. + public var showsMatchCount: Bool { + hasQuery && matchCount < totalCount + } + + /// Creates a snapshot. + /// + /// - Parameters: + /// - queryText: Text currently in the field. + /// - matchCount: Number of matching workspaces. + /// - totalCount: Number of workspaces in the sidebar. + /// - scopeField: Field the query is scoped to, if a sigil was typed. + public init( + queryText: String, + matchCount: Int, + totalCount: Int, + scopeField: SidebarFilterField? = nil + ) { + self.queryText = queryText + self.matchCount = matchCount + self.totalCount = totalCount + self.scopeField = scopeField + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldView.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldView.swift new file mode 100644 index 000000000000..1eeaea8fb41b --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldView.swift @@ -0,0 +1,63 @@ +public import SwiftUI + +/// The sidebar's live filter field: ``SidebarFilterFieldChrome`` hosting an +/// editable text field. +/// +/// Renders from a value snapshot plus an action bundle, never from a store, so +/// it satisfies the same snapshot-boundary rule as the rows below it. +public struct SidebarFilterFieldView: View { + /// Everything the field draws from. + public let model: SidebarFilterFieldModel + /// Geometry, scaled to the sidebar's font size. + public let metrics: SidebarFilterMetrics + /// The sidebar's accent color. + public let accent: Color + /// Live binding for the text field's contents. + @Binding public var queryText: String + /// Invoked when the user clears the field or presses Escape. + public let onCancel: () -> Void + + @FocusState private var isFocused: Bool + + /// Creates a filter field. + /// + /// - Parameters: + /// - model: The render snapshot. + /// - metrics: Geometry for the current font scale. + /// - accent: The sidebar accent color. + /// - queryText: Binding to the field's text. + /// - onCancel: Called on clear or Escape. + public init( + model: SidebarFilterFieldModel, + metrics: SidebarFilterMetrics = SidebarFilterMetrics(), + accent: Color, + queryText: Binding, + onCancel: @escaping () -> Void + ) { + self.model = model + self.metrics = metrics + self.accent = accent + self._queryText = queryText + self.onCancel = onCancel + } + + public var body: some View { + SidebarFilterFieldChrome( + model: model, + metrics: metrics, + accent: accent, + isFocused: isFocused, + onCancel: onCancel + ) { + TextField( + String(localized: "sidebar.filter.placeholder", defaultValue: "Filter workspaces"), + text: $queryText + ) + .textFieldStyle(.plain) + .font(.system(size: metrics.queryFontSize)) + .focused($isFocused) + .onExitCommand(perform: onCancel) + } + .accessibilityIdentifier("SidebarFilterField") + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightStyle.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightStyle.swift new file mode 100644 index 000000000000..25315699e5eb --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightStyle.swift @@ -0,0 +1,84 @@ +public import SwiftUI + +/// How a filter match is emphasised inside a row label. +/// +/// Matched characters get a tinted pill behind them rather than a coloured +/// foreground. Recolouring the text would put a second blue next to the +/// selection fill, which is already cmux's accent, and the two blues then +/// compete to mean different things. A background tint reads as "the search +/// found this here" without borrowing the colour that means "this row is +/// selected". +/// +/// On a selected row the tint is drawn from white instead of the accent, +/// because the accent is the background there, and unmatched text steps back +/// so the match wins on contrast rather than on weight alone. +public struct SidebarFilterHighlightStyle: Sendable { + /// The sidebar accent, used for the tint on unselected rows. + public let accent: Color + /// Whether the row is the selected one. + public let isActiveRow: Bool + /// Whether the sidebar is drawing in its dark scheme. + public let isDark: Bool + + /// Creates a style. + /// + /// - Parameters: + /// - accent: The sidebar accent color. + /// - isActiveRow: Whether the row being drawn is selected. + /// - isDark: Whether the sidebar is in its dark scheme. + public init(accent: Color, isActiveRow: Bool, isDark: Bool) { + self.accent = accent + self.isActiveRow = isActiveRow + self.isDark = isDark + } + + /// Background tint painted behind a matched run. + /// + /// The accent needs more alpha on a near-black ground than on a light one + /// to reach the same apparent contrast, so the tint is scheme-aware rather + /// than one constant that is right in exactly one theme. + public var matchBackground: Color { + if isActiveRow { + return Color.white.opacity(0.28) + } + return accent.opacity(isDark ? 0.34 : 0.22) + } + + /// Foreground for a matched run. + public var matchForeground: Color { + isActiveRow ? .white : .primary + } + + /// Foreground for the rest of the label. + /// + /// Stepped back on a selected row so the match reads as emphasis; left + /// alone otherwise, since the tint is already doing the work. + public var restForeground: Color { + isActiveRow ? Color.white.opacity(0.72) : .primary + } + + /// Builds the attributed label for `displayText` with `ranges` emphasised. + /// + /// - Parameters: + /// - displayText: The label exactly as the row draws it. + /// - ranges: Match ranges from ``SidebarFilterMatch/highlightRanges(for:in:)``. + /// - Returns: An attributed string whose plain text equals `displayText`. + public func attributedLabel( + displayText: String, + ranges: [Range] + ) -> AttributedString { + let runs = SidebarFilterHighlightedText.runs(displayText: displayText, ranges: ranges) + var result = AttributedString() + for run in runs { + var piece = AttributedString(run.text) + if run.isMatch { + piece.foregroundColor = matchForeground + piece.backgroundColor = matchBackground + } else { + piece.foregroundColor = restForeground + } + result.append(piece) + } + return result + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightedText.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightedText.swift new file mode 100644 index 000000000000..3f38dbb68df9 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightedText.swift @@ -0,0 +1,70 @@ +public import Foundation + +/// Builds the attributed form of a row label with the filter's matched +/// characters emphasised. +/// +/// Pure string work, deliberately separate from any view: the sidebar draws +/// rows through AppKit and SwiftUI in different places, and both need the same +/// emphasis decisions from the same match data. +public struct SidebarFilterHighlightedText: Sendable { + /// One run of the label, and whether the filter matched it. + public struct Run: Sendable, Equatable { + /// The run's text. + public let text: String + /// Whether this run should be emphasised as a match. + public let isMatch: Bool + + /// Creates a run. + public init(text: String, isMatch: Bool) { + self.text = text + self.isMatch = isMatch + } + } + + /// Splits `displayText` into alternating unmatched and matched runs. + /// + /// Adjacent matched characters collapse into one run, so a label renders as + /// few attribute changes as possible rather than one per character. + /// + /// - Parameters: + /// - displayText: The label exactly as the row draws it. + /// - ranges: Ascending, non-overlapping match ranges in character offsets, + /// as returned by ``SidebarFilterMatch/highlightRanges(for:in:)``. + /// - Returns: Runs in order; concatenating their text reproduces + /// `displayText` exactly. An empty or out-of-bounds range set yields a + /// single unmatched run. + public static func runs( + displayText: String, + ranges: [Range] + ) -> [Run] { + let characters = Array(displayText) + guard !ranges.isEmpty else { + return characters.isEmpty ? [] : [Run(text: displayText, isMatch: false)] + } + guard ranges.allSatisfy({ $0.lowerBound >= 0 && $0.upperBound <= characters.count }) + else { + return [Run(text: displayText, isMatch: false)] + } + + var runs: [Run] = [] + var cursor = 0 + for range in ranges { + // A caller that hands over unsorted or overlapping ranges would + // otherwise duplicate or drop characters; skip rather than corrupt + // the label. + guard range.lowerBound >= cursor else { continue } + if range.lowerBound > cursor { + runs.append(Run( + text: String(characters[cursor.. String { + switch field { + case .title: + return String(localized: "sidebar.filter.scope.title", defaultValue: "title") + case .branch: + return String(localized: "sidebar.filter.scope.branch", defaultValue: "branch") + case .directory: + return String(localized: "sidebar.filter.scope.directory", defaultValue: "path") + case .group: + return String(localized: "sidebar.filter.scope.group", defaultValue: "group") + case .pullRequest: + return String(localized: "sidebar.filter.scope.pullRequest", defaultValue: "pr") + case .port: + return String(localized: "sidebar.filter.scope.port", defaultValue: "port") + } + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightedTextTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightedTextTests.swift new file mode 100644 index 000000000000..1c12ab549640 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightedTextTests.swift @@ -0,0 +1,71 @@ +import Foundation +import Testing +@testable import CmuxSidebar + +@Suite("SidebarFilterHighlightedText") +struct SidebarFilterHighlightedTextTests { + private func concatenated(_ runs: [SidebarFilterHighlightedText.Run]) -> String { + runs.map(\.text).joined() + } + + @Test func noRangesYieldOneUnmatchedRun() { + let runs = SidebarFilterHighlightedText.runs(displayText: "cmux-web", ranges: []) + #expect(runs == [.init(text: "cmux-web", isMatch: false)]) + } + + @Test func emptyTextYieldsNoRuns() { + #expect(SidebarFilterHighlightedText.runs(displayText: "", ranges: []).isEmpty) + } + + @Test func aLeadingMatchSplitsIntoTwoRuns() { + let runs = SidebarFilterHighlightedText.runs(displayText: "cmux-web", ranges: [0..<4]) + #expect(runs == [ + .init(text: "cmux", isMatch: true), + .init(text: "-web", isMatch: false), + ]) + } + + @Test func anInteriorMatchSplitsIntoThreeRuns() { + let runs = SidebarFilterHighlightedText.runs(displayText: "cmux-web", ranges: [5..<8]) + #expect(runs == [ + .init(text: "cmux-", isMatch: false), + .init(text: "web", isMatch: true), + ]) + } + + @Test func severalRangesAlternate() { + let runs = SidebarFilterHighlightedText.runs( + displayText: "sidebar-rail", + ranges: [0..<2, 8..<12] + ) + #expect(runs.map(\.isMatch) == [true, false, true]) + #expect(concatenated(runs) == "sidebar-rail") + } + + @Test func runsAlwaysReproduceTheOriginalLabel() { + // The label the user reads must never change because of highlighting. + let text = "feature/sidebar-rail-42" + for ranges in [[0..<7], [8..<15], [0..<1, 4..<6, 20..<23]] { + let runs = SidebarFilterHighlightedText.runs(displayText: text, ranges: ranges) + #expect(concatenated(runs) == text) + } + } + + @Test func outOfBoundsRangesFallBackToAPlainLabel() { + let runs = SidebarFilterHighlightedText.runs(displayText: "cmux", ranges: [0..<99]) + #expect(runs == [.init(text: "cmux", isMatch: false)]) + } + + @Test func overlappingRangesAreSkippedRatherThanDuplicatingCharacters() { + let runs = SidebarFilterHighlightedText.runs( + displayText: "cmux-web", + ranges: [0..<4, 2..<6] + ) + #expect(concatenated(runs) == "cmux-web") + } + + @Test func aFullyMatchedLabelIsOneMatchedRun() { + let runs = SidebarFilterHighlightedText.runs(displayText: "cmux", ranges: [0..<4]) + #expect(runs == [.init(text: "cmux", isMatch: true)]) + } +} diff --git a/Packages/macOS/CmuxSidebarGallery/Package.resolved b/Packages/macOS/CmuxSidebarGallery/Package.resolved new file mode 100644 index 000000000000..763a67302633 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGallery/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "9af779e5749178840b9e12e3787009efd7e9756e1dafac375fcbbb2a3066978c", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + } + ], + "version" : 3 +} diff --git a/Packages/macOS/CmuxSidebarGallery/Package.swift b/Packages/macOS/CmuxSidebarGallery/Package.swift new file mode 100644 index 000000000000..2adb1d84b2fa --- /dev/null +++ b/Packages/macOS/CmuxSidebarGallery/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +// Development-only. Deliberately NOT wired into cmux.xcodeproj: it exists so +// sidebar UI can be iterated on in seconds by rendering to PNG, instead of +// paying a ~15 minute app build to look at a panel. Nothing ships from here. +let package = Package( + name: "CmuxSidebarGallery", + platforms: [ + .macOS(.v14), + ], + dependencies: [ + .package(path: "../CmuxSidebar"), + .package(path: "../CmuxFoundation"), + ], + targets: [ + .executableTarget( + name: "CmuxSidebarGallery", + dependencies: [ + .product(name: "CmuxSidebar", package: "CmuxSidebar"), + .product(name: "CmuxFoundation", package: "CmuxFoundation"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + ] + ), + ] +) diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift new file mode 100644 index 000000000000..89e11382ea7d --- /dev/null +++ b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift @@ -0,0 +1,137 @@ +import CmuxSidebar +import SwiftUI + +/// The scenes the gallery renders. +/// +/// Fields are drawn through ``SidebarFilterFieldChrome`` with a static `Text` +/// standing in for the live `TextField`: `ImageRenderer` cannot rasterise +/// AppKit-backed controls, and the chrome is what is being reviewed here +/// anyway. +@MainActor +struct GalleryCatalog { + /// cmux's sidebar accent, matching `cmuxAccentNSColor(for:)`. + static let accent = Color(.sRGB, red: 0, green: 145.0 / 255.0, blue: 1, opacity: 1) + + /// A non-editable stand-in for the live text field. + @ViewBuilder + static func staticInput(_ text: String, metrics: SidebarFilterMetrics) -> some View { + if text.isEmpty { + Text(verbatim: "Filter workspaces") + .font(.system(size: metrics.queryFontSize)) + .foregroundStyle(.tertiary) + } else { + Text(verbatim: text) + .font(.system(size: metrics.queryFontSize)) + .foregroundStyle(.primary) + } + } + + static func field( + _ model: SidebarFilterFieldModel, + isFocused: Bool = false + ) -> some View { + let metrics = SidebarFilterMetrics() + // A sigil scopes the query, so the chip replaces it in the visible text. + let visible = model.scopeField == nil + ? model.queryText + : String(model.queryText.dropFirst()) + return SidebarFilterFieldChrome( + model: model, + metrics: metrics, + accent: accent, + isFocused: isFocused, + onCancel: {} + ) { + staticInput(visible, metrics: metrics) + } + } + + // MARK: - Fixtures + + static let workspaces: [(String, String, String)] = [ + ("cmux", "main", "~/repos/cmux"), + ("sidebar drag fix", "fix/sidebar-drag-failsafe", "~/repos/cmux"), + ("ghostty", "cmux-fork", "~/repos/ghostty"), + ("dragnet", "feat/drag-reorder", "~/repos/dragnet"), + ("web", "main", "~/repos/cmux/web"), + ("daemon", "main", "~/repos/cmux/daemon"), + ] + + @ViewBuilder + static func rail( + @ViewBuilder header: () -> Header, + @ViewBuilder content: () -> Body + ) -> some View { + VStack(alignment: .leading, spacing: 8) { + header() + VStack(alignment: .leading, spacing: 2) { + content() + } + } + } + + static func scenes() -> [GalleryScene] { + [ + GalleryScene(name: "filter-field-idle") { + field(SidebarFilterFieldModel(queryText: "", matchCount: 24, totalCount: 24)) + }, + GalleryScene(name: "filter-field-scoped") { + field( + SidebarFilterFieldModel( + queryText: "@fix-drag", + matchCount: 2, + totalCount: 24, + scopeField: .branch + ), + isFocused: true + ) + }, + GalleryScene(name: "rail-resting", width: 260) { + rail { + field(SidebarFilterFieldModel(queryText: "", matchCount: 6, totalCount: 6)) + } content: { + ForEach(Array(workspaces.enumerated()), id: \.offset) { index, workspace in + GalleryMockRow( + title: workspace.0, + branch: workspace.1, + directory: workspace.2, + isActive: index == 1 + ) + } + } + }, + GalleryScene(name: "rail-filtering", width: 260) { + rail { + field( + SidebarFilterFieldModel(queryText: "drag", matchCount: 2, totalCount: 6), + isFocused: true + ) + } content: { + GalleryMockRow( + title: "sidebar drag fix", + branch: "fix/sidebar-drag-failsafe", + directory: "~/repos/cmux", + isActive: true, + titleRanges: [8..<12] + ) + GalleryMockRow( + title: "dragnet", + branch: "feat/drag-reorder", + directory: "~/repos/dragnet", + titleRanges: [0..<4] + ) + } + }, + GalleryScene(name: "rail-empty-result", width: 260) { + rail { + field( + SidebarFilterFieldModel(queryText: "zzzz", matchCount: 0, totalCount: 6), + isFocused: true + ) + } content: { + SidebarFilterEmptyStateView(queryText: "zzzz", accent: accent, onClear: {}) + } + }, + ] + } +} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryMockRow.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryMockRow.swift new file mode 100644 index 000000000000..87797d76911a --- /dev/null +++ b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryMockRow.swift @@ -0,0 +1,67 @@ +import CmuxSidebar +import SwiftUI + +/// A stand-in for cmux's AppKit workspace row. +/// +/// The real row is an AppKit cell in the app target and cannot be reached from +/// a package. This mirrors its metrics exactly (12.5pt semibold title, 10.5pt +/// details, 8pt leading inset, 6pt corner radius, 8/4 vertical rhythm) so the +/// new chrome is reviewed in a list that lays out like the real one. It is +/// gallery-only and ships nowhere. +struct GalleryMockRow: View { + let title: String + let branch: String? + let directory: String? + var isActive: Bool = false + var titleRanges: [Range] = [] + var branchRanges: [Range] = [] + var isDimmed: Bool = false + + @Environment(\.colorScheme) private var colorScheme + + private let accent = GalleryCatalog.accent + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + highlighted(title, ranges: titleRanges, size: 12.5, weight: .semibold) + if let branch { + HStack(spacing: 4) { + Image(systemName: "arrow.triangle.branch") + .font(.system(size: 9)) + .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.7)) : AnyShapeStyle(.secondary)) + highlighted(branch, ranges: branchRanges, size: 10.5, weight: .regular) + .opacity(isActive ? 1 : 0.72) + } + } + if let directory { + Text(verbatim: directory) + .font(.system(size: 10)) + .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.7)) : AnyShapeStyle(.secondary)) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(isActive ? accent : .clear) + ) + .padding(.horizontal, 8) + .opacity(isDimmed ? 0.35 : 1) + } + + private func highlighted( + _ text: String, + ranges: [Range], + size: CGFloat, + weight: Font.Weight + ) -> some View { + let style = SidebarFilterHighlightStyle( + accent: accent, + isActiveRow: isActive, + isDark: colorScheme == .dark + ) + return Text(style.attributedLabel(displayText: text, ranges: ranges)) + .font(.system(size: size, weight: weight)) + } +} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryRenderer.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryRenderer.swift new file mode 100644 index 000000000000..500f327e9079 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryRenderer.swift @@ -0,0 +1,60 @@ +import AppKit +import SwiftUI + +/// Renders scenes to PNG files offscreen. +/// +/// `ImageRenderer` rasterises a SwiftUI view without a window, so this needs no +/// Screen Recording permission and produces byte-identical output run to run. +@MainActor +struct GalleryRenderer { + let outputDirectory: URL + let scale: CGFloat + + func render(_ scenes: [GalleryScene]) throws { + try FileManager.default.createDirectory( + at: outputDirectory, + withIntermediateDirectories: true + ) + for scene in scenes { + for scheme in [ColorScheme.dark, .light] { + let suffix = scheme == .dark ? "dark" : "light" + let url = outputDirectory.appendingPathComponent("\(scene.name)-\(suffix).png") + try write(scene: scene, scheme: scheme, to: url) + print("rendered \(url.lastPathComponent)") + } + } + } + + private func write(scene: GalleryScene, scheme: ColorScheme, to url: URL) throws { + let background = scheme == .dark + ? Color(nsColor: NSColor(srgbRed: 0.106, green: 0.110, blue: 0.118, alpha: 1)) + : Color(nsColor: NSColor(srgbRed: 0.945, green: 0.945, blue: 0.953, alpha: 1)) + + let wrapped = scene.content + .frame(width: scene.width, alignment: .leading) + .padding(.vertical, 12) + .background(background) + .environment(\.colorScheme, scheme) + + let renderer = ImageRenderer(content: wrapped) + renderer.scale = scale + guard let image = renderer.nsImage, + let tiff = image.tiffRepresentation, + let rep = NSBitmapImageRep(data: tiff), + let png = rep.representation(using: .png, properties: [:]) else { + throw GalleryError.renderFailed(scene.name) + } + try png.write(to: url) + } +} + +enum GalleryError: Error, CustomStringConvertible { + case renderFailed(String) + + var description: String { + switch self { + case .renderFailed(let name): + return "failed to rasterise scene '\(name)'" + } + } +} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryScene.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryScene.swift new file mode 100644 index 000000000000..46611a946c13 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryScene.swift @@ -0,0 +1,14 @@ +import SwiftUI + +/// One named view to render, at a fixed width, in both color schemes. +struct GalleryScene { + let name: String + let width: CGFloat + let content: AnyView + + init(name: String, width: CGFloat = 260, @ViewBuilder content: () -> Content) { + self.name = name + self.width = width + self.content = AnyView(content()) + } +} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/main.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/main.swift new file mode 100644 index 000000000000..b902c74aa2d6 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/main.swift @@ -0,0 +1,31 @@ +import AppKit +import CmuxSidebar +import Foundation +import SwiftUI + +@MainActor +func run() { + var outputPath = NSString(string: "~/Developer/lab/cmux-rail/.gallery").expandingTildeInPath + var arguments = Array(CommandLine.arguments.dropFirst()) + while let flag = arguments.first { + arguments.removeFirst() + if flag == "--output", let value = arguments.first { + outputPath = value + arguments.removeFirst() + } + } + + let renderer = GalleryRenderer( + outputDirectory: URL(fileURLWithPath: outputPath), + scale: 2 + ) + do { + try renderer.render(GalleryCatalog.scenes()) + print("gallery written to \(outputPath)") + } catch { + print("gallery failed: \(error)") + exit(1) + } +} + +run() From 48cdd75703b0b96b1095ff7521b6b0ff34d83b0a Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 11:29:12 +1200 Subject: [PATCH 04/69] add floating peek panel chrome, presentation mode, panel actions the float/dock switch is one control in a fixed corner, and both modes host the identical rail at the identical width: switching moves the frame, never reflows the rows, so the row being read keeps its shape across the change. the gloss is the rim, not the fill. a uniform hairline reads as a sticker; catching more light along the top edge and falling off downward is what makes the card read as glass lying over the terminal rather than a rectangle drawn on it. the border is drawn from the panel's own side because the backdrop is whatever the terminal happens to be showing. new-workspace is a full-width footer row rather than a + glyph, so the gesture that picks a workspace is the gesture that makes one. note: ImageRenderer cannot rasterise backdrop blur, so the gallery shows the material as a flat fill. layout, rim and shadow are reviewable offscreen; the blur itself has to be judged in the running app. --- .../Peek/SidebarPresentationMode.swift | 22 +++++ .../Peek/UI/SidebarNewWorkspaceButton.swift | 60 ++++++++++++ .../Peek/UI/SidebarPeekPanelChrome.swift | 74 +++++++++++++++ .../Peek/UI/SidebarPeekPanelMetrics.swift | 69 ++++++++++++++ .../UI/SidebarPresentationToggleButton.swift | 73 ++++++++++++++ .../CmuxSidebarGallery/GalleryCatalog.swift | 94 +++++++++++++++---- .../GalleryTerminalBackdrop.swift | 55 +++++++++++ 7 files changed, 427 insertions(+), 20 deletions(-) create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarNewWorkspaceButton.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelMetrics.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPresentationToggleButton.swift create mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryTerminalBackdrop.swift diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode.swift new file mode 100644 index 000000000000..b979cb868f90 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode.swift @@ -0,0 +1,22 @@ +/// Whether the sidebar occupies layout or floats above the content. +/// +/// The two modes are the same list in two frames, and switching between them is +/// a first-class, one-click move rather than a preference buried in settings. +/// Docked is for working *in* the list; floating is for reaching into it and +/// getting back out. +public enum SidebarPresentationMode: String, Sendable, Hashable, CaseIterable { + /// The sidebar takes width from the content, as a normal split. + case docked + /// The sidebar floats over the content as an inset, shadowed card. + case floating + + /// The mode this one toggles to. + public var toggled: SidebarPresentationMode { + self == .docked ? .floating : .docked + } + + /// Whether the sidebar draws as a detached card rather than a flush pane. + public var isFloating: Bool { + self == .floating + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarNewWorkspaceButton.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarNewWorkspaceButton.swift new file mode 100644 index 000000000000..cb64cedef92d --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarNewWorkspaceButton.swift @@ -0,0 +1,60 @@ +public import SwiftUI + +/// The panel's footer action: create a workspace without leaving the sidebar. +/// +/// A full-width row rather than a small `+` glyph. The panel is often reached +/// by pointer, and a row-shaped target matches the rows above it, so the +/// gesture that picks a workspace is the same gesture that makes one. +public struct SidebarNewWorkspaceButton: View { + /// The sidebar accent color. + public let accent: Color + /// Point size of the label. + public let fontSize: CGFloat + /// Creates a workspace. + public let onCreate: () -> Void + + @State private var isHovering = false + + /// Creates the footer action. + /// + /// - Parameters: + /// - accent: The sidebar accent color. + /// - fontSize: Point size of the label. + /// - onCreate: Called to create a workspace. + public init( + accent: Color, + fontSize: CGFloat = 11.5, + onCreate: @escaping () -> Void + ) { + self.accent = accent + self.fontSize = fontSize + self.onCreate = onCreate + } + + public var body: some View { + Button(action: onCreate) { + HStack(spacing: 6) { + Image(systemName: "plus") + .font(.system(size: fontSize - 1, weight: .semibold)) + Text(String( + localized: "sidebar.newWorkspace", + defaultValue: "New workspace" + )) + .font(.system(size: fontSize, weight: .medium)) + Spacer(minLength: 0) + } + .foregroundStyle(isHovering ? AnyShapeStyle(accent) : AnyShapeStyle(.secondary)) + .padding(.horizontal, 8) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.primary.opacity(isHovering ? 0.06 : 0)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .padding(.horizontal, 8) + .accessibilityIdentifier("SidebarNewWorkspaceButton") + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift new file mode 100644 index 000000000000..87562c96bc96 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift @@ -0,0 +1,74 @@ +public import SwiftUI + +/// The floating peek panel's frame: material, hairline rim, and shadow, with +/// the workspace list hosted inside it. +/// +/// Content-agnostic on purpose. The list, the filter field, and the footer are +/// the same views the docked sidebar draws; only the frame around them changes +/// between modes, which is what makes float and dock feel like one surface in +/// two positions rather than two different sidebars. +public struct SidebarPeekPanelChrome: View { + /// Geometry for the card. + public let metrics: SidebarPeekPanelMetrics + /// The AppKit material to blur the backdrop with. + public let material: Material + /// The panel's contents. + @ViewBuilder public let content: () -> Content + + @Environment(\.colorScheme) private var colorScheme + + /// Creates the panel frame. + /// + /// - Parameters: + /// - metrics: Card geometry. + /// - material: Backdrop material for the glass. + /// - content: The list and chrome to host. + public init( + metrics: SidebarPeekPanelMetrics = .default, + material: Material = .regularMaterial, + @ViewBuilder content: @escaping () -> Content + ) { + self.metrics = metrics + self.material = material + self.content = content + } + + public var body: some View { + content() + .background(shape.fill(material)) + .overlay(rim) + .clipShape(shape) + .shadow( + color: .black.opacity(metrics.shadowOpacity), + radius: metrics.shadowRadius, + x: 0, + y: metrics.shadowOffsetY + ) + .padding(.leading, metrics.leadingInset) + .padding(.vertical, metrics.verticalInset) + .accessibilityIdentifier("SidebarPeekPanel") + } + + private var shape: RoundedRectangle { + RoundedRectangle(cornerRadius: metrics.cornerRadius, style: .continuous) + } + + /// The rim: a hairline border plus a brighter top edge. + /// + /// The gradient is the whole gloss effect. A uniform border reads as a + /// sticker; catching a little more light along the top edge and letting it + /// fall off downward is what makes the card read as a pane of glass lying + /// over the content rather than a rectangle drawn on it. + private var rim: some View { + shape.strokeBorder( + LinearGradient( + colors: colorScheme == .dark + ? [Color.white.opacity(0.22), Color.white.opacity(0.06)] + : [Color.white.opacity(0.75), Color.black.opacity(0.10)], + startPoint: .top, + endPoint: .bottom + ), + lineWidth: metrics.borderWidth + ) + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelMetrics.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelMetrics.swift new file mode 100644 index 000000000000..f2c5bb4bd758 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelMetrics.swift @@ -0,0 +1,69 @@ +public import CoreGraphics +public import SwiftUI + +/// Geometry for the floating peek panel. +/// +/// The floating frame is the docked frame inset on three sides. Keeping the +/// list's own width identical across modes is deliberate: switching float and +/// dock should move the frame, not reflow the rows inside it, so the row a user +/// was reading stays the same shape. +public struct SidebarPeekPanelMetrics: Sendable, Equatable { + /// Distance from the window's leading edge to the panel. + public let leadingInset: CGFloat + /// Distance from the window's top and bottom to the panel. + public let verticalInset: CGFloat + /// The panel's corner radius. + public let cornerRadius: CGFloat + /// Radius of the drop shadow. + public let shadowRadius: CGFloat + /// Vertical offset of the drop shadow. + public let shadowOffsetY: CGFloat + /// Opacity of the drop shadow. + public let shadowOpacity: Double + /// Width of the hairline border. + public let borderWidth: CGFloat + + /// The shipped defaults. + /// + /// A 12pt radius against the row's 6pt keeps the card reading as a + /// container for the rows rather than as one big row. The shadow is large + /// and soft rather than tight and dark, so the panel separates from a + /// terminal's arbitrary background without printing a hard edge on it. + public static let `default` = SidebarPeekPanelMetrics( + leadingInset: 8, + verticalInset: 10, + cornerRadius: 12, + shadowRadius: 22, + shadowOffsetY: 6, + shadowOpacity: 0.34, + borderWidth: 0.5 + ) + + /// Creates panel metrics. + public init( + leadingInset: CGFloat, + verticalInset: CGFloat, + cornerRadius: CGFloat, + shadowRadius: CGFloat, + shadowOffsetY: CGFloat, + shadowOpacity: Double, + borderWidth: CGFloat + ) { + self.leadingInset = leadingInset + self.verticalInset = verticalInset + self.cornerRadius = cornerRadius + self.shadowRadius = shadowRadius + self.shadowOffsetY = shadowOffsetY + self.shadowOpacity = shadowOpacity + self.borderWidth = borderWidth + } + + /// Hairline colour for the panel's edge. + /// + /// The card sits on an unknown background (whatever the terminal is + /// showing), so the border is drawn from the panel's own side of the edge: + /// a light inner rim in dark mode, a dark one in light mode. + public func borderColor(isDark: Bool) -> Color { + isDark ? Color.white.opacity(0.12) : Color.black.opacity(0.10) + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPresentationToggleButton.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPresentationToggleButton.swift new file mode 100644 index 000000000000..a20d38856dc8 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPresentationToggleButton.swift @@ -0,0 +1,73 @@ +public import SwiftUI + +/// The one-click switch between the docked and floating sidebar. +/// +/// Sits in the panel's top-trailing corner where a window control would, and +/// stays in the same place in both modes so the gesture to go back is the +/// gesture that got you here. +public struct SidebarPresentationToggleButton: View { + /// The mode currently in effect. + public let mode: SidebarPresentationMode + /// The sidebar accent color. + public let accent: Color + /// Point size of the glyph. + public let fontSize: CGFloat + /// Switches to the other mode. + public let onToggle: () -> Void + + @State private var isHovering = false + + /// Creates the toggle. + /// + /// - Parameters: + /// - mode: The mode currently in effect. + /// - accent: The sidebar accent color. + /// - fontSize: Point size of the glyph. + /// - onToggle: Called to switch modes. + public init( + mode: SidebarPresentationMode, + accent: Color, + fontSize: CGFloat = 11, + onToggle: @escaping () -> Void + ) { + self.mode = mode + self.accent = accent + self.fontSize = fontSize + self.onToggle = onToggle + } + + public var body: some View { + Button(action: onToggle) { + Image(systemName: glyph) + .font(.system(size: fontSize, weight: .medium)) + .foregroundStyle(isHovering ? AnyShapeStyle(accent) : AnyShapeStyle(.secondary)) + .frame(width: 20, height: 20) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(Color.primary.opacity(isHovering ? 0.08 : 0)) + ) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { isHovering = $0 } + .help(helpText) + .accessibilityLabel(helpText) + .accessibilityIdentifier("SidebarPresentationToggle") + } + + /// Shows the mode being switched *to*, not the mode in effect: the button + /// is a door, and a door is labelled with where it leads. + /// + /// Both glyphs are drawn from the same `sidebar.*` family so the control + /// reads as one switch with two positions. A pushpin would say "keep this", + /// which is not what docking does to the layout. + private var glyph: String { + mode.isFloating ? "sidebar.leading" : "macwindow" + } + + private var helpText: String { + mode.isFloating + ? String(localized: "sidebar.presentation.dock", defaultValue: "Dock sidebar") + : String(localized: "sidebar.presentation.float", defaultValue: "Float sidebar") + } +} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift index 89e11382ea7d..2b87b471ce7e 100644 --- a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift +++ b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift @@ -53,8 +53,6 @@ struct GalleryCatalog { ("sidebar drag fix", "fix/sidebar-drag-failsafe", "~/repos/cmux"), ("ghostty", "cmux-fork", "~/repos/ghostty"), ("dragnet", "feat/drag-reorder", "~/repos/dragnet"), - ("web", "main", "~/repos/cmux/web"), - ("daemon", "main", "~/repos/cmux/daemon"), ] @ViewBuilder @@ -70,11 +68,75 @@ struct GalleryCatalog { } } + /// The full rail contents: filter field, rows, footer action. + @ViewBuilder + static func railContents( + mode: SidebarPresentationMode, + filter: SidebarFilterFieldModel, + activeIndex: Int = 1 + ) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 4) { + field(filter, isFocused: filter.hasQuery) + SidebarPresentationToggleButton(mode: mode, accent: accent, onToggle: {}) + .padding(.trailing, 8) + } + .padding(.top, 10) + + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(workspaces.enumerated()), id: \.offset) { index, workspace in + GalleryMockRow( + title: workspace.0, + branch: workspace.1, + directory: workspace.2, + isActive: index == activeIndex + ) + } + } + .padding(.top, 8) + + Spacer(minLength: 8) + Divider().opacity(0.4).padding(.horizontal, 8) + SidebarNewWorkspaceButton(accent: accent, onCreate: {}) + .padding(.vertical, 6) + } + } + + static let sceneWidth: CGFloat = 620 + static let sceneHeight: CGFloat = 400 + static let railWidth: CGFloat = 260 + + /// The floating panel over mock terminal content, as it appears on reveal. + static func floatingOverContent(filter: SidebarFilterFieldModel) -> some View { + ZStack(alignment: .topLeading) { + GalleryTerminalBackdrop() + SidebarPeekPanelChrome { + railContents(mode: .floating, filter: filter) + .frame(maxHeight: .infinity, alignment: .top) + } + .frame(width: railWidth + SidebarPeekPanelMetrics.default.leadingInset) + } + .frame(width: sceneWidth, height: sceneHeight) + .clipped() + } + + /// The docked sidebar beside the same content, for the mode comparison. + static func dockedBesideContent(filter: SidebarFilterFieldModel) -> some View { + HStack(spacing: 0) { + railContents(mode: .docked, filter: filter) + .frame(width: railWidth) + .frame(maxHeight: .infinity, alignment: .top) + .background(Color.primary.opacity(0.04)) + Divider().opacity(0.5) + GalleryTerminalBackdrop() + } + .frame(width: sceneWidth, height: sceneHeight) + .clipped() + } + static func scenes() -> [GalleryScene] { - [ - GalleryScene(name: "filter-field-idle") { - field(SidebarFilterFieldModel(queryText: "", matchCount: 24, totalCount: 24)) - }, + let resting = SidebarFilterFieldModel(queryText: "", matchCount: 6, totalCount: 6) + return [ GalleryScene(name: "filter-field-scoped") { field( SidebarFilterFieldModel( @@ -86,20 +148,6 @@ struct GalleryCatalog { isFocused: true ) }, - GalleryScene(name: "rail-resting", width: 260) { - rail { - field(SidebarFilterFieldModel(queryText: "", matchCount: 6, totalCount: 6)) - } content: { - ForEach(Array(workspaces.enumerated()), id: \.offset) { index, workspace in - GalleryMockRow( - title: workspace.0, - branch: workspace.1, - directory: workspace.2, - isActive: index == 1 - ) - } - } - }, GalleryScene(name: "rail-filtering", width: 260) { rail { field( @@ -132,6 +180,12 @@ struct GalleryCatalog { SidebarFilterEmptyStateView(queryText: "zzzz", accent: accent, onClear: {}) } }, + GalleryScene(name: "peek-floating", width: sceneWidth) { + floatingOverContent(filter: resting) + }, + GalleryScene(name: "peek-docked", width: sceneWidth) { + dockedBesideContent(filter: resting) + }, ] } } diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryTerminalBackdrop.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryTerminalBackdrop.swift new file mode 100644 index 000000000000..3ee32af4d687 --- /dev/null +++ b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryTerminalBackdrop.swift @@ -0,0 +1,55 @@ +import SwiftUI + +/// Mock terminal content for the floating panel to sit over. +/// +/// The panel's material and rim only mean anything against real content, so +/// the gallery draws something with the texture of a terminal (monospaced +/// lines of varying length and colour) rather than a flat fill. +struct GalleryTerminalBackdrop: View { + @Environment(\.colorScheme) private var colorScheme + + private let lines: [(String, Int)] = [ + ("$ swift test --filter SidebarFilter", 0), + ("Test run started.", 1), + ("BENCH sidebar-filter realistic workspaces=200", 2), + (" worstKeystroke=2.79ms typing=14.43ms matches=17", 1), + ("✔ Suite \"SidebarFilterIndex\" passed", 3), + ("✔ Test run with 34 tests in 4 suites passed", 3), + ("", 1), + ("$ git status --short", 0), + (" M Packages/macOS/CmuxSidebar/Sources/Filter", 2), + ("?? Packages/macOS/CmuxSidebarGallery", 4), + ("", 1), + ("$ ./scripts/reload.sh --tag rail", 0), + ("==> reload succeeded in 936s", 3), + ("", 1), + ("$ ", 0), + ] + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(lines.enumerated()), id: \.offset) { _, line in + Text(verbatim: line.0) + .font(.system(size: 10.5, design: .monospaced)) + .foregroundStyle(color(for: line.1)) + .frame(maxWidth: .infinity, alignment: .leading) + } + Spacer(minLength: 0) + } + .padding(14) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(colorScheme == .dark + ? Color(.sRGB, red: 0.055, green: 0.059, blue: 0.067, opacity: 1) + : Color(.sRGB, red: 0.99, green: 0.99, blue: 0.985, opacity: 1)) + } + + private func color(for kind: Int) -> Color { + switch kind { + case 0: return colorScheme == .dark ? .white.opacity(0.85) : .black.opacity(0.8) + case 2: return Color(.sRGB, red: 0.42, green: 0.62, blue: 0.95, opacity: 1) + case 3: return Color(.sRGB, red: 0.35, green: 0.75, blue: 0.5, opacity: 1) + case 4: return Color(.sRGB, red: 0.92, green: 0.68, blue: 0.3, opacity: 1) + default: return colorScheme == .dark ? .white.opacity(0.45) : .black.opacity(0.45) + } + } +} From a93b3009637fa5857fdfa79c9e786dcb8819b6fb Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 12:16:02 +1200 Subject: [PATCH 05/69] wire floating sidebar and hover-reveal into the app rides on the existing retained-AppKit-sidebar path: cmux already keeps the workspace table mounted behind a zero-width shell while the sidebar is hidden, so revealing it is a frame and opacity change with no table to build. that is what makes peek instant rather than merely fast, and it is why the reveal translates a laid-out subtree instead of animating its width, which would re-lay-out every row on every frame. the edge strip is an NSTrackingArea, not a hit-test. hitTest on the terminal host is on the typing-latency path per CLAUDE.md and runs for every keystroke; a tracking area is serviced by AppKit's mouse-moved plumbing and removes itself entirely while the sidebar is docked. it also returns nil from hitTest so it can never eat a click meant for the leading column of the terminal. presentationMode joins VerticalTabsSidebar's == because the toggle's glyph flips on it and would otherwise be skipped by the equality gate; the toggle closure stays out, like every other action bundle. --- Sources/ContentView+SidebarPeek.swift | 26 ++++ Sources/ContentView.swift | 51 ++++++- .../Filter/SidebarFilterController.swift | 129 ++++++++++++++++++ .../Filter/SidebarFilterCorpusKey.swift | 53 +++++++ .../Sidebar/Peek/SidebarPeekController.swift | 108 +++++++++++++++ .../Peek/SidebarPeekEdgeTrackingView.swift | 104 ++++++++++++++ .../Peek/SidebarPeekPresentation.swift | 104 ++++++++++++++ ...rticalTabsSidebar+PresentationToggle.swift | 26 ++++ Sources/Sidebar/SidebarState.swift | 29 +++- cmux.xcodeproj/project.pbxproj | 28 ++++ 10 files changed, 652 insertions(+), 6 deletions(-) create mode 100644 Sources/ContentView+SidebarPeek.swift create mode 100644 Sources/Sidebar/Filter/SidebarFilterController.swift create mode 100644 Sources/Sidebar/Filter/SidebarFilterCorpusKey.swift create mode 100644 Sources/Sidebar/Peek/SidebarPeekController.swift create mode 100644 Sources/Sidebar/Peek/SidebarPeekEdgeTrackingView.swift create mode 100644 Sources/Sidebar/Peek/SidebarPeekPresentation.swift create mode 100644 Sources/Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift new file mode 100644 index 000000000000..42d68963d822 --- /dev/null +++ b/Sources/ContentView+SidebarPeek.swift @@ -0,0 +1,26 @@ +import CmuxSidebar +import SwiftUI + +extension ContentView { + /// The invisible leading-edge strip that arms the hover-reveal. + /// + /// Mounted only while the sidebar is collapsed and floating: a docked + /// sidebar already occupies the leading edge, and a collapsed docked + /// sidebar is a deliberate choice the pointer should not undo. The strip + /// removes its tracking area entirely when disabled rather than ignoring + /// callbacks, so the common case costs nothing. + @ViewBuilder + var sidebarPeekEdgeStrip: some View { + let isArmed = sidebarState.presentationMode == .floating && !sidebarState.isVisible + SidebarPeekEdgeTrackingView( + width: sidebarPeek.policy.edgeWidth, + isEnabled: isArmed, + onEnter: { sidebarPeek.pointerEnteredEdge() }, + onExit: { sidebarPeek.pointerExitedEdge() } + ) + .frame(width: sidebarPeek.policy.edgeWidth) + .frame(maxHeight: .infinity) + .allowsHitTesting(false) + .zIndex(1) + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index f6b7371013f3..95b2993cd452 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -939,6 +939,11 @@ struct ContentView: View { /// consume the width, never this body. All reads/writes outside view /// bodies go through `sidebarLayout.width` directly; the computed /// `sidebarWidth` alias keeps call sites readable. + /// Owns the hover-reveal timers for this window. A StateObject (not an + /// environment object) because peek is per-window: two windows can be in + /// different peek phases at once. + // Not `private`: `ContentView+SidebarPeek` builds the edge strip from it. + @StateObject var sidebarPeek = SidebarPeekController() @State private var sidebarLayout = SidebarLayoutModel( width: CGFloat(SessionPersistencePolicy.defaultSidebarWidth) ) @@ -1683,6 +1688,17 @@ struct ContentView: View { debugSource: "titlebar.hiddenNewWorkspace" ) }, + presentationMode: sidebarState.presentationMode, + onTogglePresentationMode: { + withAnimation(SidebarPeekMotion.modeChange) { + sidebarState.togglePresentationMode() + } + if sidebarState.presentationMode == .docked { + sidebarPeek.sidebarDocked() + } else { + sidebarPeek.sidebarCollapsed() + } + }, observedWindowReference: observedWindowReference, chromeBackgroundColor: windowAppearanceSnapshot.resolvedChromeBackgroundColor, selection: $sidebarSelectionState.selection, @@ -2467,18 +2483,31 @@ struct ContentView: View { layout = AnyView( ZStack(alignment: .leading) { terminalContentWithRightSidebarPanel(appearance: appearance) + // `occupiesLayout`, not `isVisible`: a floating sidebar + // is visible but does not push the terminal aside. .modifier(SidebarWidthLeadingPaddingModifier( layout: sidebarLayout, - enabled: sidebarState.isVisible + enabled: sidebarState.occupiesLayout )) + sidebarPeekEdgeStrip SidebarWidthReader(layout: sidebarLayout) { width in sidebarPanelWithBackdrop(appearance: appearance) - .frame(width: sidebarState.isVisible ? width : 0, alignment: .leading) - .clipped() - .allowsHitTesting(sidebarState.isVisible) - .accessibilityHidden(!sidebarState.isVisible) + .sidebarPeekPresentation( + mode: sidebarState.presentationMode, + isDockedVisible: sidebarState.isVisible, + isPeeking: sidebarPeek.presentsPanel, + width: width, + onPanelHoverChange: { isInside in + if isInside { + sidebarPeek.acquire(.pointerInsidePanel) + } else { + sidebarPeek.release(.pointerInsidePanel) + } + } + ) } } + .animation(SidebarPeekMotion.modeChange, value: sidebarState.presentationMode) ) } else if useWithinWindow { // Overlay mode keeps the left sidebar on top, but the right @@ -11290,6 +11319,7 @@ struct VerticalTabsSidebar: View, Equatable { && lhs.sidebarUnread === rhs.sidebarUnread && lhs.titlebarControlsLayoutModel === rhs.titlebarControlsLayoutModel && lhs.isPresented == rhs.isPresented + && lhs.presentationMode == rhs.presentationMode && lhs.chromeBackgroundColor.isEqual(rhs.chromeBackgroundColor) } @@ -11304,6 +11334,10 @@ struct VerticalTabsSidebar: View, Equatable { let onSendFeedback: () -> Void let onToggleSidebar: () -> Void let onNewTab: () -> Void + /// Docked or floating. Part of `==`: the toggle's own glyph flips on it, + /// so a change here has to defeat the equality gate. + var presentationMode: SidebarPresentationMode = .docked + let onTogglePresentationMode: () -> Void let observedWindowReference: WeakWindowReference let chromeBackgroundColor: NSColor var observedWindow: NSWindow? { observedWindowReference.window } @@ -11318,6 +11352,8 @@ struct VerticalTabsSidebar: View, Equatable { @Binding var lastSidebarSelectionIndex: Int? @Binding var sidebarRenderWorkerClient: RenderWorkerClient? @State var modifierKeyMonitor = WindowScopedShortcutHintModifierMonitor(activation: .commandOnly) + @State var isHoveringTopStrip = false + @Environment(\.colorScheme) var sidebarChromeColorScheme @State var pointerInteractionMonitor = SidebarPointerInteractionMonitor() @StateObject var dragAutoScrollController = SidebarDragAutoScrollController() @StateObject private var tabItemSettingsStore = SidebarTabItemSettingsStore( @@ -12126,6 +12162,11 @@ struct VerticalTabsSidebar: View, Equatable { .overlay(alignment: .topLeading) { minimalModeSidebarTitlebarControlsOverlay() } + .overlay(alignment: .topTrailing) { + // Layered after the drag handle so the button wins the click: + // the strip is draggable everywhere except this control. + sidebarPresentationToggleOverlay + } .overlay(alignment: .top) { workspaceReorderDropOverlay( renderContext: renderContext, diff --git a/Sources/Sidebar/Filter/SidebarFilterController.swift b/Sources/Sidebar/Filter/SidebarFilterController.swift new file mode 100644 index 000000000000..f7290e3ad5d0 --- /dev/null +++ b/Sources/Sidebar/Filter/SidebarFilterController.swift @@ -0,0 +1,129 @@ +import CmuxSidebar +import CmuxWorkspaces +import Combine +import Foundation + +/// Owns the sidebar filter for one window: the query, the prepared index, and +/// the outcome the row projection reads. +/// +/// The index is rebuilt only when the searchable text actually changes, keyed +/// by ``SidebarFilterCorpusKey``. Everything else (scoring, group expansion, +/// highlight offsets) is pure logic in `CmuxSidebar`; this type is the cache +/// and the published surface around it. +@MainActor +final class SidebarFilterController: ObservableObject { + /// The text in the filter field. + @Published var queryText: String = "" { + didSet { + guard queryText != oldValue else { return } + recomputeOutcome() + } + } + + /// Whether the filter field is mounted. + /// + /// Separate from `queryText` being empty: the field can be open and empty, + /// which shows every row but keeps focus for the next keystroke. + @Published private(set) var isFieldPresented = false + + /// The current filter result. ``SidebarFilterOutcome/inactive`` when off. + @Published private(set) var outcome: SidebarFilterOutcome = .inactive + + private var index: SidebarFilterIndex? + private var corpusKey: SidebarFilterCorpusKey? + private var totalWorkspaceCount = 0 + + /// The snapshot the filter field renders from. + var fieldModel: SidebarFilterFieldModel { + SidebarFilterFieldModel( + queryText: queryText, + matchCount: outcome.isFiltering ? outcome.orderedMatchWorkspaceIds.count : totalWorkspaceCount, + totalCount: totalWorkspaceCount, + scopeField: SidebarFilterQuery(queryText).restrictedField + ) + } + + /// Opens the field and focuses it. + func presentField() { + isFieldPresented = true + } + + /// Closes the field and clears the query. + /// + /// Clearing on dismiss is deliberate: a filter left applied behind a hidden + /// field is a sidebar that is silently lying about how many workspaces + /// exist. + func dismissField() { + isFieldPresented = false + if !queryText.isEmpty { + queryText = "" + } + } + + /// Rebuilds the prepared index if the searchable text changed. + /// + /// Called from the sidebar's render-context construction, which runs on + /// every sidebar invalidation; the corpus key is what keeps that cheap. + /// + /// - Parameters: + /// - entries: Searchable text per workspace, in sidebar order. + /// - groups: The workspace groups. + func updateCorpus(entries: [SidebarFilterCorpusEntry], groups: [WorkspaceGroup]) { + totalWorkspaceCount = entries.count + let key = SidebarFilterCorpusKey(entries: entries, groups: groups) + guard key != corpusKey else { return } + corpusKey = key + index = SidebarFilterIndex( + candidates: entries.map(Self.candidate(from:)), + groups: groups.map { + SidebarFilterGroup( + id: $0.id, + anchorWorkspaceId: $0.anchorWorkspaceId, + name: $0.name + ) + } + ) + recomputeOutcome() + } + + private func recomputeOutcome() { + guard let index, isFieldPresented else { + if outcome.isFiltering { outcome = .inactive } + return + } + let next = index.outcome(for: SidebarFilterQuery(queryText)) + // Republishing an equal outcome would invalidate the sidebar on every + // keystroke that changes nothing, such as typing into an already-empty + // result. + guard next != outcome else { return } + outcome = next + } + + private static func candidate( + from entry: SidebarFilterCorpusEntry + ) -> SidebarFilterCandidate { + var fields: [SidebarFilterCandidateField] = [ + SidebarFilterCandidateField(field: .title, displayText: entry.title) + ] + if let branch = entry.branch, !branch.isEmpty { + fields.append(SidebarFilterCandidateField(field: .branch, displayText: branch)) + } + if let directory = entry.directory, !directory.isEmpty { + fields.append(SidebarFilterCandidateField(field: .directory, displayText: directory)) + } + if let pullRequest = entry.pullRequest, !pullRequest.isEmpty { + fields.append( + SidebarFilterCandidateField(field: .pullRequest, displayText: pullRequest) + ) + } + for port in entry.ports where !port.isEmpty { + fields.append(SidebarFilterCandidateField(field: .port, displayText: port)) + } + return SidebarFilterCandidate( + id: entry.workspaceId, + groupId: entry.groupId, + isGroupAnchor: entry.isGroupAnchor, + fields: fields + ) + } +} diff --git a/Sources/Sidebar/Filter/SidebarFilterCorpusKey.swift b/Sources/Sidebar/Filter/SidebarFilterCorpusKey.swift new file mode 100644 index 000000000000..07ee9d46e61d --- /dev/null +++ b/Sources/Sidebar/Filter/SidebarFilterCorpusKey.swift @@ -0,0 +1,53 @@ +import CmuxWorkspaces +import Foundation + +/// A cheap identity for the text the sidebar filter searches. +/// +/// The prepared index is the filter's whole performance story, so it must be +/// rebuilt when the searchable text changes and never merely because a +/// workspace published something unrelated (a notification, a port, a spinner +/// tick). This key hashes exactly the fields the index reads, so an unrelated +/// change compares equal and the index survives. +struct SidebarFilterCorpusKey: Hashable { + private let hash: Int + + /// Builds the key from the current workspaces and groups. + /// + /// - Parameters: + /// - entries: One entry per workspace, in sidebar order. + /// - groups: The workspace groups. + init(entries: [SidebarFilterCorpusEntry], groups: [WorkspaceGroup]) { + var hasher = Hasher() + for entry in entries { + hasher.combine(entry.workspaceId) + hasher.combine(entry.title) + hasher.combine(entry.branch) + hasher.combine(entry.directory) + hasher.combine(entry.pullRequest) + hasher.combine(entry.ports) + hasher.combine(entry.groupId) + hasher.combine(entry.isGroupAnchor) + } + for group in groups { + hasher.combine(group.id) + hasher.combine(group.name) + hasher.combine(group.anchorWorkspaceId) + } + self.hash = hasher.finalize() + } +} + +/// The searchable text of one workspace, extracted from the live model. +/// +/// A value snapshot taken at the boundary: no `Workspace` reference reaches the +/// filter, which keeps the index pure and lets the corpus key be a plain hash. +struct SidebarFilterCorpusEntry: Hashable { + let workspaceId: UUID + let title: String + let branch: String? + let directory: String? + let pullRequest: String? + let ports: [String] + let groupId: UUID? + let isGroupAnchor: Bool +} diff --git a/Sources/Sidebar/Peek/SidebarPeekController.swift b/Sources/Sidebar/Peek/SidebarPeekController.swift new file mode 100644 index 000000000000..62acf1ef09c2 --- /dev/null +++ b/Sources/Sidebar/Peek/SidebarPeekController.swift @@ -0,0 +1,108 @@ +import AppKit +import CmuxSidebar +import Combine +import SwiftUI + +/// Drives the collapsed sidebar's hover-reveal for one window. +/// +/// Owns the timers and the published presentation flag; every transition +/// decision belongs to `SidebarPeekMachine`, which is pure and lives in +/// `CmuxSidebar`. This type does nothing but turn machine effects into +/// cancellable tasks and republish the resulting phase. +@MainActor +final class SidebarPeekController: ObservableObject { + /// Whether the floating panel should be mounted and hit-testable. + @Published private(set) var presentsPanel = false + + private var state = SidebarPeekState.idle + private var machine = SidebarPeekMachine(policy: .default) + private var dwellTask: Task? + private var graceTask: Task? + + /// The timing and behaviour policy in force. + var policy: SidebarPeekPolicy { + machine.policy + } + + /// Replaces the policy, for example after a settings change. + /// + /// - Parameter policy: The new timings and behaviour flags. + func setPolicy(_ policy: SidebarPeekPolicy) { + machine = SidebarPeekMachine(policy: policy) + guard !policy.isEnabled else { return } + send(.escapePressed) + } + + // MARK: - Events + + func pointerEnteredEdge() { send(.pointerEnteredEdge) } + func pointerExitedEdge() { send(.pointerExitedEdge) } + func dragEnteredEdge() { send(.dragEnteredEdge) } + func workspaceActivated() { send(.workspaceActivated) } + func escapePressed() { send(.escapePressed) } + func sidebarDocked() { send(.sidebarDocked) } + func sidebarCollapsed() { send(.sidebarCollapsed) } + + /// Adds a hold, keeping the panel open regardless of pointer position. + func acquire(_ hold: SidebarPeekHold) { send(.holdAcquired(hold)) } + + /// Removes a hold. + func release(_ hold: SidebarPeekHold) { send(.holdReleased(hold)) } + + /// Cancels every timer, for window teardown. + func stop() { + dwellTask?.cancel() + dwellTask = nil + graceTask?.cancel() + graceTask = nil + state = .idle + presentsPanel = false + } + + // MARK: - Machine plumbing + + private func send(_ event: SidebarPeekEvent) { + let effects = machine.apply(event, to: &state) + for effect in effects { + perform(effect) + } + // Publishing unconditionally would invalidate the window body on every + // pointer crossing of the edge strip, most of which change nothing. + if presentsPanel != state.presentsPanel { + presentsPanel = state.presentsPanel + } + } + + private func perform(_ effect: SidebarPeekEffect) { + switch effect { + case .startDwellTimer: + dwellTask?.cancel() + dwellTask = schedule(after: machine.policy.dwell) { [weak self] in + self?.send(.dwellElapsed) + } + case .cancelDwellTimer: + dwellTask?.cancel() + dwellTask = nil + case .startGraceTimer: + graceTask?.cancel() + graceTask = schedule(after: machine.policy.grace) { [weak self] in + self?.send(.graceElapsed) + } + case .cancelGraceTimer: + graceTask?.cancel() + graceTask = nil + } + } + + private func schedule( + after duration: Duration, + _ body: @escaping @MainActor () -> Void + ) -> Task { + Task { @MainActor in + // A cancelled sleep throws, which is the cancellation path: the + // body must not run for a timer that was superseded. + guard (try? await Task.sleep(for: duration)) != nil else { return } + body() + } + } +} diff --git a/Sources/Sidebar/Peek/SidebarPeekEdgeTrackingView.swift b/Sources/Sidebar/Peek/SidebarPeekEdgeTrackingView.swift new file mode 100644 index 000000000000..91a913dc4aaf --- /dev/null +++ b/Sources/Sidebar/Peek/SidebarPeekEdgeTrackingView.swift @@ -0,0 +1,104 @@ +import AppKit +import SwiftUI + +/// A thin, invisible strip along the window's leading edge that reports when +/// the pointer rests in it. +/// +/// Deliberately an `NSTrackingArea` rather than a hit-test or an event monitor. +/// `WindowTerminalHostView.hitTest()` is on the typing-latency path (see +/// CLAUDE.md) and every keystroke passes through it; a tracking area is +/// serviced by AppKit's own mouse-moved plumbing and costs nothing while the +/// pointer is elsewhere. The strip also declines to become first responder and +/// passes clicks through, so it can never eat a click meant for the terminal +/// underneath. +struct SidebarPeekEdgeTrackingView: NSViewRepresentable { + /// Width of the sensitive strip. + let width: CGFloat + /// Whether tracking is live. False removes the tracking area entirely + /// rather than ignoring callbacks, so a docked sidebar costs nothing. + let isEnabled: Bool + /// Called when the pointer enters the strip. + let onEnter: () -> Void + /// Called when the pointer leaves the strip. + let onExit: () -> Void + + func makeNSView(context: Context) -> EdgeStripView { + let view = EdgeStripView() + view.onEnter = onEnter + view.onExit = onExit + view.isTrackingEnabled = isEnabled + return view + } + + func updateNSView(_ nsView: EdgeStripView, context: Context) { + nsView.onEnter = onEnter + nsView.onExit = onExit + nsView.isTrackingEnabled = isEnabled + } + + static func dismantleNSView(_ nsView: EdgeStripView, coordinator: ()) { + nsView.isTrackingEnabled = false + } + + /// The strip itself. + final class EdgeStripView: NSView { + var onEnter: (() -> Void)? + var onExit: (() -> Void)? + + var isTrackingEnabled = true { + didSet { + guard isTrackingEnabled != oldValue else { return } + rebuildTrackingArea() + } + } + + private var trackingArea: NSTrackingArea? + private var isInside = false + + override var acceptsFirstResponder: Bool { false } + + /// Transparent to clicks: the strip observes the pointer, it does not + /// capture it. Without this the leading column of the terminal would + /// stop responding to text selection. + override func hitTest(_ point: NSPoint) -> NSView? { nil } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + rebuildTrackingArea() + } + + override func mouseEntered(with event: NSEvent) { + guard isTrackingEnabled, !isInside else { return } + isInside = true + onEnter?() + } + + override func mouseExited(with event: NSEvent) { + guard isInside else { return } + isInside = false + onExit?() + } + + private func rebuildTrackingArea() { + if let trackingArea { + removeTrackingArea(trackingArea) + self.trackingArea = nil + } + // Leaving the strip while tracking is switched off would otherwise + // never deliver `mouseExited`, stranding the machine mid-arm. + if isInside, !isTrackingEnabled { + isInside = false + onExit?() + } + guard isTrackingEnabled, !bounds.isEmpty else { return } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + } + } +} diff --git a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift new file mode 100644 index 000000000000..1978f3ef01a8 --- /dev/null +++ b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift @@ -0,0 +1,104 @@ +import CmuxSidebar +import SwiftUI + +/// Presents the already-mounted sidebar subtree either flush in the layout or +/// as a floating card, without rebuilding it. +/// +/// The whole point is that the two modes share one mounted subtree. cmux keeps +/// the AppKit workspace table alive behind a zero-width shell while the sidebar +/// is hidden (see `retainsDefaultAppKitSidebarWhenHidden` in `ContentView`), so +/// revealing it is a frame and opacity change, never a cold start. Peek rides +/// on that: the reveal has no table to build, which is what lets it be +/// instant instead of merely fast. +struct SidebarPeekPresentation: ViewModifier { + /// Docked or floating. + let mode: SidebarPresentationMode + /// Whether the sidebar is docked open. + let isDockedVisible: Bool + /// Whether peek is currently presenting the floating panel. + let isPeeking: Bool + /// The sidebar's resolved width. + let width: CGFloat + /// Card geometry for floating mode. + let panelMetrics: SidebarPeekPanelMetrics + /// Acquires and releases the pointer hold as the pointer crosses the panel. + let onPanelHoverChange: (Bool) -> Void + + /// Whether anything should be drawn at all. + private var isPresented: Bool { + switch mode { + case .docked: + return isDockedVisible + case .floating: + return isDockedVisible || isPeeking + } + } + + /// Outer width including the card's leading inset, so floating and docked + /// place the list's leading edge identically. + private var floatingWidth: CGFloat { + width + panelMetrics.leadingInset + } + + func body(content: Content) -> some View { + switch mode { + case .docked: + content + .frame(width: isPresented ? width : 0, alignment: .leading) + .clipped() + .allowsHitTesting(isPresented) + .accessibilityHidden(!isPresented) + case .floating: + SidebarPeekPanelChrome(metrics: panelMetrics) { + content.frame(width: width, alignment: .leading) + } + .frame(width: floatingWidth, alignment: .leading) + // Slide from just behind the window edge rather than from zero + // width. Animating the frame would re-lay-out every row on every + // frame of the reveal; translating a laid-out subtree does not. + .offset(x: isPresented ? 0 : -floatingWidth) + .opacity(isPresented ? 1 : 0) + .allowsHitTesting(isPresented) + .accessibilityHidden(!isPresented) + .onHover(perform: onPanelHoverChange) + .animation(SidebarPeekMotion.reveal, value: isPresented) + } + } +} + +/// The motion curves the peek panel uses. +enum SidebarPeekMotion { + /// Reveal and dismissal. + /// + /// A spring rather than an ease so an interrupted reveal (pointer leaves + /// mid-animation) retargets from wherever the panel currently is instead of + /// snapping. `bounce` is zero: this is a panel arriving, not a toy. + static let reveal = Animation.spring(response: 0.28, dampingFraction: 0.86) + + /// Switching between docked and floating. + /// + /// Slower than the reveal because the terminal reflows with it, and a fast + /// reflow of a full screen of text reads as a flicker. + static let modeChange = Animation.spring(response: 0.38, dampingFraction: 0.9) +} + +extension View { + /// Applies ``SidebarPeekPresentation``. + func sidebarPeekPresentation( + mode: SidebarPresentationMode, + isDockedVisible: Bool, + isPeeking: Bool, + width: CGFloat, + panelMetrics: SidebarPeekPanelMetrics = .default, + onPanelHoverChange: @escaping (Bool) -> Void + ) -> some View { + modifier(SidebarPeekPresentation( + mode: mode, + isDockedVisible: isDockedVisible, + isPeeking: isPeeking, + width: width, + panelMetrics: panelMetrics, + onPanelHoverChange: onPanelHoverChange + )) + } +} diff --git a/Sources/Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift b/Sources/Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift new file mode 100644 index 000000000000..efddcfdd845e --- /dev/null +++ b/Sources/Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift @@ -0,0 +1,26 @@ +import CmuxSidebar +import SwiftUI + +extension VerticalTabsSidebar { + /// The float/dock switch, pinned to the sidebar's top-trailing corner. + /// + /// Placed opposite the window controls and in the same spot in both modes, + /// so the gesture that floats the rail is the gesture that docks it again. + /// It fades in on hover rather than sitting there permanently: a resting + /// sidebar is a list of workspaces, and a control parked in the corner + /// competes with the first row for the eye. + @ViewBuilder + var sidebarPresentationToggleOverlay: some View { + SidebarPresentationToggleButton( + mode: presentationMode, + accent: Color(nsColor: cmuxAccentNSColor(for: sidebarChromeColorScheme)), + onToggle: onTogglePresentationMode + ) + .padding(.trailing, 8) + .frame(height: MinimalModeChromeMetrics.titlebarHeight) + .opacity(isHoveringTopStrip ? 1 : 0) + .animation(.easeOut(duration: 0.12), value: isHoveringTopStrip) + .onHover { isHoveringTopStrip = $0 } + .accessibilityHidden(false) + } +} diff --git a/Sources/Sidebar/SidebarState.swift b/Sources/Sidebar/SidebarState.swift index 8798a49d7c4b..20b58c656765 100644 --- a/Sources/Sidebar/SidebarState.swift +++ b/Sources/Sidebar/SidebarState.swift @@ -1,3 +1,4 @@ +import CmuxSidebar import CmuxWorkspaces import Combine import CoreGraphics @@ -9,11 +10,32 @@ final class SidebarState: ObservableObject { /// Hidden because the window was too narrow (SidePanelWidthFit), not by the /// person; session snapshots save it as visible. var isAutoCollapsed = false + + /// Whether the sidebar takes layout width or floats over the content. + /// + /// Window-scoped rather than global: a user can dock the rail in the window + /// they are reading a long list in and leave it floating everywhere else. + /// The last chosen mode is persisted separately as the default for new + /// windows. + @Published var presentationMode: SidebarPresentationMode + + /// Whether the sidebar currently consumes layout width from the content. + /// + /// The distinction the whole floating mode rests on: a floating sidebar is + /// still "visible", it simply does not push the terminal aside. + var occupiesLayout: Bool { + isVisible && presentationMode == .docked + } private var visibilityWillChangeOwnerId: UUID? private var visibilityWillChange: ((Bool) -> Void)? - init(isVisible: Bool = true, persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) { + init( + isVisible: Bool = true, + persistedWidth: CGFloat = CGFloat(SessionPersistencePolicy.defaultSidebarWidth), + presentationMode: SidebarPresentationMode = .docked + ) { self.isVisible = isVisible + self.presentationMode = presentationMode let sanitized = SessionPersistencePolicy.sanitizedSidebarWidth(Double(persistedWidth)) self.persistedWidth = CGFloat(sanitized) } @@ -22,6 +44,11 @@ final class SidebarState: ObservableObject { setVisible(!isVisible) } + /// Switches between docked and floating. + func togglePresentationMode() { + presentationMode = presentationMode.toggled + } + func setVisible(_ nextValue: Bool) { guard nextValue != isVisible else { return } visibilityWillChange?(nextValue) diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 57c273840a2e..1729ba9c5448 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -1774,6 +1774,7 @@ C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */; }; AEBC559E83E4B911AEBFAAA7 /* ContentView+RightSidebarWidth.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */; }; C0DE1A010000000000000001 /* ContentView+SavedLayoutCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */; }; + 3FE57BD8654C3AA6A5AC2FA2 /* ContentView+SidebarPeek.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96435E143866F49F8A0C1684 /* ContentView+SidebarPeek.swift */; }; C51A740000000000000000B1 /* ContentView+SidebarSurfaceKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = C51A740000000000000000B2 /* ContentView+SidebarSurfaceKind.swift */; }; 875200000000000000000011 /* ContentView+SurfaceNavigationCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */; }; BB358B91BD861CF1882EB036 /* ContentView+TerminalScrollCommandPalette.swift in Sources */ = {isa = PBXBuildFile; fileRef = C916AD2024A810937F4718CE /* ContentView+TerminalScrollCommandPalette.swift */; }; @@ -3240,6 +3241,8 @@ 9212A0069212A0069212A006 /* SidebarEmptyAreaWindowDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0069212B0069212B006 /* SidebarEmptyAreaWindowDragTests.swift */; }; 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */; }; A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; + 3D6E1C0362B56791490C8135 /* SidebarFilterController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AA6EC5A67508F26074B60B /* SidebarFilterController.swift */; }; + 4BB8A42E2371AC7FB155ADF5 /* SidebarFilterCorpusKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA48771BCBE1F596B455835 /* SidebarFilterCorpusKey.swift */; }; B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; }; A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; }; @@ -3267,6 +3270,9 @@ 130160000000000000000001 /* SidebarNewLocalWorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130160000000000000000002 /* SidebarNewLocalWorkspaceTests.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000002 /* SidebarPathFormatter.swift */; }; + 22326FBAA042DDAFA15F818C /* SidebarPeekController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B954AEE8BD37A8D69AA13832 /* SidebarPeekController.swift */; }; + F3E95CEBB40A6AA79A9B4CEE /* SidebarPeekEdgeTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C733A9B22B4426FB8C1CD4F /* SidebarPeekEdgeTrackingView.swift */; }; + 5842622F8ED35FF5B317C174 /* SidebarPeekPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A9185ED75125180313AF0 /* SidebarPeekPresentation.swift */; }; A80040010000000000000001 /* SidebarPointerFrameReportingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */; }; A80040030000000000000001 /* SidebarPointerInteractionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */; }; A80070010000000000000001 /* SidebarPointerInteractionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80070020000000000000001 /* SidebarPointerInteractionMonitorTests.swift */; }; @@ -4142,6 +4148,7 @@ D4476F0B0000000000000001 /* VaultSessionViewMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4476F0B0000000000000002 /* VaultSessionViewMenu.swift */; }; C5BEA2B0EA914759A8BFD577 /* VaultSocketPayloadTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E8649C5174544BFFA8674D52 /* VaultSocketPayloadTests.swift */; }; F7216001000000000000001 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7216001000000000000002 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift */; }; + 29DE8768B3D117A3C5B53428 /* VerticalTabsSidebar+PresentationToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */; }; C9A57209C9A57209C9A57209 /* VerticalTabsSidebar+WorkspaceGroups.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */; }; A28B087F0000000000000005 /* ViewerNavigationKeyRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B087F0000000000000006 /* ViewerNavigationKeyRouter.swift */; }; A3340002A3340002A3340002 /* ViewerNavigationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3340001A3340001A3340001 /* ViewerNavigationTests.swift */; }; @@ -6193,6 +6200,7 @@ C3408A000000000000000002 /* ContentView+RightSidebarCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarCommandPalette.swift"; sourceTree = ""; }; 970FE2D3951BB4AF4AA52F09 /* ContentView+RightSidebarWidth.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+RightSidebarWidth.swift"; sourceTree = ""; }; C0DE1A010000000000000002 /* ContentView+SavedLayoutCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SavedLayoutCommands.swift"; sourceTree = ""; }; + 96435E143866F49F8A0C1684 /* ContentView+SidebarPeek.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SidebarPeek.swift"; sourceTree = ""; }; C51A740000000000000000B2 /* ContentView+SidebarSurfaceKind.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SidebarSurfaceKind.swift"; sourceTree = ""; }; 875200000000000000000001 /* ContentView+SurfaceNavigationCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+SurfaceNavigationCommands.swift"; sourceTree = ""; }; C916AD2024A810937F4718CE /* ContentView+TerminalScrollCommandPalette.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ContentView+TerminalScrollCommandPalette.swift"; sourceTree = ""; }; @@ -7643,6 +7651,8 @@ 9212B0069212B0069212B006 /* SidebarEmptyAreaWindowDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarEmptyAreaWindowDragTests.swift; sourceTree = ""; }; 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift; sourceTree = ""; }; A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; + 62AA6EC5A67508F26074B60B /* SidebarFilterController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Filter/SidebarFilterController.swift; sourceTree = ""; }; + 0FA48771BCBE1F596B455835 /* SidebarFilterCorpusKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Filter/SidebarFilterCorpusKey.swift; sourceTree = ""; }; B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = ""; }; A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = ""; }; @@ -7670,6 +7680,9 @@ 130160000000000000000002 /* SidebarNewLocalWorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarNewLocalWorkspaceTests.swift; sourceTree = ""; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; EA1F00000000000000000002 /* SidebarPathFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPathFormatter.swift; sourceTree = ""; }; + B954AEE8BD37A8D69AA13832 /* SidebarPeekController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekController.swift; sourceTree = ""; }; + 2C733A9B22B4426FB8C1CD4F /* SidebarPeekEdgeTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekEdgeTrackingView.swift; sourceTree = ""; }; + 327A9185ED75125180313AF0 /* SidebarPeekPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekPresentation.swift; sourceTree = ""; }; A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPointerFrameReportingModifier.swift; sourceTree = ""; }; A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPointerInteractionMonitor.swift; sourceTree = ""; }; A80070020000000000000001 /* SidebarPointerInteractionMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPointerInteractionMonitorTests.swift; sourceTree = ""; }; @@ -8536,6 +8549,7 @@ D4476F0B0000000000000002 /* VaultSessionViewMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultSessionViewMenu.swift; sourceTree = ""; }; E8649C5174544BFFA8674D52 /* VaultSocketPayloadTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VaultSocketPayloadTests.swift; sourceTree = ""; }; F7216001000000000000002 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerticalTabsSidebar+EmptyAreasAndFooter.swift"; sourceTree = ""; }; + A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift"; sourceTree = ""; }; C9A5720AC9A5720AC9A5720A /* VerticalTabsSidebar+WorkspaceGroups.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "VerticalTabsSidebar+WorkspaceGroups.swift"; sourceTree = ""; }; A28B087F0000000000000006 /* ViewerNavigationKeyRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/ViewerNavigationKeyRouter.swift; sourceTree = ""; }; A3340001A3340001A3340001 /* ViewerNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewerNavigationTests.swift; sourceTree = ""; }; @@ -10120,6 +10134,13 @@ 6042D0056042D0056042D005 /* WindowScopedShortcutHintModifierMonitor.swift */, A5001901A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift */, D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */, + A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */, + 62AA6EC5A67508F26074B60B /* SidebarFilterController.swift */, + 0FA48771BCBE1F596B455835 /* SidebarFilterCorpusKey.swift */, + B954AEE8BD37A8D69AA13832 /* SidebarPeekController.swift */, + 2C733A9B22B4426FB8C1CD4F /* SidebarPeekEdgeTrackingView.swift */, + 327A9185ED75125180313AF0 /* SidebarPeekPresentation.swift */, + 96435E143866F49F8A0C1684 /* ContentView+SidebarPeek.swift */, 5B0C00010000000000000002 /* SidebarResizerOcclusionResolver.swift */, C0DEFF200000000000000002 /* CommandPaletteOverlay.swift */, AFBCA7B529B723C1525D40DB /* CurrentWorkPalettePresentation.swift */, @@ -15036,6 +15057,7 @@ C3408A000000000000000001 /* ContentView+RightSidebarCommandPalette.swift in Sources */, AEBC559E83E4B911AEBFAAA7 /* ContentView+RightSidebarWidth.swift in Sources */, C0DE1A010000000000000001 /* ContentView+SavedLayoutCommands.swift in Sources */, + 3FE57BD8654C3AA6A5AC2FA2 /* ContentView+SidebarPeek.swift in Sources */, C51A740000000000000000B1 /* ContentView+SidebarSurfaceKind.swift in Sources */, 875200000000000000000011 /* ContentView+SurfaceNavigationCommands.swift in Sources */, BB358B91BD861CF1882EB036 /* ContentView+TerminalScrollCommandPalette.swift in Sources */, @@ -15982,6 +16004,8 @@ 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */, 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */, 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */, + 3D6E1C0362B56791490C8135 /* SidebarFilterController.swift in Sources */, + 4BB8A42E2371AC7FB155ADF5 /* SidebarFilterCorpusKey.swift in Sources */, A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */, F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */, B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */, @@ -15998,6 +16022,9 @@ A6AC73F20000000000000001 /* SidebarMediaActivityIndicators.swift in Sources */, C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */, EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */, + 22326FBAA042DDAFA15F818C /* SidebarPeekController.swift in Sources */, + F3E95CEBB40A6AA79A9B4CEE /* SidebarPeekEdgeTrackingView.swift in Sources */, + 5842622F8ED35FF5B317C174 /* SidebarPeekPresentation.swift in Sources */, A80040010000000000000001 /* SidebarPointerFrameReportingModifier.swift in Sources */, A80040030000000000000001 /* SidebarPointerInteractionMonitor.swift in Sources */, C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */, @@ -16651,6 +16678,7 @@ 021B98686B73408892D7C876 /* VaultSessionSearchQuery.swift in Sources */, D4476F0B0000000000000001 /* VaultSessionViewMenu.swift in Sources */, F7216001000000000000001 /* VerticalTabsSidebar+EmptyAreasAndFooter.swift in Sources */, + 29DE8768B3D117A3C5B53428 /* VerticalTabsSidebar+PresentationToggle.swift in Sources */, C9A57209C9A57209C9A57209 /* VerticalTabsSidebar+WorkspaceGroups.swift in Sources */, A28B087F0000000000000005 /* ViewerNavigationKeyRouter.swift in Sources */, ECB6A18DEC5D08DC2383081D /* VMClientSocketCommands+AgentUpdates.swift in Sources */, From 8bdb9f6eddc35b229671b22926aa18eb4fbf45a0 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 18:30:58 +1200 Subject: [PATCH 06/69] freeform drag reorder: poll-driven lift, springs, single commit on release drag visuals run on a 120hz pointer poll that no appkit drag callback can touch. destination callbacks (planner, overlay, exited) only track the drop plan; the commit stays on the stock release path. frozen frame capture at drag start makes displacement boundaries immovable mid-drag, spring glides use explicit CASpringAnimation because view-backing layers ignore implicit transaction animations, and dragged rows keep full opacity since the row itself is the drag visual (ghost suppressed for single-row drags). --- .../Cells/SidebarGroupHeaderRowView.swift | 5 +- .../Cells/SidebarWorkspaceRowCellView.swift | 5 +- .../SidebarWorkspaceTableController.swift | 282 ++++++++++++++++-- 3 files changed, 273 insertions(+), 19 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 8e21460afc6b..12a9a0991d9e 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -276,7 +276,10 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { representedIdentity: model.groupId ) - alphaValue = model.isBeingDragged ? 0.6 : 1 + // Full opacity while dragged: the freeform reorder suppresses the + // floating ghost, so the row itself is the drag visual and must keep + // its exact resting appearance (the dim was a ghost-era cue). + alphaValue = 1 updatePlusVisibility() setAccessibilityIdentifier("sidebarWorkspaceGroup.\(model.groupId.uuidString)") setAccessibilityLabel(model.name) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 49e1582ab827..166db786e2c5 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -741,7 +741,10 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { bottomDropIndicator.layer?.backgroundColor = palette.accentColor.cgColor topDropIndicator.isHidden = !model.topDropIndicatorVisible bottomDropIndicator.isHidden = !model.bottomDropIndicatorVisible - alphaValue = model.isBeingDragged ? 0.6 : 1 + // Full opacity while dragged: the freeform reorder suppresses the + // floating ghost, so the row itself is the drag visual and must keep + // its exact resting appearance (the dim was a ghost-era cue). + alphaValue = 1 // Done rows read as settled (legacy parity): dim the row CONTENT to // ~60% — never the selection background, rail, or drop chrome. contentContainer.alphaValue = snapshot.taskStatus == .done ? 0.6 : 1 diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index 37b3aadb65ff..b414c4053e54 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -141,6 +141,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS #endif deinit { + reorderPollTimer?.invalidate() if let clipBoundsObserver { NotificationCenter.default.removeObserver(clipBoundsObserver) } @@ -814,11 +815,16 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS ) } #endif + let handsOffReorderTransforms = clearsReorderTransformsOnNextApply && hasStructuralChanges + if handsOffReorderTransforms { + clearsReorderTransformsOnNextApply = false + } if hasStructuralChanges { if forceTableReload { let table = containerView.tableView let postUpdateActions = detachLoadedCells() performTableGeometryUpdateWithoutAnimation(heightChanges, in: table) { + if handsOffReorderTransforms { endReorderLift(animated: false) } table.reloadData() restoreViewportOrigin(forcedReloadViewportOrigin, in: table) viewportAnchor?.restore(table: table, rows: nextRows) @@ -834,6 +840,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS // clipping checklist or notification content. let table = containerView.tableView performTableGeometryUpdateWithoutAnimation(in: table) { + if handsOffReorderTransforms { endReorderLift(animated: false) } table.beginUpdates() var current = previousIds for targetIndex in nextIds.indices where current[targetIndex] != nextIds[targetIndex] { @@ -1393,16 +1400,33 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } guard let workspaceId else { return } let count = actionBundle?.movingWorkspaceCount?(workspaceId) ?? 1 - guard count > 1, - let image = workspaceDragImage( - tableView: tableView, - row: row, - size: draggingItem.draggingFrame.size, - count: count - ) else { - return + if count > 1, + let image = workspaceDragImage( + tableView: tableView, + row: row, + size: draggingItem.draggingFrame.size, + count: count + ) { + draggingItem.setDraggingFrame(draggingItem.draggingFrame, contents: image) + } else { + // Single-row drags have no floating ghost: the real row is + // the drag visual, lifted and translated by the freeform + // reorder session. Multi-row drags keep the badge image, + // which is the only place the moved-count shows. + let clearImage = NSImage( + size: draggingItem.draggingFrame.size, + flipped: false + ) { _ in true } + draggingItem.setDraggingFrame(draggingItem.draggingFrame, contents: clearImage) } - draggingItem.setDraggingFrame(draggingItem.draggingFrame, contents: image) + } + // The real row settles via its own transforms; a ghost flying home + // from the release point would be a second copy at the exact moment + // the drag ends. + session.animatesToStartingPositionsOnCancelOrFail = false + if let workspaceId = pendingWorkspaceDragWorkspaceId + ?? draggedRows.first.flatMap({ rows.indices.contains($0) ? rows[$0].workspaceId : nil }) { + startReorderPoll(workspaceId: workspaceId) } } @@ -1462,6 +1486,13 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS endedAt screenPoint: NSPoint, operation: NSDragOperation ) { + stopReorderPoll() + // Covers every way a session can end without a local drop (Escape, + // release outside, a cross-window drop): if the transforms were not + // handed to a commit apply, glide the rows home. + if !clearsReorderTransformsOnNextApply { + endReorderLift(animated: true) + } workspaceDragSessionDidEnd(session: session) } @@ -1812,12 +1843,25 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS #endif if performed { suppressSelectedScrollAfterLocalDrop = true + // The rows already stand at their new positions via transforms; + // the apply that lands the committed order clears them in the + // same update, so the real frames take over in place. + clearsReorderTransformsOnNextApply = true + } else { + endReorderLift(animated: true) } retireReorderIndicator() return performed } func reorderDropDragExited() { +#if DEBUG + cmuxDebugLog("sidebar.reorder.exited") +#endif + // Visual-inert on purpose: destination exit fires at churn rate + // whenever the plan goes nil (the overlay's operation flips to none + // and AppKit re-resolves the destination). The poll owns the + // visuals; the session ends only at endedAt. reorderDragPayloadWorkspaceId = nil guard reorderDragWindowPoint != nil || reorderIndicatorPainter != nil else { return } reorderDragWindowPoint = nil @@ -1865,18 +1909,22 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS else { reorderDragWindowPoint = nil retireReorderIndicator() +#if DEBUG + cmuxDebugLog("sidebar.reorder.plan-nil payload=\(payloadWorkspaceId?.uuidString.prefix(5) ?? "none")") +#endif + // No legal gap at this point (commonly: hovering the dragged + // row's own slot). Harmless: the poll owns the visuals, and this + // callback owns only the plan. return false } - reorderIndicatorPainter = SidebarWorkspaceTableReorderIndicatorPainter( - indicator: update.indicator, - scope: update.scope, - draggedWorkspaceId: update.draggedWorkspaceId, - indicatorRowIds: update.indicatorRowIds - ) + // The plan is still tracked exactly as stock (the release commits + // it), but it is drawn as displacement, not indicator lines: the + // rows shifting around the lifted row are the drop preview. lastAcceptedReorderDropPlan = update.plan - enforceReorderIndicatorPaintOnVisibleCells() - setAppKitDropIndicator(update.indicator, scope: update.scope, includeRowTargets: false) reorderDragWindowPoint = windowPoint +#if DEBUG + cmuxDebugLog("sidebar.reorder.tick y=\(Int(point.y))") +#endif return true } @@ -1889,6 +1937,206 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS setAppKitDropIndicator(nil, scope: .raw, includeRowTargets: false) } + // MARK: Freeform reorder visuals + + /// Pointer poll driving the drag visuals. + /// + /// A 120Hz timer reading `NSEvent.mouseLocation`, not any AppKit drag + /// callback: the destination callbacks churn (a nil plan near the + /// dragged row's own slot flips the overlay's operation to none, AppKit + /// re-resolves the destination, and exited fires at ~60Hz), and the + /// source's movedTo callback silently broke the drop commit when it was + /// tried. Polling has no interaction surface with the drag machinery at + /// all, so nothing can interrupt or reset the visuals mid-session. + /// + /// `nonisolated(unsafe)` so deinit can invalidate; every other access is + /// main-actor. + private nonisolated(unsafe) var reorderPollTimer: Timer? + private var reorderPollWorkspaceId: UUID? + + private func startReorderPoll(workspaceId: UUID) { + stopReorderPoll() + reorderPollWorkspaceId = workspaceId + let timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + self?.reorderPollTick() + } + } + // .common so the timer keeps firing inside the drag's event-tracking + // loop, which is the only time it exists. + RunLoop.main.add(timer, forMode: .common) + reorderPollTimer = timer + } + + private func stopReorderPoll() { + reorderPollTimer?.invalidate() + reorderPollTimer = nil + reorderPollWorkspaceId = nil + } + + private func reorderPollTick() { + guard let workspaceId = reorderPollWorkspaceId, + let containerView, + let window = containerView.tableView.window else { return } + let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) + let table = containerView.tableView + let point = table.convert(windowPoint, from: nil) + // Outside the sidebar horizontally (a cross-window or into-terminal + // excursion): freeze the shift state rather than keep reacting to a + // pointer that is no longer about this list. The session ends only + // at endedAt. + guard point.x >= -48, point.x <= table.bounds.width + 48 else { return } + updateReorderLift(windowPoint: windowPoint, workspaceId: workspaceId) + } + + /// Frozen drag geometry. Captured once when the drag first hovers, and + /// never updated until the drag ends: every visual decision during the + /// drag is made against these frames, so displacement boundaries cannot + /// move mid-drag and the interaction cannot oscillate. The model is not + /// touched until release; everything the user sees during the drag is + /// layer transforms over an unchanged table. + private struct ReorderLiftSession { + let workspaceId: UUID + let sourceIndex: Int + let rowHeight: CGFloat + let frames: [CGRect] + /// The applied translation target per row index, so a row only + /// animates when its target actually flips. + var appliedTargets: [CGFloat] + } + + private var reorderLiftSession: ReorderLiftSession? + /// Set at drop-commit time: the structural apply that lands the new + /// order clears every drag transform in the same update, so the rows' + /// real frames take over exactly where the transforms left them. + private var clearsReorderTransformsOnNextApply = false + + /// Glides a row's vertical shift to `target` with a soft spring. + /// + /// Explicit animation on purpose: view-backing layers on macOS ignore + /// implicit CATransaction animations (NSView's layer delegate returns + /// no action), so transform changes here otherwise land instantly no + /// matter what the transaction says. Starting from the presentation + /// layer's live value means a retarget mid-glide continues from where + /// the row visually is instead of jumping. + private func glideRowShift(layer: CALayer, to target: CGFloat) { + let currentY = (layer.presentation() ?? layer) + .value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 + let spring = CASpringAnimation(keyPath: "transform.translation.y") + spring.fromValue = currentY + spring.toValue = target + spring.mass = 1 + spring.stiffness = 300 + spring.damping = 30 + spring.duration = spring.settlingDuration + layer.add(spring, forKey: "cmux.reorderShift") + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.transform = CATransform3DMakeTranslation(0, target, 0) + CATransaction.commit() + } + + /// Drives the freeform drag visuals for the pointer at `windowPoint`. + private func updateReorderLift(windowPoint: NSPoint, workspaceId: UUID) { + guard let containerView else { return } + let table = containerView.tableView + let point = table.convert(windowPoint, from: nil) + + if reorderLiftSession?.workspaceId != workspaceId { + endReorderLift(animated: false) + guard let sourceIndex = rows.firstIndex(where: { $0.workspaceId == workspaceId }) else { return } + let frames = (0.. 0 else { return } + reorderLiftSession = ReorderLiftSession( + workspaceId: workspaceId, + sourceIndex: sourceIndex, + rowHeight: frames[sourceIndex].height, + frames: frames, + appliedTargets: Array(repeating: 0, count: rows.count) + ) + } + guard var session = reorderLiftSession else { return } + + // The dragged row's visual centre: the pointer, clamped so the row + // cannot leave the list. The top clamp is the first row's slot (a + // tab cannot go above the new-workspace row); the bottom is the + // full extent of the list area, so a tab can be carried all the way + // down past the last row. + let minCenter = session.frames[0].midY + let maxCenter = max(minCenter, table.bounds.maxY - session.rowHeight / 2) + let draggedCenter = min(max(point.y, minCenter), maxCenter) + + // Insertion slot from frozen midpoints: how many other rows sit + // above the dragged row's centre. + var insertion = 0 + for index in session.frames.indices where index != session.sourceIndex { + if session.frames[index].midY < draggedCenter { insertion += 1 } + } + + table.enumerateAvailableRowViews { rowView, row in + guard session.frames.indices.contains(row) else { return } + rowView.wantsLayer = true + guard let layer = rowView.layer else { return } + if row == session.sourceIndex { + // Pointer-driven, never animated: any smoothing here reads + // as the row lagging the hand. + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.zPosition = 100 + layer.transform = CATransform3DMakeTranslation( + 0, draggedCenter - session.frames[row].midY, 0 + ) + CATransaction.commit() + return + } + // Final position of this row if dropped now: removing the + // dragged row shifts later rows up one; inserting at the slot + // pushes rows at or past it down one. The offset is always + // exactly one dragged-row height or zero. + var finalPosition = row - (row > session.sourceIndex ? 1 : 0) + if finalPosition >= insertion { finalPosition += 1 } + let target = CGFloat(finalPosition - row) * session.rowHeight + if session.appliedTargets[row] != target { + session.appliedTargets[row] = target + layer.zPosition = 0 + glideRowShift(layer: layer, to: target) + } else if layer.animation(forKey: "cmux.reorderShift") == nil { + // Reused row views arrive with whatever transform their + // previous tenant had; keep visible rows pinned to their + // current target every tick (but never stamp over a glide + // that is still in flight). + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.transform = CATransform3DMakeTranslation(0, target, 0) + CATransaction.commit() + } + } + reorderLiftSession = session + } + + /// Ends the visual session. Animated: rows glide back to their real + /// frames (a cancelled drag). Not animated: transforms drop instantly + /// (the structural apply is about to redraw the true order). + private func endReorderLift(animated: Bool) { + guard reorderLiftSession != nil else { return } + reorderLiftSession = nil + guard let containerView else { return } + let table = containerView.tableView + table.enumerateAvailableRowViews { [self] rowView, _ in + guard let layer = rowView.layer else { return } + if animated { + glideRowShift(layer: layer, to: 0) + } else { + layer.removeAnimation(forKey: "cmux.reorderShift") + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.transform = CATransform3DIdentity + CATransaction.commit() + } + layer.zPosition = 0 + } + } + private func enforceReorderIndicatorPaintOnVisibleCells() { guard reorderIndicatorPainter != nil else { return } sweepReorderIndicatorPaint(reorderIndicatorPainter) From 3429333816d2fad1aa805ed411229d58332a2e75 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 20:08:27 +1200 Subject: [PATCH 07/69] sidebar list feel: rows animate in and out, snappier selection closing a workspace slides its row out and creating one inserts in place instead of reloading every visible cell, which was both the create lag and the visual blink. selection coalescing window drops to 40ms now that switches are cheap. --- .../SidebarSelectionCoalescer.swift | 7 +- .../SidebarWorkspaceTableController.swift | 101 ++++++++++++++++++ 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/Sources/Sidebar/AppKitList/SidebarSelectionCoalescer.swift b/Sources/Sidebar/AppKitList/SidebarSelectionCoalescer.swift index 77c3b89a0c03..871ebcea46f8 100644 --- a/Sources/Sidebar/AppKitList/SidebarSelectionCoalescer.swift +++ b/Sources/Sidebar/AppKitList/SidebarSelectionCoalescer.swift @@ -2,6 +2,9 @@ import AppKit /// Coalesces rapid plain-click workspace selections to the latest request. /// +/// The window is deliberately small: with recent workspaces kept mounted, +/// a commit is cheap enough that responsiveness beats batching. +/// /// A selection commit re-renders the container and swaps the terminal /// content (~tens of ms); without coalescing, a burst of clicks queues one /// full commit per click and later selections feel progressively slower. @@ -21,7 +24,7 @@ final class SidebarSelectionCoalescer where C.Duration == Duration { private let window: Duration private let clock: C - init(window: Duration = .milliseconds(100), clock: C) { + init(window: Duration = .milliseconds(40), clock: C) { self.window = window self.clock = clock } @@ -75,7 +78,7 @@ final class SidebarSelectionCoalescer where C.Duration == Duration { } extension SidebarSelectionCoalescer where C == ContinuousClock { - convenience init(window: Duration = .milliseconds(100)) { + convenience init(window: Duration = .milliseconds(40)) { self.init(window: window, clock: ContinuousClock()) } } diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index b414c4053e54..923199ff20c4 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -788,6 +788,52 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS pureEdit = SidebarWorkspaceTableRowEdit(from: previousIds, to: nextIds) } } + // Pure removal (closing tabs): the surviving rows keep their order, + // at most a few rows disappear. These animate out instead of + // reloading, so a closed tab leaves the way the peek panel does: + // sliding off, not blinking away. + var isSmallPureRemoval = false + var removedRowIndexes = IndexSet() + if hasStructuralChanges, + previousIds.count > nextIds.count, + previousIds.count - nextIds.count <= 3 { + var survivorCursor = 0 + var removed = IndexSet() + for (index, id) in previousIds.enumerated() { + if survivorCursor < nextIds.count, nextIds[survivorCursor] == id { + survivorCursor += 1 + } else { + removed.insert(index) + } + } + if survivorCursor == nextIds.count { + isSmallPureRemoval = true + removedRowIndexes = removed + } + } + // Pure insertion (creating workspaces): the existing rows keep their + // order, at most a few rows appear. These insert in place instead of + // reloading: reloadData tears down every visible cell just to add + // one row, which is both slower to first paint and visually abrupt. + var isSmallPureInsertion = false + var insertedRowIndexes = IndexSet() + if hasStructuralChanges, + nextIds.count > previousIds.count, + nextIds.count - previousIds.count <= 3 { + var existingCursor = 0 + var inserted = IndexSet() + for (index, id) in nextIds.enumerated() { + if existingCursor < previousIds.count, previousIds[existingCursor] == id { + existingCursor += 1 + } else { + inserted.insert(index) + } + } + if existingCursor == previousIds.count { + isSmallPureInsertion = true + insertedRowIndexes = inserted + } + } let requiresAtomicReorderReload = hasStructuralChanges && !heightChanges.isEmpty && isSmallPureReorder // A forced reload follows hidden-presentation pruning, where @@ -876,6 +922,49 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS // content did not; configure skips cells whose model is equal. reconfigureLoadedRows(in: table) } + } else if heightChanges.isEmpty, isSmallPureInsertion { + // In-place insert: existing cells stay alive, the new row + // arrives with a short fade-slide, and first paint beats the + // reload path because nothing else is rebuilt. + let table = containerView.tableView + performTableGeometryUpdateWithoutAnimation(in: table) { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.22 + context.timingFunction = CAMediaTimingFunction(controlPoints: 0.3, 1.0, 0.4, 1.0) + table.beginUpdates() + table.insertRows(at: insertedRowIndexes, withAnimation: [.slideDown, .effectFade]) + table.endUpdates() + } + let visible = table.rows(in: table.visibleRect) + if visible.length > 0 { + reconfigureVisibleRows( + IndexSet(integersIn: visible.lowerBound..<(visible.lowerBound + visible.length)) + ) + } + } + } else if heightChanges.isEmpty, isSmallPureRemoval { + // Animated close: the row slides out and its neighbours take + // up the space, all inside one update so the data source and + // the table never disagree. + let table = containerView.tableView + performTableGeometryUpdateWithoutAnimation(in: table) { + // Nested group: the outer wrapper zeroes the context so + // structural churn stays snap; the removal opts back in + // with its own duration for the slide-out. + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.25 + context.timingFunction = CAMediaTimingFunction(controlPoints: 0.3, 1.0, 0.4, 1.0) + table.beginUpdates() + table.removeRows(at: removedRowIndexes, withAnimation: [.slideLeft, .effectFade]) + table.endUpdates() + } + let visible = table.rows(in: table.visibleRect) + if visible.length > 0 { + reconfigureVisibleRows( + IndexSet(integersIn: visible.lowerBound..<(visible.lowerBound + visible.length)) + ) + } + } } else { let table = containerView.tableView // The atomic height-changing reorder path replaces visible @@ -1426,6 +1515,18 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS session.animatesToStartingPositionsOnCancelOrFail = false if let workspaceId = pendingWorkspaceDragWorkspaceId ?? draggedRows.first.flatMap({ rows.indices.contains($0) ? rows[$0].workspaceId : nil }) { + // Aside behaviour: picking up a workspace you are not on switches + // to it, so the tab in your hand is the tab you are on. Single + // drags only; a multi-selection drag must not collapse the very + // selection it is moving. + if (actions?.movingWorkspaceCount?(workspaceId) ?? 1) <= 1, + let sourceRow = draggedRows.first, + rows.indices.contains(sourceRow), + rows[sourceRow].appKitWorkspaceRowModel?.isActive == false, + let rowActions = rows[sourceRow].appKitWorkspaceRowActions { + previewSelection(row: sourceRow, modifiers: [], hitView: nil) + rowActions.commands.updateSelection(modifiers: []) + } startReorderPoll(workspaceId: workspaceId) } } From 6d79325ea73b06b0509a3ecde00083464d6890d5 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 20:08:27 +1200 Subject: [PATCH 08/69] floating sidebar: glass panel window, hover reveal, honest cursor the peek card lives in its own child panel above the portal-hosted terminal (the only way to draw over it), with real behind-window blur, its own hover tracking, and instant reveal from the titlebar toggle. terminal cursor rects yield to the card while it accepts the pointer so titles stop flashing the ibeam. docking over a revealed card swaps in place with no ghost slide. --- .../CmuxSidebar/Peek/SidebarPeekPolicy.swift | 13 +- .../Peek/UI/SidebarPeekGlassBackdrop.swift | 35 ++ .../Peek/UI/SidebarPeekPanelChrome.swift | 18 +- .../Peek/UI/SidebarPeekPanelMetrics.swift | 22 +- Sources/ContentView+SidebarPeek.swift | 138 ++++++- Sources/GhosttyTerminalView.swift | 7 + .../Sidebar/Peek/SidebarPeekController.swift | 10 + .../Sidebar/Peek/SidebarPeekPanelHost.swift | 345 ++++++++++++++++++ .../Peek/SidebarPeekPresentation.swift | 82 +++-- Sources/Update/UpdateTitlebarAccessory.swift | 10 + cmux.xcodeproj/project.pbxproj | 8 + 11 files changed, 631 insertions(+), 57 deletions(-) create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift create mode 100644 Sources/Sidebar/Peek/SidebarPeekPanelHost.swift diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPolicy.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPolicy.swift index 260ae376d182..8d96cd2a9976 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPolicy.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPolicy.swift @@ -26,12 +26,15 @@ public struct SidebarPeekPolicy: Sendable, Hashable { /// /// 180ms of dwell is long enough that crossing the edge on the way /// somewhere else does not trigger a reveal, and short enough that a - /// deliberate rest feels immediate. 260ms of grace comfortably covers the - /// diagonal from the edge strip into the panel. + /// deliberate rest feels immediate. 250ms of grace covers the diagonal + /// from the edge strip into the panel and a grazing exit along a row, + /// while keeping the dismissal feeling immediate once the pointer has + /// genuinely left. The 10pt strip forgives a pointer thrown at the + /// screen edge that stops a few points short. public static let `default` = SidebarPeekPolicy( - dwell: .milliseconds(180), - grace: .milliseconds(260), - edgeWidth: 6, + dwell: .milliseconds(120), + grace: .milliseconds(250), + edgeWidth: 14, dismissesOnWorkspaceActivation: true, isEnabled: true ) diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift new file mode 100644 index 000000000000..f4da5261292c --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift @@ -0,0 +1,35 @@ +import AppKit +public import SwiftUI + +/// Behind-window blur for the floating card. +/// +/// The card lives in its own borderless child window, which is exactly what +/// makes real glass possible: an NSVisualEffectView blending behind-window +/// samples whatever is under the panel, which here is the live terminal. +/// SwiftUI's `Material` cannot do this job in the panel; it blurs only +/// content inside its own window, and the panel's own backdrop is empty. +public struct SidebarPeekGlassBackdrop: NSViewRepresentable { + /// Corner radius matching the card shape. + public let cornerRadius: CGFloat + + /// Creates the backdrop. + public init(cornerRadius: CGFloat) { + self.cornerRadius = cornerRadius + } + + public func makeNSView(context: Context) -> NSVisualEffectView { + let view = NSVisualEffectView() + view.blendingMode = .behindWindow + view.material = .popover + view.state = .active + view.wantsLayer = true + view.layer?.cornerRadius = cornerRadius + view.layer?.cornerCurve = .continuous + view.layer?.masksToBounds = true + return view + } + + public func updateNSView(_ view: NSVisualEffectView, context: Context) { + view.layer?.cornerRadius = cornerRadius + } +} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift index 87562c96bc96..ce75081d46fc 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift @@ -35,7 +35,7 @@ public struct SidebarPeekPanelChrome: View { public var body: some View { content() - .background(shape.fill(material)) + .background(surface) .overlay(rim) .clipShape(shape) .shadow( @@ -45,10 +45,24 @@ public struct SidebarPeekPanelChrome: View { y: metrics.shadowOffsetY ) .padding(.leading, metrics.leadingInset) - .padding(.vertical, metrics.verticalInset) + .padding(.top, metrics.topInset) + .padding(.bottom, metrics.bottomInset) .accessibilityIdentifier("SidebarPeekPanel") } + /// The card's ground: real behind-window glass with a legibility tint. + /// + /// The glass samples the terminal under the panel window; the tint is + /// what keeps row labels readable over it. Blur alone leaves ghosted + /// terminal text inside the card; the tint pushes it back to a shimmer + /// without going opaque, which is the whole glassmorphism effect. + private var surface: some View { + ZStack { + SidebarPeekGlassBackdrop(cornerRadius: metrics.cornerRadius) + shape.fill(Color(nsColor: .windowBackgroundColor).opacity(0.52)) + } + } + private var shape: RoundedRectangle { RoundedRectangle(cornerRadius: metrics.cornerRadius, style: .continuous) } diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelMetrics.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelMetrics.swift index f2c5bb4bd758..aef11b8d0a3b 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelMetrics.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelMetrics.swift @@ -10,8 +10,15 @@ public import SwiftUI public struct SidebarPeekPanelMetrics: Sendable, Equatable { /// Distance from the window's leading edge to the panel. public let leadingInset: CGFloat - /// Distance from the window's top and bottom to the panel. - public let verticalInset: CGFloat + /// Distance from the top of the content area to the panel. + /// + /// Larger than the other insets so the card hangs below the titlebar band + /// instead of sliding underneath it. A card that reaches into the titlebar + /// reads as part of the window chrome; starting it below the band is what + /// makes it read as a sheet lying over the content. + public let topInset: CGFloat + /// Distance from the window's bottom edge to the panel. + public let bottomInset: CGFloat /// The panel's corner radius. public let cornerRadius: CGFloat /// Radius of the drop shadow. @@ -30,8 +37,9 @@ public struct SidebarPeekPanelMetrics: Sendable, Equatable { /// and soft rather than tight and dark, so the panel separates from a /// terminal's arbitrary background without printing a hard edge on it. public static let `default` = SidebarPeekPanelMetrics( - leadingInset: 8, - verticalInset: 10, + leadingInset: 10, + topInset: 10, + bottomInset: 10, cornerRadius: 12, shadowRadius: 22, shadowOffsetY: 6, @@ -42,7 +50,8 @@ public struct SidebarPeekPanelMetrics: Sendable, Equatable { /// Creates panel metrics. public init( leadingInset: CGFloat, - verticalInset: CGFloat, + topInset: CGFloat, + bottomInset: CGFloat, cornerRadius: CGFloat, shadowRadius: CGFloat, shadowOffsetY: CGFloat, @@ -50,7 +59,8 @@ public struct SidebarPeekPanelMetrics: Sendable, Equatable { borderWidth: CGFloat ) { self.leadingInset = leadingInset - self.verticalInset = verticalInset + self.topInset = topInset + self.bottomInset = bottomInset self.cornerRadius = cornerRadius self.shadowRadius = shadowRadius self.shadowOffsetY = shadowOffsetY diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index 42d68963d822..f4847d4de24e 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -1,26 +1,148 @@ +import CmuxFoundation import CmuxSidebar import SwiftUI +extension Notification.Name { + /// Posted by the titlebar accessory as the pointer enters or leaves the + /// sidebar toggle button. userInfo: ["hovering": Bool]. + static let cmuxSidebarToggleHoverChanged = Notification.Name("cmux.sidebarToggleHoverChanged") +} + extension ContentView { + /// Whether the sidebar should be drawn, from any cause. + /// + /// Peek is a third way to be on screen, alongside docked and floating. + /// Anything gating on "is the sidebar showing" has to ask this, not + /// `isVisible`, or it will treat a peeked sidebar as absent. + var sidebarIsRevealed: Bool { + sidebarState.isVisible || sidebarPeek.presentsPanel + } + + /// Whether the sidebar draws as a detached card instead of a flush pane. + /// + /// Anything not taking layout width is a card. A peek is a card even when + /// the persisted mode is docked, because the terminal did not move aside + /// for it. + var sidebarRendersAsCard: Bool { + !sidebarState.occupiesLayout + } + + /// Card geometry tuned to this window's chrome. + /// + /// The panel window itself already starts below the titlebar band (so the + /// traffic lights stay clickable), which leaves the card needing only a + /// small headroom inside it. + var sidebarPeekPanelMetrics: SidebarPeekPanelMetrics { + SidebarPeekPanelMetrics( + leadingInset: 10, + topInset: 6, + bottomInset: 12, + cornerRadius: 12, + shadowRadius: 22, + shadowOffsetY: 6, + shadowOpacity: 0.34, + borderWidth: 0.5 + ) + } + + /// The in-layout presentation for the sidebar pane. + /// + /// Flush-only: the layout hosts the sidebar exclusively while it occupies + /// layout width. Every card presentation (peek, persistent floating) goes + /// through the child panel window instead, because an in-layout card draws + /// underneath the portal-hosted terminal. + func sidebarPeekPresentationModifier(width: CGFloat) -> SidebarPeekPresentation { + SidebarPeekPresentation( + isRevealed: sidebarState.occupiesLayout, + rendersAsCard: false, + width: width, + panelMetrics: sidebarPeekPanelMetrics, + onPanelHoverChange: { _ in } + ) + } + + /// Whether the floating card is currently the active sidebar presentation. + var sidebarPanelCardIsRevealed: Bool { + sidebarIsRevealed && sidebarRendersAsCard + } + + /// Zero-sized anchor owning the child panel window that floats the card + /// above the terminal. Mounted from `contentAndSidebarLayout`. + @ViewBuilder + var sidebarPeekPanelHost: some View { + SidebarWidthReader(layout: sidebarLayout) { width in + SidebarPeekPanelBridge( + contentWidth: width, + metrics: sidebarPeekPanelMetrics, + acceptsMouse: sidebarPanelCardIsRevealed, + content: AnyView(sidebarPeekCard(width: width)) + ) + } + .frame(width: 0, height: 0) + } + + /// The floating card's full content, built fresh on every ContentView + /// update so the panel window tracks state without its own environment + /// plumbing. The environment injection mirrors what AppDelegate gives + /// ContentView's own hosting view: the child window's hosting view + /// inherits none of it. + func sidebarPeekCard(width: CGFloat) -> some View { + let revealed = sidebarPanelCardIsRevealed + let appearance = windowAppearanceSnapshot + // `isPresented: true` keeps the panel's list live even while hidden: + // suspending it on dismissal blanks the rows the moment the slide-out + // starts, which reads as the panel vanishing instead of leaving. + return sidebarView(isPresented: true, attachesFocusBoundary: false) + .environment(\.colorScheme, appearance.sidebarContentColorScheme) + .modifier(SidebarPeekPresentation( + isRevealed: revealed, + rendersAsCard: true, + width: width, + // Docking supersedes the card in place: no exit slide. + dismissesInstantly: sidebarState.occupiesLayout, + panelMetrics: sidebarPeekPanelMetrics, + onPanelHoverChange: { isInside in + if isInside { + sidebarPeek.acquire(.pointerInsidePanel) + } else { + sidebarPeek.release(.pointerInsidePanel) + } + } + )) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) + .environmentObject(cmuxConfigStore) + .environment(\.sessionDragRegistry, sessionDragRegistryEnv) + .environment(\.tabDragTransferRegistry, tabDragTransferRegistryEnv) + .environment(\.settingsRuntime, settingsRuntimeEnv) + .cmuxFontMagnificationEnvironment() + } + /// The invisible leading-edge strip that arms the hover-reveal. /// - /// Mounted only while the sidebar is collapsed and floating: a docked - /// sidebar already occupies the leading edge, and a collapsed docked - /// sidebar is a deliberate choice the pointer should not undo. The strip - /// removes its tracking area entirely when disabled rather than ignoring - /// callbacks, so the common case costs nothing. + /// Armed whenever the sidebar is hidden, in either presentation mode. An + /// earlier version required floating mode first, which made peek + /// unreachable in practice: the mode toggle only appears on a *visible* + /// sidebar, so a user who hid a docked sidebar had no way to reach either + /// the toggle or the peek. Hiding the sidebar is the moment peek exists + /// for. + /// + /// The strip removes its tracking area entirely when disarmed rather than + /// ignoring callbacks, so a docked sidebar costs nothing. @ViewBuilder var sidebarPeekEdgeStrip: some View { - let isArmed = sidebarState.presentationMode == .floating && !sidebarState.isVisible SidebarPeekEdgeTrackingView( width: sidebarPeek.policy.edgeWidth, - isEnabled: isArmed, + isEnabled: !sidebarState.isVisible && sidebarPeek.policy.isEnabled, onEnter: { sidebarPeek.pointerEnteredEdge() }, onExit: { sidebarPeek.pointerExitedEdge() } ) .frame(width: sidebarPeek.policy.edgeWidth) .frame(maxHeight: .infinity) - .allowsHitTesting(false) .zIndex(1) } } diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 4d3c9c399687..bf54553835cc 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5474,6 +5474,13 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func resetCursorRects() { super.resetCursorRects() + // While the floating sidebar card is up, the pointer over its rows is + // geometrically over this surface too. Keep the terminal from + // replacing the card's arrow cursor with an I-beam. + if window?.cmuxSuppressesTerminalCursorRects == true { + addCursorRect(bounds, cursor: .arrow) + return + } let cursor = codexActionCommandHovering ? NSCursor.pointingHand : Self.ghosttyMouseCursor(for: ghosttyMouseShape) diff --git a/Sources/Sidebar/Peek/SidebarPeekController.swift b/Sources/Sidebar/Peek/SidebarPeekController.swift index 62acf1ef09c2..6279d5ed388d 100644 --- a/Sources/Sidebar/Peek/SidebarPeekController.swift +++ b/Sources/Sidebar/Peek/SidebarPeekController.swift @@ -36,6 +36,13 @@ final class SidebarPeekController: ObservableObject { // MARK: - Events func pointerEnteredEdge() { send(.pointerEnteredEdge) } + /// Edge-enter with the dwell skipped: hovering an activation control (the + /// titlebar's sidebar toggle) is already a deliberate act, so the reveal + /// starts the moment the pointer lands instead of after the edge dwell. + func pointerEnteredActivationControl() { + send(.pointerEnteredEdge) + send(.dwellElapsed) + } func pointerExitedEdge() { send(.pointerExitedEdge) } func dragEnteredEdge() { send(.dragEnteredEdge) } func workspaceActivated() { send(.workspaceActivated) } @@ -63,6 +70,9 @@ final class SidebarPeekController: ObservableObject { private func send(_ event: SidebarPeekEvent) { let effects = machine.apply(event, to: &state) +#if DEBUG + cmuxDebugLog("sidebar.peek event=\(event) phase=\(state.phase.rawValue) holds=\(state.holds.rawValue)") +#endif for effect in effects { perform(effect) } diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift new file mode 100644 index 000000000000..0f908ba31306 --- /dev/null +++ b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift @@ -0,0 +1,345 @@ +import AppKit +import CmuxSidebar +import ObjectiveC +import SwiftUI + +/// Borderless nonactivating panel that floats the sidebar card above the +/// window's portal-hosted terminal views. +/// +/// The terminal is not SwiftUI: portal hosting reparents every surface into a +/// `WindowTerminalHostView` that sits above the window's SwiftUI hosting view, +/// so nothing drawn inside ContentView's own tree can appear over live +/// terminal content, whatever its zIndex says. cmux's rich overlays solve +/// this with their own windows (the command palette, browser popups); the +/// peek card does the same. A child window also gives the card its own event +/// stream, which is what makes the hover holds reliable: inside the main +/// window, pointer events over the card's area belong to whatever hit-test +/// carve-outs the terminal host happens to have. +private var cmuxSuppressTerminalCursorRectsKey: UInt8 = 0 + +extension NSWindow { + /// While true, portal-hosted terminal surfaces in this window register an + /// arrow cursor rect instead of ghostty's shape. Set by the peek panel + /// controller while the floating card accepts the pointer. + var cmuxSuppressesTerminalCursorRects: Bool { + get { + (objc_getAssociatedObject(self, &cmuxSuppressTerminalCursorRectsKey) as? NSNumber)?.boolValue == true + } + set { + objc_setAssociatedObject( + self, + &cmuxSuppressTerminalCursorRectsKey, + NSNumber(value: newValue), + .OBJC_ASSOCIATION_RETAIN + ) + } + } +} + +final class SidebarPeekPanelWindow: NSPanel { + // Never key or main: the parent keeps keyboard focus, so keystrokes keep + // flowing to the terminal while the card is up, and clicking a row cannot + // steal the window's key state out from under the peek gesture. + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } +} + +/// Owns the child panel and keeps it glued to its parent window's leading +/// edge. One controller per main window, held by the bridge's coordinator. +@MainActor +final class SidebarPeekPanelWindowController { + private var panel: SidebarPeekPanelWindow? + private var hostingView: SidebarPeekPanelHostingView? + private weak var parentWindow: NSWindow? + // `nonisolated(unsafe)` so deinit can drop the tokens; every other access + // is main-actor. + private nonisolated(unsafe) var parentObservers: [NSObjectProtocol] = [] + private var lastContentWidth: CGFloat = 0 + private var lastMetrics: SidebarPeekPanelMetrics = .default + /// Whether the previous update had the card revealed. Content pushes are + /// skipped while hidden (repainting an invisible panel on every + /// ContentView update is pure cost); the one push after reveal flips to + /// false still lands so the exit animation runs. + private var lastRevealed = false + private var hasPushedContent = false + /// Trailing-edge coalescing for content pushes. ContentView can evaluate + /// its body many times inside one runloop turn (worst during a workspace + /// switch); the first push of a turn lands synchronously so clicks + /// repaint instantly, and the rest collapse into one trailing push. + private var didPushContentThisTurn = false + private var pendingCoalescedContent: AnyView? + + /// Whether the panel is already attached to `window`, letting the bridge + /// update synchronously instead of hopping the runloop. + func isAttached(to window: NSWindow) -> Bool { + parentWindow === window && panel?.parent === window + } + + /// Extra room on the trailing edge so the card's SwiftUI shadow is not + /// clipped by the panel's bounds. Kept slim on purpose: pointer events + /// inside this window cannot fall through to the parent, so every point + /// of margin is a point of terminal that stops responding while the + /// card is up. + private static let shadowMargin: CGFloat = 20 + + /// The titlebar strip stays outside the panel so the traffic lights and + /// titlebar controls in the parent window remain clickable while the + /// card is up. + private static var topExclusion: CGFloat { + WindowChromeMetrics.appTitlebarHeight + 2 + } + + deinit { + // Thread-safe by NotificationCenter contract; the panel itself is + // torn down by orderOut in detach or by the parent window closing. + for observer in parentObservers { + NotificationCenter.default.removeObserver(observer) + } + } + + func update( + parent: NSWindow?, + contentWidth: CGFloat, + metrics: SidebarPeekPanelMetrics, + acceptsMouse: Bool, + content: AnyView + ) { + guard let parent, parent.isVisible else { + detach() + return + } + lastContentWidth = contentWidth + lastMetrics = metrics + ensurePanel(parent: parent) + guard let panel, let hostingView else { return } + // Push content only while the card shows (or on the hide transition, + // which the slide-out animation needs). Pushing on every ContentView + // update while hidden re-diffed the whole sidebar subtree during + // workspace-switch churn, which is where the click-to-switch lag in + // floating mode came from. + if acceptsMouse || lastRevealed || !hasPushedContent { + if !didPushContentThisTurn { + didPushContentThisTurn = true + hostingView.rootView = content + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.didPushContentThisTurn = false + if let pending = self.pendingCoalescedContent { + self.pendingCoalescedContent = nil + self.hostingView?.rootView = pending + } + } + } else { + pendingCoalescedContent = content + } + hasPushedContent = true + } + lastRevealed = acceptsMouse + // When the card is not revealed the panel must be transparent to the + // pointer, or it would swallow the very edge hovers that arm the + // reveal in the parent window below it. + panel.ignoresMouseEvents = !acceptsMouse + panel.appearance = parent.appearance + // Terminal cursor rects yield to the card while it can be pointed + // at, and come back the moment it cannot. + if parent.cmuxSuppressesTerminalCursorRects != acceptsMouse { + parent.cmuxSuppressesTerminalCursorRects = acceptsMouse + parent.resetCursorRects() + } + layoutPanel() + } + + private func ensurePanel(parent: NSWindow) { + if let panel, parentWindow === parent, panel.parent === parent { + return + } + detach() + + let hosting = SidebarPeekPanelHostingView(rootView: AnyView(EmptyView())) + hosting.autoresizingMask = [.width, .height] + + let panel = SidebarPeekPanelWindow( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = false + panel.isMovable = false + panel.hidesOnDeactivate = false + panel.isReleasedWhenClosed = false + panel.animationBehavior = .none + panel.collectionBehavior = [.fullScreenAuxiliary] + panel.contentView = hosting + panel.ignoresMouseEvents = true + + parent.addChildWindow(panel, ordered: .above) + + self.panel = panel + self.hostingView = hosting + self.parentWindow = parent + + // Child windows ride along on parent moves, but a resize changes the + // region the panel must cover, and a screen change can do both. + let center = NotificationCenter.default + parentObservers = [ + center.addObserver( + forName: NSWindow.didResizeNotification, object: parent, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.layoutPanel() } + }, + center.addObserver( + forName: NSWindow.didChangeScreenNotification, object: parent, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.layoutPanel() } + }, + center.addObserver( + forName: NSWindow.willCloseNotification, object: parent, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.detach() } + }, + ] + layoutPanel() + } + + /// Pins the panel over the parent's leading content region: full height, + /// wide enough for the inset card plus shadow bleed. + private func layoutPanel() { + guard let panel, let parent = parentWindow else { return } + let parentContent = parent.contentRect(forFrameRect: parent.frame) + let width = lastMetrics.leadingInset + lastContentWidth + Self.shadowMargin + let height = max(0, parentContent.height - Self.topExclusion) + let frame = NSRect( + x: parentContent.minX, + y: parentContent.minY, + width: min(width, parentContent.width), + height: height + ) + if panel.frame != frame { + panel.setFrame(frame, display: true) + } + } + + private func detach() { + for observer in parentObservers { + NotificationCenter.default.removeObserver(observer) + } + parentObservers = [] + if let panel { + panel.parent?.removeChildWindow(panel) + panel.orderOut(nil) + } + panel = nil + hostingView = nil + parentWindow = nil + } +} + +/// Hosting view that keeps the pointer honest over the card. +/// +/// The terminal underneath registers an I-beam cursor rect with the key +/// window, and this panel is deliberately never key, so its own cursor rects +/// are ignored: AppKit keeps applying the terminal's I-beam straight through +/// the card. Re-asserting the arrow on entry and movement is the only lever +/// a non-key window has. +private final class SidebarPeekPanelHostingView: NSHostingView { + private var cursorTrackingArea: NSTrackingArea? + + @MainActor required init(rootView: AnyView) { + super.init(rootView: rootView) + } + + @MainActor required dynamic init?(coder aDecoder: NSCoder) { + fatalError("SidebarPeekPanelHostingView does not support NSCoder") + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let cursorTrackingArea { + removeTrackingArea(cursorTrackingArea) + } + let area = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + cursorTrackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + NSCursor.arrow.set() + } + + override func mouseMoved(with event: NSEvent) { + super.mouseMoved(with: event) + NSCursor.arrow.set() + } +} + +/// Mounts the panel controller into SwiftUI: a zero-sized anchor that tells +/// the controller which window to attach to and pushes fresh card content on +/// every ContentView update, so bindings and store references inside the card +/// stay live without the panel needing its own environment plumbing. +struct SidebarPeekPanelBridge: NSViewRepresentable { + let contentWidth: CGFloat + let metrics: SidebarPeekPanelMetrics + let acceptsMouse: Bool + let content: AnyView + + @MainActor + final class Coordinator { + let controller = SidebarPeekPanelWindowController() + } + + final class AnchorView: NSView { + var onWindowChange: (() -> Void)? + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + onWindowChange?() + } + + override func hitTest(_ point: NSPoint) -> NSView? { nil } + } + + func makeCoordinator() -> Coordinator { Coordinator() } + + func makeNSView(context: Context) -> AnchorView { + AnchorView(frame: .zero) + } + + func updateNSView(_ view: AnchorView, context: Context) { + let controller = context.coordinator.controller + let contentWidth = contentWidth + let metrics = metrics + let acceptsMouse = acceptsMouse + let content = content + let push: @MainActor () -> Void = { [weak view] in + controller.update( + parent: view?.window, + contentWidth: contentWidth, + metrics: metrics, + acceptsMouse: acceptsMouse, + content: content + ) + } + view.onWindowChange = { + // Window attachment lands outside our own update pass. + Task { @MainActor in push() } + } + if let window = view.window, controller.isAttached(to: window) { + // Already attached: update in place so a click inside the panel + // repaints on this runloop turn, not the next. The async hop is + // only needed while attaching, when the window hierarchy itself + // mutates. + push() + } else { + Task { @MainActor in push() } + } + } +} diff --git a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift index 1978f3ef01a8..965a1d796992 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift @@ -11,29 +11,26 @@ import SwiftUI /// on that: the reveal has no table to build, which is what lets it be /// instant instead of merely fast. struct SidebarPeekPresentation: ViewModifier { - /// Docked or floating. - let mode: SidebarPresentationMode - /// Whether the sidebar is docked open. - let isDockedVisible: Bool - /// Whether peek is currently presenting the floating panel. - let isPeeking: Bool + /// Whether the sidebar should be drawn at all, from any cause: docked + /// open, floating open, or temporarily revealed by peek. + let isRevealed: Bool + /// Whether to draw as a detached card rather than a flush pane. + /// + /// True whenever the sidebar is not taking layout width. Peek is a card by + /// nature: it is a temporary reveal over content that did not move aside + /// for it, so it draws as one even when the persisted mode is docked. + let rendersAsCard: Bool /// The sidebar's resolved width. let width: CGFloat + /// Whether a dismissal should skip the exit slide. True when the card + /// is being superseded by the docked pane: the fixed sidebar replaces it + /// in place, and a card gliding off underneath reads as a ghost. + var dismissesInstantly: Bool = false /// Card geometry for floating mode. let panelMetrics: SidebarPeekPanelMetrics /// Acquires and releases the pointer hold as the pointer crosses the panel. let onPanelHoverChange: (Bool) -> Void - /// Whether anything should be drawn at all. - private var isPresented: Bool { - switch mode { - case .docked: - return isDockedVisible - case .floating: - return isDockedVisible || isPeeking - } - } - /// Outer width including the card's leading inset, so floating and docked /// place the list's leading edge identically. private var floatingWidth: CGFloat { @@ -41,14 +38,7 @@ struct SidebarPeekPresentation: ViewModifier { } func body(content: Content) -> some View { - switch mode { - case .docked: - content - .frame(width: isPresented ? width : 0, alignment: .leading) - .clipped() - .allowsHitTesting(isPresented) - .accessibilityHidden(!isPresented) - case .floating: + if rendersAsCard { SidebarPeekPanelChrome(metrics: panelMetrics) { content.frame(width: width, alignment: .leading) } @@ -56,12 +46,30 @@ struct SidebarPeekPresentation: ViewModifier { // Slide from just behind the window edge rather than from zero // width. Animating the frame would re-lay-out every row on every // frame of the reveal; translating a laid-out subtree does not. - .offset(x: isPresented ? 0 : -floatingWidth) - .opacity(isPresented ? 1 : 0) - .allowsHitTesting(isPresented) - .accessibilityHidden(!isPresented) + .offset(x: isRevealed ? 0 : -floatingWidth) + .opacity(isRevealed ? 1 : 0) + .allowsHitTesting(isRevealed) + .accessibilityHidden(!isRevealed) .onHover(perform: onPanelHoverChange) - .animation(SidebarPeekMotion.reveal, value: isPresented) + // The exit is its own curve: a reveal wants a touch of arrival + // settle, but a dismissal should read as the panel leaving the + // screen, fast and without ceremony. + .animation( + isRevealed + ? SidebarPeekMotion.reveal + : (dismissesInstantly ? nil : SidebarPeekMotion.dismiss), + value: isRevealed + ) + } else { + // The width itself is live during a toggle: the toggle animator + // sweeps the real layout width (a synthetic divider drag), so the + // pane collapses and the terminal expands through the same + // proven live-resize path. No SwiftUI animation here at all. + content + .frame(width: isRevealed ? width : 0, alignment: .leading) + .clipped() + .allowsHitTesting(isRevealed) + .accessibilityHidden(!isRevealed) } } } @@ -73,7 +81,11 @@ enum SidebarPeekMotion { /// A spring rather than an ease so an interrupted reveal (pointer leaves /// mid-animation) retargets from wherever the panel currently is instead of /// snapping. `bounce` is zero: this is a panel arriving, not a toy. - static let reveal = Animation.spring(response: 0.28, dampingFraction: 0.86) + static let reveal = Animation.spring(response: 0.21, dampingFraction: 0.9) + + /// Dismissal: faster than the reveal and fully damped, so the panel + /// slides off the edge rather than lingering or bouncing on its way out. + static let dismiss = Animation.spring(response: 0.2, dampingFraction: 1.0) /// Switching between docked and floating. /// @@ -85,17 +97,15 @@ enum SidebarPeekMotion { extension View { /// Applies ``SidebarPeekPresentation``. func sidebarPeekPresentation( - mode: SidebarPresentationMode, - isDockedVisible: Bool, - isPeeking: Bool, + isRevealed: Bool, + rendersAsCard: Bool, width: CGFloat, panelMetrics: SidebarPeekPanelMetrics = .default, onPanelHoverChange: @escaping (Bool) -> Void ) -> some View { modifier(SidebarPeekPresentation( - mode: mode, - isDockedVisible: isDockedVisible, - isPeeking: isPeeking, + isRevealed: isRevealed, + rendersAsCard: rendersAsCard, width: width, panelMetrics: panelMetrics, onPanelHoverChange: onPanelHoverChange diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index a4c12a3ef3f9..537cdfe65d4a 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1150,6 +1150,16 @@ struct TitlebarControlsView: View { sidebarIconLabel(config: config, iconGeometryKeyPrefix: "titlebarControl_toggleSidebarIcon") } .safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) + .onHover { hovering in + // Pre-reveals the peek card while the pointer rests on the + // toggle (Aside behaviour); ContentView owns the peek state, + // so this only broadcasts. + NotificationCenter.default.post( + name: .cmuxSidebarToggleHoverChanged, + object: nil, + userInfo: ["hovering": hovering] + ) + } TitlebarControlButton( config: config, diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1729ba9c5448..3f527d79ec75 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3272,6 +3272,7 @@ EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000002 /* SidebarPathFormatter.swift */; }; 22326FBAA042DDAFA15F818C /* SidebarPeekController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B954AEE8BD37A8D69AA13832 /* SidebarPeekController.swift */; }; F3E95CEBB40A6AA79A9B4CEE /* SidebarPeekEdgeTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C733A9B22B4426FB8C1CD4F /* SidebarPeekEdgeTrackingView.swift */; }; + 12262B17C8E63041FCC7513B /* SidebarPeekPanelHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */; }; 5842622F8ED35FF5B317C174 /* SidebarPeekPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A9185ED75125180313AF0 /* SidebarPeekPresentation.swift */; }; A80040010000000000000001 /* SidebarPointerFrameReportingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */; }; A80040030000000000000001 /* SidebarPointerInteractionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */; }; @@ -3330,6 +3331,7 @@ F7216006000000000000001 /* SidebarTabItemContextMenuState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7216006000000000000002 /* SidebarTabItemContextMenuState.swift */; }; 8430A0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430B0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift */; }; A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73F10000000000000002 /* SidebarTitleFirstLineCenterAlignment.swift */; }; + 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430B0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift */; }; EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */; }; @@ -7682,6 +7684,7 @@ EA1F00000000000000000002 /* SidebarPathFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPathFormatter.swift; sourceTree = ""; }; B954AEE8BD37A8D69AA13832 /* SidebarPeekController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekController.swift; sourceTree = ""; }; 2C733A9B22B4426FB8C1CD4F /* SidebarPeekEdgeTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekEdgeTrackingView.swift; sourceTree = ""; }; + 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekPanelHost.swift; sourceTree = ""; }; 327A9185ED75125180313AF0 /* SidebarPeekPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekPresentation.swift; sourceTree = ""; }; A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPointerFrameReportingModifier.swift; sourceTree = ""; }; A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPointerInteractionMonitor.swift; sourceTree = ""; }; @@ -7739,6 +7742,7 @@ F7216006000000000000002 /* SidebarTabItemContextMenuState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabItemContextMenuState.swift; sourceTree = ""; }; 8430B0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabItemSettingsSnapshot.swift; sourceTree = ""; }; A6AC73F10000000000000002 /* SidebarTitleFirstLineCenterAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarTitleFirstLineCenterAlignment.swift; sourceTree = ""; }; + 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleAnimator.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; 8430B0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceBranchDirectorySettings.swift; sourceTree = ""; }; BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceChecklistPopover.swift"; sourceTree = ""; }; @@ -10134,6 +10138,8 @@ 6042D0056042D0056042D005 /* WindowScopedShortcutHintModifierMonitor.swift */, A5001901A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift */, D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */, + 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */, + 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */, A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */, 62AA6EC5A67508F26074B60B /* SidebarFilterController.swift */, 0FA48771BCBE1F596B455835 /* SidebarFilterCorpusKey.swift */, @@ -16024,6 +16030,7 @@ EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */, 22326FBAA042DDAFA15F818C /* SidebarPeekController.swift in Sources */, F3E95CEBB40A6AA79A9B4CEE /* SidebarPeekEdgeTrackingView.swift in Sources */, + 12262B17C8E63041FCC7513B /* SidebarPeekPanelHost.swift in Sources */, 5842622F8ED35FF5B317C174 /* SidebarPeekPresentation.swift in Sources */, A80040010000000000000001 /* SidebarPointerFrameReportingModifier.swift in Sources */, A80040030000000000000001 /* SidebarPointerInteractionMonitor.swift in Sources */, @@ -16067,6 +16074,7 @@ F7216006000000000000001 /* SidebarTabItemContextMenuState.swift in Sources */, 8430A0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift in Sources */, A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */, + 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */, 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */, EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */, B290FAD92C810ACB2D9CD9C6 /* SidebarWorkspaceChecklistView.swift in Sources */, From fbbe32a4b0758805afed4f1339dd4abc80ab9402 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Tue, 1 Sep 2026 20:08:27 +1200 Subject: [PATCH 09/69] toggle as divider sweep, glass ground, workspace card, mount lru the sidebar toggle sweeps the real layout width through the same live resize path the manual divider uses, so pane and terminal move together with no choreography to drift out of sync. window ground is the sidebar material window-wide, the workspace band and terminal draw as one card rounded where it meets the glass, and the last four workspaces stay mounted so recent tab switches are a visibility flip instead of a cold mount. --- .../Workspace/WorkspaceMountPlan.swift | 10 +- Sources/ContentView.swift | 189 ++++++++++++++---- Sources/Sidebar/SidebarState.swift | 16 ++ Sources/Sidebar/SidebarToggleAnimator.swift | 111 ++++++++++ Sources/WindowChromeMetrics.swift | 56 +++++- 5 files changed, 338 insertions(+), 44 deletions(-) create mode 100644 Sources/Sidebar/SidebarToggleAnimator.swift diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceMountPlan.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceMountPlan.swift index 57604eea2bf7..610608195a51 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceMountPlan.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceMountPlan.swift @@ -5,8 +5,14 @@ public import Foundation /// holds no state and touches no UI. Construct it with the current mount state /// and read ``mountedWorkspaceIds``. public struct WorkspaceMountPlan: Equatable { - // Keep only the selected workspace mounted to minimize layer-tree traversal. - public static let maxMountedWorkspaces = 1 + // An LRU of recently used workspaces stays mounted, so switching back + // to a recent tab is a visibility flip instead of a cold mount of its + // whole terminal subtree. One was the old value; every switch paid the + // full mount cost, which read as click latency in the sidebar. + public static let maxMountedWorkspaces = 4 + // During workspace cycling, keep the same budget: rapid flicking is + // exactly when cold mounts hurt most. + public static let maxMountedWorkspacesDuringCycle = 4 private let current: [UUID] private let selected: UUID? diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 95b2993cd452..3ed14fa1bf29 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -944,9 +944,21 @@ struct ContentView: View { /// different peek phases at once. // Not `private`: `ContentView+SidebarPeek` builds the edge strip from it. @StateObject var sidebarPeek = SidebarPeekController() - @State private var sidebarLayout = SidebarLayoutModel( + /// Sweeps the real layout width on toggle (a synthetic divider drag), so + /// the sidebar and terminal live-resize together through the same path + /// the manual divider uses. + @StateObject private var sidebarToggleAnimator = SidebarToggleAnimator() + // Not `private`: `ContentView+SidebarPeek` reads the width for the + // floating panel window's geometry. + @State var sidebarLayout = SidebarLayoutModel( width: CGFloat(SessionPersistencePolicy.defaultSidebarWidth) ) + // The floating peek panel lives in its own child window, whose hosting + // view does not inherit this window's SwiftUI environment. These readers + // let the peek card re-inject exactly what AppDelegate injects here. + @Environment(\.sessionDragRegistry) var sessionDragRegistryEnv + @Environment(\.tabDragTransferRegistry) var tabDragTransferRegistryEnv + @Environment(\.settingsRuntime) var settingsRuntimeEnv @State private var sidebarFocusBoundary = SidebarFocusBoundaryReference() private var sidebarWidth: CGFloat { get { sidebarLayout.width } @@ -1670,13 +1682,23 @@ struct ContentView: View { ) } - private var sidebarView: some View { + // Not `private`: the peek panel card builds a second instance of this + // subtree inside its own child window. `isPresented` gates the AppKit + // table's suspension, and each host passes its own presentation truth: + // the in-layout instance is presented only while the sidebar occupies + // layout width; the panel instance stays presented so reveal and + // dismissal animate real rows instead of a blank card. + // + // Only one instance may claim the focus boundary: it is last-write-wins + // and window-scoped, so the panel instance (in its own child window) + // must not shadow the in-layout instance's claim. + func sidebarView(isPresented: Bool, attachesFocusBoundary: Bool = true) -> some View { let sidebar = VerticalTabsSidebar( updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, sessionIndexStore: sessionIndexStore, featureFlags: featureFlags, - isPresented: sidebarState.isVisible, + isPresented: isPresented, sidebarUnread: sidebarUnread, titlebarControlsLayoutModel: titlebarControlsLayoutModel, windowId: windowId, @@ -1718,7 +1740,7 @@ struct ContentView: View { .modifier(SidebarWidthFrameModifier(layout: sidebarLayout)) .frame(maxHeight: .infinity, alignment: .topLeading) .background(SidebarPointerEventHost( - { sidebarFocusBoundary.attach($0) }, + { if attachesFocusBoundary { sidebarFocusBoundary.attach($0) } }, onDismantle: { sidebarFocusBoundary.detach($0) } )) } @@ -1837,10 +1859,20 @@ struct ContentView: View { .allowsHitTesting(sidebarSelectionState.selection == .tabs) .accessibilityHidden(sidebarSelectionState.selection != .tabs) } + // Inner insets: panes sit inside the workspace card, clear of its + // rounded border. + .padding(.top, WorkspaceCardMetrics.bandGap) + .padding(.leading, WorkspaceCardMetrics.paneInset) + .padding(.trailing, WorkspaceCardMetrics.paneInset) + .padding(.bottom, WorkspaceCardMetrics.paneInset) + // Reserves the titlebar band's height inside the card, so the band + // (drawn by the window-level overlay at the same fixed position) + // reads as the card's header. .modifier(WorkspacePresentationModeContentTopPaddingModifier( isFullScreen: isFullScreen, runtimeCache: workspacePresentationModeRuntimeCache )) + .background(WorkspaceCardBackground(fill: appearance.terminalBackgroundColor)) } private func terminalContentWithSidebarDropOverlay(appearance: WindowAppearanceSnapshot) -> some View { @@ -1885,10 +1917,17 @@ struct ContentView: View { alignment: Alignment, role: WindowBackdropRole, appearance: WindowAppearanceSnapshot, + hidesBackdrop: Bool = false, @ViewBuilder content: () -> Content ) -> some View { ZStack(alignment: alignment) { + // Faded rather than removed when the panel floats: swapping the + // branch would give the subtree a new identity and cold-start the + // retained AppKit table. The backdrop blurs whatever is behind it + // in the window, which against a floating card is live terminal + // text; the peek chrome owns the card's surface instead. sidebarBackdropLayer(width: width, role: role, appearance: appearance) + .opacity(hidesBackdrop ? 0 : 1) content() .environment(\.colorScheme, appearance.sidebarContentColorScheme) .environment(\.sidebarReadabilityBackdrop, appearance.sidebarReadabilityBackdrop) @@ -1902,8 +1941,17 @@ struct ContentView: View { private func sidebarPanelWithBackdrop(appearance: WindowAppearanceSnapshot) -> some View { SidebarWidthReader(layout: sidebarLayout) { width in - sidebarPanelContainer(width: width, alignment: .leading, role: .leftSidebar, appearance: appearance) { - sidebarView + sidebarPanelContainer( + width: width, + alignment: .leading, + role: .leftSidebar, + appearance: appearance, + // Always: the window ground now paints the sidebar material + // across the whole window, so a second copy here would double + // the tint inside the sidebar column. + hidesBackdrop: true + ) { + sidebarView(isPresented: sidebarState.occupiesLayout) } } } @@ -1985,8 +2033,11 @@ struct ContentView: View { } } - @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue - @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = SidebarAppearanceCatalogSection().matchTerminalBackground.defaultValue + // behindWindow by default: the window ground is desktop glass and the + // sidebar lives directly on it, with the workspace card as the one + // opaque surface on top (the Aside model). + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue + @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults().opacity @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults().hex @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? @@ -2002,7 +2053,9 @@ struct ContentView: View { @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false @State private var titlebarLeadingInset: CGFloat = 12 private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } - private var windowAppearanceSnapshot: WindowAppearanceSnapshot { + // Not `private`: the peek panel card in `ContentView+SidebarPeek` derives + // its content colour scheme from the same snapshot the docked sidebar uses. + var windowAppearanceSnapshot: WindowAppearanceSnapshot { _ = titlebarThemeGeneration return windowChrome.appearanceSnapshot( settings: WindowAppearanceUserSettingsSnapshot( @@ -2489,22 +2542,9 @@ struct ContentView: View { layout: sidebarLayout, enabled: sidebarState.occupiesLayout )) - sidebarPeekEdgeStrip SidebarWidthReader(layout: sidebarLayout) { width in sidebarPanelWithBackdrop(appearance: appearance) - .sidebarPeekPresentation( - mode: sidebarState.presentationMode, - isDockedVisible: sidebarState.isVisible, - isPeeking: sidebarPeek.presentsPanel, - width: width, - onPanelHoverChange: { isInside in - if isInside { - sidebarPeek.acquire(.pointerInsidePanel) - } else { - sidebarPeek.release(.pointerInsidePanel) - } - } - ) + .modifier(sidebarPeekPresentationModifier(width: width)) } } .animation(SidebarPeekMotion.modeChange, value: sidebarState.presentationMode) @@ -2519,31 +2559,90 @@ struct ContentView: View { terminalContentWithSidebarDropOverlay(appearance: appearance) .modifier(SidebarWidthLeadingPaddingModifier( layout: sidebarLayout, - enabled: sidebarState.isVisible + enabled: sidebarState.occupiesLayout )) .frame(maxWidth: .infinity, maxHeight: .infinity) .layoutPriority(1) rightSidebarPanelWithBackdrop(appearance: appearance) } - if sidebarState.isVisible { - sidebarPanelWithBackdrop(appearance: appearance) + if sidebarState.occupiesLayout { + SidebarWidthReader(layout: sidebarLayout) { width in + sidebarPanelWithBackdrop(appearance: appearance) + .modifier(sidebarPeekPresentationModifier(width: width)) + } } } + .animation(SidebarPeekMotion.modeChange, value: sidebarState.presentationMode) ) } else { // Standard HStack mode for behindWindow blur + // A peeked sidebar cannot sit in the HStack: it must float over the + // terminal rather than push it aside, so the reveal overlays and + // only a docked sidebar takes a slot in the stack. layout = AnyView( - HStack(spacing: 0) { - if sidebarState.isVisible { - sidebarPanelWithBackdrop(appearance: appearance) + ZStack(alignment: .leading) { + HStack(spacing: 0) { + if sidebarState.occupiesLayout { + SidebarWidthReader(layout: sidebarLayout) { width in + sidebarPanelWithBackdrop(appearance: appearance) + .modifier(sidebarPeekPresentationModifier(width: width)) + } + } + terminalContentWithRightSidebarPanel(appearance: appearance) } - terminalContentWithRightSidebarPanel(appearance: appearance) } + .animation(SidebarPeekMotion.modeChange, value: sidebarState.presentationMode) ) } return AnyView( layout + .onAppear { + sidebarToggleAnimator.install( + sidebarState: sidebarState, + layout: sidebarLayout, + isPeekPresenting: { sidebarPeek.presentsPanel } + ) + } + .onChange(of: sidebarState.occupiesLayout) { occupies in + if occupies { + // Docking retires any active peek: the card handed + // its place to the fixed pane. + sidebarPeek.sidebarDocked() + } + } + .onReceive(NotificationCenter.default.publisher(for: .cmuxSidebarToggleHoverChanged)) { note in + // Aside behaviour: hovering the titlebar's sidebar toggle + // pre-reveals the peek card, so the click lands on a + // sidebar that is already gliding in. Routed through the + // peek machine's edge events, so dwell, grace, and + // dismissal all behave exactly like the screen-edge peek. + guard let hovering = note.userInfo?["hovering"] as? Bool else { return } + guard observedWindow?.isKeyWindow == true else { return } + guard !sidebarState.isVisible, sidebarPeek.policy.isEnabled else { return } + if hovering { + sidebarPeek.pointerEnteredActivationControl() + } else { + sidebarPeek.pointerExitedEdge() + } + } + .background( + // Zero-sized anchor that owns the floating card's child + // window. The card cannot live in this tree: the portal + // hosts every terminal surface above the window's SwiftUI + // hosting view, so an in-tree card draws underneath the + // terminal no matter its zIndex. + sidebarPeekPanelHost + ) + .overlay(alignment: .leading) { + // Same slot and layering as the resizer overlay, which is + // the proven way in this codebase to receive pointer + // events over the portal-hosted terminal. A strip placed + // inside the layout stack can end up beneath the terminal's + // AppKit view and never see the pointer at all. + sidebarPeekEdgeStrip + .zIndex(999) + } .overlay(alignment: .leading) { if sidebarState.isVisible { sidebarResizerOverlay @@ -2601,6 +2700,17 @@ struct ContentView: View { .ignoresSafeArea() .allowsHitTesting(false) + // The window ground is the sidebar's material, applied + // window-wide: the root policy above paints the terminal's + // own colour, which made the workspace card's rounded + // corners invisible (card-coloured card on a card-coloured + // ground). The card covers this everywhere but the sidebar + // column, the corner reveals, and the pane gaps, which is + // exactly where the glass should show. + WindowBackdropLayer(role: .leftSidebar, snapshot: appearance) + .ignoresSafeArea() + .allowsHitTesting(false) + contentAndSidebarLayout(appearance: appearance) WorkspaceTitlebarModeLayer { @@ -11948,14 +12058,21 @@ struct VerticalTabsSidebar: View, Equatable { .frame(maxWidth: .infinity, alignment: .leading) } } + .overlay(alignment: .top) { + // Aside-style create action at the head of the list, where the + // eye starts: a full-width row, so the gesture that picks a + // workspace is the same gesture that makes one. The list's top + // inset reserves this row's height. + if isPresented { + SidebarNewWorkspaceButton( + accent: cmuxAccentColor(), + onCreate: onNewTab + ) + .padding(.top, SidebarWorkspaceListMetrics.firstRowTopOffset) + } + } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() - .overlay(alignment: .trailing) { - WindowChromeBorder( - orientation: .vertical, - backgroundColor: chromeBackgroundColor - ) - } .background( WindowAccessor(refreshID: showModifierHoldHints) { window in modifierKeyMonitor.setHostWindow(showModifierHoldHints ? window : nil) diff --git a/Sources/Sidebar/SidebarState.swift b/Sources/Sidebar/SidebarState.swift index 20b58c656765..3a474c367903 100644 --- a/Sources/Sidebar/SidebarState.swift +++ b/Sources/Sidebar/SidebarState.swift @@ -28,6 +28,11 @@ final class SidebarState: ObservableObject { } private var visibilityWillChangeOwnerId: UUID? private var visibilityWillChange: ((Bool) -> Void)? + /// When installed, visibility changes defer to this orchestrator (the + /// toggle animator's width sweep), which applies the final value itself + /// through ``applyVisibilityBypassingOrchestrator(_:)``. Returning false + /// hands the change back to the instant default path. + var animatedVisibilityOrchestrator: ((Bool) -> Bool)? init( isVisible: Bool = true, @@ -50,6 +55,17 @@ final class SidebarState: ObservableObject { } func setVisible(_ nextValue: Bool) { + guard nextValue != isVisible else { return } + if let animatedVisibilityOrchestrator, animatedVisibilityOrchestrator(nextValue) { + return + } + visibilityWillChange?(nextValue) + isVisible = nextValue + } + + /// The orchestrator's commit path: applies visibility without consulting + /// the orchestrator again. + func applyVisibilityBypassingOrchestrator(_ nextValue: Bool) { guard nextValue != isVisible else { return } visibilityWillChange?(nextValue) isVisible = nextValue diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift new file mode 100644 index 000000000000..28d3d823a907 --- /dev/null +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -0,0 +1,111 @@ +import AppKit +import Foundation + +/// The sidebar toggle as a synthetic divider drag. +/// +/// cmux already has one place where the terminal live-resizes beautifully: +/// dragging the sidebar's width divider, which feeds real widths through the +/// layout on every pointer tick. The toggle borrows exactly that mechanism: +/// a timer sweeps the REAL `SidebarLayoutModel.width` along an ease curve, so +/// the sidebar collapse, the terminal's live expansion, and every dependent +/// piece of chrome move together through the same proven path. No SwiftUI +/// animation is involved anywhere (the earlier attempts layered clip sweeps +/// and offsets over a static layout, and every seam showed); this is the +/// same drive-the-truth approach that fixed the drag reorder. +@MainActor +final class SidebarToggleAnimator: ObservableObject { + private weak var sidebarState: SidebarState? + private weak var layout: SidebarLayoutModel? + private var isPeekPresenting: () -> Bool = { false } + // `nonisolated(unsafe)` so deinit can invalidate; every other access is + // main-actor. + private nonisolated(unsafe) var timer: Timer? + /// The width the pane rests at when open, captured at hide time and + /// restored after, so the sweep never corrupts the persisted width. + private var restingWidth: CGFloat = 0 + + deinit { + timer?.invalidate() + } + + func install( + sidebarState: SidebarState, + layout: SidebarLayoutModel, + isPeekPresenting: @escaping () -> Bool + ) { + self.sidebarState = sidebarState + self.layout = layout + self.isPeekPresenting = isPeekPresenting + sidebarState.animatedVisibilityOrchestrator = { [weak self] targetVisible in + guard let self else { return false } + return self.animateVisibility(to: targetVisible) + } + } + + /// Returns true when the change was consumed and will be applied by the + /// sweep; false hands the change back to the instant default path. + private func animateVisibility(to targetVisible: Bool) -> Bool { + guard let sidebarState, let layout else { return false } + // Only a docked pane trades width with the terminal; floating + // visibility is the peek panel's business. + guard sidebarState.presentationMode == .docked else { return false } + cancelSweep() + if targetVisible { + // Docking over an already-revealed peek card swaps in place: the + // card is where the pane belongs, so any sweep would be a ghost. + if isPeekPresenting() { + return false + } + let target = restingWidth > 1 + ? restingWidth + : max(layout.width, CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) + sidebarState.applyVisibilityBypassingOrchestrator(true) + layout.width = 1 + sweep(from: 1, to: target, completion: nil) + return true + } else { + restingWidth = layout.width + sweep(from: layout.width, to: 1) { [weak self] in + guard let self, + let sidebarState = self.sidebarState, + let layout = self.layout else { return } + sidebarState.applyVisibilityBypassingOrchestrator(false) + // The pane is unmounted now; restore the resting width so the + // next show (and the persisted value) see the real width. + layout.width = self.restingWidth + } + return true + } + } + + private func sweep(from: CGFloat, to: CGFloat, completion: (() -> Void)?) { + let duration: TimeInterval = 0.17 + let start = ProcessInfo.processInfo.systemUptime + let timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] tick in + MainActor.assumeIsolated { + guard let self, let layout = self.layout else { + tick.invalidate() + return + } + let progress = min(1, (ProcessInfo.processInfo.systemUptime - start) / duration) + // Ease in-out: the pane leaves and arrives, it does not snap. + let eased = progress < 0.5 + ? 2 * progress * progress + : 1 - pow(-2 * progress + 2, 2) / 2 + layout.width = from + (to - from) * CGFloat(eased) + if progress >= 1 { + tick.invalidate() + self.timer = nil + completion?() + } + } + } + RunLoop.main.add(timer, forMode: .common) + self.timer = timer + } + + private func cancelSweep() { + timer?.invalidate() + timer = nil + } +} diff --git a/Sources/WindowChromeMetrics.swift b/Sources/WindowChromeMetrics.swift index 5b5ae2fa9ac2..57b591831708 100644 --- a/Sources/WindowChromeMetrics.swift +++ b/Sources/WindowChromeMetrics.swift @@ -1,5 +1,7 @@ +import AppKit import CmuxFoundation import CoreGraphics +import SwiftUI enum WindowChromeMetrics { static let sharedChromeBarHeight: CGFloat = 28 @@ -15,6 +17,48 @@ enum WindowChromeMetrics { } } +/// The workspace card: the titlebar band plus the terminal panes framed as +/// one rounded surface, the way Onyx and Aside frame their content. The card +/// is pure SwiftUI drawn behind the portal-hosted terminal: its fill and +/// border only show through in the band strip and the gaps around the panes, +/// so nothing has to clip the terminal's own AppKit layers. +enum WorkspaceCardMetrics { + static let cornerRadius: CGFloat = 16 + static let borderWidth: CGFloat = 1 + /// Pane edge to card edge. Larger than the corner sagitta so square pane + /// corners never intrude on the card's rounded border. + static let paneInset: CGFloat = 8 + /// Gap between the band's bottom and the first pane row. + static let bandGap: CGFloat = 2 +} + +/// The card surface, filled with the terminal's own background colour. +/// +/// Matching the terminal is the whole design: the pane insets stop reading +/// as a frame-within-a-frame because the gap is the same colour as the pane, +/// and the pane's square corners vanish against the identically coloured +/// card behind them. The eye sees exactly two surfaces: the window chrome, +/// and one rounded content card. +struct WorkspaceCardBackground: View { + let fill: NSColor + + var body: some View { + // Rounded only where the card meets the glass: the leading corners. + // The trailing and bottom edges run flush to the window, so rounding + // them would just notch the window's own frame. No stroke: the card + // is one surface against the glass, separated by fill and rounding + // alone. + UnevenRoundedRectangle( + topLeadingRadius: WorkspaceCardMetrics.cornerRadius, + bottomLeadingRadius: WorkspaceCardMetrics.cornerRadius, + bottomTrailingRadius: 0, + topTrailingRadius: 0, + style: .continuous + ) + .fill(Color(nsColor: fill)) + } +} + enum MinimalModeChromeMetrics { static let titlebarHeight: CGFloat = WindowChromeMetrics.appTitlebarHeight } @@ -73,14 +117,12 @@ enum RightSidebarChromeMetrics { enum SidebarWorkspaceListMetrics { static let firstRowTopOffset: CGFloat = MinimalModeChromeMetrics.titlebarHeight + 2 + /// Height reserved at the head of the list for the new-workspace row. + static let newWorkspaceRowHeight: CGFloat = 30 static let rowVerticalPadding: CGFloat = 8 static let rowOuterHorizontalPadding: CGFloat = 6 static let rowContentHorizontalPadding: CGFloat = 10 - /// The top fade ends where the first row rests, so rows fade only while - /// scrolled under the titlebar. It used to reach 20 pt into the list, - /// which left the first row partly transparent at rest; over a light - /// terminal-matched backdrop that washed out the selected row's top. - static let topScrimHeight: CGFloat = firstRowTopOffset + static let topScrimHeight: CGFloat = firstRowTopOffset + newWorkspaceRowHeight + 20 static let bottomScrimHeight: CGFloat = firstRowTopOffset + 20 static var trailingAccessoryRightEdgeOffset: CGFloat { @@ -92,7 +134,9 @@ enum SidebarWorkspaceListMetrics { } static var scrollTopInset: CGFloat { - max(0, firstRowTopOffset - rowVerticalPadding) + // The new-workspace row sits in this reserved band, above the first + // workspace row. + max(0, firstRowTopOffset - rowVerticalPadding) + newWorkspaceRowHeight } } From aa81c0a33017e77430a5b3b1d36578eddbe43d19 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 2 Sep 2026 09:03:48 +1200 Subject: [PATCH 10/69] sidebar customization, window glass, file icons, nav timings - settings: sidebar glass tint (one wash shared by docked and floating), peek reveal speed, disable peek, row density, disable switch on drag - window glass on by default: transparent window so the sidebar samples real behind-window vibrancy instead of flat frost - vscode material icon pack in the file explorer, finder/sf fallback - env-gated nav timing lines around switch, create, close, toggle, peek - compact floating panel top inset, dropped the new workspace button --- .../Appearance/WindowAppearanceResolver.swift | 2 +- .../Keys/SidebarCatalogSection.swift | 28 + .../Values/SidebarCustomizationOptions.swift | 19 + .../CuratedSettingEntry+Default.swift | 5 + .../SidebarSection+Customization.swift | 129 + .../Sections/SidebarSection.swift | 17 + .../Peek/UI/SidebarPeekGlassBackdrop.swift | 10 +- .../Peek/UI/SidebarPeekPanelChrome.swift | 17 +- Resources/MaterialIcons/LICENSE | 21 + Resources/MaterialIcons/angular-component.svg | 3 + Resources/MaterialIcons/angular-directive.svg | 4 + Resources/MaterialIcons/angular-guard.svg | 3 + Resources/MaterialIcons/angular-module.svg | 3 + Resources/MaterialIcons/angular-pipe.svg | 3 + Resources/MaterialIcons/angular-service.svg | 3 + Resources/MaterialIcons/angular.svg | 3 + Resources/MaterialIcons/animate.svg | 5 + Resources/MaterialIcons/astro.svg | 3 + Resources/MaterialIcons/audio.svg | 7 + Resources/MaterialIcons/babel.svg | 3 + Resources/MaterialIcons/biome.svg | 3 + Resources/MaterialIcons/brackets-blue.svg | 3 + Resources/MaterialIcons/brackets-gray.svg | 3 + Resources/MaterialIcons/brackets-green.svg | 3 + Resources/MaterialIcons/brackets-orange.svg | 3 + Resources/MaterialIcons/brackets-pink.svg | 4 + Resources/MaterialIcons/brackets-purple.svg | 3 + Resources/MaterialIcons/brackets-red.svg | 3 + Resources/MaterialIcons/brackets-sky.svg | 3 + Resources/MaterialIcons/brackets-yellow.svg | 3 + Resources/MaterialIcons/browserslist.svg | 5 + Resources/MaterialIcons/bruno.svg | 22 + Resources/MaterialIcons/bun.svg | 16 + Resources/MaterialIcons/c.svg | 3 + Resources/MaterialIcons/caddy.svg | 10 + Resources/MaterialIcons/capacitor.svg | 3 + Resources/MaterialIcons/clapper.svg | 3 + Resources/MaterialIcons/claude.svg | 3 + Resources/MaterialIcons/clojure.svg | 4 + .../MaterialIcons/cloudflare-workers.svg | 4 + Resources/MaterialIcons/cmake.svg | 5 + Resources/MaterialIcons/code-blue.svg | 5 + Resources/MaterialIcons/code-gray.svg | 5 + Resources/MaterialIcons/code-green.svg | 5 + Resources/MaterialIcons/code-orange.svg | 5 + Resources/MaterialIcons/code-pink.svg | 6 + Resources/MaterialIcons/code-purple.svg | 5 + Resources/MaterialIcons/code-red.svg | 5 + Resources/MaterialIcons/code-sky.svg | 5 + Resources/MaterialIcons/code-yellow.svg | 5 + Resources/MaterialIcons/coffeescript.svg | 9 + Resources/MaterialIcons/coldfusion.svg | 4 + Resources/MaterialIcons/compressed.svg | 3 + Resources/MaterialIcons/contentlayer.svg | 3 + Resources/MaterialIcons/cplus.svg | 12 + Resources/MaterialIcons/crystal.svg | 4 + Resources/MaterialIcons/csharp.svg | 4 + Resources/MaterialIcons/csv.svg | 13 + Resources/MaterialIcons/cucumber.svg | 3 + Resources/MaterialIcons/cuda.svg | 3 + Resources/MaterialIcons/cursor.svg | 3 + Resources/MaterialIcons/cypress.svg | 4 + Resources/MaterialIcons/dart.svg | 4 + Resources/MaterialIcons/database.svg | 4 + Resources/MaterialIcons/deno.svg | 4 + Resources/MaterialIcons/docker-green.svg | 85 + Resources/MaterialIcons/docker-orange.svg | 85 + Resources/MaterialIcons/docker-pink.svg | 85 + Resources/MaterialIcons/docker-purple.svg | 85 + Resources/MaterialIcons/docker-red.svg | 85 + Resources/MaterialIcons/docker-yellow.svg | 85 + Resources/MaterialIcons/docker.svg | 12 + Resources/MaterialIcons/document.svg | 4 + Resources/MaterialIcons/docusaurus.svg | 17 + Resources/MaterialIcons/drawio.svg | 7 + Resources/MaterialIcons/drizzle.svg | 6 + Resources/MaterialIcons/dts.svg | 3 + Resources/MaterialIcons/dune.svg | 3 + Resources/MaterialIcons/earthfile.svg | 42 + Resources/MaterialIcons/editorconfig.svg | 9 + Resources/MaterialIcons/elixir.svg | 3 + Resources/MaterialIcons/erlang.svg | 3 + Resources/MaterialIcons/eslint.svg | 4 + Resources/MaterialIcons/exe.svg | 4 + Resources/MaterialIcons/expressive-code.svg | 99 + Resources/MaterialIcons/firebase.svg | 6 + Resources/MaterialIcons/folder-actions.svg | 4 + Resources/MaterialIcons/folder-android.svg | 5 + Resources/MaterialIcons/folder-angular.svg | 4 + Resources/MaterialIcons/folder-app.svg | 4 + Resources/MaterialIcons/folder-assets.svg | 7 + Resources/MaterialIcons/folder-aws.svg | 13 + Resources/MaterialIcons/folder-azure.svg | 18 + Resources/MaterialIcons/folder-blue-code.svg | 6 + .../MaterialIcons/folder-blue-outline.svg | 4 + Resources/MaterialIcons/folder-blue.svg | 4 + Resources/MaterialIcons/folder-bruno.svg | 16 + Resources/MaterialIcons/folder-build.svg | 6 + Resources/MaterialIcons/folder-changeset.svg | 13 + Resources/MaterialIcons/folder-claude.svg | 11 + Resources/MaterialIcons/folder-config.svg | 4 + Resources/MaterialIcons/folder-constants.svg | 6 + Resources/MaterialIcons/folder-context.svg | 6 + Resources/MaterialIcons/folder-core.svg | 5 + Resources/MaterialIcons/folder-cursor.svg | 4 + Resources/MaterialIcons/folder-cypress.svg | 34 + Resources/MaterialIcons/folder-database.svg | 5 + Resources/MaterialIcons/folder-docker.svg | 13 + Resources/MaterialIcons/folder-documents.svg | 5 + Resources/MaterialIcons/folder-drizzle.svg | 4 + Resources/MaterialIcons/folder-effects.svg | 4 + Resources/MaterialIcons/folder-expo.svg | 4 + Resources/MaterialIcons/folder-facade.svg | 4 + Resources/MaterialIcons/folder-firebase.svg | 7 + Resources/MaterialIcons/folder-fonts.svg | 17 + Resources/MaterialIcons/folder-github.svg | 4 + Resources/MaterialIcons/folder-gitlab.svg | 7 + Resources/MaterialIcons/folder-globe.svg | 6 + Resources/MaterialIcons/folder-godot.svg | 4 + Resources/MaterialIcons/folder-gradle.svg | 4 + Resources/MaterialIcons/folder-graphql.svg | 4 + Resources/MaterialIcons/folder-gray-code.svg | 6 + .../MaterialIcons/folder-gray-outline.svg | 4 + Resources/MaterialIcons/folder-gray.svg | 4 + Resources/MaterialIcons/folder-green-code.svg | 6 + .../MaterialIcons/folder-green-outline.svg | 4 + Resources/MaterialIcons/folder-green.svg | 4 + Resources/MaterialIcons/folder-helpers.svg | 4 + Resources/MaterialIcons/folder-hooks.svg | 6 + Resources/MaterialIcons/folder-i18n.svg | 4 + Resources/MaterialIcons/folder-images.svg | 6 + .../MaterialIcons/folder-interceptors.svg | 5 + Resources/MaterialIcons/folder-interfaces.svg | 4 + Resources/MaterialIcons/folder-ios.svg | 4 + Resources/MaterialIcons/folder-js.svg | 4 + Resources/MaterialIcons/folder-layout.svg | 4 + Resources/MaterialIcons/folder-lock.svg | 5 + Resources/MaterialIcons/folder-mail.svg | 14 + Resources/MaterialIcons/folder-maven.svg | 10 + Resources/MaterialIcons/folder-middleware.svg | 4 + Resources/MaterialIcons/folder-models.svg | 4 + Resources/MaterialIcons/folder-modules.svg | 4 + Resources/MaterialIcons/folder-mongo.svg | 4 + Resources/MaterialIcons/folder-netlify.svg | 17 + Resources/MaterialIcons/folder-next.svg | 4 + Resources/MaterialIcons/folder-nginx.svg | 4 + .../MaterialIcons/folder-node-modules.svg | 11 + Resources/MaterialIcons/folder-open.svg | 3 + .../MaterialIcons/folder-orange-code-1.svg | 6 + .../MaterialIcons/folder-orange-code-2.svg | 6 + .../MaterialIcons/folder-orange-code-3.svg | 6 + .../MaterialIcons/folder-orange-code-4.svg | 6 + .../MaterialIcons/folder-orange-code.svg | 6 + .../MaterialIcons/folder-orange-outline.svg | 4 + Resources/MaterialIcons/folder-orange.svg | 4 + Resources/MaterialIcons/folder-pink-code.svg | 7 + .../MaterialIcons/folder-pink-outline.svg | 4 + Resources/MaterialIcons/folder-pink.svg | 5 + Resources/MaterialIcons/folder-pipes.svg | 4 + Resources/MaterialIcons/folder-prisma.svg | 4 + Resources/MaterialIcons/folder-providers.svg | 4 + .../MaterialIcons/folder-purple-code.svg | 6 + .../MaterialIcons/folder-purple-outline.svg | 4 + Resources/MaterialIcons/folder-purple.svg | 4 + Resources/MaterialIcons/folder-react.svg | 14 + Resources/MaterialIcons/folder-red-code.svg | 6 + .../MaterialIcons/folder-red-outline.svg | 4 + Resources/MaterialIcons/folder-red.svg | 4 + Resources/MaterialIcons/folder-redis.svg | 10 + Resources/MaterialIcons/folder-reducer.svg | 4 + Resources/MaterialIcons/folder-router.svg | 5 + Resources/MaterialIcons/folder-sass.svg | 4 + Resources/MaterialIcons/folder-selector.svg | 4 + Resources/MaterialIcons/folder-services.svg | 4 + Resources/MaterialIcons/folder-shared.svg | 4 + Resources/MaterialIcons/folder-sky-code.svg | 6 + .../MaterialIcons/folder-sky-outline.svg | 4 + Resources/MaterialIcons/folder-sky.svg | 4 + Resources/MaterialIcons/folder-src.svg | 6 + Resources/MaterialIcons/folder-supabase.svg | 16 + Resources/MaterialIcons/folder-target.svg | 5 + Resources/MaterialIcons/folder-tauri.svg | 14 + Resources/MaterialIcons/folder-tina.svg | 5 + Resources/MaterialIcons/folder-utils.svg | 4 + Resources/MaterialIcons/folder-vercel.svg | 4 + Resources/MaterialIcons/folder-vscode.svg | 42 + .../MaterialIcons/folder-yellow-code.svg | 6 + .../MaterialIcons/folder-yellow-outline.svg | 4 + Resources/MaterialIcons/folder-yellow.svg | 4 + Resources/MaterialIcons/folder-zed.svg | 4 + Resources/MaterialIcons/folder.svg | 3 + Resources/MaterialIcons/font.svg | 11 + Resources/MaterialIcons/fortran.svg | 3 + Resources/MaterialIcons/fresh.svg | 6 + Resources/MaterialIcons/fsharp.svg | 11 + Resources/MaterialIcons/func.svg | 3 + Resources/MaterialIcons/gatsby.svg | 3 + Resources/MaterialIcons/gdextension.svg | 3 + Resources/MaterialIcons/gdignore.svg | 3 + Resources/MaterialIcons/gdproject.svg | 3 + Resources/MaterialIcons/gdscript.svg | 3 + Resources/MaterialIcons/gdshader.svg | 3 + Resources/MaterialIcons/gear.svg | 3 + Resources/MaterialIcons/gif.svg | 4 + Resources/MaterialIcons/git.svg | 3 + Resources/MaterialIcons/github.svg | 3 + Resources/MaterialIcons/gitlab.svg | 6 + Resources/MaterialIcons/gleam.svg | 5 + Resources/MaterialIcons/globe.svg | 5 + Resources/MaterialIcons/go-green.svg | 14 + Resources/MaterialIcons/go-orange.svg | 14 + Resources/MaterialIcons/go-pink.svg | 14 + Resources/MaterialIcons/go-purple.svg | 14 + Resources/MaterialIcons/go-red.svg | 14 + Resources/MaterialIcons/go-yellow.svg | 14 + Resources/MaterialIcons/go.svg | 14 + Resources/MaterialIcons/gradle.svg | 3 + Resources/MaterialIcons/graphql.svg | 3 + Resources/MaterialIcons/gulp.svg | 3 + Resources/MaterialIcons/h.svg | 3 + Resources/MaterialIcons/haml.svg | 3 + Resources/MaterialIcons/haskell.svg | 5 + Resources/MaterialIcons/http.svg | 3 + Resources/MaterialIcons/hugo.svg | 4 + Resources/MaterialIcons/i18n.svg | 3 + Resources/MaterialIcons/ignore.svg | 4 + Resources/MaterialIcons/illustrator.svg | 5 + Resources/MaterialIcons/image.svg | 5 + Resources/MaterialIcons/ionic.svg | 3 + Resources/MaterialIcons/java.svg | 10 + Resources/MaterialIcons/jenkins.svg | 40 + Resources/MaterialIcons/jest.svg | 5 + Resources/MaterialIcons/js-test.svg | 3 + Resources/MaterialIcons/js.svg | 3 + Resources/MaterialIcons/julia-markdown.svg | 5 + Resources/MaterialIcons/julia.svg | 5 + Resources/MaterialIcons/keystatic.svg | 10 + Resources/MaterialIcons/knip.svg | 3 + Resources/MaterialIcons/kotlin.svg | 3 + Resources/MaterialIcons/kubb.svg | 2077 +++++++++++++++++ Resources/MaterialIcons/laravel.svg | 3 + Resources/MaterialIcons/license.svg | 4 + Resources/MaterialIcons/link.svg | 3 + Resources/MaterialIcons/liquid.svg | 3 + Resources/MaterialIcons/lock.svg | 4 + Resources/MaterialIcons/lua.svg | 4 + Resources/MaterialIcons/luau.svg | 3 + Resources/MaterialIcons/lunaria.svg | 27 + Resources/MaterialIcons/markdoc.svg | 3 + Resources/MaterialIcons/markdown.svg | 3 + Resources/MaterialIcons/maven.svg | 9 + Resources/MaterialIcons/mdx.svg | 4 + Resources/MaterialIcons/minecraft.svg | 3 + Resources/MaterialIcons/mongo.svg | 3 + Resources/MaterialIcons/nest-controller.svg | 3 + Resources/MaterialIcons/nest-decorator.svg | 3 + Resources/MaterialIcons/nest-guard.svg | 3 + Resources/MaterialIcons/nest-middleware.svg | 3 + Resources/MaterialIcons/nest-service.svg | 3 + Resources/MaterialIcons/nest.svg | 3 + Resources/MaterialIcons/netlify.svg | 16 + Resources/MaterialIcons/next.svg | 15 + Resources/MaterialIcons/nim.svg | 3 + Resources/MaterialIcons/nix.svg | 3 + Resources/MaterialIcons/node.svg | 3 + Resources/MaterialIcons/nodemon.svg | 3 + Resources/MaterialIcons/notebook.svg | 4 + Resources/MaterialIcons/npm.svg | 3 + Resources/MaterialIcons/nunjucks.svg | 5 + Resources/MaterialIcons/nuxt.svg | 3 + Resources/MaterialIcons/nx.svg | 4 + Resources/MaterialIcons/ocaml.svg | 3 + Resources/MaterialIcons/orval.svg | 66 + Resources/MaterialIcons/oxc.svg | 3 + Resources/MaterialIcons/panda.svg | 3 + Resources/MaterialIcons/patch.svg | 3 + Resources/MaterialIcons/pdf.svg | 6 + Resources/MaterialIcons/perl.svg | 3 + Resources/MaterialIcons/photoshop.svg | 5 + Resources/MaterialIcons/php.svg | 3 + Resources/MaterialIcons/pkl.svg | 10 + Resources/MaterialIcons/pnpm.svg | 10 + Resources/MaterialIcons/postcss.svg | 5 + Resources/MaterialIcons/prettier.svg | 24 + Resources/MaterialIcons/prisma.svg | 3 + Resources/MaterialIcons/proto.svg | 6 + Resources/MaterialIcons/pug.svg | 12 + Resources/MaterialIcons/pulumi.svg | 5 + Resources/MaterialIcons/puppeteer.svg | 3 + Resources/MaterialIcons/puzzle.svg | 3 + Resources/MaterialIcons/python.svg | 4 + Resources/MaterialIcons/r.svg | 4 + Resources/MaterialIcons/razor.svg | 3 + Resources/MaterialIcons/react-test.svg | 6 + Resources/MaterialIcons/react-ts.svg | 6 + Resources/MaterialIcons/react.svg | 6 + Resources/MaterialIcons/redux-actions.svg | 3 + Resources/MaterialIcons/redux-effects.svg | 3 + Resources/MaterialIcons/redux-facade.svg | 3 + Resources/MaterialIcons/redux-reducer.svg | 3 + Resources/MaterialIcons/redux-selector.svg | 3 + .../MaterialIcons/rescript-interface.svg | 4 + Resources/MaterialIcons/rescript.svg | 3 + Resources/MaterialIcons/resource.svg | 3 + Resources/MaterialIcons/robot.svg | 3 + Resources/MaterialIcons/rolldown.svg | 3 + Resources/MaterialIcons/rome.svg | 4 + Resources/MaterialIcons/rsbuild.svg | 208 ++ Resources/MaterialIcons/rslib.svg | 67 + Resources/MaterialIcons/rspack.svg | 137 ++ Resources/MaterialIcons/ruby.svg | 11 + Resources/MaterialIcons/rust.svg | 3 + Resources/MaterialIcons/sanity.svg | 3 + Resources/MaterialIcons/sass.svg | 3 + Resources/MaterialIcons/sbt.svg | 3 + Resources/MaterialIcons/scala.svg | 7 + Resources/MaterialIcons/severless.svg | 3 + Resources/MaterialIcons/shadcn.svg | 4 + Resources/MaterialIcons/shell.svg | 4 + Resources/MaterialIcons/slint.svg | 4 + Resources/MaterialIcons/solidity.svg | 10 + Resources/MaterialIcons/statamic-antlers.svg | 1 + Resources/MaterialIcons/storybook.svg | 8 + Resources/MaterialIcons/stylelint.svg | 8 + Resources/MaterialIcons/stylus.svg | 3 + Resources/MaterialIcons/supabase.svg | 1 + Resources/MaterialIcons/svelte-service.svg | 7 + Resources/MaterialIcons/svelte-ts.svg | 7 + Resources/MaterialIcons/svelte.svg | 7 + Resources/MaterialIcons/svg.svg | 7 + Resources/MaterialIcons/svx.svg | 4 + Resources/MaterialIcons/swc.svg | 7 + Resources/MaterialIcons/swift.svg | 3 + Resources/MaterialIcons/tailwind.svg | 3 + Resources/MaterialIcons/taskfile.svg | 5 + Resources/MaterialIcons/tauri.svg | 11 + Resources/MaterialIcons/terraform.svg | 4 + Resources/MaterialIcons/tex.svg | 3 + Resources/MaterialIcons/text.svg | 5 + Resources/MaterialIcons/ts-test.svg | 3 + Resources/MaterialIcons/ts-types.svg | 3 + Resources/MaterialIcons/ts.svg | 3 + Resources/MaterialIcons/tsconfig.svg | 4 + Resources/MaterialIcons/turborepo.svg | 10 + Resources/MaterialIcons/twig.svg | 3 + Resources/MaterialIcons/typst.svg | 3 + Resources/MaterialIcons/unocss.svg | 12 + Resources/MaterialIcons/uv.svg | 3 + Resources/MaterialIcons/v.svg | 19 + Resources/MaterialIcons/vanilla-extract.svg | 14 + Resources/MaterialIcons/vercel.svg | 3 + Resources/MaterialIcons/video.svg | 3 + Resources/MaterialIcons/visual-studio.svg | 7 + Resources/MaterialIcons/vite.svg | 3 + Resources/MaterialIcons/vitest.svg | 3 + Resources/MaterialIcons/vue.svg | 4 + Resources/MaterialIcons/webgpu.svg | 1 + Resources/MaterialIcons/webpack.svg | 9 + Resources/MaterialIcons/xml.svg | 15 + Resources/MaterialIcons/yaml.svg | 3 + Resources/MaterialIcons/yarn.svg | 3 + Resources/MaterialIcons/yummacss.svg | 4 + Resources/MaterialIcons/zig.svg | 3 + Resources/material-icons.json | 1 + Sources/AppDelegate.swift | 5 + Sources/ContentView+SidebarPeek.swift | 22 +- Sources/ContentView.swift | 54 +- Sources/FileExplorerCellView.swift | 10 +- Sources/MaterialFileIconResolver.swift | 84 + .../Cells/SidebarWorkspaceRowCellView.swift | 5 +- .../SidebarWorkspaceTableController.swift | 37 +- .../SidebarWorkspaceTableView.swift | 2 + .../Sidebar/Peek/SidebarPeekController.swift | 14 +- .../Peek/SidebarPeekPresentation.swift | 12 +- .../SidebarCustomizationSettings.swift | 65 + .../Sidebar/SidebarNavigationTimings.swift | 112 + Sources/Sidebar/SidebarToggleAnimator.swift | 6 + Sources/SidebarTabItemSettingsSnapshot.swift | 2 + Sources/WindowChromeMetrics.swift | 21 +- .../WindowBackgroundComposition.swift | 2 +- Sources/cmuxApp.swift | 4 +- cmux.xcodeproj/project.pbxproj | 20 + 382 files changed, 5864 insertions(+), 44 deletions(-) create mode 100644 Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift create mode 100644 Resources/MaterialIcons/LICENSE create mode 100644 Resources/MaterialIcons/angular-component.svg create mode 100644 Resources/MaterialIcons/angular-directive.svg create mode 100644 Resources/MaterialIcons/angular-guard.svg create mode 100644 Resources/MaterialIcons/angular-module.svg create mode 100644 Resources/MaterialIcons/angular-pipe.svg create mode 100644 Resources/MaterialIcons/angular-service.svg create mode 100644 Resources/MaterialIcons/angular.svg create mode 100644 Resources/MaterialIcons/animate.svg create mode 100644 Resources/MaterialIcons/astro.svg create mode 100644 Resources/MaterialIcons/audio.svg create mode 100644 Resources/MaterialIcons/babel.svg create mode 100644 Resources/MaterialIcons/biome.svg create mode 100644 Resources/MaterialIcons/brackets-blue.svg create mode 100644 Resources/MaterialIcons/brackets-gray.svg create mode 100644 Resources/MaterialIcons/brackets-green.svg create mode 100644 Resources/MaterialIcons/brackets-orange.svg create mode 100644 Resources/MaterialIcons/brackets-pink.svg create mode 100644 Resources/MaterialIcons/brackets-purple.svg create mode 100644 Resources/MaterialIcons/brackets-red.svg create mode 100644 Resources/MaterialIcons/brackets-sky.svg create mode 100644 Resources/MaterialIcons/brackets-yellow.svg create mode 100644 Resources/MaterialIcons/browserslist.svg create mode 100644 Resources/MaterialIcons/bruno.svg create mode 100644 Resources/MaterialIcons/bun.svg create mode 100644 Resources/MaterialIcons/c.svg create mode 100644 Resources/MaterialIcons/caddy.svg create mode 100644 Resources/MaterialIcons/capacitor.svg create mode 100644 Resources/MaterialIcons/clapper.svg create mode 100644 Resources/MaterialIcons/claude.svg create mode 100644 Resources/MaterialIcons/clojure.svg create mode 100644 Resources/MaterialIcons/cloudflare-workers.svg create mode 100644 Resources/MaterialIcons/cmake.svg create mode 100644 Resources/MaterialIcons/code-blue.svg create mode 100644 Resources/MaterialIcons/code-gray.svg create mode 100644 Resources/MaterialIcons/code-green.svg create mode 100644 Resources/MaterialIcons/code-orange.svg create mode 100644 Resources/MaterialIcons/code-pink.svg create mode 100644 Resources/MaterialIcons/code-purple.svg create mode 100644 Resources/MaterialIcons/code-red.svg create mode 100644 Resources/MaterialIcons/code-sky.svg create mode 100644 Resources/MaterialIcons/code-yellow.svg create mode 100644 Resources/MaterialIcons/coffeescript.svg create mode 100644 Resources/MaterialIcons/coldfusion.svg create mode 100644 Resources/MaterialIcons/compressed.svg create mode 100644 Resources/MaterialIcons/contentlayer.svg create mode 100644 Resources/MaterialIcons/cplus.svg create mode 100644 Resources/MaterialIcons/crystal.svg create mode 100644 Resources/MaterialIcons/csharp.svg create mode 100644 Resources/MaterialIcons/csv.svg create mode 100644 Resources/MaterialIcons/cucumber.svg create mode 100644 Resources/MaterialIcons/cuda.svg create mode 100644 Resources/MaterialIcons/cursor.svg create mode 100644 Resources/MaterialIcons/cypress.svg create mode 100644 Resources/MaterialIcons/dart.svg create mode 100644 Resources/MaterialIcons/database.svg create mode 100644 Resources/MaterialIcons/deno.svg create mode 100644 Resources/MaterialIcons/docker-green.svg create mode 100644 Resources/MaterialIcons/docker-orange.svg create mode 100644 Resources/MaterialIcons/docker-pink.svg create mode 100644 Resources/MaterialIcons/docker-purple.svg create mode 100644 Resources/MaterialIcons/docker-red.svg create mode 100644 Resources/MaterialIcons/docker-yellow.svg create mode 100644 Resources/MaterialIcons/docker.svg create mode 100644 Resources/MaterialIcons/document.svg create mode 100644 Resources/MaterialIcons/docusaurus.svg create mode 100644 Resources/MaterialIcons/drawio.svg create mode 100644 Resources/MaterialIcons/drizzle.svg create mode 100644 Resources/MaterialIcons/dts.svg create mode 100644 Resources/MaterialIcons/dune.svg create mode 100644 Resources/MaterialIcons/earthfile.svg create mode 100644 Resources/MaterialIcons/editorconfig.svg create mode 100644 Resources/MaterialIcons/elixir.svg create mode 100644 Resources/MaterialIcons/erlang.svg create mode 100644 Resources/MaterialIcons/eslint.svg create mode 100644 Resources/MaterialIcons/exe.svg create mode 100644 Resources/MaterialIcons/expressive-code.svg create mode 100644 Resources/MaterialIcons/firebase.svg create mode 100644 Resources/MaterialIcons/folder-actions.svg create mode 100644 Resources/MaterialIcons/folder-android.svg create mode 100644 Resources/MaterialIcons/folder-angular.svg create mode 100644 Resources/MaterialIcons/folder-app.svg create mode 100644 Resources/MaterialIcons/folder-assets.svg create mode 100644 Resources/MaterialIcons/folder-aws.svg create mode 100644 Resources/MaterialIcons/folder-azure.svg create mode 100644 Resources/MaterialIcons/folder-blue-code.svg create mode 100644 Resources/MaterialIcons/folder-blue-outline.svg create mode 100644 Resources/MaterialIcons/folder-blue.svg create mode 100644 Resources/MaterialIcons/folder-bruno.svg create mode 100644 Resources/MaterialIcons/folder-build.svg create mode 100644 Resources/MaterialIcons/folder-changeset.svg create mode 100644 Resources/MaterialIcons/folder-claude.svg create mode 100644 Resources/MaterialIcons/folder-config.svg create mode 100644 Resources/MaterialIcons/folder-constants.svg create mode 100644 Resources/MaterialIcons/folder-context.svg create mode 100644 Resources/MaterialIcons/folder-core.svg create mode 100644 Resources/MaterialIcons/folder-cursor.svg create mode 100644 Resources/MaterialIcons/folder-cypress.svg create mode 100644 Resources/MaterialIcons/folder-database.svg create mode 100644 Resources/MaterialIcons/folder-docker.svg create mode 100644 Resources/MaterialIcons/folder-documents.svg create mode 100644 Resources/MaterialIcons/folder-drizzle.svg create mode 100644 Resources/MaterialIcons/folder-effects.svg create mode 100644 Resources/MaterialIcons/folder-expo.svg create mode 100644 Resources/MaterialIcons/folder-facade.svg create mode 100644 Resources/MaterialIcons/folder-firebase.svg create mode 100644 Resources/MaterialIcons/folder-fonts.svg create mode 100644 Resources/MaterialIcons/folder-github.svg create mode 100644 Resources/MaterialIcons/folder-gitlab.svg create mode 100644 Resources/MaterialIcons/folder-globe.svg create mode 100644 Resources/MaterialIcons/folder-godot.svg create mode 100644 Resources/MaterialIcons/folder-gradle.svg create mode 100644 Resources/MaterialIcons/folder-graphql.svg create mode 100644 Resources/MaterialIcons/folder-gray-code.svg create mode 100644 Resources/MaterialIcons/folder-gray-outline.svg create mode 100644 Resources/MaterialIcons/folder-gray.svg create mode 100644 Resources/MaterialIcons/folder-green-code.svg create mode 100644 Resources/MaterialIcons/folder-green-outline.svg create mode 100644 Resources/MaterialIcons/folder-green.svg create mode 100644 Resources/MaterialIcons/folder-helpers.svg create mode 100644 Resources/MaterialIcons/folder-hooks.svg create mode 100644 Resources/MaterialIcons/folder-i18n.svg create mode 100644 Resources/MaterialIcons/folder-images.svg create mode 100644 Resources/MaterialIcons/folder-interceptors.svg create mode 100644 Resources/MaterialIcons/folder-interfaces.svg create mode 100644 Resources/MaterialIcons/folder-ios.svg create mode 100644 Resources/MaterialIcons/folder-js.svg create mode 100644 Resources/MaterialIcons/folder-layout.svg create mode 100644 Resources/MaterialIcons/folder-lock.svg create mode 100644 Resources/MaterialIcons/folder-mail.svg create mode 100644 Resources/MaterialIcons/folder-maven.svg create mode 100644 Resources/MaterialIcons/folder-middleware.svg create mode 100644 Resources/MaterialIcons/folder-models.svg create mode 100644 Resources/MaterialIcons/folder-modules.svg create mode 100644 Resources/MaterialIcons/folder-mongo.svg create mode 100644 Resources/MaterialIcons/folder-netlify.svg create mode 100644 Resources/MaterialIcons/folder-next.svg create mode 100644 Resources/MaterialIcons/folder-nginx.svg create mode 100644 Resources/MaterialIcons/folder-node-modules.svg create mode 100644 Resources/MaterialIcons/folder-open.svg create mode 100644 Resources/MaterialIcons/folder-orange-code-1.svg create mode 100644 Resources/MaterialIcons/folder-orange-code-2.svg create mode 100644 Resources/MaterialIcons/folder-orange-code-3.svg create mode 100644 Resources/MaterialIcons/folder-orange-code-4.svg create mode 100644 Resources/MaterialIcons/folder-orange-code.svg create mode 100644 Resources/MaterialIcons/folder-orange-outline.svg create mode 100644 Resources/MaterialIcons/folder-orange.svg create mode 100644 Resources/MaterialIcons/folder-pink-code.svg create mode 100644 Resources/MaterialIcons/folder-pink-outline.svg create mode 100644 Resources/MaterialIcons/folder-pink.svg create mode 100644 Resources/MaterialIcons/folder-pipes.svg create mode 100644 Resources/MaterialIcons/folder-prisma.svg create mode 100644 Resources/MaterialIcons/folder-providers.svg create mode 100644 Resources/MaterialIcons/folder-purple-code.svg create mode 100644 Resources/MaterialIcons/folder-purple-outline.svg create mode 100644 Resources/MaterialIcons/folder-purple.svg create mode 100644 Resources/MaterialIcons/folder-react.svg create mode 100644 Resources/MaterialIcons/folder-red-code.svg create mode 100644 Resources/MaterialIcons/folder-red-outline.svg create mode 100644 Resources/MaterialIcons/folder-red.svg create mode 100644 Resources/MaterialIcons/folder-redis.svg create mode 100644 Resources/MaterialIcons/folder-reducer.svg create mode 100644 Resources/MaterialIcons/folder-router.svg create mode 100644 Resources/MaterialIcons/folder-sass.svg create mode 100644 Resources/MaterialIcons/folder-selector.svg create mode 100644 Resources/MaterialIcons/folder-services.svg create mode 100644 Resources/MaterialIcons/folder-shared.svg create mode 100644 Resources/MaterialIcons/folder-sky-code.svg create mode 100644 Resources/MaterialIcons/folder-sky-outline.svg create mode 100644 Resources/MaterialIcons/folder-sky.svg create mode 100644 Resources/MaterialIcons/folder-src.svg create mode 100644 Resources/MaterialIcons/folder-supabase.svg create mode 100644 Resources/MaterialIcons/folder-target.svg create mode 100644 Resources/MaterialIcons/folder-tauri.svg create mode 100644 Resources/MaterialIcons/folder-tina.svg create mode 100644 Resources/MaterialIcons/folder-utils.svg create mode 100644 Resources/MaterialIcons/folder-vercel.svg create mode 100644 Resources/MaterialIcons/folder-vscode.svg create mode 100644 Resources/MaterialIcons/folder-yellow-code.svg create mode 100644 Resources/MaterialIcons/folder-yellow-outline.svg create mode 100644 Resources/MaterialIcons/folder-yellow.svg create mode 100644 Resources/MaterialIcons/folder-zed.svg create mode 100644 Resources/MaterialIcons/folder.svg create mode 100644 Resources/MaterialIcons/font.svg create mode 100644 Resources/MaterialIcons/fortran.svg create mode 100644 Resources/MaterialIcons/fresh.svg create mode 100644 Resources/MaterialIcons/fsharp.svg create mode 100644 Resources/MaterialIcons/func.svg create mode 100644 Resources/MaterialIcons/gatsby.svg create mode 100644 Resources/MaterialIcons/gdextension.svg create mode 100644 Resources/MaterialIcons/gdignore.svg create mode 100644 Resources/MaterialIcons/gdproject.svg create mode 100644 Resources/MaterialIcons/gdscript.svg create mode 100644 Resources/MaterialIcons/gdshader.svg create mode 100644 Resources/MaterialIcons/gear.svg create mode 100644 Resources/MaterialIcons/gif.svg create mode 100644 Resources/MaterialIcons/git.svg create mode 100644 Resources/MaterialIcons/github.svg create mode 100644 Resources/MaterialIcons/gitlab.svg create mode 100644 Resources/MaterialIcons/gleam.svg create mode 100644 Resources/MaterialIcons/globe.svg create mode 100644 Resources/MaterialIcons/go-green.svg create mode 100644 Resources/MaterialIcons/go-orange.svg create mode 100644 Resources/MaterialIcons/go-pink.svg create mode 100644 Resources/MaterialIcons/go-purple.svg create mode 100644 Resources/MaterialIcons/go-red.svg create mode 100644 Resources/MaterialIcons/go-yellow.svg create mode 100644 Resources/MaterialIcons/go.svg create mode 100644 Resources/MaterialIcons/gradle.svg create mode 100644 Resources/MaterialIcons/graphql.svg create mode 100644 Resources/MaterialIcons/gulp.svg create mode 100644 Resources/MaterialIcons/h.svg create mode 100644 Resources/MaterialIcons/haml.svg create mode 100644 Resources/MaterialIcons/haskell.svg create mode 100644 Resources/MaterialIcons/http.svg create mode 100644 Resources/MaterialIcons/hugo.svg create mode 100644 Resources/MaterialIcons/i18n.svg create mode 100644 Resources/MaterialIcons/ignore.svg create mode 100644 Resources/MaterialIcons/illustrator.svg create mode 100644 Resources/MaterialIcons/image.svg create mode 100644 Resources/MaterialIcons/ionic.svg create mode 100644 Resources/MaterialIcons/java.svg create mode 100644 Resources/MaterialIcons/jenkins.svg create mode 100644 Resources/MaterialIcons/jest.svg create mode 100644 Resources/MaterialIcons/js-test.svg create mode 100644 Resources/MaterialIcons/js.svg create mode 100644 Resources/MaterialIcons/julia-markdown.svg create mode 100644 Resources/MaterialIcons/julia.svg create mode 100644 Resources/MaterialIcons/keystatic.svg create mode 100644 Resources/MaterialIcons/knip.svg create mode 100644 Resources/MaterialIcons/kotlin.svg create mode 100644 Resources/MaterialIcons/kubb.svg create mode 100644 Resources/MaterialIcons/laravel.svg create mode 100644 Resources/MaterialIcons/license.svg create mode 100644 Resources/MaterialIcons/link.svg create mode 100644 Resources/MaterialIcons/liquid.svg create mode 100644 Resources/MaterialIcons/lock.svg create mode 100644 Resources/MaterialIcons/lua.svg create mode 100644 Resources/MaterialIcons/luau.svg create mode 100644 Resources/MaterialIcons/lunaria.svg create mode 100644 Resources/MaterialIcons/markdoc.svg create mode 100644 Resources/MaterialIcons/markdown.svg create mode 100644 Resources/MaterialIcons/maven.svg create mode 100644 Resources/MaterialIcons/mdx.svg create mode 100644 Resources/MaterialIcons/minecraft.svg create mode 100644 Resources/MaterialIcons/mongo.svg create mode 100644 Resources/MaterialIcons/nest-controller.svg create mode 100644 Resources/MaterialIcons/nest-decorator.svg create mode 100644 Resources/MaterialIcons/nest-guard.svg create mode 100644 Resources/MaterialIcons/nest-middleware.svg create mode 100644 Resources/MaterialIcons/nest-service.svg create mode 100644 Resources/MaterialIcons/nest.svg create mode 100644 Resources/MaterialIcons/netlify.svg create mode 100644 Resources/MaterialIcons/next.svg create mode 100644 Resources/MaterialIcons/nim.svg create mode 100644 Resources/MaterialIcons/nix.svg create mode 100644 Resources/MaterialIcons/node.svg create mode 100644 Resources/MaterialIcons/nodemon.svg create mode 100644 Resources/MaterialIcons/notebook.svg create mode 100644 Resources/MaterialIcons/npm.svg create mode 100644 Resources/MaterialIcons/nunjucks.svg create mode 100644 Resources/MaterialIcons/nuxt.svg create mode 100644 Resources/MaterialIcons/nx.svg create mode 100644 Resources/MaterialIcons/ocaml.svg create mode 100644 Resources/MaterialIcons/orval.svg create mode 100644 Resources/MaterialIcons/oxc.svg create mode 100644 Resources/MaterialIcons/panda.svg create mode 100644 Resources/MaterialIcons/patch.svg create mode 100644 Resources/MaterialIcons/pdf.svg create mode 100644 Resources/MaterialIcons/perl.svg create mode 100644 Resources/MaterialIcons/photoshop.svg create mode 100644 Resources/MaterialIcons/php.svg create mode 100644 Resources/MaterialIcons/pkl.svg create mode 100644 Resources/MaterialIcons/pnpm.svg create mode 100644 Resources/MaterialIcons/postcss.svg create mode 100644 Resources/MaterialIcons/prettier.svg create mode 100644 Resources/MaterialIcons/prisma.svg create mode 100644 Resources/MaterialIcons/proto.svg create mode 100644 Resources/MaterialIcons/pug.svg create mode 100644 Resources/MaterialIcons/pulumi.svg create mode 100644 Resources/MaterialIcons/puppeteer.svg create mode 100644 Resources/MaterialIcons/puzzle.svg create mode 100644 Resources/MaterialIcons/python.svg create mode 100644 Resources/MaterialIcons/r.svg create mode 100644 Resources/MaterialIcons/razor.svg create mode 100644 Resources/MaterialIcons/react-test.svg create mode 100644 Resources/MaterialIcons/react-ts.svg create mode 100644 Resources/MaterialIcons/react.svg create mode 100644 Resources/MaterialIcons/redux-actions.svg create mode 100644 Resources/MaterialIcons/redux-effects.svg create mode 100644 Resources/MaterialIcons/redux-facade.svg create mode 100644 Resources/MaterialIcons/redux-reducer.svg create mode 100644 Resources/MaterialIcons/redux-selector.svg create mode 100644 Resources/MaterialIcons/rescript-interface.svg create mode 100644 Resources/MaterialIcons/rescript.svg create mode 100644 Resources/MaterialIcons/resource.svg create mode 100644 Resources/MaterialIcons/robot.svg create mode 100644 Resources/MaterialIcons/rolldown.svg create mode 100644 Resources/MaterialIcons/rome.svg create mode 100644 Resources/MaterialIcons/rsbuild.svg create mode 100644 Resources/MaterialIcons/rslib.svg create mode 100644 Resources/MaterialIcons/rspack.svg create mode 100644 Resources/MaterialIcons/ruby.svg create mode 100644 Resources/MaterialIcons/rust.svg create mode 100644 Resources/MaterialIcons/sanity.svg create mode 100644 Resources/MaterialIcons/sass.svg create mode 100644 Resources/MaterialIcons/sbt.svg create mode 100644 Resources/MaterialIcons/scala.svg create mode 100644 Resources/MaterialIcons/severless.svg create mode 100644 Resources/MaterialIcons/shadcn.svg create mode 100644 Resources/MaterialIcons/shell.svg create mode 100644 Resources/MaterialIcons/slint.svg create mode 100644 Resources/MaterialIcons/solidity.svg create mode 100644 Resources/MaterialIcons/statamic-antlers.svg create mode 100644 Resources/MaterialIcons/storybook.svg create mode 100644 Resources/MaterialIcons/stylelint.svg create mode 100644 Resources/MaterialIcons/stylus.svg create mode 100644 Resources/MaterialIcons/supabase.svg create mode 100644 Resources/MaterialIcons/svelte-service.svg create mode 100644 Resources/MaterialIcons/svelte-ts.svg create mode 100644 Resources/MaterialIcons/svelte.svg create mode 100644 Resources/MaterialIcons/svg.svg create mode 100644 Resources/MaterialIcons/svx.svg create mode 100644 Resources/MaterialIcons/swc.svg create mode 100644 Resources/MaterialIcons/swift.svg create mode 100644 Resources/MaterialIcons/tailwind.svg create mode 100644 Resources/MaterialIcons/taskfile.svg create mode 100644 Resources/MaterialIcons/tauri.svg create mode 100644 Resources/MaterialIcons/terraform.svg create mode 100644 Resources/MaterialIcons/tex.svg create mode 100644 Resources/MaterialIcons/text.svg create mode 100644 Resources/MaterialIcons/ts-test.svg create mode 100644 Resources/MaterialIcons/ts-types.svg create mode 100644 Resources/MaterialIcons/ts.svg create mode 100644 Resources/MaterialIcons/tsconfig.svg create mode 100644 Resources/MaterialIcons/turborepo.svg create mode 100644 Resources/MaterialIcons/twig.svg create mode 100644 Resources/MaterialIcons/typst.svg create mode 100644 Resources/MaterialIcons/unocss.svg create mode 100644 Resources/MaterialIcons/uv.svg create mode 100644 Resources/MaterialIcons/v.svg create mode 100644 Resources/MaterialIcons/vanilla-extract.svg create mode 100644 Resources/MaterialIcons/vercel.svg create mode 100644 Resources/MaterialIcons/video.svg create mode 100644 Resources/MaterialIcons/visual-studio.svg create mode 100644 Resources/MaterialIcons/vite.svg create mode 100644 Resources/MaterialIcons/vitest.svg create mode 100644 Resources/MaterialIcons/vue.svg create mode 100644 Resources/MaterialIcons/webgpu.svg create mode 100644 Resources/MaterialIcons/webpack.svg create mode 100644 Resources/MaterialIcons/xml.svg create mode 100644 Resources/MaterialIcons/yaml.svg create mode 100644 Resources/MaterialIcons/yarn.svg create mode 100644 Resources/MaterialIcons/yummacss.svg create mode 100644 Resources/MaterialIcons/zig.svg create mode 100644 Resources/material-icons.json create mode 100644 Sources/MaterialFileIconResolver.swift create mode 100644 Sources/Sidebar/SidebarCustomizationSettings.swift create mode 100644 Sources/Sidebar/SidebarNavigationTimings.swift diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift index b99dd45b173f..44e3fabbeb4f 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift @@ -86,7 +86,7 @@ public struct WindowAppearanceResolver { sidebarTintOpacity: defaults.object(forKey: "sidebarTintOpacity") as? Double ?? tintDefaults.opacity, sidebarCornerRadius: defaults.object(forKey: "sidebarCornerRadius") as? Double ?? 0.0, sidebarBlurOpacity: defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? 1.0, - bgGlassEnabled: defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false, + bgGlassEnabled: defaults.object(forKey: "bgGlassEnabled") as? Bool ?? true, bgGlassTintHex: defaults.string(forKey: "bgGlassTintHex") ?? "#000000", bgGlassTintOpacity: defaults.object(forKey: "bgGlassTintOpacity") as? Double ?? 0.03, reduceTransparency: reduceTransparency diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift index d71253644833..7e65795c58e9 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift @@ -229,5 +229,33 @@ public struct SidebarCatalogSection: SettingCatalogSection { userDefaultsKey: "sidebarNotificationBadgeColorHex" ) + public let peekReveal = DefaultsKey( + id: "sidebar.peekReveal", + defaultValue: .quick, + userDefaultsKey: "sidebarPeekReveal" + ) + + /// When true the hidden sidebar never hover-reveals; only the toggle + /// shortcut brings it back. + public let peekDisabled = DefaultsKey( + id: "sidebar.peekDisabled", + defaultValue: false, + userDefaultsKey: "sidebarPeekDisabled" + ) + + public let rowDensity = DefaultsKey( + id: "sidebar.rowDensity", + defaultValue: .cozy, + userDefaultsKey: "sidebarRowDensity" + ) + + /// When true, starting a drag on an unselected workspace no longer + /// switches to it first. + public let dragSwitchDisabled = DefaultsKey( + id: "sidebar.dragSwitchDisabled", + defaultValue: false, + userDefaultsKey: "sidebarDragSwitchDisabled" + ) + public init() {} } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift new file mode 100644 index 000000000000..e2a78c8b9bd2 --- /dev/null +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift @@ -0,0 +1,19 @@ +import Foundation + +/// How eagerly the collapsed sidebar's hover-reveal responds to the pointer. +public enum SidebarPeekRevealPreset: String, CaseIterable, Sendable, SettingCodable { + /// No dwell at all: touching the edge strip reveals immediately. + case instant + /// The shipped default: a short rest distinguishes intent from transit. + case quick + /// A longer rest and a narrower strip, for pointers that live near the + /// window edge. + case relaxed +} + +/// Vertical breathing room inside each workspace row. +public enum SidebarRowDensity: String, CaseIterable, Sendable, SettingCodable { + case compact + case cozy + case spacious +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 468dc11a2159..1b48f3624815 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -324,6 +324,11 @@ extension Array where Element == CuratedSettingEntry { // Sidebar appearance + sidebar workspace row details .init(section: .sidebarAppearance, id: "match-terminal", title: String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), synonyms: "Match Terminal Background sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), + .init(section: .sidebarAppearance, id: "sidebar-glass-tint", title: "Sidebar Glass Tint", synonyms: "sidebarAppearance.tintOpacity sidebarTintOpacity glass glassmorphism tint opacity transparency blur floating docked panel peek clear frosted opaque"), + .init(section: .sidebarAppearance, id: "sidebar-peek-reveal", title: "Sidebar Peek Reveal Speed", synonyms: "sidebar.peekReveal peek hover reveal edge dwell instant quick relaxed sensitivity floating sidebar"), + .init(section: .sidebarAppearance, id: "sidebar-peek-disabled", title: "Disable Sidebar Peek", synonyms: "sidebar.peekDisabled peek hover reveal disable never auto hide floating sidebar edge"), + .init(section: .sidebarAppearance, id: "sidebar-row-density", title: "Row Density", synonyms: "sidebar.rowDensity density compact cozy spacious row height padding workspace list"), + .init(section: .sidebarAppearance, id: "sidebar-drag-switch", title: "Disable Switch on Drag", synonyms: "sidebar.dragSwitchDisabled drag reorder switch selection workspace pick up"), .init(section: .sidebarAppearance, id: "hide-sidebar-details", title: String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), synonyms: "Hide All Sidebar Details sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), .init(section: .sidebarAppearance, id: "wrap-workspace-titles", title: String(localized: "settings.app.wrapWorkspaceTitles", defaultValue: "Wrap Workspace Titles in Sidebar"), synonyms: "Wrap Workspace Titles in Sidebar sidebar.wrapWorkspaceTitles workspace title wrap multiline pr pull request"), .init(section: .sidebarAppearance, id: "show-workspace-description", title: String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"), synonyms: "Show Workspace Description in Sidebar sidebar.showWorkspaceDescription workspace description notes markdown sidebar"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift new file mode 100644 index 000000000000..c12fa6eb7e79 --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift @@ -0,0 +1,129 @@ +import CmuxSettings +import SwiftUI + +extension SidebarSection { + /// The sidebar feel group: glass, reveal, motion, density, drag. + @ViewBuilder + var customizationRows: some View { + SettingsCardRow( + configurationReview: .json("sidebarAppearance.tintOpacity"), + String(localized: "settings.sidebar.glassTint", defaultValue: "Sidebar Glass Tint"), + subtitle: String(localized: "settings.sidebar.glassTint.subtitle", defaultValue: "How strongly the sidebar tints the glass behind it, docked or floating. Lower is clearer glass."), + controlWidth: 250 + ) { + HStack(spacing: 8) { + Slider( + value: Binding( + get: { glassTint.current }, + // Continuous track (a stepped slider draws tick dots); + // store rounded so the percent label stays stable. + set: { glassTint.set(($0 * 100).rounded() / 100) } + ), + in: 0.0...1.0 + ) + .frame(width: 130) + .accessibilityIdentifier("SettingsSidebarGlassTintSlider") + + Text("\(Int((glassTint.current * 100).rounded()))%") + .cmuxFont(size: 12, weight: .medium, design: .rounded) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + + Button(String(localized: "settings.sidebar.glassTint.reset", defaultValue: "Reset")) { + glassTint.set(0.18) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(abs(glassTint.current - 0.18) < 0.001) + } + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.peekReveal"), + String(localized: "settings.sidebar.peekReveal", defaultValue: "Sidebar Peek Reveal Speed"), + subtitle: String(localized: "settings.sidebar.peekReveal.subtitle", defaultValue: "How eagerly the hidden sidebar peeks out when the pointer reaches the left edge or the sidebar button.") + ) { + Picker("", selection: Binding( + get: { peekReveal.current }, + set: { peekReveal.set($0) } + )) { + ForEach(SidebarPeekRevealPreset.allCases, id: \.self) { preset in + Text(peekRevealLabel(preset)).tag(preset) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + .disabled(peekDisabled.current) + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.peekDisabled"), + String(localized: "settings.sidebar.peekDisabled", defaultValue: "Disable Sidebar Peek"), + subtitle: peekDisabled.current + ? String(localized: "settings.sidebar.peekDisabled.subtitleOn", defaultValue: "The hidden sidebar never peeks out; bring it back with the shortcut or the sidebar button.") + : String(localized: "settings.sidebar.peekDisabled.subtitleOff", defaultValue: "The hidden sidebar peeks out when the pointer rests at the left edge.") + ) { + Toggle("", isOn: Binding(get: { peekDisabled.current }, set: { peekDisabled.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.rowDensity"), + String(localized: "settings.sidebar.rowDensity", defaultValue: "Row Density"), + subtitle: String(localized: "settings.sidebar.rowDensity.subtitle", defaultValue: "Vertical breathing room inside each workspace row.") + ) { + Picker("", selection: Binding( + get: { rowDensity.current }, + set: { rowDensity.set($0) } + )) { + ForEach(SidebarRowDensity.allCases, id: \.self) { density in + Text(densityLabel(density)).tag(density) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.dragSwitchDisabled"), + String(localized: "settings.sidebar.dragSwitchDisabled", defaultValue: "Disable Switch on Drag"), + subtitle: dragSwitchDisabled.current + ? String(localized: "settings.sidebar.dragSwitchDisabled.subtitleOn", defaultValue: "Dragging a workspace only reorders it; your current workspace stays selected.") + : String(localized: "settings.sidebar.dragSwitchDisabled.subtitleOff", defaultValue: "Picking up a workspace you are not on switches to it, so the row in your hand is the one you are using.") + ) { + Toggle("", isOn: Binding(get: { dragSwitchDisabled.current }, set: { dragSwitchDisabled.set($0) })) + .labelsHidden() + .controlSize(.small) + } + SettingsCardDivider() + } + + private func peekRevealLabel(_ preset: SidebarPeekRevealPreset) -> String { + switch preset { + case .instant: + String(localized: "settings.sidebar.peekReveal.instant", defaultValue: "Instant") + case .quick: + String(localized: "settings.sidebar.peekReveal.quick", defaultValue: "Quick") + case .relaxed: + String(localized: "settings.sidebar.peekReveal.relaxed", defaultValue: "Relaxed") + } + } + + private func densityLabel(_ density: SidebarRowDensity) -> String { + switch density { + case .compact: + String(localized: "settings.sidebar.rowDensity.compact", defaultValue: "Compact") + case .cozy: + String(localized: "settings.sidebar.rowDensity.cozy", defaultValue: "Cozy") + case .spacious: + String(localized: "settings.sidebar.rowDensity.spacious", defaultValue: "Spacious") + } + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift index 5e6af8e966e6..50ab5e2722c0 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift @@ -38,6 +38,11 @@ public struct SidebarSection: View { @State private var compactAgentStatus: DefaultsValueModel @State private var rightMaxWidth: DefaultsValueModel @State private var rememberedRightMaxWidth: DefaultsValueModel + @State var glassTint: DefaultsValueModel + @State var peekReveal: DefaultsValueModel + @State var peekDisabled: DefaultsValueModel + @State var rowDensity: DefaultsValueModel + @State var dragSwitchDisabled: DefaultsValueModel public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog, hostActions: SettingsHostActions) { self.catalog = catalog self.hostActions = hostActions @@ -71,6 +76,11 @@ public struct SidebarSection: View { _compactAgentStatus = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.compactAgentStatus)) _rightMaxWidth = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.rightMaxWidth)) _rememberedRightMaxWidth = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.rememberedRightMaxWidth)) + _glassTint = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.tintOpacity)) + _peekReveal = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.peekReveal)) + _peekDisabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.peekDisabled)) + _rowDensity = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.rowDensity)) + _dragSwitchDisabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.dragSwitchDisabled)) } /// The rendered sidebar settings section. public var body: some View { @@ -110,6 +120,11 @@ public struct SidebarSection: View { compactAgentStatus, rightMaxWidth, rememberedRightMaxWidth, + glassTint, + peekReveal, + peekDisabled, + rowDensity, + dragSwitchDisabled, ] models.forEach { $0.startObserving() } } @@ -189,6 +204,8 @@ public struct SidebarSection: View { } SettingsCardDivider() + customizationRows + SettingsCardRow( configurationReview: .settingsOnly, String(localized: "settings.sidebarAppearance.fontSize", defaultValue: "Sidebar Font Size"), diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift index f4da5261292c..dc08f4cc7186 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift @@ -1,4 +1,4 @@ -import AppKit +public import AppKit public import SwiftUI /// Behind-window blur for the floating card. @@ -11,16 +11,19 @@ public import SwiftUI public struct SidebarPeekGlassBackdrop: NSViewRepresentable { /// Corner radius matching the card shape. public let cornerRadius: CGFloat + /// The material to blur with, matching the docked ground's. + public let material: NSVisualEffectView.Material /// Creates the backdrop. - public init(cornerRadius: CGFloat) { + public init(cornerRadius: CGFloat, material: NSVisualEffectView.Material = .popover) { self.cornerRadius = cornerRadius + self.material = material } public func makeNSView(context: Context) -> NSVisualEffectView { let view = NSVisualEffectView() view.blendingMode = .behindWindow - view.material = .popover + view.material = material view.state = .active view.wantsLayer = true view.layer?.cornerRadius = cornerRadius @@ -30,6 +33,7 @@ public struct SidebarPeekGlassBackdrop: NSViewRepresentable { } public func updateNSView(_ view: NSVisualEffectView, context: Context) { + view.material = material view.layer?.cornerRadius = cornerRadius } } diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift index ce75081d46fc..d516032a3881 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift @@ -1,3 +1,4 @@ +public import AppKit public import SwiftUI /// The floating peek panel's frame: material, hairline rim, and shadow, with @@ -12,6 +13,11 @@ public struct SidebarPeekPanelChrome: View { public let metrics: SidebarPeekPanelMetrics /// The AppKit material to blur the backdrop with. public let material: Material + /// The legibility tint painted over the glass, alpha included. Injected + /// so the floating card and the docked pane wear the exact same wash. + public let tint: Color + /// The AppKit material behind the tint, matching the docked ground's. + public let glassMaterial: NSVisualEffectView.Material /// The panel's contents. @ViewBuilder public let content: () -> Content @@ -26,10 +32,14 @@ public struct SidebarPeekPanelChrome: View { public init( metrics: SidebarPeekPanelMetrics = .default, material: Material = .regularMaterial, + tint: Color = Color(nsColor: .windowBackgroundColor).opacity(0.52), + glassMaterial: NSVisualEffectView.Material = .popover, @ViewBuilder content: @escaping () -> Content ) { self.metrics = metrics self.material = material + self.tint = tint + self.glassMaterial = glassMaterial self.content = content } @@ -58,8 +68,11 @@ public struct SidebarPeekPanelChrome: View { /// without going opaque, which is the whole glassmorphism effect. private var surface: some View { ZStack { - SidebarPeekGlassBackdrop(cornerRadius: metrics.cornerRadius) - shape.fill(Color(nsColor: .windowBackgroundColor).opacity(0.52)) + SidebarPeekGlassBackdrop( + cornerRadius: metrics.cornerRadius, + material: glassMaterial + ) + shape.fill(tint) } } diff --git a/Resources/MaterialIcons/LICENSE b/Resources/MaterialIcons/LICENSE new file mode 100644 index 000000000000..49f788b07163 --- /dev/null +++ b/Resources/MaterialIcons/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020-22 Miguel Solorio + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Resources/MaterialIcons/angular-component.svg b/Resources/MaterialIcons/angular-component.svg new file mode 100644 index 000000000000..546c8abe0dfd --- /dev/null +++ b/Resources/MaterialIcons/angular-component.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/angular-directive.svg b/Resources/MaterialIcons/angular-directive.svg new file mode 100644 index 000000000000..5aa0e89fbef1 --- /dev/null +++ b/Resources/MaterialIcons/angular-directive.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/angular-guard.svg b/Resources/MaterialIcons/angular-guard.svg new file mode 100644 index 000000000000..f5712f78a620 --- /dev/null +++ b/Resources/MaterialIcons/angular-guard.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/angular-module.svg b/Resources/MaterialIcons/angular-module.svg new file mode 100644 index 000000000000..76af278cedea --- /dev/null +++ b/Resources/MaterialIcons/angular-module.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/angular-pipe.svg b/Resources/MaterialIcons/angular-pipe.svg new file mode 100644 index 000000000000..07f765246113 --- /dev/null +++ b/Resources/MaterialIcons/angular-pipe.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/angular-service.svg b/Resources/MaterialIcons/angular-service.svg new file mode 100644 index 000000000000..4660f2400264 --- /dev/null +++ b/Resources/MaterialIcons/angular-service.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/angular.svg b/Resources/MaterialIcons/angular.svg new file mode 100644 index 000000000000..4b22b929df7b --- /dev/null +++ b/Resources/MaterialIcons/angular.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/animate.svg b/Resources/MaterialIcons/animate.svg new file mode 100644 index 000000000000..729ad3a2952e --- /dev/null +++ b/Resources/MaterialIcons/animate.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/astro.svg b/Resources/MaterialIcons/astro.svg new file mode 100644 index 000000000000..15cda6cf0994 --- /dev/null +++ b/Resources/MaterialIcons/astro.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/audio.svg b/Resources/MaterialIcons/audio.svg new file mode 100644 index 000000000000..0ecfe1f08f9f --- /dev/null +++ b/Resources/MaterialIcons/audio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/babel.svg b/Resources/MaterialIcons/babel.svg new file mode 100644 index 000000000000..ab2803ddc584 --- /dev/null +++ b/Resources/MaterialIcons/babel.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/biome.svg b/Resources/MaterialIcons/biome.svg new file mode 100644 index 000000000000..8c0d84e4bfb7 --- /dev/null +++ b/Resources/MaterialIcons/biome.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/brackets-blue.svg b/Resources/MaterialIcons/brackets-blue.svg new file mode 100644 index 000000000000..45665c7f7b71 --- /dev/null +++ b/Resources/MaterialIcons/brackets-blue.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/brackets-gray.svg b/Resources/MaterialIcons/brackets-gray.svg new file mode 100644 index 000000000000..ad59a5e75524 --- /dev/null +++ b/Resources/MaterialIcons/brackets-gray.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/brackets-green.svg b/Resources/MaterialIcons/brackets-green.svg new file mode 100644 index 000000000000..7f23b1df6f35 --- /dev/null +++ b/Resources/MaterialIcons/brackets-green.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/brackets-orange.svg b/Resources/MaterialIcons/brackets-orange.svg new file mode 100644 index 000000000000..5e598b334c0e --- /dev/null +++ b/Resources/MaterialIcons/brackets-orange.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/brackets-pink.svg b/Resources/MaterialIcons/brackets-pink.svg new file mode 100644 index 000000000000..41fef969a5cb --- /dev/null +++ b/Resources/MaterialIcons/brackets-pink.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/brackets-purple.svg b/Resources/MaterialIcons/brackets-purple.svg new file mode 100644 index 000000000000..2d05a9c174c5 --- /dev/null +++ b/Resources/MaterialIcons/brackets-purple.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/brackets-red.svg b/Resources/MaterialIcons/brackets-red.svg new file mode 100644 index 000000000000..9fecfb497a8a --- /dev/null +++ b/Resources/MaterialIcons/brackets-red.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/brackets-sky.svg b/Resources/MaterialIcons/brackets-sky.svg new file mode 100644 index 000000000000..08fdc268b418 --- /dev/null +++ b/Resources/MaterialIcons/brackets-sky.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/brackets-yellow.svg b/Resources/MaterialIcons/brackets-yellow.svg new file mode 100644 index 000000000000..23f59572bdff --- /dev/null +++ b/Resources/MaterialIcons/brackets-yellow.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/browserslist.svg b/Resources/MaterialIcons/browserslist.svg new file mode 100644 index 000000000000..e6ea34fa7968 --- /dev/null +++ b/Resources/MaterialIcons/browserslist.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/bruno.svg b/Resources/MaterialIcons/bruno.svg new file mode 100644 index 000000000000..ac17f3508d61 --- /dev/null +++ b/Resources/MaterialIcons/bruno.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/bun.svg b/Resources/MaterialIcons/bun.svg new file mode 100644 index 000000000000..b4a4322c449d --- /dev/null +++ b/Resources/MaterialIcons/bun.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/c.svg b/Resources/MaterialIcons/c.svg new file mode 100644 index 000000000000..df3f6f0810c2 --- /dev/null +++ b/Resources/MaterialIcons/c.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/caddy.svg b/Resources/MaterialIcons/caddy.svg new file mode 100644 index 000000000000..70cdcbe6faf3 --- /dev/null +++ b/Resources/MaterialIcons/caddy.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/capacitor.svg b/Resources/MaterialIcons/capacitor.svg new file mode 100644 index 000000000000..d62920e5900b --- /dev/null +++ b/Resources/MaterialIcons/capacitor.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/clapper.svg b/Resources/MaterialIcons/clapper.svg new file mode 100644 index 000000000000..262df79d50ca --- /dev/null +++ b/Resources/MaterialIcons/clapper.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/claude.svg b/Resources/MaterialIcons/claude.svg new file mode 100644 index 000000000000..ef0ffefaa35b --- /dev/null +++ b/Resources/MaterialIcons/claude.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/clojure.svg b/Resources/MaterialIcons/clojure.svg new file mode 100644 index 000000000000..9b809e38f764 --- /dev/null +++ b/Resources/MaterialIcons/clojure.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/cloudflare-workers.svg b/Resources/MaterialIcons/cloudflare-workers.svg new file mode 100644 index 000000000000..78bca49c2f34 --- /dev/null +++ b/Resources/MaterialIcons/cloudflare-workers.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/cmake.svg b/Resources/MaterialIcons/cmake.svg new file mode 100644 index 000000000000..23d48dd49550 --- /dev/null +++ b/Resources/MaterialIcons/cmake.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/code-blue.svg b/Resources/MaterialIcons/code-blue.svg new file mode 100644 index 000000000000..b957743d6433 --- /dev/null +++ b/Resources/MaterialIcons/code-blue.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/code-gray.svg b/Resources/MaterialIcons/code-gray.svg new file mode 100644 index 000000000000..05ab051ecac7 --- /dev/null +++ b/Resources/MaterialIcons/code-gray.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/code-green.svg b/Resources/MaterialIcons/code-green.svg new file mode 100644 index 000000000000..77a161266396 --- /dev/null +++ b/Resources/MaterialIcons/code-green.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/code-orange.svg b/Resources/MaterialIcons/code-orange.svg new file mode 100644 index 000000000000..dd7868dbe802 --- /dev/null +++ b/Resources/MaterialIcons/code-orange.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/code-pink.svg b/Resources/MaterialIcons/code-pink.svg new file mode 100644 index 000000000000..db3b930f1d91 --- /dev/null +++ b/Resources/MaterialIcons/code-pink.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/code-purple.svg b/Resources/MaterialIcons/code-purple.svg new file mode 100644 index 000000000000..d376b281b938 --- /dev/null +++ b/Resources/MaterialIcons/code-purple.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/code-red.svg b/Resources/MaterialIcons/code-red.svg new file mode 100644 index 000000000000..dc30bc86efdf --- /dev/null +++ b/Resources/MaterialIcons/code-red.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/code-sky.svg b/Resources/MaterialIcons/code-sky.svg new file mode 100644 index 000000000000..1cbe913e8eb1 --- /dev/null +++ b/Resources/MaterialIcons/code-sky.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/code-yellow.svg b/Resources/MaterialIcons/code-yellow.svg new file mode 100644 index 000000000000..94eb314d127f --- /dev/null +++ b/Resources/MaterialIcons/code-yellow.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/coffeescript.svg b/Resources/MaterialIcons/coffeescript.svg new file mode 100644 index 000000000000..e648af59d20e --- /dev/null +++ b/Resources/MaterialIcons/coffeescript.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Resources/MaterialIcons/coldfusion.svg b/Resources/MaterialIcons/coldfusion.svg new file mode 100644 index 000000000000..35af75a44aab --- /dev/null +++ b/Resources/MaterialIcons/coldfusion.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/compressed.svg b/Resources/MaterialIcons/compressed.svg new file mode 100644 index 000000000000..39c7b38fcbe1 --- /dev/null +++ b/Resources/MaterialIcons/compressed.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/contentlayer.svg b/Resources/MaterialIcons/contentlayer.svg new file mode 100644 index 000000000000..e381c2272379 --- /dev/null +++ b/Resources/MaterialIcons/contentlayer.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/cplus.svg b/Resources/MaterialIcons/cplus.svg new file mode 100644 index 000000000000..5e6673c8eccd --- /dev/null +++ b/Resources/MaterialIcons/cplus.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/crystal.svg b/Resources/MaterialIcons/crystal.svg new file mode 100644 index 000000000000..eae5320e006d --- /dev/null +++ b/Resources/MaterialIcons/crystal.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/csharp.svg b/Resources/MaterialIcons/csharp.svg new file mode 100644 index 000000000000..12eacf1b5f30 --- /dev/null +++ b/Resources/MaterialIcons/csharp.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/csv.svg b/Resources/MaterialIcons/csv.svg new file mode 100644 index 000000000000..7840fab0b6a1 --- /dev/null +++ b/Resources/MaterialIcons/csv.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/cucumber.svg b/Resources/MaterialIcons/cucumber.svg new file mode 100644 index 000000000000..10d880a6c203 --- /dev/null +++ b/Resources/MaterialIcons/cucumber.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/cuda.svg b/Resources/MaterialIcons/cuda.svg new file mode 100644 index 000000000000..cb88c9eb3ff3 --- /dev/null +++ b/Resources/MaterialIcons/cuda.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/cursor.svg b/Resources/MaterialIcons/cursor.svg new file mode 100644 index 000000000000..7d3bd29ccd49 --- /dev/null +++ b/Resources/MaterialIcons/cursor.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/cypress.svg b/Resources/MaterialIcons/cypress.svg new file mode 100644 index 000000000000..d06fb0bbf185 --- /dev/null +++ b/Resources/MaterialIcons/cypress.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/dart.svg b/Resources/MaterialIcons/dart.svg new file mode 100644 index 000000000000..71ddc65fc208 --- /dev/null +++ b/Resources/MaterialIcons/dart.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/database.svg b/Resources/MaterialIcons/database.svg new file mode 100644 index 000000000000..0dc2a6264e19 --- /dev/null +++ b/Resources/MaterialIcons/database.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/deno.svg b/Resources/MaterialIcons/deno.svg new file mode 100644 index 000000000000..301ac36f418e --- /dev/null +++ b/Resources/MaterialIcons/deno.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/docker-green.svg b/Resources/MaterialIcons/docker-green.svg new file mode 100644 index 000000000000..404482127bd9 --- /dev/null +++ b/Resources/MaterialIcons/docker-green.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/docker-orange.svg b/Resources/MaterialIcons/docker-orange.svg new file mode 100644 index 000000000000..d3bcc60794c6 --- /dev/null +++ b/Resources/MaterialIcons/docker-orange.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/docker-pink.svg b/Resources/MaterialIcons/docker-pink.svg new file mode 100644 index 000000000000..f0875fab3dd3 --- /dev/null +++ b/Resources/MaterialIcons/docker-pink.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/docker-purple.svg b/Resources/MaterialIcons/docker-purple.svg new file mode 100644 index 000000000000..69e320126e7e --- /dev/null +++ b/Resources/MaterialIcons/docker-purple.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/docker-red.svg b/Resources/MaterialIcons/docker-red.svg new file mode 100644 index 000000000000..d5c87655ee63 --- /dev/null +++ b/Resources/MaterialIcons/docker-red.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/docker-yellow.svg b/Resources/MaterialIcons/docker-yellow.svg new file mode 100644 index 000000000000..b70b2a1fb378 --- /dev/null +++ b/Resources/MaterialIcons/docker-yellow.svg @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/docker.svg b/Resources/MaterialIcons/docker.svg new file mode 100644 index 000000000000..de4b75bf7142 --- /dev/null +++ b/Resources/MaterialIcons/docker.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/document.svg b/Resources/MaterialIcons/document.svg new file mode 100644 index 000000000000..ca60cc796596 --- /dev/null +++ b/Resources/MaterialIcons/document.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/docusaurus.svg b/Resources/MaterialIcons/docusaurus.svg new file mode 100644 index 000000000000..629e9f364f26 --- /dev/null +++ b/Resources/MaterialIcons/docusaurus.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/drawio.svg b/Resources/MaterialIcons/drawio.svg new file mode 100644 index 000000000000..09b5ec322aeb --- /dev/null +++ b/Resources/MaterialIcons/drawio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/drizzle.svg b/Resources/MaterialIcons/drizzle.svg new file mode 100644 index 000000000000..05e9740f8cdf --- /dev/null +++ b/Resources/MaterialIcons/drizzle.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/dts.svg b/Resources/MaterialIcons/dts.svg new file mode 100644 index 000000000000..73aceb4dba2d --- /dev/null +++ b/Resources/MaterialIcons/dts.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/dune.svg b/Resources/MaterialIcons/dune.svg new file mode 100644 index 000000000000..b66a6fefc235 --- /dev/null +++ b/Resources/MaterialIcons/dune.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/earthfile.svg b/Resources/MaterialIcons/earthfile.svg new file mode 100644 index 000000000000..b2960fcace4a --- /dev/null +++ b/Resources/MaterialIcons/earthfile.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/editorconfig.svg b/Resources/MaterialIcons/editorconfig.svg new file mode 100644 index 000000000000..3ad29a77bb23 --- /dev/null +++ b/Resources/MaterialIcons/editorconfig.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Resources/MaterialIcons/elixir.svg b/Resources/MaterialIcons/elixir.svg new file mode 100644 index 000000000000..4f50f027dc60 --- /dev/null +++ b/Resources/MaterialIcons/elixir.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/erlang.svg b/Resources/MaterialIcons/erlang.svg new file mode 100644 index 000000000000..432226aabfcb --- /dev/null +++ b/Resources/MaterialIcons/erlang.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/eslint.svg b/Resources/MaterialIcons/eslint.svg new file mode 100644 index 000000000000..62e35b1254cb --- /dev/null +++ b/Resources/MaterialIcons/eslint.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/exe.svg b/Resources/MaterialIcons/exe.svg new file mode 100644 index 000000000000..34d54cbfc808 --- /dev/null +++ b/Resources/MaterialIcons/exe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/expressive-code.svg b/Resources/MaterialIcons/expressive-code.svg new file mode 100644 index 000000000000..3b4f18468f5c --- /dev/null +++ b/Resources/MaterialIcons/expressive-code.svg @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/firebase.svg b/Resources/MaterialIcons/firebase.svg new file mode 100644 index 000000000000..ef9aec14c8dc --- /dev/null +++ b/Resources/MaterialIcons/firebase.svg @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/folder-actions.svg b/Resources/MaterialIcons/folder-actions.svg new file mode 100644 index 000000000000..f6bf2e1dfd4e --- /dev/null +++ b/Resources/MaterialIcons/folder-actions.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-android.svg b/Resources/MaterialIcons/folder-android.svg new file mode 100644 index 000000000000..c5a4fe8322b7 --- /dev/null +++ b/Resources/MaterialIcons/folder-android.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-angular.svg b/Resources/MaterialIcons/folder-angular.svg new file mode 100644 index 000000000000..cbf0247d1180 --- /dev/null +++ b/Resources/MaterialIcons/folder-angular.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-app.svg b/Resources/MaterialIcons/folder-app.svg new file mode 100644 index 000000000000..af8a1586893f --- /dev/null +++ b/Resources/MaterialIcons/folder-app.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-assets.svg b/Resources/MaterialIcons/folder-assets.svg new file mode 100644 index 000000000000..90298cab8604 --- /dev/null +++ b/Resources/MaterialIcons/folder-assets.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/folder-aws.svg b/Resources/MaterialIcons/folder-aws.svg new file mode 100644 index 000000000000..4b3475c9361a --- /dev/null +++ b/Resources/MaterialIcons/folder-aws.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-azure.svg b/Resources/MaterialIcons/folder-azure.svg new file mode 100644 index 000000000000..73a26dc9a87f --- /dev/null +++ b/Resources/MaterialIcons/folder-azure.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-blue-code.svg b/Resources/MaterialIcons/folder-blue-code.svg new file mode 100644 index 000000000000..21368815d265 --- /dev/null +++ b/Resources/MaterialIcons/folder-blue-code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-blue-outline.svg b/Resources/MaterialIcons/folder-blue-outline.svg new file mode 100644 index 000000000000..2eb7ceb05705 --- /dev/null +++ b/Resources/MaterialIcons/folder-blue-outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-blue.svg b/Resources/MaterialIcons/folder-blue.svg new file mode 100644 index 000000000000..0f15e6d4d303 --- /dev/null +++ b/Resources/MaterialIcons/folder-blue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-bruno.svg b/Resources/MaterialIcons/folder-bruno.svg new file mode 100644 index 000000000000..29d5f4f3fb9b --- /dev/null +++ b/Resources/MaterialIcons/folder-bruno.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-build.svg b/Resources/MaterialIcons/folder-build.svg new file mode 100644 index 000000000000..551feee1217b --- /dev/null +++ b/Resources/MaterialIcons/folder-build.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-changeset.svg b/Resources/MaterialIcons/folder-changeset.svg new file mode 100644 index 000000000000..a4c2949727e5 --- /dev/null +++ b/Resources/MaterialIcons/folder-changeset.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-claude.svg b/Resources/MaterialIcons/folder-claude.svg new file mode 100644 index 000000000000..8ed1afe6d7f7 --- /dev/null +++ b/Resources/MaterialIcons/folder-claude.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-config.svg b/Resources/MaterialIcons/folder-config.svg new file mode 100644 index 000000000000..e823baa53302 --- /dev/null +++ b/Resources/MaterialIcons/folder-config.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-constants.svg b/Resources/MaterialIcons/folder-constants.svg new file mode 100644 index 000000000000..97702688dd07 --- /dev/null +++ b/Resources/MaterialIcons/folder-constants.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-context.svg b/Resources/MaterialIcons/folder-context.svg new file mode 100644 index 000000000000..7bf3ef83c182 --- /dev/null +++ b/Resources/MaterialIcons/folder-context.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-core.svg b/Resources/MaterialIcons/folder-core.svg new file mode 100644 index 000000000000..fefa0431494a --- /dev/null +++ b/Resources/MaterialIcons/folder-core.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-cursor.svg b/Resources/MaterialIcons/folder-cursor.svg new file mode 100644 index 000000000000..75465bf0fed1 --- /dev/null +++ b/Resources/MaterialIcons/folder-cursor.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-cypress.svg b/Resources/MaterialIcons/folder-cypress.svg new file mode 100644 index 000000000000..87ce784082a0 --- /dev/null +++ b/Resources/MaterialIcons/folder-cypress.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-database.svg b/Resources/MaterialIcons/folder-database.svg new file mode 100644 index 000000000000..2e8792f2cbcc --- /dev/null +++ b/Resources/MaterialIcons/folder-database.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-docker.svg b/Resources/MaterialIcons/folder-docker.svg new file mode 100644 index 000000000000..415d42b8a572 --- /dev/null +++ b/Resources/MaterialIcons/folder-docker.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-documents.svg b/Resources/MaterialIcons/folder-documents.svg new file mode 100644 index 000000000000..650eb359ab79 --- /dev/null +++ b/Resources/MaterialIcons/folder-documents.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-drizzle.svg b/Resources/MaterialIcons/folder-drizzle.svg new file mode 100644 index 000000000000..7ff031e8a883 --- /dev/null +++ b/Resources/MaterialIcons/folder-drizzle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-effects.svg b/Resources/MaterialIcons/folder-effects.svg new file mode 100644 index 000000000000..b570687faf6f --- /dev/null +++ b/Resources/MaterialIcons/folder-effects.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-expo.svg b/Resources/MaterialIcons/folder-expo.svg new file mode 100644 index 000000000000..98c280da81eb --- /dev/null +++ b/Resources/MaterialIcons/folder-expo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-facade.svg b/Resources/MaterialIcons/folder-facade.svg new file mode 100644 index 000000000000..13dead3376f2 --- /dev/null +++ b/Resources/MaterialIcons/folder-facade.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-firebase.svg b/Resources/MaterialIcons/folder-firebase.svg new file mode 100644 index 000000000000..98b097d8f69e --- /dev/null +++ b/Resources/MaterialIcons/folder-firebase.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/folder-fonts.svg b/Resources/MaterialIcons/folder-fonts.svg new file mode 100644 index 000000000000..6c4338cad732 --- /dev/null +++ b/Resources/MaterialIcons/folder-fonts.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-github.svg b/Resources/MaterialIcons/folder-github.svg new file mode 100644 index 000000000000..5cae64f116b3 --- /dev/null +++ b/Resources/MaterialIcons/folder-github.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-gitlab.svg b/Resources/MaterialIcons/folder-gitlab.svg new file mode 100644 index 000000000000..dd0c7aa39f0b --- /dev/null +++ b/Resources/MaterialIcons/folder-gitlab.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/folder-globe.svg b/Resources/MaterialIcons/folder-globe.svg new file mode 100644 index 000000000000..441a19103dba --- /dev/null +++ b/Resources/MaterialIcons/folder-globe.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-godot.svg b/Resources/MaterialIcons/folder-godot.svg new file mode 100644 index 000000000000..c810b269ee92 --- /dev/null +++ b/Resources/MaterialIcons/folder-godot.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-gradle.svg b/Resources/MaterialIcons/folder-gradle.svg new file mode 100644 index 000000000000..aca6934e28d2 --- /dev/null +++ b/Resources/MaterialIcons/folder-gradle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-graphql.svg b/Resources/MaterialIcons/folder-graphql.svg new file mode 100644 index 000000000000..3955a42c3e22 --- /dev/null +++ b/Resources/MaterialIcons/folder-graphql.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-gray-code.svg b/Resources/MaterialIcons/folder-gray-code.svg new file mode 100644 index 000000000000..39dcfcbcc75f --- /dev/null +++ b/Resources/MaterialIcons/folder-gray-code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-gray-outline.svg b/Resources/MaterialIcons/folder-gray-outline.svg new file mode 100644 index 000000000000..e8cfcbeb22f4 --- /dev/null +++ b/Resources/MaterialIcons/folder-gray-outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-gray.svg b/Resources/MaterialIcons/folder-gray.svg new file mode 100644 index 000000000000..1fdbac75f69c --- /dev/null +++ b/Resources/MaterialIcons/folder-gray.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-green-code.svg b/Resources/MaterialIcons/folder-green-code.svg new file mode 100644 index 000000000000..5a326d60380d --- /dev/null +++ b/Resources/MaterialIcons/folder-green-code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-green-outline.svg b/Resources/MaterialIcons/folder-green-outline.svg new file mode 100644 index 000000000000..d4cb7fb741db --- /dev/null +++ b/Resources/MaterialIcons/folder-green-outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-green.svg b/Resources/MaterialIcons/folder-green.svg new file mode 100644 index 000000000000..a6f062957c50 --- /dev/null +++ b/Resources/MaterialIcons/folder-green.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-helpers.svg b/Resources/MaterialIcons/folder-helpers.svg new file mode 100644 index 000000000000..90bfde1f51f5 --- /dev/null +++ b/Resources/MaterialIcons/folder-helpers.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-hooks.svg b/Resources/MaterialIcons/folder-hooks.svg new file mode 100644 index 000000000000..f36070578484 --- /dev/null +++ b/Resources/MaterialIcons/folder-hooks.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-i18n.svg b/Resources/MaterialIcons/folder-i18n.svg new file mode 100644 index 000000000000..2c4aa2605a7b --- /dev/null +++ b/Resources/MaterialIcons/folder-i18n.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-images.svg b/Resources/MaterialIcons/folder-images.svg new file mode 100644 index 000000000000..b36487fa8208 --- /dev/null +++ b/Resources/MaterialIcons/folder-images.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-interceptors.svg b/Resources/MaterialIcons/folder-interceptors.svg new file mode 100644 index 000000000000..dacc5199c44d --- /dev/null +++ b/Resources/MaterialIcons/folder-interceptors.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-interfaces.svg b/Resources/MaterialIcons/folder-interfaces.svg new file mode 100644 index 000000000000..64639a66d16e --- /dev/null +++ b/Resources/MaterialIcons/folder-interfaces.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-ios.svg b/Resources/MaterialIcons/folder-ios.svg new file mode 100644 index 000000000000..a430c3a90dfd --- /dev/null +++ b/Resources/MaterialIcons/folder-ios.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-js.svg b/Resources/MaterialIcons/folder-js.svg new file mode 100644 index 000000000000..edcb2ae706b7 --- /dev/null +++ b/Resources/MaterialIcons/folder-js.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-layout.svg b/Resources/MaterialIcons/folder-layout.svg new file mode 100644 index 000000000000..eab15d86dd95 --- /dev/null +++ b/Resources/MaterialIcons/folder-layout.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-lock.svg b/Resources/MaterialIcons/folder-lock.svg new file mode 100644 index 000000000000..e982eaed1a23 --- /dev/null +++ b/Resources/MaterialIcons/folder-lock.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-mail.svg b/Resources/MaterialIcons/folder-mail.svg new file mode 100644 index 000000000000..4d02debefeb2 --- /dev/null +++ b/Resources/MaterialIcons/folder-mail.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-maven.svg b/Resources/MaterialIcons/folder-maven.svg new file mode 100644 index 000000000000..232e946b0501 --- /dev/null +++ b/Resources/MaterialIcons/folder-maven.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-middleware.svg b/Resources/MaterialIcons/folder-middleware.svg new file mode 100644 index 000000000000..a3d3175b78ee --- /dev/null +++ b/Resources/MaterialIcons/folder-middleware.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-models.svg b/Resources/MaterialIcons/folder-models.svg new file mode 100644 index 000000000000..8d28605dbe65 --- /dev/null +++ b/Resources/MaterialIcons/folder-models.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-modules.svg b/Resources/MaterialIcons/folder-modules.svg new file mode 100644 index 000000000000..2a13ee8e763e --- /dev/null +++ b/Resources/MaterialIcons/folder-modules.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-mongo.svg b/Resources/MaterialIcons/folder-mongo.svg new file mode 100644 index 000000000000..127abdd779d1 --- /dev/null +++ b/Resources/MaterialIcons/folder-mongo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-netlify.svg b/Resources/MaterialIcons/folder-netlify.svg new file mode 100644 index 000000000000..29d1e202c9b5 --- /dev/null +++ b/Resources/MaterialIcons/folder-netlify.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-next.svg b/Resources/MaterialIcons/folder-next.svg new file mode 100644 index 000000000000..ad83f0fcd00c --- /dev/null +++ b/Resources/MaterialIcons/folder-next.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-nginx.svg b/Resources/MaterialIcons/folder-nginx.svg new file mode 100644 index 000000000000..bed8b573dde6 --- /dev/null +++ b/Resources/MaterialIcons/folder-nginx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-node-modules.svg b/Resources/MaterialIcons/folder-node-modules.svg new file mode 100644 index 000000000000..6b18b2bd622f --- /dev/null +++ b/Resources/MaterialIcons/folder-node-modules.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-open.svg b/Resources/MaterialIcons/folder-open.svg new file mode 100644 index 000000000000..3c1576435e62 --- /dev/null +++ b/Resources/MaterialIcons/folder-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/folder-orange-code-1.svg b/Resources/MaterialIcons/folder-orange-code-1.svg new file mode 100644 index 000000000000..8e7ec14ca165 --- /dev/null +++ b/Resources/MaterialIcons/folder-orange-code-1.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-orange-code-2.svg b/Resources/MaterialIcons/folder-orange-code-2.svg new file mode 100644 index 000000000000..618ed2d63350 --- /dev/null +++ b/Resources/MaterialIcons/folder-orange-code-2.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-orange-code-3.svg b/Resources/MaterialIcons/folder-orange-code-3.svg new file mode 100644 index 000000000000..02a7dc89159a --- /dev/null +++ b/Resources/MaterialIcons/folder-orange-code-3.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-orange-code-4.svg b/Resources/MaterialIcons/folder-orange-code-4.svg new file mode 100644 index 000000000000..93c922e0acb4 --- /dev/null +++ b/Resources/MaterialIcons/folder-orange-code-4.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-orange-code.svg b/Resources/MaterialIcons/folder-orange-code.svg new file mode 100644 index 000000000000..259ddbe089cc --- /dev/null +++ b/Resources/MaterialIcons/folder-orange-code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-orange-outline.svg b/Resources/MaterialIcons/folder-orange-outline.svg new file mode 100644 index 000000000000..acf9d3f5b61e --- /dev/null +++ b/Resources/MaterialIcons/folder-orange-outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-orange.svg b/Resources/MaterialIcons/folder-orange.svg new file mode 100644 index 000000000000..204b509dec20 --- /dev/null +++ b/Resources/MaterialIcons/folder-orange.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-pink-code.svg b/Resources/MaterialIcons/folder-pink-code.svg new file mode 100644 index 000000000000..abfb85990197 --- /dev/null +++ b/Resources/MaterialIcons/folder-pink-code.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/folder-pink-outline.svg b/Resources/MaterialIcons/folder-pink-outline.svg new file mode 100644 index 000000000000..5381a3e3c3fc --- /dev/null +++ b/Resources/MaterialIcons/folder-pink-outline.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/folder-pink.svg b/Resources/MaterialIcons/folder-pink.svg new file mode 100644 index 000000000000..4490e4fe362b --- /dev/null +++ b/Resources/MaterialIcons/folder-pink.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/folder-pipes.svg b/Resources/MaterialIcons/folder-pipes.svg new file mode 100644 index 000000000000..7c6d52cf302c --- /dev/null +++ b/Resources/MaterialIcons/folder-pipes.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-prisma.svg b/Resources/MaterialIcons/folder-prisma.svg new file mode 100644 index 000000000000..8b228e3106f7 --- /dev/null +++ b/Resources/MaterialIcons/folder-prisma.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-providers.svg b/Resources/MaterialIcons/folder-providers.svg new file mode 100644 index 000000000000..cda52a1498b7 --- /dev/null +++ b/Resources/MaterialIcons/folder-providers.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-purple-code.svg b/Resources/MaterialIcons/folder-purple-code.svg new file mode 100644 index 000000000000..3d8f40d8f814 --- /dev/null +++ b/Resources/MaterialIcons/folder-purple-code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-purple-outline.svg b/Resources/MaterialIcons/folder-purple-outline.svg new file mode 100644 index 000000000000..b319fe521122 --- /dev/null +++ b/Resources/MaterialIcons/folder-purple-outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-purple.svg b/Resources/MaterialIcons/folder-purple.svg new file mode 100644 index 000000000000..495eb49ce5a3 --- /dev/null +++ b/Resources/MaterialIcons/folder-purple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-react.svg b/Resources/MaterialIcons/folder-react.svg new file mode 100644 index 000000000000..b70f3d13ce3c --- /dev/null +++ b/Resources/MaterialIcons/folder-react.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-red-code.svg b/Resources/MaterialIcons/folder-red-code.svg new file mode 100644 index 000000000000..8ef26e58693e --- /dev/null +++ b/Resources/MaterialIcons/folder-red-code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-red-outline.svg b/Resources/MaterialIcons/folder-red-outline.svg new file mode 100644 index 000000000000..f0bf4102b40b --- /dev/null +++ b/Resources/MaterialIcons/folder-red-outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-red.svg b/Resources/MaterialIcons/folder-red.svg new file mode 100644 index 000000000000..bd6914c13e2a --- /dev/null +++ b/Resources/MaterialIcons/folder-red.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-redis.svg b/Resources/MaterialIcons/folder-redis.svg new file mode 100644 index 000000000000..fc006fdf11f9 --- /dev/null +++ b/Resources/MaterialIcons/folder-redis.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-reducer.svg b/Resources/MaterialIcons/folder-reducer.svg new file mode 100644 index 000000000000..8ccb22445358 --- /dev/null +++ b/Resources/MaterialIcons/folder-reducer.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-router.svg b/Resources/MaterialIcons/folder-router.svg new file mode 100644 index 000000000000..71a79b2ffbc9 --- /dev/null +++ b/Resources/MaterialIcons/folder-router.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-sass.svg b/Resources/MaterialIcons/folder-sass.svg new file mode 100644 index 000000000000..0398eafb31c4 --- /dev/null +++ b/Resources/MaterialIcons/folder-sass.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-selector.svg b/Resources/MaterialIcons/folder-selector.svg new file mode 100644 index 000000000000..2038c043af7e --- /dev/null +++ b/Resources/MaterialIcons/folder-selector.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-services.svg b/Resources/MaterialIcons/folder-services.svg new file mode 100644 index 000000000000..3b726d124a27 --- /dev/null +++ b/Resources/MaterialIcons/folder-services.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-shared.svg b/Resources/MaterialIcons/folder-shared.svg new file mode 100644 index 000000000000..70ee69319afd --- /dev/null +++ b/Resources/MaterialIcons/folder-shared.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-sky-code.svg b/Resources/MaterialIcons/folder-sky-code.svg new file mode 100644 index 000000000000..db46a5ca863e --- /dev/null +++ b/Resources/MaterialIcons/folder-sky-code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-sky-outline.svg b/Resources/MaterialIcons/folder-sky-outline.svg new file mode 100644 index 000000000000..89e97d968579 --- /dev/null +++ b/Resources/MaterialIcons/folder-sky-outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-sky.svg b/Resources/MaterialIcons/folder-sky.svg new file mode 100644 index 000000000000..db59b4ca5739 --- /dev/null +++ b/Resources/MaterialIcons/folder-sky.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-src.svg b/Resources/MaterialIcons/folder-src.svg new file mode 100644 index 000000000000..93c922e0acb4 --- /dev/null +++ b/Resources/MaterialIcons/folder-src.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-supabase.svg b/Resources/MaterialIcons/folder-supabase.svg new file mode 100644 index 000000000000..88d4d6158e13 --- /dev/null +++ b/Resources/MaterialIcons/folder-supabase.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-target.svg b/Resources/MaterialIcons/folder-target.svg new file mode 100644 index 000000000000..63e0148408a7 --- /dev/null +++ b/Resources/MaterialIcons/folder-target.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-tauri.svg b/Resources/MaterialIcons/folder-tauri.svg new file mode 100644 index 000000000000..90ba82688bad --- /dev/null +++ b/Resources/MaterialIcons/folder-tauri.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-tina.svg b/Resources/MaterialIcons/folder-tina.svg new file mode 100644 index 000000000000..520950aab594 --- /dev/null +++ b/Resources/MaterialIcons/folder-tina.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/folder-utils.svg b/Resources/MaterialIcons/folder-utils.svg new file mode 100644 index 000000000000..2299d1bc6b4a --- /dev/null +++ b/Resources/MaterialIcons/folder-utils.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-vercel.svg b/Resources/MaterialIcons/folder-vercel.svg new file mode 100644 index 000000000000..2831b521954e --- /dev/null +++ b/Resources/MaterialIcons/folder-vercel.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-vscode.svg b/Resources/MaterialIcons/folder-vscode.svg new file mode 100644 index 000000000000..5b744d2ff647 --- /dev/null +++ b/Resources/MaterialIcons/folder-vscode.svg @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/folder-yellow-code.svg b/Resources/MaterialIcons/folder-yellow-code.svg new file mode 100644 index 000000000000..30c7bac29de6 --- /dev/null +++ b/Resources/MaterialIcons/folder-yellow-code.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/folder-yellow-outline.svg b/Resources/MaterialIcons/folder-yellow-outline.svg new file mode 100644 index 000000000000..b010eb8f5781 --- /dev/null +++ b/Resources/MaterialIcons/folder-yellow-outline.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-yellow.svg b/Resources/MaterialIcons/folder-yellow.svg new file mode 100644 index 000000000000..6dd19a53ef39 --- /dev/null +++ b/Resources/MaterialIcons/folder-yellow.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder-zed.svg b/Resources/MaterialIcons/folder-zed.svg new file mode 100644 index 000000000000..b961990461ec --- /dev/null +++ b/Resources/MaterialIcons/folder-zed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/folder.svg b/Resources/MaterialIcons/folder.svg new file mode 100644 index 000000000000..57915e514a46 --- /dev/null +++ b/Resources/MaterialIcons/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/font.svg b/Resources/MaterialIcons/font.svg new file mode 100644 index 000000000000..18710cc2a804 --- /dev/null +++ b/Resources/MaterialIcons/font.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Resources/MaterialIcons/fortran.svg b/Resources/MaterialIcons/fortran.svg new file mode 100644 index 000000000000..5fbe20c9f993 --- /dev/null +++ b/Resources/MaterialIcons/fortran.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/fresh.svg b/Resources/MaterialIcons/fresh.svg new file mode 100644 index 000000000000..1f206c735b58 --- /dev/null +++ b/Resources/MaterialIcons/fresh.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/fsharp.svg b/Resources/MaterialIcons/fsharp.svg new file mode 100644 index 000000000000..0423d2185a9d --- /dev/null +++ b/Resources/MaterialIcons/fsharp.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Resources/MaterialIcons/func.svg b/Resources/MaterialIcons/func.svg new file mode 100644 index 000000000000..9b4a19d08534 --- /dev/null +++ b/Resources/MaterialIcons/func.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gatsby.svg b/Resources/MaterialIcons/gatsby.svg new file mode 100644 index 000000000000..474653258014 --- /dev/null +++ b/Resources/MaterialIcons/gatsby.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gdextension.svg b/Resources/MaterialIcons/gdextension.svg new file mode 100644 index 000000000000..0a9bde403a8c --- /dev/null +++ b/Resources/MaterialIcons/gdextension.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gdignore.svg b/Resources/MaterialIcons/gdignore.svg new file mode 100644 index 000000000000..87ac325ff439 --- /dev/null +++ b/Resources/MaterialIcons/gdignore.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gdproject.svg b/Resources/MaterialIcons/gdproject.svg new file mode 100644 index 000000000000..b68ce5f9f2de --- /dev/null +++ b/Resources/MaterialIcons/gdproject.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gdscript.svg b/Resources/MaterialIcons/gdscript.svg new file mode 100644 index 000000000000..6a576970744c --- /dev/null +++ b/Resources/MaterialIcons/gdscript.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gdshader.svg b/Resources/MaterialIcons/gdshader.svg new file mode 100644 index 000000000000..118029396b72 --- /dev/null +++ b/Resources/MaterialIcons/gdshader.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gear.svg b/Resources/MaterialIcons/gear.svg new file mode 100644 index 000000000000..b04757a4df46 --- /dev/null +++ b/Resources/MaterialIcons/gear.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gif.svg b/Resources/MaterialIcons/gif.svg new file mode 100644 index 000000000000..802c96dd350d --- /dev/null +++ b/Resources/MaterialIcons/gif.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/git.svg b/Resources/MaterialIcons/git.svg new file mode 100644 index 000000000000..609a2e43b657 --- /dev/null +++ b/Resources/MaterialIcons/git.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/github.svg b/Resources/MaterialIcons/github.svg new file mode 100644 index 000000000000..a0f9781132e1 --- /dev/null +++ b/Resources/MaterialIcons/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gitlab.svg b/Resources/MaterialIcons/gitlab.svg new file mode 100644 index 000000000000..08cd5357b1d0 --- /dev/null +++ b/Resources/MaterialIcons/gitlab.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/gleam.svg b/Resources/MaterialIcons/gleam.svg new file mode 100644 index 000000000000..c2d5f0bbc7ae --- /dev/null +++ b/Resources/MaterialIcons/gleam.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/globe.svg b/Resources/MaterialIcons/globe.svg new file mode 100644 index 000000000000..eebfe6797e56 --- /dev/null +++ b/Resources/MaterialIcons/globe.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/go-green.svg b/Resources/MaterialIcons/go-green.svg new file mode 100644 index 000000000000..a51d5c40c225 --- /dev/null +++ b/Resources/MaterialIcons/go-green.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/go-orange.svg b/Resources/MaterialIcons/go-orange.svg new file mode 100644 index 000000000000..3a2c19ce97c8 --- /dev/null +++ b/Resources/MaterialIcons/go-orange.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/go-pink.svg b/Resources/MaterialIcons/go-pink.svg new file mode 100644 index 000000000000..12de923dbddb --- /dev/null +++ b/Resources/MaterialIcons/go-pink.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/go-purple.svg b/Resources/MaterialIcons/go-purple.svg new file mode 100644 index 000000000000..efbc8f590a0e --- /dev/null +++ b/Resources/MaterialIcons/go-purple.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/go-red.svg b/Resources/MaterialIcons/go-red.svg new file mode 100644 index 000000000000..374bbd70737c --- /dev/null +++ b/Resources/MaterialIcons/go-red.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/go-yellow.svg b/Resources/MaterialIcons/go-yellow.svg new file mode 100644 index 000000000000..a20846b110df --- /dev/null +++ b/Resources/MaterialIcons/go-yellow.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/go.svg b/Resources/MaterialIcons/go.svg new file mode 100644 index 000000000000..fe441044c5e8 --- /dev/null +++ b/Resources/MaterialIcons/go.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/gradle.svg b/Resources/MaterialIcons/gradle.svg new file mode 100644 index 000000000000..b59ffaa615d9 --- /dev/null +++ b/Resources/MaterialIcons/gradle.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/graphql.svg b/Resources/MaterialIcons/graphql.svg new file mode 100644 index 000000000000..2922eef22517 --- /dev/null +++ b/Resources/MaterialIcons/graphql.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/gulp.svg b/Resources/MaterialIcons/gulp.svg new file mode 100644 index 000000000000..60ed0ab702f3 --- /dev/null +++ b/Resources/MaterialIcons/gulp.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/h.svg b/Resources/MaterialIcons/h.svg new file mode 100644 index 000000000000..c2c8dde1e8a3 --- /dev/null +++ b/Resources/MaterialIcons/h.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/haml.svg b/Resources/MaterialIcons/haml.svg new file mode 100644 index 000000000000..5dd95a2af37a --- /dev/null +++ b/Resources/MaterialIcons/haml.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/haskell.svg b/Resources/MaterialIcons/haskell.svg new file mode 100644 index 000000000000..b0dc22edbdec --- /dev/null +++ b/Resources/MaterialIcons/haskell.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/http.svg b/Resources/MaterialIcons/http.svg new file mode 100644 index 000000000000..2780986fe829 --- /dev/null +++ b/Resources/MaterialIcons/http.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/hugo.svg b/Resources/MaterialIcons/hugo.svg new file mode 100644 index 000000000000..8d5e3fdb11aa --- /dev/null +++ b/Resources/MaterialIcons/hugo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/i18n.svg b/Resources/MaterialIcons/i18n.svg new file mode 100644 index 000000000000..f02be56b2677 --- /dev/null +++ b/Resources/MaterialIcons/i18n.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/ignore.svg b/Resources/MaterialIcons/ignore.svg new file mode 100644 index 000000000000..c1cc1786482d --- /dev/null +++ b/Resources/MaterialIcons/ignore.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/illustrator.svg b/Resources/MaterialIcons/illustrator.svg new file mode 100644 index 000000000000..2a5cefcecac1 --- /dev/null +++ b/Resources/MaterialIcons/illustrator.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/image.svg b/Resources/MaterialIcons/image.svg new file mode 100644 index 000000000000..bf445bf40922 --- /dev/null +++ b/Resources/MaterialIcons/image.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/ionic.svg b/Resources/MaterialIcons/ionic.svg new file mode 100644 index 000000000000..d20ea52eef6b --- /dev/null +++ b/Resources/MaterialIcons/ionic.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/java.svg b/Resources/MaterialIcons/java.svg new file mode 100644 index 000000000000..fa66fa607dba --- /dev/null +++ b/Resources/MaterialIcons/java.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/jenkins.svg b/Resources/MaterialIcons/jenkins.svg new file mode 100644 index 000000000000..5240fd429ce1 --- /dev/null +++ b/Resources/MaterialIcons/jenkins.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/jest.svg b/Resources/MaterialIcons/jest.svg new file mode 100644 index 000000000000..00fe4763bd5b --- /dev/null +++ b/Resources/MaterialIcons/jest.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/js-test.svg b/Resources/MaterialIcons/js-test.svg new file mode 100644 index 000000000000..70bc81adbafd --- /dev/null +++ b/Resources/MaterialIcons/js-test.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/js.svg b/Resources/MaterialIcons/js.svg new file mode 100644 index 000000000000..9adc2f4c1c5b --- /dev/null +++ b/Resources/MaterialIcons/js.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/julia-markdown.svg b/Resources/MaterialIcons/julia-markdown.svg new file mode 100644 index 000000000000..4a723829232d --- /dev/null +++ b/Resources/MaterialIcons/julia-markdown.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/julia.svg b/Resources/MaterialIcons/julia.svg new file mode 100644 index 000000000000..879010856e1f --- /dev/null +++ b/Resources/MaterialIcons/julia.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/keystatic.svg b/Resources/MaterialIcons/keystatic.svg new file mode 100644 index 000000000000..fb46ea3bf30b --- /dev/null +++ b/Resources/MaterialIcons/keystatic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/knip.svg b/Resources/MaterialIcons/knip.svg new file mode 100644 index 000000000000..cabecf560eb1 --- /dev/null +++ b/Resources/MaterialIcons/knip.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/kotlin.svg b/Resources/MaterialIcons/kotlin.svg new file mode 100644 index 000000000000..1736df104719 --- /dev/null +++ b/Resources/MaterialIcons/kotlin.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/kubb.svg b/Resources/MaterialIcons/kubb.svg new file mode 100644 index 000000000000..b705b21ea5fc --- /dev/null +++ b/Resources/MaterialIcons/kubb.svg @@ -0,0 +1,2077 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Resources/MaterialIcons/laravel.svg b/Resources/MaterialIcons/laravel.svg new file mode 100644 index 000000000000..da1d5ff6db94 --- /dev/null +++ b/Resources/MaterialIcons/laravel.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/license.svg b/Resources/MaterialIcons/license.svg new file mode 100644 index 000000000000..eb3d1c24aec7 --- /dev/null +++ b/Resources/MaterialIcons/license.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/link.svg b/Resources/MaterialIcons/link.svg new file mode 100644 index 000000000000..de864490282d --- /dev/null +++ b/Resources/MaterialIcons/link.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/liquid.svg b/Resources/MaterialIcons/liquid.svg new file mode 100644 index 000000000000..da2fcbd513ca --- /dev/null +++ b/Resources/MaterialIcons/liquid.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/lock.svg b/Resources/MaterialIcons/lock.svg new file mode 100644 index 000000000000..3f39de9b8a93 --- /dev/null +++ b/Resources/MaterialIcons/lock.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/lua.svg b/Resources/MaterialIcons/lua.svg new file mode 100644 index 000000000000..e86024bba84f --- /dev/null +++ b/Resources/MaterialIcons/lua.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/luau.svg b/Resources/MaterialIcons/luau.svg new file mode 100644 index 000000000000..d3e472d550f1 --- /dev/null +++ b/Resources/MaterialIcons/luau.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/lunaria.svg b/Resources/MaterialIcons/lunaria.svg new file mode 100644 index 000000000000..541b5311c555 --- /dev/null +++ b/Resources/MaterialIcons/lunaria.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/markdoc.svg b/Resources/MaterialIcons/markdoc.svg new file mode 100644 index 000000000000..7e47e8575ed8 --- /dev/null +++ b/Resources/MaterialIcons/markdoc.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/markdown.svg b/Resources/MaterialIcons/markdown.svg new file mode 100644 index 000000000000..382f137beb47 --- /dev/null +++ b/Resources/MaterialIcons/markdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/maven.svg b/Resources/MaterialIcons/maven.svg new file mode 100644 index 000000000000..e98745bb39ac --- /dev/null +++ b/Resources/MaterialIcons/maven.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Resources/MaterialIcons/mdx.svg b/Resources/MaterialIcons/mdx.svg new file mode 100644 index 000000000000..a221a1becae9 --- /dev/null +++ b/Resources/MaterialIcons/mdx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/minecraft.svg b/Resources/MaterialIcons/minecraft.svg new file mode 100644 index 000000000000..b23ca85c6b59 --- /dev/null +++ b/Resources/MaterialIcons/minecraft.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/mongo.svg b/Resources/MaterialIcons/mongo.svg new file mode 100644 index 000000000000..a1ee1d28fc64 --- /dev/null +++ b/Resources/MaterialIcons/mongo.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nest-controller.svg b/Resources/MaterialIcons/nest-controller.svg new file mode 100644 index 000000000000..3c56ba0eb1df --- /dev/null +++ b/Resources/MaterialIcons/nest-controller.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nest-decorator.svg b/Resources/MaterialIcons/nest-decorator.svg new file mode 100644 index 000000000000..49699392b442 --- /dev/null +++ b/Resources/MaterialIcons/nest-decorator.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nest-guard.svg b/Resources/MaterialIcons/nest-guard.svg new file mode 100644 index 000000000000..1b44c8273501 --- /dev/null +++ b/Resources/MaterialIcons/nest-guard.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nest-middleware.svg b/Resources/MaterialIcons/nest-middleware.svg new file mode 100644 index 000000000000..4179204d80ce --- /dev/null +++ b/Resources/MaterialIcons/nest-middleware.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nest-service.svg b/Resources/MaterialIcons/nest-service.svg new file mode 100644 index 000000000000..ea16568b3de6 --- /dev/null +++ b/Resources/MaterialIcons/nest-service.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nest.svg b/Resources/MaterialIcons/nest.svg new file mode 100644 index 000000000000..1bd7d0fc1042 --- /dev/null +++ b/Resources/MaterialIcons/nest.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/netlify.svg b/Resources/MaterialIcons/netlify.svg new file mode 100644 index 000000000000..da803c8fca72 --- /dev/null +++ b/Resources/MaterialIcons/netlify.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/next.svg b/Resources/MaterialIcons/next.svg new file mode 100644 index 000000000000..8c3649ce134f --- /dev/null +++ b/Resources/MaterialIcons/next.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/nim.svg b/Resources/MaterialIcons/nim.svg new file mode 100644 index 000000000000..00fa93d353f6 --- /dev/null +++ b/Resources/MaterialIcons/nim.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nix.svg b/Resources/MaterialIcons/nix.svg new file mode 100644 index 000000000000..285f0e4a25a2 --- /dev/null +++ b/Resources/MaterialIcons/nix.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/node.svg b/Resources/MaterialIcons/node.svg new file mode 100644 index 000000000000..2e8d4028cf69 --- /dev/null +++ b/Resources/MaterialIcons/node.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nodemon.svg b/Resources/MaterialIcons/nodemon.svg new file mode 100644 index 000000000000..9253abe60397 --- /dev/null +++ b/Resources/MaterialIcons/nodemon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/notebook.svg b/Resources/MaterialIcons/notebook.svg new file mode 100644 index 000000000000..92fffad260f0 --- /dev/null +++ b/Resources/MaterialIcons/notebook.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/npm.svg b/Resources/MaterialIcons/npm.svg new file mode 100644 index 000000000000..e9ebca6d0ad3 --- /dev/null +++ b/Resources/MaterialIcons/npm.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nunjucks.svg b/Resources/MaterialIcons/nunjucks.svg new file mode 100644 index 000000000000..deb35a0cfc7f --- /dev/null +++ b/Resources/MaterialIcons/nunjucks.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/nuxt.svg b/Resources/MaterialIcons/nuxt.svg new file mode 100644 index 000000000000..a70b403cdd50 --- /dev/null +++ b/Resources/MaterialIcons/nuxt.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/nx.svg b/Resources/MaterialIcons/nx.svg new file mode 100644 index 000000000000..618a7fe6bbb9 --- /dev/null +++ b/Resources/MaterialIcons/nx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/ocaml.svg b/Resources/MaterialIcons/ocaml.svg new file mode 100644 index 000000000000..4d84d33edd0d --- /dev/null +++ b/Resources/MaterialIcons/ocaml.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/orval.svg b/Resources/MaterialIcons/orval.svg new file mode 100644 index 000000000000..9ac12562caa4 --- /dev/null +++ b/Resources/MaterialIcons/orval.svg @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/oxc.svg b/Resources/MaterialIcons/oxc.svg new file mode 100644 index 000000000000..07eacdb7c117 --- /dev/null +++ b/Resources/MaterialIcons/oxc.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/panda.svg b/Resources/MaterialIcons/panda.svg new file mode 100644 index 000000000000..70ae9a1aedf8 --- /dev/null +++ b/Resources/MaterialIcons/panda.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/patch.svg b/Resources/MaterialIcons/patch.svg new file mode 100644 index 000000000000..484204f19cb5 --- /dev/null +++ b/Resources/MaterialIcons/patch.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/pdf.svg b/Resources/MaterialIcons/pdf.svg new file mode 100644 index 000000000000..1b9e7f28256e --- /dev/null +++ b/Resources/MaterialIcons/pdf.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/perl.svg b/Resources/MaterialIcons/perl.svg new file mode 100644 index 000000000000..235fb1342696 --- /dev/null +++ b/Resources/MaterialIcons/perl.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/photoshop.svg b/Resources/MaterialIcons/photoshop.svg new file mode 100644 index 000000000000..74dffefc9704 --- /dev/null +++ b/Resources/MaterialIcons/photoshop.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/php.svg b/Resources/MaterialIcons/php.svg new file mode 100644 index 000000000000..7cd34a769353 --- /dev/null +++ b/Resources/MaterialIcons/php.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/pkl.svg b/Resources/MaterialIcons/pkl.svg new file mode 100644 index 000000000000..deabdc3815e0 --- /dev/null +++ b/Resources/MaterialIcons/pkl.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/pnpm.svg b/Resources/MaterialIcons/pnpm.svg new file mode 100644 index 000000000000..678dc1cb3995 --- /dev/null +++ b/Resources/MaterialIcons/pnpm.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/postcss.svg b/Resources/MaterialIcons/postcss.svg new file mode 100644 index 000000000000..f372e737a1d8 --- /dev/null +++ b/Resources/MaterialIcons/postcss.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/prettier.svg b/Resources/MaterialIcons/prettier.svg new file mode 100644 index 000000000000..e4f0eec3f80f --- /dev/null +++ b/Resources/MaterialIcons/prettier.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/prisma.svg b/Resources/MaterialIcons/prisma.svg new file mode 100644 index 000000000000..9ae11f8ad37f --- /dev/null +++ b/Resources/MaterialIcons/prisma.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/proto.svg b/Resources/MaterialIcons/proto.svg new file mode 100644 index 000000000000..17c6e9cba66e --- /dev/null +++ b/Resources/MaterialIcons/proto.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/pug.svg b/Resources/MaterialIcons/pug.svg new file mode 100644 index 000000000000..6fe8c2f321fb --- /dev/null +++ b/Resources/MaterialIcons/pug.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/pulumi.svg b/Resources/MaterialIcons/pulumi.svg new file mode 100644 index 000000000000..b91289b9f71e --- /dev/null +++ b/Resources/MaterialIcons/pulumi.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/puppeteer.svg b/Resources/MaterialIcons/puppeteer.svg new file mode 100644 index 000000000000..24689336282b --- /dev/null +++ b/Resources/MaterialIcons/puppeteer.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/puzzle.svg b/Resources/MaterialIcons/puzzle.svg new file mode 100644 index 000000000000..ce2b8f9ab54e --- /dev/null +++ b/Resources/MaterialIcons/puzzle.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/python.svg b/Resources/MaterialIcons/python.svg new file mode 100644 index 000000000000..d6d7beded6db --- /dev/null +++ b/Resources/MaterialIcons/python.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/r.svg b/Resources/MaterialIcons/r.svg new file mode 100644 index 000000000000..dea7494f5a1c --- /dev/null +++ b/Resources/MaterialIcons/r.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/razor.svg b/Resources/MaterialIcons/razor.svg new file mode 100644 index 000000000000..4242cd546a88 --- /dev/null +++ b/Resources/MaterialIcons/razor.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/react-test.svg b/Resources/MaterialIcons/react-test.svg new file mode 100644 index 000000000000..75fc8c539e81 --- /dev/null +++ b/Resources/MaterialIcons/react-test.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/react-ts.svg b/Resources/MaterialIcons/react-ts.svg new file mode 100644 index 000000000000..2d5a57864b92 --- /dev/null +++ b/Resources/MaterialIcons/react-ts.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/react.svg b/Resources/MaterialIcons/react.svg new file mode 100644 index 000000000000..3bc9749eddb1 --- /dev/null +++ b/Resources/MaterialIcons/react.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/Resources/MaterialIcons/redux-actions.svg b/Resources/MaterialIcons/redux-actions.svg new file mode 100644 index 000000000000..a7f92754afe8 --- /dev/null +++ b/Resources/MaterialIcons/redux-actions.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/redux-effects.svg b/Resources/MaterialIcons/redux-effects.svg new file mode 100644 index 000000000000..52e654715f8d --- /dev/null +++ b/Resources/MaterialIcons/redux-effects.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/redux-facade.svg b/Resources/MaterialIcons/redux-facade.svg new file mode 100644 index 000000000000..abfb8c7c4d2d --- /dev/null +++ b/Resources/MaterialIcons/redux-facade.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/redux-reducer.svg b/Resources/MaterialIcons/redux-reducer.svg new file mode 100644 index 000000000000..b6bfa4e59828 --- /dev/null +++ b/Resources/MaterialIcons/redux-reducer.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/redux-selector.svg b/Resources/MaterialIcons/redux-selector.svg new file mode 100644 index 000000000000..a21e7be0992f --- /dev/null +++ b/Resources/MaterialIcons/redux-selector.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/rescript-interface.svg b/Resources/MaterialIcons/rescript-interface.svg new file mode 100644 index 000000000000..b24262795fe4 --- /dev/null +++ b/Resources/MaterialIcons/rescript-interface.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/rescript.svg b/Resources/MaterialIcons/rescript.svg new file mode 100644 index 000000000000..d7123ec1ae8e --- /dev/null +++ b/Resources/MaterialIcons/rescript.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/resource.svg b/Resources/MaterialIcons/resource.svg new file mode 100644 index 000000000000..3c310c58b781 --- /dev/null +++ b/Resources/MaterialIcons/resource.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/robot.svg b/Resources/MaterialIcons/robot.svg new file mode 100644 index 000000000000..86d1dd02ce82 --- /dev/null +++ b/Resources/MaterialIcons/robot.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/rolldown.svg b/Resources/MaterialIcons/rolldown.svg new file mode 100644 index 000000000000..f18c9f4ad0fd --- /dev/null +++ b/Resources/MaterialIcons/rolldown.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/rome.svg b/Resources/MaterialIcons/rome.svg new file mode 100644 index 000000000000..e13e1ae8a857 --- /dev/null +++ b/Resources/MaterialIcons/rome.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/rsbuild.svg b/Resources/MaterialIcons/rsbuild.svg new file mode 100644 index 000000000000..ba5ad44aede4 --- /dev/null +++ b/Resources/MaterialIcons/rsbuild.svg @@ -0,0 +1,208 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/rslib.svg b/Resources/MaterialIcons/rslib.svg new file mode 100644 index 000000000000..c2d971512790 --- /dev/null +++ b/Resources/MaterialIcons/rslib.svg @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/rspack.svg b/Resources/MaterialIcons/rspack.svg new file mode 100644 index 000000000000..45755efbbd4b --- /dev/null +++ b/Resources/MaterialIcons/rspack.svg @@ -0,0 +1,137 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/ruby.svg b/Resources/MaterialIcons/ruby.svg new file mode 100644 index 000000000000..6b1b864d3268 --- /dev/null +++ b/Resources/MaterialIcons/ruby.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Resources/MaterialIcons/rust.svg b/Resources/MaterialIcons/rust.svg new file mode 100644 index 000000000000..f854f027f68c --- /dev/null +++ b/Resources/MaterialIcons/rust.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/sanity.svg b/Resources/MaterialIcons/sanity.svg new file mode 100644 index 000000000000..8e2dcf76d065 --- /dev/null +++ b/Resources/MaterialIcons/sanity.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/sass.svg b/Resources/MaterialIcons/sass.svg new file mode 100644 index 000000000000..57c03183df91 --- /dev/null +++ b/Resources/MaterialIcons/sass.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/sbt.svg b/Resources/MaterialIcons/sbt.svg new file mode 100644 index 000000000000..5728df30edea --- /dev/null +++ b/Resources/MaterialIcons/sbt.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/scala.svg b/Resources/MaterialIcons/scala.svg new file mode 100644 index 000000000000..7619b60f44ec --- /dev/null +++ b/Resources/MaterialIcons/scala.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/severless.svg b/Resources/MaterialIcons/severless.svg new file mode 100644 index 000000000000..22d4ee2a6559 --- /dev/null +++ b/Resources/MaterialIcons/severless.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/shadcn.svg b/Resources/MaterialIcons/shadcn.svg new file mode 100644 index 000000000000..bf0e2a82c38e --- /dev/null +++ b/Resources/MaterialIcons/shadcn.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/shell.svg b/Resources/MaterialIcons/shell.svg new file mode 100644 index 000000000000..dc011d958cc8 --- /dev/null +++ b/Resources/MaterialIcons/shell.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/slint.svg b/Resources/MaterialIcons/slint.svg new file mode 100644 index 000000000000..2cd738a0d839 --- /dev/null +++ b/Resources/MaterialIcons/slint.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/solidity.svg b/Resources/MaterialIcons/solidity.svg new file mode 100644 index 000000000000..27883d3e82a1 --- /dev/null +++ b/Resources/MaterialIcons/solidity.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/statamic-antlers.svg b/Resources/MaterialIcons/statamic-antlers.svg new file mode 100644 index 000000000000..e491053e776a --- /dev/null +++ b/Resources/MaterialIcons/statamic-antlers.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/MaterialIcons/storybook.svg b/Resources/MaterialIcons/storybook.svg new file mode 100644 index 000000000000..d3e66e62f2de --- /dev/null +++ b/Resources/MaterialIcons/storybook.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Resources/MaterialIcons/stylelint.svg b/Resources/MaterialIcons/stylelint.svg new file mode 100644 index 000000000000..b480997b1e70 --- /dev/null +++ b/Resources/MaterialIcons/stylelint.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Resources/MaterialIcons/stylus.svg b/Resources/MaterialIcons/stylus.svg new file mode 100644 index 000000000000..eb4e95dfb0d3 --- /dev/null +++ b/Resources/MaterialIcons/stylus.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/supabase.svg b/Resources/MaterialIcons/supabase.svg new file mode 100644 index 000000000000..007d414be290 --- /dev/null +++ b/Resources/MaterialIcons/supabase.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/MaterialIcons/svelte-service.svg b/Resources/MaterialIcons/svelte-service.svg new file mode 100644 index 000000000000..73d955533d09 --- /dev/null +++ b/Resources/MaterialIcons/svelte-service.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/svelte-ts.svg b/Resources/MaterialIcons/svelte-ts.svg new file mode 100644 index 000000000000..9a4451aa7516 --- /dev/null +++ b/Resources/MaterialIcons/svelte-ts.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/svelte.svg b/Resources/MaterialIcons/svelte.svg new file mode 100644 index 000000000000..c774e9029442 --- /dev/null +++ b/Resources/MaterialIcons/svelte.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/svg.svg b/Resources/MaterialIcons/svg.svg new file mode 100644 index 000000000000..a2f81f380e7c --- /dev/null +++ b/Resources/MaterialIcons/svg.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/svx.svg b/Resources/MaterialIcons/svx.svg new file mode 100644 index 000000000000..5183f2111032 --- /dev/null +++ b/Resources/MaterialIcons/svx.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/swc.svg b/Resources/MaterialIcons/swc.svg new file mode 100644 index 000000000000..273f0f623d2f --- /dev/null +++ b/Resources/MaterialIcons/swc.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/swift.svg b/Resources/MaterialIcons/swift.svg new file mode 100644 index 000000000000..5b8f1d1b97da --- /dev/null +++ b/Resources/MaterialIcons/swift.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/tailwind.svg b/Resources/MaterialIcons/tailwind.svg new file mode 100644 index 000000000000..07875db80fc7 --- /dev/null +++ b/Resources/MaterialIcons/tailwind.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/taskfile.svg b/Resources/MaterialIcons/taskfile.svg new file mode 100644 index 000000000000..82f5a91464e1 --- /dev/null +++ b/Resources/MaterialIcons/taskfile.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/tauri.svg b/Resources/MaterialIcons/tauri.svg new file mode 100644 index 000000000000..788079de45ed --- /dev/null +++ b/Resources/MaterialIcons/tauri.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/Resources/MaterialIcons/terraform.svg b/Resources/MaterialIcons/terraform.svg new file mode 100644 index 000000000000..b651cbd64627 --- /dev/null +++ b/Resources/MaterialIcons/terraform.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/tex.svg b/Resources/MaterialIcons/tex.svg new file mode 100644 index 000000000000..b9529a7f6acc --- /dev/null +++ b/Resources/MaterialIcons/tex.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/text.svg b/Resources/MaterialIcons/text.svg new file mode 100644 index 000000000000..3efbf5e46308 --- /dev/null +++ b/Resources/MaterialIcons/text.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Resources/MaterialIcons/ts-test.svg b/Resources/MaterialIcons/ts-test.svg new file mode 100644 index 000000000000..8ac37231d8df --- /dev/null +++ b/Resources/MaterialIcons/ts-test.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/ts-types.svg b/Resources/MaterialIcons/ts-types.svg new file mode 100644 index 000000000000..49e9d329490e --- /dev/null +++ b/Resources/MaterialIcons/ts-types.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/ts.svg b/Resources/MaterialIcons/ts.svg new file mode 100644 index 000000000000..a972a8fc401b --- /dev/null +++ b/Resources/MaterialIcons/ts.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/tsconfig.svg b/Resources/MaterialIcons/tsconfig.svg new file mode 100644 index 000000000000..98913d79bd17 --- /dev/null +++ b/Resources/MaterialIcons/tsconfig.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/turborepo.svg b/Resources/MaterialIcons/turborepo.svg new file mode 100644 index 000000000000..c119ec690d8b --- /dev/null +++ b/Resources/MaterialIcons/turborepo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Resources/MaterialIcons/twig.svg b/Resources/MaterialIcons/twig.svg new file mode 100644 index 000000000000..ea35cee7d9dc --- /dev/null +++ b/Resources/MaterialIcons/twig.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/typst.svg b/Resources/MaterialIcons/typst.svg new file mode 100644 index 000000000000..0f8935da211d --- /dev/null +++ b/Resources/MaterialIcons/typst.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/unocss.svg b/Resources/MaterialIcons/unocss.svg new file mode 100644 index 000000000000..3e965c93af75 --- /dev/null +++ b/Resources/MaterialIcons/unocss.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/uv.svg b/Resources/MaterialIcons/uv.svg new file mode 100644 index 000000000000..1f0f224f6f96 --- /dev/null +++ b/Resources/MaterialIcons/uv.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/v.svg b/Resources/MaterialIcons/v.svg new file mode 100644 index 000000000000..198476e78bdb --- /dev/null +++ b/Resources/MaterialIcons/v.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/vanilla-extract.svg b/Resources/MaterialIcons/vanilla-extract.svg new file mode 100644 index 000000000000..b926d218dc92 --- /dev/null +++ b/Resources/MaterialIcons/vanilla-extract.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/vercel.svg b/Resources/MaterialIcons/vercel.svg new file mode 100644 index 000000000000..37a8917010ce --- /dev/null +++ b/Resources/MaterialIcons/vercel.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/video.svg b/Resources/MaterialIcons/video.svg new file mode 100644 index 000000000000..6efd162b2bbe --- /dev/null +++ b/Resources/MaterialIcons/video.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/visual-studio.svg b/Resources/MaterialIcons/visual-studio.svg new file mode 100644 index 000000000000..994db7c9fb6a --- /dev/null +++ b/Resources/MaterialIcons/visual-studio.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Resources/MaterialIcons/vite.svg b/Resources/MaterialIcons/vite.svg new file mode 100644 index 000000000000..e3bcddccb9f6 --- /dev/null +++ b/Resources/MaterialIcons/vite.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/vitest.svg b/Resources/MaterialIcons/vitest.svg new file mode 100644 index 000000000000..8e42b0fcf226 --- /dev/null +++ b/Resources/MaterialIcons/vitest.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/vue.svg b/Resources/MaterialIcons/vue.svg new file mode 100644 index 000000000000..74d9ab3f213b --- /dev/null +++ b/Resources/MaterialIcons/vue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/webgpu.svg b/Resources/MaterialIcons/webgpu.svg new file mode 100644 index 000000000000..558c8991ed97 --- /dev/null +++ b/Resources/MaterialIcons/webgpu.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Resources/MaterialIcons/webpack.svg b/Resources/MaterialIcons/webpack.svg new file mode 100644 index 000000000000..564a9eb8a2ca --- /dev/null +++ b/Resources/MaterialIcons/webpack.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/Resources/MaterialIcons/xml.svg b/Resources/MaterialIcons/xml.svg new file mode 100644 index 000000000000..d82bdba70ed6 --- /dev/null +++ b/Resources/MaterialIcons/xml.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Resources/MaterialIcons/yaml.svg b/Resources/MaterialIcons/yaml.svg new file mode 100644 index 000000000000..5764cf68310d --- /dev/null +++ b/Resources/MaterialIcons/yaml.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/yarn.svg b/Resources/MaterialIcons/yarn.svg new file mode 100644 index 000000000000..b94eaa799bd5 --- /dev/null +++ b/Resources/MaterialIcons/yarn.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/MaterialIcons/yummacss.svg b/Resources/MaterialIcons/yummacss.svg new file mode 100644 index 000000000000..24281dce8859 --- /dev/null +++ b/Resources/MaterialIcons/yummacss.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Resources/MaterialIcons/zig.svg b/Resources/MaterialIcons/zig.svg new file mode 100644 index 000000000000..97d7477cd234 --- /dev/null +++ b/Resources/MaterialIcons/zig.svg @@ -0,0 +1,3 @@ + + + diff --git a/Resources/material-icons.json b/Resources/material-icons.json new file mode 100644 index 000000000000..579bf0b9619c --- /dev/null +++ b/Resources/material-icons.json @@ -0,0 +1 @@ +{"file":"document","folder":"folder","folderExpanded":null,"fileExtensions":{"7z":"compressed","deb":"compressed","dmg":"compressed","gz":"compressed","iso":"compressed","rar":"compressed","rpm":"compressed","tar":"compressed","tar.bz2":"compressed","tar.gz":"compressed","zip":"compressed","orval":"orval","nim":"nim","f90":"fortran","f95":"fortran","f03":"fortran","f":"fortran","for":"fortran","d.ts":"ts-types","d.cts":"ts-types","d.mts":"ts-types","antlers.html":"statamic-antlers","mcfunction":"minecraft","mcmeta":"minecraft","mcworld":"minecraft","mcstructure":"minecraft","mcpack":"minecraft","mcaddon":"minecraft","mongodb":"mongo","lang":"i18n","mo":"i18n","po":"i18n","pot":"i18n","arb":"i18n","xlf":"i18n","xliff":"i18n","resx":"i18n","resw":"i18n","strings":"i18n","stringsdict":"i18n","ftl":"i18n","gleam":"gleam","actions.ts":"redux-actions","effects.ts":"redux-effects","facade.ts":"redux-facade","reducer.ts":"redux-reducer","selector.ts":"redux-selector","selectors.ts":"redux-selector","pdf":"pdf","env":"gear","env.example":"gear","pkl":"pkl","hs":"haskell","component.dart":"angular-component","component.ts":"angular-component","component.js":"angular-component","service.dart":"angular-service","service.ts":"angular-service","service.js":"angular-service","service.svelte.ts":"svelte-service","directive.dart":"angular-directive","directive.ts":"angular-directive","directive.js":"angular-directive","module.dart":"angular-module","module.ts":"angular-module","module.js":"angular-module","guard.dart":"angular-guard","guard.ts":"angular-guard","guard.js":"angular-guard","pipe.dart":"angular-pipe","pipe.ts":"angular-pipe","pipe.js":"angular-pipe","earthlyignore":"earthfile","sol":"solidity","slint":"slint","mdoc":"markdoc","ml":"ocaml","mli":"ocaml","cmx":"ocaml","stylelint":"stylelint","lock":"lock","cmake":"cmake","njk":"nunjucks","nunjucks":"nunjucks","csproj":"visual-studio","ruleset":"visual-studio","sln":"visual-studio","slnx":"visual-studio","suo":"visual-studio","vb":"visual-studio","vbs":"visual-studio","vcxitems":"visual-studio","vcxitems.filters":"visual-studio","vcxproj":"visual-studio","vcxproj.filters":"visual-studio","h":"h","liquid":"liquid","mdx":"mdx","svx":"svx","cfml":"coldfusion","cfc":"coldfusion","lucee":"coldfusion","cfm":"coldfusion","erl":"erlang","hrl":"erlang","haml":"haml","deno":"deno","url":"globe","webloc":"globe","netlify":"netlify","vercel":"vercel","editorconfig":"editorconfig","tex":"tex","typ":"typst","sty":"tex","dtx":"tex","ltx":"tex","drawio":"drawio","dio":"drawio","patch":"patch","gif":"gif","webm":"video","mkv":"video","flv":"video","vob":"video","ogv":"video","ogg":"video","gifv":"video","avi":"video","mov":"video","qt":"video","wmv":"video","yuv":"video","rm":"video","rmvb":"video","mp4":"video","m4v":"video","mpg":"video","mp2":"video","mpeg":"video","mpe":"video","mpv":"video","m2v":"video","mp3":"audio","flac":"audio","m4a":"audio","wma":"audio","aiff":"audio","wav":"audio","al":"code-green","http":"http","rest":"http","bru":"http","cls":"code-blue","exe":"exe","msi":"exe","zig":"zig","proto":"proto","test.mjs":"js-test","spec.mjs":"js-test","test.js":"js-test","spec.js":"js-test","test.ts":"ts-test","spec.ts":"ts-test","spec.jsx":"react-test","test.jsx":"react-test","spec.tsx":"react-test","test.tsx":"react-test","jenkinsfile":"jenkins","jenkins":"jenkins","tsconfig.json":"tsconfig","tf":"terraform","tf.json":"terraform","tfvars":"terraform","tfstate":"terraform","woff":"font","woff2":"font","ttf":"font","eot":"font","suit":"font","otf":"font","bmap":"font","fnt":"font","odttf":"font","ttc":"font","font":"font","fonts":"font","sui":"font","ntf":"font","mrf":"font","ex":"elixir","exs":"elixir","eex":"elixir","leex":"elixir","heex":"elixir","stories.js":"storybook","stories.jsx":"storybook","stories.mdx":"storybook","story.js":"storybook","story.jsx":"storybook","stories.ts":"storybook","stories.tsx":"storybook","story.ts":"storybook","story.tsx":"storybook","stories.svelte":"storybook","svelte.ts":"svelte-ts","blade.php":"laravel","twig":"twig","html.twig":"twig","story.mdx":"storybook","yml":"yaml","yaml":"yaml","yml.dist":"yaml","yaml.dist":"yaml","YAML-tmLanguage":"yaml","gradle":"gradle","pcss":"postcss","sss":"postcss","sbt":"sbt","scala":"scala","sc":"scala","styl":"stylus","prisma":"prisma","astro":"astro","puppeteer":"puppeteer","pulumi":"pulumi","graphql":"graphql","gql":"graphql","rs":"rust","ron":"rust","swift":"swift","rb":"ruby","erb":"ruby","svelte":"svelte","kt":"kotlin","kts":"kotlin","r":"r","rmd":"r","jade":"pug","pug":"pug","lua":"lua","luau":"luau","less":"less","java":"java","jsp":"java","dart":"dart","freezed.dart":"dart","g.dart":"dart","fs":"fsharp","fsx":"fsharp","fsi":"fsharp","fsproj":"fsharp","cr":"crystal","cs":"csharp","cshtml":"razor","csx":"csharp","jl":"julia","ssh_config":"shell","sh":"shell","ksh":"shell","csh":"shell","tcsh":"shell","zsh":"shell","bash":"shell","nu":"shell","bat":"shell","cmd":"shell","awk":"shell","fish":"shell","exp":"shell","ps1":"shell","psm1":"shell","psd1":"shell","ps1xml":"shell","psc1":"shell","pssc":"shell","py":"python","python":"python","go":"go","go.mod":"go-mod","c":"c","i":"c","mi":"c","cc":"cplus","cpp":"cplus","cxx":"cplus","c++":"cplus","cp":"cplus","mm":"cplus","mii":"cplus","ii":"cplus","cu":"cuda","cuh":"cuda","jsx":"react","tsx":"react-ts","vsixmanifest":"puzzle","vsix":"puzzle","pdb":"database","sql":"database","pks":"database","pkb":"database","accdb":"database","mdb":"database","sqlite":"database","sqlite3":"database","pgsql":"database","postgres":"database","psql":"database","db":"database","db3":"database","scss":"sass","sass":"sass","dockerignore":"docker","dockerfile":"docker","containerignore":"docker","xml":"xml","plist":"xml","xsd":"xml","dtd":"xml","xsl":"xml","xslt":"xml","iml":"xml","xquery":"xml","tmLanguage":"xml","manifest":"xml","project":"xml","xml.dist":"xml","xml.dist.sample":"xml","dmn":"xml","htaccess":"document","txt":"text","xlsx":"csv","xlsm":"csv","xls":"csv","csv":"csv","tsv":"csv","psv":"csv","ods":"csv","ipynb":"notebook","svg":"svg","css":"brackets-purple","test":"code-orange","js":"js","mjs":"js","cjs":"js","ts":"ts","res":"rescript","resi":"rescript-interface","json":"brackets-yellow","html":"code-orange","htm":"code-orange","shtml":"code-orange","md":"markdown","png":"image","jpeg":"image","jpg":"image","ico":"image","tif":"image","tiff":"image","ami":"image","apx":"image","avif":"image","bmp":"image","bpg":"image","brk":"image","cur":"image","dds":"image","dng":"image","exr":"image","fpx":"image","gbr":"image","img":"image","jbig2":"image","jb2":"image","jng":"image","jxr":"image","pgf":"image","pic":"image","raw":"image","webp":"image","eps":"image","afphoto":"image","ase":"image","aseprite":"image","clip":"image","cpt":"image","heif":"image","heic":"image","kra":"image","mdp":"image","ora":"image","pdn":"image","reb":"image","sai":"image","tga":"image","xcf":"image","jfif":"image","ppm":"image","pbm":"image","pgm":"image","pnm":"image","svgx":"image","toml":"gear","v":"v","nix":"nix","fc":"func","gd":"gdscript","gd.uid":"link","tscn":"clapper","scn":"clapper","tres":"resource","import":"gear","gdshader":"gdshader","gdshader.uid":"link","gdextension":"gdextension","godot":"gdproject","wgsl":"webgpu","mdc":"cursor","edge":"animate","an":"animate","fla":"animate","psd":"photoshop","psb":"photoshop","ai":"illustrator"},"fileNames":{"orval.config.js":"orval","orval.config.mjs":"orval","orval.config.ts":"orval","orval.config.cjs":"orval","orval.config.mts":"orval","orval.config.cts":"orval",".orvalrc":"orval",".orvalrc.js":"orval",".orvalrc.json":"orval",".orvalrc.ts":"orval",".orvalrc.yaml":"orval",".orvalrc.yml":"orval",".gitlab-ci.yml":"gitlab","file.config":"gear","lunaria.config.json":"lunaria","ec.config.mjs":"expressive-code",".mcattributes":"minecraft",".mcdefinitions":"minecraft",".mcignore":"minecraft","uno.config.js":"unocss","uno.config.mjs":"unocss","uno.config.ts":"unocss","uno.config.mts":"unocss","unocss.config.js":"unocss","unocss.config.mjs":"unocss","unocss.config.ts":"unocss","unocss.config.mts":"unocss","Taskfile.yaml":"taskfile","Taskfile.yml":"taskfile","Taskfile.dist.yml":"taskfile","Taskfile.dist.yaml":"taskfile","taskfile.yml":"taskfile","taskfile.yaml":"taskfile","uv.lock":"uv","uv.toml":"uv","knip.json":"knip","knip.jsonc":"knip",".knip.json":"knip",".knip.jsonc":"knip","knip.ts":"knip","knip.js":"knip","knip.config.ts":"knip","knip.config.js":"knip","css.ts":"vanilla-extract","dev.vars":"cloudflare-workers","serverless.yml":"severless","gatsby-config.js":"gatsby","gatsby-config.mjs":"gatsby","gatsby-config.ts":"gatsby","gatsby-node.js":"gatsby","gatsby-node.mjs":"gatsby","gatsby-node.ts":"gatsby","gatsby-browser.js":"gatsby","gatsby-browser.tsx":"gatsby","gatsby-ssr.js":"gatsby","gatsby-ssr.tsx":"gatsby","panda.config.ts":"panda","sanity.cli.ts":"sanity","sanity.config.ts":"sanity","sanity.theme.mjs":"sanity","markdoc.config.ts":"markdoc","keystatic.page.ts":"keystatic","keystatic.config.ts":"keystatic","bruno.json":"bruno","bun.lock":"bun","bun.lockb":"bun","bunfig.toml":"bun","dune":"dune","dune-project":"dune","dune-workspace":"dune","dune-workspace.dev":"dune","drizzle.config.ts":"drizzle",".stylelintrc":"stylelint","stylelint.config.js":"stylelint","stylelint.config.cjs":"stylelint",".stylelintrc.json":"stylelint",".stylelintrc.yaml":"stylelint",".stylelintrc.yml":"stylelint",".stylelintrc.js":"stylelint",".stylelintrc.cjs":"stylelint",".stylelintignore":"stylelint",".stylelintcache":"stylelint","cmakelists.txt":"cmake","cmakecache.txt":"cmake","nest-cli.json":"nest",".nest-cli.json":"nest","nestconfig.json":"nest",".nestconfig.json":"nest","contentlayer.config.ts":"contentlayer","contentlayer.config.js":"contentlayer","go.mod":"go-mod","go.sum":"go-mod","go.work":"go-mod","go.work.sum":"go-mod","deno.json":"deno","deno.jsonc":"deno","docusaurus.config.js":"docusaurus","docusaurus.config.ts":"docusaurus","netlify.json":"netlify","netlify.yml":"netlify","netlify.yaml":"netlify","netlify.toml":"netlify",".vercel":"vercel","vercel.json":"vercel",".vercelignore":"vercel","now.json":"vercel",".nowignore":"vercel",".editorconfig":"editorconfig","test.js":"js-test","test.ts":"ts-test","gulpfile.js":"gulp","gulpfile.mjs":"gulp","gulpfile.ts":"gulp","gulpfile.cts":"gulp","gulpfile.mts":"gulp","gulpfile.babel.js":"gulp","cypress.json":"cypress","cypress.env.json":"cypress","cypress.config.ts":"cypress","cypress.config.js":"cypress","cypress.config.cjs":"cypress","cypress.config.mjs":"cypress",".feature":"cucumber","capacitor.config.json":"capacitor","capacitor.config.ts":"capacitor","ionic.config.json":"ionic",".io-config.json":"ionic","nodemon.json":"nodemon","nodemon-debug.json":"nodemon","jenkinsfile":"jenkins","package.json":"node","package-lock.json":"node",".nvmrc":"node",".esmrc":"node",".node-version":"node","gradle.properties":"gradle","gradlew":"gradle","gradle-wrapper.properties":"gradle","pom.xml":"maven","pom.properties":"maven","mvnw":"maven","mvnw.cmd":"maven","maven.config":"maven","maven-wrapper.properties":"maven","postcss.config.js":"postcss","postcss.config.cjs":"postcss","postcss.config.mjs":"postcss","postcss.config.ts":"postcss","postcss.config.cts":"postcss","postcss.config.mts":"postcss",".postcssrc.js":"postcss",".postcssrc.cjs":"postcss",".postcssrc.ts":"postcss",".postcssrc.cts":"postcss",".postcssrc":"postcss",".postcssrc.json":"postcss",".postcssrc.yaml":"postcss",".postcssrc.yml":"postcss",".styl":"stylus","prisma.yml":"prisma","astro.config.js":"astro","astro.config.mjs":"astro","astro.config.cjs":"astro","astro.config.ts":"astro","astro.config.cts":"astro","astro.config.mts":"astro","vite.config.js":"vite","vite.config.mjs":"vite","vite.config.ts":"vite","vite.config.cjs":"vite","vite.config.mts":"vite","vite.config.cts":"vite","vite.base.config.js":"vite","vite.base.config.mjs":"vite","vite.base.config.ts":"vite","vite.base.config.cjs":"vite","vite.base.config.mts":"vite","vite.base.config.cts":"vite","vite.main.config.js":"vite","vite.main.config.mjs":"vite","vite.main.config.ts":"vite","vite.main.config.cjs":"vite","vite.main.config.mts":"vite","vite.main.config.cts":"vite","vite.preload.config.js":"vite","vite.preload.config.mjs":"vite","vite.preload.config.ts":"vite","vite.preload.config.cjs":"vite","vite.preload.config.mts":"vite","vite.preload.config.cts":"vite","vite.renderer.config.js":"vite","vite.renderer.config.mjs":"vite","vite.renderer.config.ts":"vite","vite.renderer.config.cjs":"vite","vite.renderer.config.mts":"vite","vite.renderer.config.cts":"vite","vitest.config.js":"vitest","vitest.config.mjs":"vitest","vitest.config.ts":"vitest","vitest.config.cjs":"vitest","vitest.config.mts":"vitest","vitest.config.cts":"vitest","vite.config.electron.js":"vite","vite.config.electron.mjs":"vite","vite.config.electron.ts":"vite","vite.config.electron.cjs":"vite","vite.config.electron.mts":"vite","vite.config.electron.cts":"vite","rolldown.config.js":"rolldown","rolldown.config.cjs":"rolldown","rolldown.config.mjs":"rolldown","rolldown.config.ts":"rolldown","rolldown.config.mts":"rolldown","rolldown.config.cts":"rolldown","gulp.config.json":"gulp",".babelrc":"babel",".babelrc.js":"babel",".babelrc.cjs":"babel",".babelrc.mjs":"babel",".babelrc.cts":"babel",".babelrc.json":"babel","babel.config.js":"babel","babel.config.cjs":"babel","babel.config.mjs":"babel","babel.config.ts":"babel","babel.config.cts":"babel","babel.config.json":"babel",".prettierrc":"prettier","prettier.config.js":"prettier","prettier.config.cjs":"prettier","prettier.config.mjs":"prettier","prettier.config.ts":"prettier","prettier.config.cts":"prettier","prettier.config.mts":"prettier",".prettierrc.js":"prettier",".prettierrc.cjs":"prettier",".prettierrc.mjs":"prettier",".prettierrc.ts":"prettier",".prettierrc.cts":"prettier",".prettierrc.mts":"prettier",".prettierrc.json":"prettier",".prettierrc.json5":"prettier",".prettierrc.yaml":"prettier",".prettierrc.yml":"prettier",".prettierignore":"prettier",".prettierrc.toml":"prettier",".cursorrules":"cursor",".cursorignore":"cursor",".cursorindexingignore":"cursor","CLAUDE.md":"claude",".claude":"claude",".clauderc":"claude","claude.json":"claude","claude.yaml":"claude","claude.yml":"claude",".claude.md":"claude","claude-prompt.md":"claude",".claudeignore":"claude","tauri.conf.json":"tauri","tauri.linux.json":"tauri","tauri.windows.json":"tauri","tauri.macos.json":"tauri","tauri.android.json":"tauri","tauri.ios.json":"tauri","tauri.conf.toml":"tauri","tauri.linux.toml":"tauri","tauri.windows.toml":"tauri","tauri.macos.toml":"tauri","tauri.android.toml":"tauri","tauri.ios.toml":"tauri","pulumi.yaml":"pulumi","pnpm-lock.yaml":"pnpm","pnpm-workspace.yaml":"pnpm",".pnpmfile.cjs":"pnpm",".npmignore":"npm",".npmrc":"npm",".graphqlconfig":"graphql",".graphqlrc":"graphql",".graphqlrc.json":"graphql",".graphqlrc.js":"graphql",".graphqlrc.cjs":"graphql",".graphqlrc.ts":"graphql",".graphqlrc.toml":"graphql",".graphqlrc.yaml":"graphql",".graphqlrc.yml":"graphql","graphql.config.json":"graphql","graphql.config.js":"graphql","graphql.config.ts":"graphql","graphql.config.toml":"graphql","graphql.config.yaml":"graphql","graphql.config.yml":"graphql","svelte.config.js":"svelte","svelte.config.cjs":"svelte","svelte.config.ts":"svelte-ts","svelte.config.cts":"svelte-ts","svelte.ts":"svelte-ts",".Rhistory":"r",".pug-lintrc":"pug",".pug-lintrc.js":"pug",".pug-lintrc.json":"pug",".perl":"perl",".luacheckrc":"lua",".pubignore":"dart","requirements.txt":"python","pipfile":"python",".python-version":"python","manifest.in":"python","pylintrc":"python",".pylintrc":"python",".puppeteerrc":"puppeteer",".puppeteerrc.cjs":"puppeteer",".puppeteerrc.js":"puppeteer",".puppeteerrc.json":"puppeteer",".puppeteerrc.yaml":"puppeteer",".puppeteerrc.yml":"puppeteer","puppeteer.config.cjs":"puppeteer","puppeteer.config.js":"puppeteer","puppeteer.config.mjs":"puppeteer","puppeteer.config.ts":"puppeteer","pyproject.toml":"gear","setup.cfg":"python","jest.config.js":"jest","jest.config.cjs":"jest","jest.config.mjs":"jest","jest.config.ts":"jest","jest.config.cts":"jest","jest.config.mts":"jest","jest.config.json":"jest","jest.e2e.config.js":"jest","jest.e2e.config.cjs":"jest","jest.e2e.config.mjs":"jest","jest.e2e.config.ts":"jest","jest.e2e.config.cts":"jest","jest.e2e.config.mts":"jest","jest.e2e.config.json":"jest","jest.e2e.json":"jest","jest-unit.config.js":"jest","jest-e2e.config.js":"jest","jest-e2e.config.cjs":"jest","jest-e2e.config.mjs":"jest","jest-e2e.config.ts":"jest","jest-e2e.config.cts":"jest","jest-e2e.config.mts":"jest","jest-e2e.config.json":"jest","jest-e2e.json":"jest","jest-github-actions-reporter.js":"jest","jest.setup.js":"jest","jest.setup.ts":"jest","jest.json":"jest",".jestrc":"jest",".jestrc.js":"jest",".jestrc.json":"jest","jest.teardown.js":"jest","dockerfile":"docker","dockerfile.prod":"docker","dockerfile.production":"docker","dockerfile.alpha":"docker","dockerfile.beta":"docker","dockerfile.stage":"docker","dockerfile.staging":"docker","dockerfile.dev":"docker","dockerfile.development":"docker","dockerfile.local":"docker","dockerfile.test":"docker","dockerfile.testing":"docker","dockerfile.ci":"docker","dockerfile.web":"docker","dockerfile.worker":"docker","docker-compose.yml":"docker-pink","docker-compose.override.yml":"docker-pink","docker-compose.prod.yml":"docker-pink","docker-compose.production.yml":"docker-pink","docker-compose.alpha.yml":"docker-pink","docker-compose.beta.yml":"docker-pink","docker-compose.stage.yml":"docker-pink","docker-compose.staging.yml":"docker-pink","docker-compose.dev.yml":"docker-pink","docker-compose.development.yml":"docker-pink","docker-compose.local.yml":"docker-pink","docker-compose.test.yml":"docker-pink","docker-compose.testing.yml":"docker-pink","docker-compose.ci.yml":"docker-pink","docker-compose.web.yml":"docker-pink","docker-compose.worker.yml":"docker-pink","docker-compose.yaml":"docker-pink","docker-compose.override.yaml":"docker-pink","docker-compose.prod.yaml":"docker-pink","docker-compose.production.yaml":"docker-pink","docker-compose.alpha.yaml":"docker-pink","docker-compose.beta.yaml":"docker-pink","docker-compose.stage.yaml":"docker-pink","docker-compose.staging.yaml":"docker-pink","docker-compose.dev.yaml":"docker-pink","docker-compose.development.yaml":"docker-pink","docker-compose.local.yaml":"docker-pink","docker-compose.test.yaml":"docker-pink","docker-compose.testing.yaml":"docker-pink","docker-compose.ci.yaml":"docker-pink","docker-compose.web.yaml":"docker-pink","docker-compose.worker.yaml":"docker-pink","compose.yml":"docker-pink","compose.override.yml":"docker-pink","compose.prod.yml":"docker-pink","compose.production.yml":"docker-pink","compose.alpha.yml":"docker-pink","compose.beta.yml":"docker-pink","compose.stage.yml":"docker-pink","compose.staging.yml":"docker-pink","compose.dev.yml":"docker-pink","compose.development.yml":"docker-pink","compose.local.yml":"docker-pink","compose.test.yml":"docker-pink","compose.testing.yml":"docker-pink","compose.ci.yml":"docker-pink","compose.web.yml":"docker-pink","compose.worker.yml":"docker-pink","compose.yaml":"docker-pink","compose.override.yaml":"docker-pink","compose.prod.yaml":"docker-pink","compose.production.yaml":"docker-pink","compose.alpha.yaml":"docker-pink","compose.beta.yaml":"docker-pink","compose.stage.yaml":"docker-pink","compose.staging.yaml":"docker-pink","compose.dev.yaml":"docker-pink","compose.development.yaml":"docker-pink","compose.local.yaml":"docker-pink","compose.test.yaml":"docker-pink","compose.testing.yaml":"docker-pink","compose.ci.yaml":"docker-pink","compose.web.yaml":"docker-pink","compose.worker.yaml":"docker-pink","docker-healthcheck":"docker-green","docker-healthcheck.prod":"docker-green","docker-healthcheck.production":"docker-green","docker-healthcheck.alpha":"docker-green","docker-healthcheck.beta":"docker-green","docker-healthcheck.stage":"docker-green","docker-healthcheck.staging":"docker-green","docker-healthcheck.dev":"docker-green","docker-healthcheck.development":"docker-green","docker-healthcheck.local":"docker-green","docker-healthcheck.test":"docker-green","docker-healthcheck.testing":"docker-green","docker-healthcheck.ci":"docker-green","docker-healthcheck.web":"docker-green","docker-healthcheck.worker":"docker-green","firebase.json":"firebase",".firebaserc":"firebase","firestore.rules":"firebase","firestore.indexes.json":"firebase",".d.ts":"dts",".vscodeignore":"ignore",".hugo_build.lock":"hugo","CNAME":"globe","sitemap.xml":"globe","sitemap_index.xml":"globe","security.txt":"globe","ads.txt":"globe","robots.txt":"text","yarn.lock":"yarn","yarn":"yarn",".direnv":"gear",".env":"gear",".env.local":"gear",".env.development":"gear",".env.dev":"gear",".env.production":"gear",".env.prod":"gear",".env.test":"gear","pre-commit":"shell","commit-msg":"shell","pre-push":"shell","post-merge":"shell","rome.json":"rome","biome.json":"biome","biome.jsonc":"biome","eslint.config.js":"eslint","eslint.config.cjs":"eslint","eslint.config.mjs":"eslint","eslint.config.ts":"eslint","eslint.config.cts":"eslint","eslint.config.mts":"eslint",".eslintrc.js":"eslint",".eslintrc.cjs":"eslint",".eslintrc.yaml":"eslint",".eslintrc.yml":"eslint",".eslintrc.json":"eslint",".eslintrc-md.js":"eslint",".eslintrc-jsdoc.js":"eslint",".eslintrc":"eslint",".eslintignore":"eslint",".eslintcache":"eslint",".git-blame-ignore":"git",".gitignore":"git",".gitignore-global":"git",".gitignore_global":"git",".gitconfig":"git",".gitattributes":"git",".gitmodules":"git",".gitkeep":"git",".gitinclude":"git","git-history":"git",".swcrc":"swc","license":"license","license-agpl":"license","license-apache":"license","license-bsd":"license","license-mit":"license","license-gpl":"license","license-lgpl":"license","license.md":"license","license.rst":"license","license.txt":"license","next.config.js":"next","next.config.mjs":"next","next.config.ts":"next","next.config.mts":"next",".nuxtrc":"nuxt",".nuxtignore":"nuxt","nuxt.config.js":"nuxt","nuxt.config.mjs":"nuxt","nuxt.config.ts":"nuxt","nuxt.config.mts":"nuxt","fresh.config.js":"fresh","fresh.config.mjs":"fresh","fresh.config.ts":"fresh","fresh.config.mts":"fresh","angular-cli.json":"angular",".angular-cli.json":"angular","angular.json":"angular","turbo.json":"turborepo","tailwind.js":"tailwind","tailwind.ts":"tailwind","tailwind.config.js":"tailwind","tailwind.config.cjs":"tailwind","tailwind.config.mjs":"tailwind","tailwind.config.ts":"tailwind","tailwind.config.cts":"tailwind","tailwind.config.mts":"tailwind","tsconfig.json":"tsconfig","tsconfig.app.json":"tsconfig","tsconfig.editor.json":"tsconfig","tsconfig.spec.json":"tsconfig","tsconfig.base.json":"tsconfig","tsconfig.build.json":"tsconfig","tsconfig.eslint.json":"tsconfig","tsconfig.lib.json":"tsconfig","tsconfig.node.json":"tsconfig","tsconfig.test.json":"ts-test","tsconfig.e2e.json":"tsconfig","tsconfig.web.json":"tsconfig","tsconfig.webworker.json":"tsconfig","tsconfig.config.json":"tsconfig","tsconfig.vitest.json":"tsconfig","tsconfig.cjs.json":"tsconfig","tsconfig.esm.json":"tsconfig","tsconfig.mjs.json":"tsconfig","webpack.js":"webpack","webpack.cjs":"webpack","webpack.mjs":"webpack","webpack.ts":"webpack","webpack.cts":"webpack","webpack.mts":"webpack","webpack.base.js":"webpack","webpack.base.cjs":"webpack","webpack.base.mjs":"webpack","webpack.base.ts":"webpack","webpack.base.cts":"webpack","webpack.base.mts":"webpack","webpack.config.js":"webpack","webpack.config.cjs":"webpack","webpack.config.mjs":"webpack","webpack.config.ts":"webpack","webpack.config.cts":"webpack","webpack.config.mts":"webpack","webpack.common.js":"webpack","webpack.common.cjs":"webpack","webpack.common.mjs":"webpack","webpack.common.ts":"webpack","webpack.common.cts":"webpack","webpack.common.mts":"webpack","webpack.config.common.js":"webpack","webpack.config.common.cjs":"webpack","webpack.config.common.mjs":"webpack","webpack.config.common.ts":"webpack","webpack.config.common.cts":"webpack","webpack.config.common.mts":"webpack","webpack.config.common.babel.js":"webpack","webpack.config.common.babel.ts":"webpack","webpack.dev.js":"webpack","webpack.dev.cjs":"webpack","webpack.dev.mjs":"webpack","webpack.dev.ts":"webpack","webpack.dev.cts":"webpack","webpack.dev.mts":"webpack","webpack.development.js":"webpack","webpack.development.cjs":"webpack","webpack.development.mjs":"webpack","webpack.development.ts":"webpack","webpack.development.cts":"webpack","webpack.development.mts":"webpack","webpack.config.dev.js":"webpack","webpack.config.dev.cjs":"webpack","webpack.config.dev.mjs":"webpack","webpack.config.dev.ts":"webpack","webpack.config.dev.cts":"webpack","webpack.config.dev.mts":"webpack","webpack.config.dev.babel.js":"webpack","webpack.config.dev.babel.ts":"webpack","webpack.mix.js":"webpack","webpack.mix.cjs":"webpack","webpack.mix.mjs":"webpack","webpack.mix.ts":"webpack","webpack.mix.cts":"webpack","webpack.mix.mts":"webpack","webpack.prod.js":"webpack","webpack.prod.cjs":"webpack","webpack.prod.mjs":"webpack","webpack.prod.ts":"webpack","webpack.prod.cts":"webpack","webpack.prod.mts":"webpack","webpack.prod.config.js":"webpack","webpack.prod.config.cjs":"webpack","webpack.prod.config.mjs":"webpack","webpack.prod.config.ts":"webpack","webpack.prod.config.cts":"webpack","webpack.prod.config.mts":"webpack","webpack.production.js":"webpack","webpack.production.cjs":"webpack","webpack.production.mjs":"webpack","webpack.production.ts":"webpack","webpack.production.cts":"webpack","webpack.production.mts":"webpack","webpack.server.js":"webpack","webpack.server.cjs":"webpack","webpack.server.mjs":"webpack","webpack.server.ts":"webpack","webpack.server.cts":"webpack","webpack.server.mts":"webpack","webpack.client.js":"webpack","webpack.client.cjs":"webpack","webpack.client.mjs":"webpack","webpack.client.ts":"webpack","webpack.client.cts":"webpack","webpack.client.mts":"webpack","webpack.config.server.js":"webpack","webpack.config.server.cjs":"webpack","webpack.config.server.mjs":"webpack","webpack.config.server.ts":"webpack","webpack.config.server.cts":"webpack","webpack.config.server.mts":"webpack","webpack.config.client.js":"webpack","webpack.config.client.cjs":"webpack","webpack.config.client.mjs":"webpack","webpack.config.client.ts":"webpack","webpack.config.client.cts":"webpack","webpack.config.client.mts":"webpack","webpack.config.production.babel.js":"webpack","webpack.config.production.babel.ts":"webpack","webpack.config.prod.babel.js":"webpack","webpack.config.prod.babel.cjs":"webpack","webpack.config.prod.babel.mjs":"webpack","webpack.config.prod.babel.ts":"webpack","webpack.config.prod.babel.cts":"webpack","webpack.config.prod.babel.mts":"webpack","webpack.config.prod.js":"webpack","webpack.config.prod.cjs":"webpack","webpack.config.prod.mjs":"webpack","webpack.config.prod.ts":"webpack","webpack.config.prod.cts":"webpack","webpack.config.prod.mts":"webpack","webpack.config.production.js":"webpack","webpack.config.production.cjs":"webpack","webpack.config.production.mjs":"webpack","webpack.config.production.ts":"webpack","webpack.config.production.cts":"webpack","webpack.config.production.mts":"webpack","webpack.config.staging.js":"webpack","webpack.config.staging.cjs":"webpack","webpack.config.staging.mjs":"webpack","webpack.config.staging.ts":"webpack","webpack.config.staging.cts":"webpack","webpack.config.staging.mts":"webpack","webpack.config.babel.js":"webpack","webpack.config.babel.ts":"webpack","webpack.config.base.babel.js":"webpack","webpack.config.base.babel.ts":"webpack","webpack.config.base.js":"webpack","webpack.config.base.cjs":"webpack","webpack.config.base.mjs":"webpack","webpack.config.base.ts":"webpack","webpack.config.base.cts":"webpack","webpack.config.base.mts":"webpack","webpack.config.staging.babel.js":"webpack","webpack.config.staging.babel.ts":"webpack","webpack.config.coffee":"webpack","webpack.config.test.js":"webpack","webpack.config.test.cjs":"webpack","webpack.config.test.mjs":"webpack","webpack.config.test.ts":"webpack","webpack.config.test.cts":"webpack","webpack.config.test.mts":"webpack","webpack.config.vendor.js":"webpack","webpack.config.vendor.cjs":"webpack","webpack.config.vendor.mjs":"webpack","webpack.config.vendor.ts":"webpack","webpack.config.vendor.cts":"webpack","webpack.config.vendor.mts":"webpack","webpack.config.vendor.production.js":"webpack","webpack.config.vendor.production.cjs":"webpack","webpack.config.vendor.production.mjs":"webpack","webpack.config.vendor.production.ts":"webpack","webpack.config.vendor.production.cts":"webpack","webpack.config.vendor.production.mts":"webpack","webpack.test.js":"webpack","webpack.test.cjs":"webpack","webpack.test.mjs":"webpack","webpack.test.ts":"webpack","webpack.test.cts":"webpack","webpack.test.mts":"webpack","webpack.dist.js":"webpack","webpack.dist.cjs":"webpack","webpack.dist.mjs":"webpack","webpack.dist.ts":"webpack","webpack.dist.cts":"webpack","webpack.dist.mts":"webpack","webpackfile.js":"webpack","webpackfile.cjs":"webpack","webpackfile.mjs":"webpack","webpackfile.ts":"webpack","webpackfile.cts":"webpack","webpackfile.mts":"webpack","rsbuild.config.mjs":"rsbuild","rsbuild.config.ts":"rsbuild","rsbuild.config.js":"rsbuild","rsbuild.config.cjs":"rsbuild","rsbuild.config.mts":"rsbuild","rsbuild.config.cts":"rsbuild","rspack.config.js":"rspack","rspack.config.ts":"rspack","rspack.config.cjs":"rspack","rspack.config.mjs":"rspack","rslib.config.mjs":"rslib","rslib.config.ts":"rslib","rslib.config.js":"rslib","rslib.config.cjs":"rslib","rslib.config.mts":"rslib","rslib.config.cts":"rslib",".oxlintignore":"oxc",".oxlintrc.json":"oxc","oxlint.config.js":"oxc","oxlint.config.cjs":"oxc","oxlint.config.mjs":"oxc","oxlint.config.ts":"oxc","oxlint.config.mts":"oxc","oxlint.config.cts":"oxc",".oxfmtrc.json":"oxc",".oxfmtrc.jsonc":"oxc","oxfmt.config.js":"oxc","oxfmt.config.cjs":"oxc","oxfmt.config.mjs":"oxc","oxfmt.config.ts":"oxc","oxfmt.config.mts":"oxc","oxfmt.config.cts":"oxc",".nxignore":"nx","nx.json":"nx","yumma.config.cjs":"yummacss","yumma.config.js":"yummacss","yumma.config.mjs":"yummacss","yumma.config.ts":"yummacss","yumma.css":"yummacss","yummacss.css":"yummacss","components.json":"shadcn",".gdignore":"gdignore","project.godot":"gdproject","kubb.config.ts":"kubb",".browserslist":"browserslist","Caddyfile":"caddy",".caddyfile":"caddy","Caddyfile.dev":"caddy","Caddyfile.prod":"caddy","Caddyfile.staging":"caddy"},"folderNames":{".husky":"folder-orange",".changeset":"folder-changeset","i18n":"folder-i18n","locales":"folder-i18n","locale":"folder-i18n","l10n":"folder-i18n","translation":"folder-i18n","translations":"folder-i18n","intl":"folder-i18n","lang":"folder-i18n","langs":"folder-i18n","languages":"folder-i18n",".venv":"folder-lock",".gitlab":"folder-gitlab","auth":"folder-lock","core":"folder-core","models":"folder-models","interface":"folder-interfaces","interfaces":"folder-interfaces","helpers":"folder-helpers","shared":"folder-shared","router":"folder-router","routers":"folder-router","routes":"folder-router","modules":"folder-modules","angular":"folder-angular",".angular":"folder-angular","services":"folder-services","service":"folder-services","providers":"folder-providers","provider":"folder-providers","interceptors":"folder-interceptors","interceptor":"folder-interceptors","pipes":"folder-pipes","pipe":"folder-pipes","firebase":"folder-firebase","supabase":"folder-supabase",".drizzle":"folder-drizzle","drizzle":"folder-drizzle","tina":"folder-tina","src-tauri":"folder-tauri","tauri":"folder-tauri",".cursor":"folder-cursor",".claude-code":"folder-claude",".claude":"folder-claude","claude":"folder-claude","claude-config":"folder-claude","claude-prompts":"folder-claude",".well-known":"folder-globe","well-known":"folder-globe",".netlify":"folder-netlify","netlify":"folder-netlify",".vercel":"folder-vercel","vercel":"folder-vercel","target":"folder-target","ios":"folder-ios","context":"folder-context","contexts":"folder-context","middleware":"folder-middleware","middlewares":"folder-middleware","pages":"folder-sky-code","screens":"folder-sky-code","util":"folder-utils","utils":"folder-utils","utility":"folder-utils","utilities":"folder-utils","db":"folder-database","database":"folder-database","databases":"folder-database","layouts":"folder-layout","layout":"folder-layout","docker":"folder-docker","docker-compose":"folder-docker","dockerfiles":"folder-docker",".docker":"folder-docker",".git":"folder-red","graphql":"folder-graphql","gql":"folder-graphql","app":"folder-app","apps":"folder-app","config":"folder-config","env":"folder-green","server":"folder-orange","client":"folder-blue","css":"folder-purple-code","styles":"folder-purple-code","scripts":"folder-red-code","storybook":"folder-pink-outline","stories":"folder-pink-code","types":"folder-blue-code","api":"folder-red",".storybook":"folder-pink",".vscode":"folder-vscode",".next":"folder-next",".nuxt":"folder-green",".turbo":"folder-red",".contentlayer":"folder-purple","node_modules":"folder-node-modules","aws":"folder-aws",".azure":"folder-azure","nginx":"folder-nginx","react":"folder-react",".github":"folder-github","public":"folder-purple-outline","source":"folder-orange-code","src":"folder-orange-code","test":"folder-red-code","tests":"folder-red-code","spec":"folder-red-code","specs":"folder-red-code","tools":"folder-utils","lib":"folder-utils","doc":"folder-documents","docs":"folder-documents","documents":"folder-documents","documentation":"folder-documents","dist":"folder-purple-outline","out":"folder-purple-outline","build":"folder-build","assets":"folder-assets","resources":"folder-assets","static":"folder-assets","components":"folder-green-code","prisma":"folder-prisma","android":"folder-android","mail":"folder-mail","mails":"folder-mail","emails":"folder-mail","smtp":"folder-mail","mailers":"folder-mail","image":"folder-images","images":"folder-images",".bru":"folder-bruno","bru":"folder-bruno","mongo":"folder-mongo","mongodb":"folder-mongo","hooks":"folder-hooks","constants":"folder-constants","expo":"folder-expo",".expo":"folder-expo","gradle":"folder-gradle",".gradle":"folder-gradle","maven":"folder-maven",".mvn":"folder-maven",".nx":"folder-gray","fonts":"folder-fonts","font":"folder-fonts","js":"folder-js","javascript":"folder-js","sass":"folder-sass","scss":"folder-sass",".godot":"folder-godot",".zed":"folder-zed"},"folderNamesExpanded":null} diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 5055aa2f337c..f5a08a135434 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8700,6 +8700,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focusInitialBrowserAddressBarOnCreate: Bool = true, createdWorkspaceHandler: ((Workspace) -> Void)? = nil ) -> Bool { + // Every create route funnels here (button, cmd-T, browser variant). + SidebarNavigationTimings.begin("create") let preferredContext = preferredTabManager.flatMap { mainWindowContext(for: $0) } let livePreferredContext: MainWindowContext? = { guard let preferredContext else { return nil } @@ -15898,6 +15900,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .closeWorkspace) { + // Workspace-close funnel; the tab-close shortcut is a different + // interaction and must not start this interval. + SidebarNavigationTimings.begin("close") tabManagerForFocusedCloseShortcut(event: event)?.closeCurrentWorkspaceWithConfirmation() return true } diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index f4847d4de24e..f464566b3507 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -1,3 +1,5 @@ +import AppKit +import CmuxAppKitSupportUI import CmuxFoundation import CmuxSidebar import SwiftUI @@ -89,10 +91,26 @@ extension ContentView { func sidebarPeekCard(width: CGFloat) -> some View { let revealed = sidebarPanelCardIsRevealed let appearance = windowAppearanceSnapshot + // The card wears the docked ground's exact wash: same resolved tint + // (colour and alpha) and same material, so floating and docked read + // as one surface in two positions. The tint-opacity slider moves + // both together through this single resolution. + var panelTint = Color(nsColor: .windowBackgroundColor).opacity(0.52) + var panelGlassMaterial = NSVisualEffectView.Material.popover + if case let .sidebarMaterial(materialPolicy) = appearance.policy(for: .leftSidebar) { + panelTint = Color(nsColor: materialPolicy.tintColor) + if let material = materialPolicy.material { + panelGlassMaterial = material + } + } // `isPresented: true` keeps the panel's list live even while hidden: // suspending it on dismissal blanks the rows the moment the slide-out // starts, which reads as the panel vanishing instead of leaving. - return sidebarView(isPresented: true, attachesFocusBoundary: false) + return sidebarView( + isPresented: true, + attachesFocusBoundary: false, + usesCompactTopInset: true + ) .environment(\.colorScheme, appearance.sidebarContentColorScheme) .modifier(SidebarPeekPresentation( isRevealed: revealed, @@ -100,6 +118,8 @@ extension ContentView { width: width, // Docking supersedes the card in place: no exit slide. dismissesInstantly: sidebarState.occupiesLayout, + panelTint: panelTint, + panelGlassMaterial: panelGlassMaterial, panelMetrics: sidebarPeekPanelMetrics, onPanelHoverChange: { isInside in if isInside { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 3ed14fa1bf29..995aa62f3164 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1692,7 +1692,11 @@ struct ContentView: View { // Only one instance may claim the focus boundary: it is last-write-wins // and window-scoped, so the panel instance (in its own child window) // must not shadow the in-layout instance's claim. - func sidebarView(isPresented: Bool, attachesFocusBoundary: Bool = true) -> some View { + func sidebarView( + isPresented: Bool, + attachesFocusBoundary: Bool = true, + usesCompactTopInset: Bool = false + ) -> some View { let sidebar = VerticalTabsSidebar( updateViewModel: updateViewModel, fileExplorerState: fileExplorerState, @@ -1705,12 +1709,14 @@ struct ContentView: View { onSendFeedback: presentFeedbackComposer, onToggleSidebar: { sidebarState.toggle() }, onNewTab: { + SidebarNavigationTimings.begin("create") AppDelegate.shared?.performNewWorkspaceAction( tabManager: tabManager, debugSource: "titlebar.hiddenNewWorkspace" ) }, presentationMode: sidebarState.presentationMode, + usesCompactTopInset: usesCompactTopInset, onTogglePresentationMode: { withAnimation(SidebarPeekMotion.modeChange) { sidebarState.togglePresentationMode() @@ -2050,7 +2056,11 @@ struct ContentView: View { // Background glass settings @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 - @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false + // Fork default: window glass on. With behindWindow blending this keeps + // the window transparent so the sidebar's vibrancy samples the actual + // desktop (an opaque window kills behind-window sampling and leaves only + // the material's flat frost). + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true @State private var titlebarLeadingInset: CGFloat = 12 private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } // Not `private`: the peek panel card in `ContentView+SidebarPeek` derives @@ -2603,6 +2613,20 @@ struct ContentView: View { layout: sidebarLayout, isPeekPresenting: { sidebarPeek.presentsPanel } ) + sidebarPeek.setPolicy(SidebarCustomizationSettings.peekPolicy()) + } + .onReceive( + NotificationCenter.default + .publisher(for: UserDefaults.didChangeNotification) + .receive(on: RunLoop.main) + ) { _ in + // Settings-window edits land here. The equality guard + // keeps the frequent defaults churn from rebuilding the + // peek machine when nothing it cares about changed. + let policy = SidebarCustomizationSettings.peekPolicy() + if policy != sidebarPeek.policy { + sidebarPeek.setPolicy(policy) + } } .onChange(of: sidebarState.occupiesLayout) { occupies in if occupies { @@ -11447,6 +11471,10 @@ struct VerticalTabsSidebar: View, Equatable { /// Docked or floating. Part of `==`: the toggle's own glyph flips on it, /// so a change here has to defeat the equality gate. var presentationMode: SidebarPresentationMode = .docked + /// The floating panel's instance takes compact top metrics: its window + /// already sits below the titlebar, so the docked pane's reserved band + /// is dead space there. + var usesCompactTopInset: Bool = false let onTogglePresentationMode: () -> Void let observedWindowReference: WeakWindowReference let chromeBackgroundColor: NSColor @@ -11758,7 +11786,9 @@ struct VerticalTabsSidebar: View, Equatable { } private var sidebarTopScrimHeight: CGFloat { - SidebarWorkspaceListMetrics.topScrimHeight + usesCompactTopInset + ? SidebarWorkspaceListMetrics.compactTopScrimHeight + : SidebarWorkspaceListMetrics.topScrimHeight } private var sidebarBottomScrimHeight: CGFloat { @@ -12058,19 +12088,6 @@ struct VerticalTabsSidebar: View, Equatable { .frame(maxWidth: .infinity, alignment: .leading) } } - .overlay(alignment: .top) { - // Aside-style create action at the head of the list, where the - // eye starts: a full-width row, so the gesture that picks a - // workspace is the same gesture that makes one. The list's top - // inset reserves this row's height. - if isPresented { - SidebarNewWorkspaceButton( - accent: cmuxAccentColor(), - onCreate: onNewTab - ) - .padding(.top, SidebarWorkspaceListMetrics.firstRowTopOffset) - } - } .accessibilityIdentifier("Sidebar") .ignoresSafeArea() .background( @@ -12229,7 +12246,8 @@ struct VerticalTabsSidebar: View, Equatable { renderContext: WorkspaceListRenderContext, unreadSnapshot: SidebarUnreadSnapshot ) -> some View { - let scrollInsets = SidebarWorkspaceScrollInsets.workspaceList + let scrollInsets: SidebarWorkspaceScrollInsets = + usesCompactTopInset ? .floatingPanel : .workspaceList return GeometryReader { viewport in // Keep viewport geometry as a downward-only layout input. Writing // this value into @State from onGeometryChange feeds an @@ -12465,6 +12483,7 @@ struct VerticalTabsSidebar: View, Equatable { selectedWorkspaceId: selectedWorkspaceId, selectedScrollTargetWorkspaceId: selectedScrollTargetWorkspaceId, isPresented: isPresented, + usesCompactTopInset: usesCompactTopInset, unreadSource: sidebarUnread, onDeferredClickAwaitingApply: { appKitTableApplyRequestToken &+= 1 } ) @@ -12630,6 +12649,7 @@ struct VerticalTabsSidebar: View, Equatable { dragAutoScrollController.attach(scrollView: scrollView) }, closeWorkspace: { workspaceId in + SidebarNavigationTimings.begin("close") guard let workspace = tabManager.tabs.first(where: { $0.id == workspaceId }) else { return } #if DEBUG cmuxDebugLog("sidebar.close workspace=\(workspaceId.uuidString.prefix(5)) method=middleClick") diff --git a/Sources/FileExplorerCellView.swift b/Sources/FileExplorerCellView.swift index f4b95742c302..7ee243aa5dac 100644 --- a/Sources/FileExplorerCellView.swift +++ b/Sources/FileExplorerCellView.swift @@ -88,7 +88,15 @@ final class FileExplorerCellView: NSTableCellView { iconHeightConstraint.constant = style.iconSize iconToTextConstraint.constant = style.iconToTextSpacing - if style == .finder { + if let materialIcon = MaterialFileIconResolver.shared.image( + name: node.name, isDirectory: node.isDirectory, pointSize: style.iconSize + ) { + // VS Code-style Material icons ship their own colors; no tint. + iconView.apply(CmuxResolvedIconRequest( + source: .image(materialIcon), + size: NSSize(width: style.iconSize, height: style.iconSize) + )) + } else if style == .finder { // Native Finder icon pixels miss 3:1 in light mode; use their masks with the dynamic palette tint. if node.isDirectory { iconView.apply(CmuxResolvedIconRequest( diff --git a/Sources/MaterialFileIconResolver.swift b/Sources/MaterialFileIconResolver.swift new file mode 100644 index 000000000000..bd2a021747a5 --- /dev/null +++ b/Sources/MaterialFileIconResolver.swift @@ -0,0 +1,84 @@ +import AppKit + +/// Resolves VS Code-style Material file icons from the bundled icon set. +/// +/// The mapping (`material-icons.json`) and the SVGs (`Resources/MaterialIcons`) +/// are the Material Icon Theme's: file names beat extensions, and compound +/// extensions match longest-first ("foo.test.tsx" tries "test.tsx" before +/// "tsx"). Resolution is cached per icon and point size; a name with no SVG is +/// remembered so the file system is only consulted once per icon. +final class MaterialFileIconResolver { + static let shared = MaterialFileIconResolver() + + private struct Mapping: Decodable { + let file: String + let folder: String + let folderExpanded: String? + let fileExtensions: [String: String] + let fileNames: [String: String] + let folderNames: [String: String] + let folderNamesExpanded: [String: String]? + } + + private let mapping: Mapping? + private let iconsDirectory: URL? + private let imageCache = NSCache() + private var missing = Set() + + private init() { + if let url = Bundle.main.url(forResource: "material-icons", withExtension: "json"), + let data = try? Data(contentsOf: url), + let decoded = try? JSONDecoder().decode(Mapping.self, from: data) { + mapping = decoded + } else { + mapping = nil + } + iconsDirectory = Bundle.main.resourceURL? + .appendingPathComponent("MaterialIcons", isDirectory: true) + } + + var isAvailable: Bool { mapping != nil && iconsDirectory != nil } + + /// Resolved, cached, colored icon (or nil when the set isn't bundled). + func image(name: String, isDirectory: Bool, pointSize: CGFloat) -> NSImage? { + guard let mapping, isAvailable else { return nil } + let resolvedName = isDirectory + ? iconName(forFolderName: name, mapping: mapping) + : iconName(forFileName: name, mapping: mapping) + return image(named: resolvedName, pointSize: pointSize) + ?? image(named: isDirectory ? mapping.folder : mapping.file, pointSize: pointSize) + } + + private func iconName(forFileName name: String, mapping: Mapping) -> String { + let lower = name.lowercased() + if let icon = mapping.fileNames[lower] { return icon } + let parts = lower.split(separator: ".", omittingEmptySubsequences: false) + if parts.count > 1 { + for index in 1.. String { + if let icon = mapping.folderNames[name.lowercased()] { return icon } + return mapping.folder + } + + private func image(named iconName: String, pointSize: CGFloat) -> NSImage? { + guard let iconsDirectory, !missing.contains(iconName) else { return nil } + let key = "\(iconName)#\(Int(pointSize))" as NSString + if let cached = imageCache.object(forKey: key) { return cached } + let url = iconsDirectory.appendingPathComponent(iconName + ".svg") + guard let image = NSImage(contentsOf: url) else { + missing.insert(iconName) + return nil + } + image.isTemplate = false + image.size = NSSize(width: pointSize, height: pointSize) + imageCache.setObject(image, forKey: key) + return image + } +} diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 166db786e2c5..b07ae974b22c 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -2,6 +2,7 @@ import AppKit import CmuxAppKitSupportUI import Combine import CmuxFoundation +import CmuxSettings import CmuxSidebar import CmuxWorkspaces import SwiftUI @@ -1274,7 +1275,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { let leading = outerPad + contentPad + (model.isGrouped ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) let trailing = width - outerPad - contentPad let contentWidth = max(10, trailing - leading) - var y: CGFloat = 8 + var y: CGFloat = model.settings.rowDensity.rowVerticalPadding let spacing: CGFloat = 4 // Title line @@ -1526,7 +1527,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } } - y += 8 + y += model.settings.rowDensity.rowVerticalPadding if apply { contentContainer.frame = NSRect(x: 0, y: 0, width: width, height: y) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index 923199ff20c4..734f5e219ec8 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -3,6 +3,7 @@ import Bonsplit import CmuxAppKitSupportUI import CmuxFoundation import CmuxNotifications +import CmuxSettings import SwiftUI /// Main-actor owner of the default sidebar table lifecycle and its AppKit interactions. @@ -150,6 +151,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } previewBailoutTask?.cancel() } +<<<<<<< HEAD private func clearPendingWorkspaceDragWriters( preserving preservedWriter: SidebarWorkspaceDragPasteboardWriter? = nil @@ -202,6 +204,14 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS detachController(from: abandonedSourceTable) } } + + /// Set by the representable before the container is built: the floating + /// panel's table has no titlebar band to clear, so it takes the compact + /// top inset. + var usesCompactTopInset = false + private var resolvedScrollInsets: SidebarWorkspaceScrollInsets { + usesCompactTopInset ? .floatingPanel : .workspaceList + } func makeContainerView() -> SidebarWorkspaceTableContainerView { let container = SidebarWorkspaceTableContainerView() containerView = container @@ -252,10 +262,10 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS scrollView.contentView.drawsBackground = false scrollView.contentView.postsBoundsChangedNotifications = true scrollView.contentInsets = NSEdgeInsets( - top: SidebarWorkspaceScrollInsets.workspaceList.top + top: resolvedScrollInsets.top + SidebarWorkspaceListMetrics.rowVerticalPadding, left: 0, - bottom: SidebarWorkspaceScrollInsets.workspaceList.bottom + bottom: resolvedScrollInsets.bottom + SidebarWorkspaceListMetrics.rowVerticalPadding, right: 0 ) @@ -1004,6 +1014,12 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } } + if hasStructuralChanges || !contentChanges.isEmpty { + SidebarNavigationTimings.lapPendingSwitch("applyReached") + // The authoritative apply is the shared completion point for + // switch, create, and close. + SidebarNavigationTimings.endAnyApplyDriven() + } noteServedHeightDivergence(in: containerView.tableView) #if DEBUG @@ -1120,6 +1136,9 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS let configuration = rows[row] if let actions = configuration.appKitWorkspaceRowActions { previewSelection(row: row, modifiers: click.modifiers, hitView: nil) + // Assume warm; the mount reconcile reclassifies to cold when the + // target workspace has to mount. + SidebarNavigationTimings.begin("switch.warm") dispatchSelection(modifiers: click.modifiers) { actions.commands.updateSelection(modifiers: click.modifiers) } @@ -1150,7 +1169,10 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS selectionCoalescer.flushNow() action() } else { - selectionCoalescer.request(action) + selectionCoalescer.request { + SidebarNavigationTimings.lapPendingSwitch("selectionDispatched") + action() + } } } @@ -1519,7 +1541,8 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS // to it, so the tab in your hand is the tab you are on. Single // drags only; a multi-selection drag must not collapse the very // selection it is moving. - if (actions?.movingWorkspaceCount?(workspaceId) ?? 1) <= 1, + if !SidebarCustomizationSettings.dragSwitchDisabled(), + (actions?.movingWorkspaceCount?(workspaceId) ?? 1) <= 1, let sourceRow = draggedRows.first, rows.indices.contains(sourceRow), rows[sourceRow].appKitWorkspaceRowModel?.isActive == false, @@ -2000,6 +2023,10 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS retireReorderIndicator() return false } + let reorderTickStart = ProcessInfo.processInfo.systemUptime + defer { + SidebarNavigationTimings.recordSampledTick("reorder.tick", startUptime: reorderTickStart) + } reorderDragPayloadWorkspaceId = payloadWorkspaceId guard !targets.isEmpty, let update = actions.updateWorkspaceDrag( @@ -3406,7 +3433,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS ).minY - 1 } else { y = container.bounds.height - - SidebarWorkspaceScrollInsets.workspaceList.top + - resolvedScrollInsets.top - SidebarWorkspaceListMetrics.rowVerticalPadding } let leadingIndent: CGFloat = { diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift index 1f1ea1374d2d..8402e73c2059 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift @@ -23,6 +23,7 @@ struct SidebarWorkspaceTableView: NSViewRepresentable { let selectedWorkspaceId: UUID? let selectedScrollTargetWorkspaceId: UUID? let isPresented: Bool + let usesCompactTopInset: Bool let unreadSource: SidebarUnreadModel /// Invoked when a completed row click parks awaiting live actions; the /// owner must invalidate itself so this view re-applies (issue #9690). @@ -39,6 +40,7 @@ struct SidebarWorkspaceTableView: NSViewRepresentable { } func makeNSView(context: Context) -> SidebarWorkspaceTableContainerView { + context.coordinator.usesCompactTopInset = usesCompactTopInset let container = context.coordinator.makeContainerView() container.appearance = WindowAppearanceSnapshot.appKitAppearance(for: colorScheme) container.emptyDropIndicatorView.colorScheme = colorScheme diff --git a/Sources/Sidebar/Peek/SidebarPeekController.swift b/Sources/Sidebar/Peek/SidebarPeekController.swift index 6279d5ed388d..8bfdeaa17938 100644 --- a/Sources/Sidebar/Peek/SidebarPeekController.swift +++ b/Sources/Sidebar/Peek/SidebarPeekController.swift @@ -35,15 +35,22 @@ final class SidebarPeekController: ObservableObject { // MARK: - Events - func pointerEnteredEdge() { send(.pointerEnteredEdge) } + func pointerEnteredEdge() { + SidebarNavigationTimings.begin("peek") + send(.pointerEnteredEdge) + } /// Edge-enter with the dwell skipped: hovering an activation control (the /// titlebar's sidebar toggle) is already a deliberate act, so the reveal /// starts the moment the pointer lands instead of after the edge dwell. func pointerEnteredActivationControl() { + SidebarNavigationTimings.begin("peek") send(.pointerEnteredEdge) send(.dwellElapsed) } - func pointerExitedEdge() { send(.pointerExitedEdge) } + func pointerExitedEdge() { + SidebarNavigationTimings.cancel("peek") + send(.pointerExitedEdge) + } func dragEnteredEdge() { send(.dragEnteredEdge) } func workspaceActivated() { send(.workspaceActivated) } func escapePressed() { send(.escapePressed) } @@ -80,6 +87,9 @@ final class SidebarPeekController: ObservableObject { // pointer crossing of the edge strip, most of which change nothing. if presentsPanel != state.presentsPanel { presentsPanel = state.presentsPanel + if presentsPanel { + SidebarNavigationTimings.end("peek") + } } } diff --git a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift index 965a1d796992..3b76dd7eba17 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift @@ -1,3 +1,4 @@ +import AppKit import CmuxSidebar import SwiftUI @@ -26,6 +27,11 @@ struct SidebarPeekPresentation: ViewModifier { /// is being superseded by the docked pane: the fixed sidebar replaces it /// in place, and a card gliding off underneath reads as a ghost. var dismissesInstantly: Bool = false + /// The card's legibility tint (alpha included), resolved from the same + /// appearance policy that paints the docked ground. + var panelTint: Color = Color(nsColor: .windowBackgroundColor).opacity(0.52) + /// The card's glass material, matching the docked ground's. + var panelGlassMaterial: NSVisualEffectView.Material = .popover /// Card geometry for floating mode. let panelMetrics: SidebarPeekPanelMetrics /// Acquires and releases the pointer hold as the pointer crosses the panel. @@ -39,7 +45,11 @@ struct SidebarPeekPresentation: ViewModifier { func body(content: Content) -> some View { if rendersAsCard { - SidebarPeekPanelChrome(metrics: panelMetrics) { + SidebarPeekPanelChrome( + metrics: panelMetrics, + tint: panelTint, + glassMaterial: panelGlassMaterial + ) { content.frame(width: width, alignment: .leading) } .frame(width: floatingWidth, alignment: .leading) diff --git a/Sources/Sidebar/SidebarCustomizationSettings.swift b/Sources/Sidebar/SidebarCustomizationSettings.swift new file mode 100644 index 000000000000..a0ce80dce1e2 --- /dev/null +++ b/Sources/Sidebar/SidebarCustomizationSettings.swift @@ -0,0 +1,65 @@ +import CmuxSettings +import CmuxSidebar +import CoreGraphics +import Foundation + +/// App-side resolution of the sidebar customization settings. +/// +/// `CmuxSidebar` deliberately has no `CmuxSettings` dependency, so anything a +/// package type consumes (the peek policy, panel tint) is resolved here and +/// injected. Event-frequency reads (a toggle press, a drag start) go through +/// these statics directly; per-row values ride `SidebarTabItemSettingsSnapshot` +/// instead so the existing defaults-change pipeline re-renders them. +enum SidebarCustomizationSettings { + /// The peek policy the current defaults describe. + static func peekPolicy(defaults: UserDefaults = .standard) -> SidebarPeekPolicy { + let settings = UserDefaultsSettingsClient(defaults: defaults) + let sidebar = SidebarCatalogSection() + let preset = settings.value(for: sidebar.peekReveal) + let enabled = !settings.value(for: sidebar.peekDisabled) + switch preset { + case .instant: + // A wider strip pairs with the zero dwell: the pointer thrown at + // the edge lands anywhere in it and the panel is already there. + return SidebarPeekPolicy( + dwell: .zero, + grace: .milliseconds(250), + edgeWidth: 20, + dismissesOnWorkspaceActivation: true, + isEnabled: enabled + ) + case .quick: + return SidebarPeekPolicy( + dwell: .milliseconds(120), + grace: .milliseconds(250), + edgeWidth: 14, + dismissesOnWorkspaceActivation: true, + isEnabled: enabled + ) + case .relaxed: + return SidebarPeekPolicy( + dwell: .milliseconds(280), + grace: .milliseconds(350), + edgeWidth: 10, + dismissesOnWorkspaceActivation: true, + isEnabled: enabled + ) + } + } + + static func dragSwitchDisabled(defaults: UserDefaults = .standard) -> Bool { + UserDefaultsSettingsClient(defaults: defaults) + .value(for: SidebarCatalogSection().dragSwitchDisabled) + } +} + +extension SidebarRowDensity { + /// Vertical padding above and below a workspace row's content. + var rowVerticalPadding: CGFloat { + switch self { + case .compact: 4 + case .cozy: 8 + case .spacious: 12 + } + } +} diff --git a/Sources/Sidebar/SidebarNavigationTimings.swift b/Sources/Sidebar/SidebarNavigationTimings.swift new file mode 100644 index 000000000000..285c776251be --- /dev/null +++ b/Sources/Sidebar/SidebarNavigationTimings.swift @@ -0,0 +1,112 @@ +import Foundation +import os + +/// Structured latency lines for the navigation interactions, plus signpost +/// intervals for Instruments. +/// +/// Env-gated by `CMUX_NAV_TIMINGS_LOG=` and compiled in EVERY +/// configuration, not `#if DEBUG`: the Release build is the acceptance +/// environment for the performance budgets, so it has to be able to +/// self-report. With the variable unset every call is a single boolean +/// check. `scripts/harvest-nav-timings.py` turns the log into the table in +/// `PERFORMANCE.md`. +@MainActor +enum SidebarNavigationTimings { + static let isEnabled: Bool = + ProcessInfo.processInfo.environment["CMUX_NAV_TIMINGS_LOG"] != nil + + private static let signposter = OSSignposter( + subsystem: "com.cmuxterm.cmux", + category: "navigation" + ) + private static var pendingStartUptime: [String: TimeInterval] = [:] + private static var pendingSignpost: [String: OSSignpostIntervalState] = [:] + private static var tickSampleCounter = 0 + + private static let logHandle: FileHandle? = { + guard let path = ProcessInfo.processInfo.environment["CMUX_NAV_TIMINGS_LOG"] else { + return nil + } + if !FileManager.default.fileExists(atPath: path) { + FileManager.default.createFile(atPath: path, contents: nil) + } + let handle = FileHandle(forWritingAtPath: path) + handle?.seekToEndOfFile() + return handle + }() + + /// Starts (or restarts) an interaction interval. + static func begin(_ interaction: String) { + guard isEnabled else { return } + pendingStartUptime[interaction] = ProcessInfo.processInfo.systemUptime + let id = signposter.makeSignpostID() + pendingSignpost[interaction] = signposter.beginInterval("nav", id: id) + } + + /// Renames a pending interval (a switch discovered to be a cold mount). + static func reclassify(_ from: String, as to: String) { + guard isEnabled, let start = pendingStartUptime.removeValue(forKey: from) else { return } + pendingStartUptime[to] = start + if let interval = pendingSignpost.removeValue(forKey: from) { + pendingSignpost[to] = interval + } + } + + /// Ends a pending interval and emits its line. No-op when none pending. + static func end(_ interaction: String) { + guard isEnabled, let start = pendingStartUptime.removeValue(forKey: interaction) else { return } + if let interval = pendingSignpost.removeValue(forKey: interaction) { + signposter.endInterval("nav", interval) + } + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + appendLine("nav.timing interaction=\(interaction) ms=\(String(format: "%.1f", ms))") + } + + /// Drops a pending interval without emitting (an interaction that never + /// completed, like a peek dismissed before reveal). + static func cancel(_ interaction: String) { + guard isEnabled else { return } + pendingStartUptime.removeValue(forKey: interaction) + if let interval = pendingSignpost.removeValue(forKey: interaction) { + signposter.endInterval("nav", interval) + } + } + + /// Emits an intermediate delta for a pending switch without consuming + /// it, so one Release pass bisects where the milliseconds go. + static func lapPendingSwitch(_ label: String) { + guard isEnabled else { return } + for key in ["switch.cold", "switch.warm"] { + guard let start = pendingStartUptime[key] else { continue } + let ms = (ProcessInfo.processInfo.systemUptime - start) * 1000 + appendLine("nav.timing interaction=switch.lap.\(label) ms=\(String(format: "%.1f", ms))") + return + } + } + + /// Ends whichever apply-driven interaction is pending. The sidebar's + /// structural apply is the shared completion point for switch, create, + /// and close; only the one that actually began emits. + static func endAnyApplyDriven() { + guard isEnabled else { return } + for name in ["switch.cold", "switch.warm", "create", "close"] { + end(name) + } + } + + /// Per-event cost sampling for high-frequency paths (reorder ticks). + /// Emits one line in eight so a 120Hz stream does not turn the log into + /// its own performance problem. + static func recordSampledTick(_ interaction: String, startUptime: TimeInterval) { + guard isEnabled else { return } + tickSampleCounter += 1 + guard tickSampleCounter % 8 == 0 else { return } + let ms = (ProcessInfo.processInfo.systemUptime - startUptime) * 1000 + appendLine("nav.timing interaction=\(interaction) ms=\(String(format: "%.2f", ms))") + } + + private static func appendLine(_ line: String) { + guard let logHandle, let data = (line + "\n").data(using: .utf8) else { return } + logHandle.write(data) + } +} diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 28d3d823a907..7b28dad805aa 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -49,6 +49,7 @@ final class SidebarToggleAnimator: ObservableObject { // Only a docked pane trades width with the terminal; floating // visibility is the peek panel's business. guard sidebarState.presentationMode == .docked else { return false } + SidebarNavigationTimings.begin(targetVisible ? "toggle.show" : "toggle.hide") cancelSweep() if targetVisible { // Docking over an already-revealed peek card swaps in place: the @@ -61,6 +62,10 @@ final class SidebarToggleAnimator: ObservableObject { : max(layout.width, CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) sidebarState.applyVisibilityBypassingOrchestrator(true) layout.width = 1 + // The width snap above IS the first visual feedback for show (the + // terminal steps aside in this very turn); the sweep that follows + // is the pane gliding in. + SidebarNavigationTimings.end("toggle.show") sweep(from: 1, to: target, completion: nil) return true } else { @@ -93,6 +98,7 @@ final class SidebarToggleAnimator: ObservableObject { ? 2 * progress * progress : 1 - pow(-2 * progress + 2, 2) / 2 layout.width = from + (to - from) * CGFloat(eased) + SidebarNavigationTimings.end("toggle.hide") if progress >= 1 { tick.invalidate() self.timer = nil diff --git a/Sources/SidebarTabItemSettingsSnapshot.swift b/Sources/SidebarTabItemSettingsSnapshot.swift index 2079a60663f7..fad993cc4e51 100644 --- a/Sources/SidebarTabItemSettingsSnapshot.swift +++ b/Sources/SidebarTabItemSettingsSnapshot.swift @@ -40,6 +40,7 @@ struct SidebarTabItemSettingsSnapshot: Equatable { /// The resolved cmux accent. Rows draw selection, progress, drop /// indicators and agent status with it, and reconfigure when it changes. let accentColor: CmuxAccentColor + let rowDensity: SidebarRowDensity var usesLastSegmentPath: Bool { branchDirectory.usesLastSegmentPath } var showsSSH: Bool { details.showSSH } @@ -109,6 +110,7 @@ struct SidebarTabItemSettingsSnapshot: Equatable { iMessageModeEnabled = IMessageModeSettings.isEnabled(defaults: defaults) workspaceTodoChecklistStyle = settings.value(for: betaFeatures.workspaceTodosChecklistStyle) self.accentColor = accentColor + rowDensity = settings.value(for: sidebar.rowDensity) } private static func bool( diff --git a/Sources/WindowChromeMetrics.swift b/Sources/WindowChromeMetrics.swift index 57b591831708..78065447705a 100644 --- a/Sources/WindowChromeMetrics.swift +++ b/Sources/WindowChromeMetrics.swift @@ -117,12 +117,10 @@ enum RightSidebarChromeMetrics { enum SidebarWorkspaceListMetrics { static let firstRowTopOffset: CGFloat = MinimalModeChromeMetrics.titlebarHeight + 2 - /// Height reserved at the head of the list for the new-workspace row. - static let newWorkspaceRowHeight: CGFloat = 30 static let rowVerticalPadding: CGFloat = 8 static let rowOuterHorizontalPadding: CGFloat = 6 static let rowContentHorizontalPadding: CGFloat = 10 - static let topScrimHeight: CGFloat = firstRowTopOffset + newWorkspaceRowHeight + 20 + static let topScrimHeight: CGFloat = firstRowTopOffset + 20 static let bottomScrimHeight: CGFloat = firstRowTopOffset + 20 static var trailingAccessoryRightEdgeOffset: CGFloat { @@ -134,10 +132,14 @@ enum SidebarWorkspaceListMetrics { } static var scrollTopInset: CGFloat { - // The new-workspace row sits in this reserved band, above the first - // workspace row. - max(0, firstRowTopOffset - rowVerticalPadding) + newWorkspaceRowHeight + max(0, firstRowTopOffset - rowVerticalPadding) } + + /// Compact top metrics for the floating panel: its window already starts + /// below the titlebar, so the docked pane's reserved titlebar band is + /// dead space there. + static let compactScrollTopInset: CGFloat = 8 + static let compactTopScrimHeight: CGFloat = 24 } struct SidebarWorkspaceScrollInsets: Equatable { @@ -146,6 +148,13 @@ struct SidebarWorkspaceScrollInsets: Equatable { bottom: SidebarWorkspaceListMetrics.bottomScrimHeight ) + /// The floating panel's list: same bottom (the footer still lives + /// there), compact top (no titlebar band to clear). + static let floatingPanel = SidebarWorkspaceScrollInsets( + top: SidebarWorkspaceListMetrics.compactScrollTopInset, + bottom: SidebarWorkspaceListMetrics.bottomScrimHeight + ) + let top: CGFloat let bottom: CGFloat diff --git a/Sources/Windowing/WindowBackgroundComposition.swift b/Sources/Windowing/WindowBackgroundComposition.swift index 076333b419ac..76bac6ceba50 100644 --- a/Sources/Windowing/WindowBackgroundComposition.swift +++ b/Sources/Windowing/WindowBackgroundComposition.swift @@ -19,7 +19,7 @@ struct UserDefaultsWindowBackgroundSettings: WindowBackgroundSettingsReading { } var isBackgroundGlassEnabled: Bool { - defaults.object(forKey: "bgGlassEnabled") as? Bool ?? false + defaults.object(forKey: "bgGlassEnabled") as? Bool ?? true } } diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 35f0b5229862..7d3719baf459 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -4495,7 +4495,7 @@ private struct BackgroundDebugView: View { @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 @AppStorage("bgGlassMaterial") private var bgGlassMaterial = "hudWindow" - @AppStorage("bgGlassEnabled") private var bgGlassEnabled = false + @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true var body: some View { ScrollView { @@ -4541,7 +4541,7 @@ private struct BackgroundDebugView: View { bgGlassTintHex = "#000000" bgGlassTintOpacity = 0.03 bgGlassMaterial = "hudWindow" - bgGlassEnabled = false + bgGlassEnabled = true updateWindowGlassTint() } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3f527d79ec75..96bd24a7fc55 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -2489,6 +2489,9 @@ A50014F3 /* MarkdownViewerAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F1 /* MarkdownViewerAssets.swift */; }; A50014F5 /* MarkdownWebRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F4 /* MarkdownWebRenderer.swift */; }; A50014F9 /* MarkdownWebSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50014F8 /* MarkdownWebSupport.swift */; }; + A11C0000000000000000F004 /* material-icons.json in Resources */ = {isa = PBXBuildFile; fileRef = A11C0000000000000000F003 /* material-icons.json */; }; + 5C01156CFF64659AE0158FI1 /* MaterialFileIconResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599E6D613B9CD389A75BFI01 /* MaterialFileIconResolver.swift */; }; + A11C0000000000000000F002 /* MaterialIcons in Resources */ = {isa = PBXBuildFile; fileRef = A11C0000000000000000F001 /* MaterialIcons */; }; A42100010000000000000003 /* MemoryPressureAggregatePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = A42100010000000000000004 /* MemoryPressureAggregatePolicy.swift */; }; A42100010000000000000005 /* MemoryPressureAggregateSampler.swift in Sources */ = {isa = PBXBuildFile; fileRef = A42100010000000000000006 /* MemoryPressureAggregateSampler.swift */; }; 7490C0027490C0027490C002 /* MemoryPressureFootprintSampling.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7490D0027490D0027490D002 /* MemoryPressureFootprintSampling.swift */; }; @@ -3231,6 +3234,7 @@ 71D6C8FB21FA9335AC60ECD2 /* SidebarCompactStatusGlyph+Resolve.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2558AE4F66A522BE42F1802 /* SidebarCompactStatusGlyph+Resolve.swift */; }; 7CB5B5D87E4CCA04CACEAEB0 /* SidebarCompactStatusGlyph.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A56F37BE9679A42D1CA606 /* SidebarCompactStatusGlyph.swift */; }; 4653DE3101390738A2CF02E0 /* SidebarCompactStatusGlyphView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EF059E045A7A63F7202EE198 /* SidebarCompactStatusGlyphView.swift */; }; + 5C01156CFF64659AE0158CU1 /* SidebarCustomizationSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */; }; A12481700000000000000001 /* SidebarDerivedStateScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12481700000000000000002 /* SidebarDerivedStateScaleTests.swift */; }; EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; }; B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0270000000000000027 /* SidebarDividerTrackingView.swift */; }; @@ -3267,6 +3271,7 @@ 385C6BA7E78DB87460E5D930 /* SidebarMarkdownRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */; }; A6AC73F20000000000000001 /* SidebarMediaActivityIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73F20000000000000002 /* SidebarMediaActivityIndicators.swift */; }; C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */; }; + 72AC501F0A669B401708CCC4 /* SidebarNavigationTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AAA0330CB6FD8B7279F8398 /* SidebarNavigationTimings.swift */; }; 130160000000000000000001 /* SidebarNewLocalWorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130160000000000000000002 /* SidebarNewLocalWorkspaceTests.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000002 /* SidebarPathFormatter.swift */; }; @@ -6908,6 +6913,9 @@ A50014F1 /* MarkdownViewerAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownViewerAssets.swift; sourceTree = ""; }; A50014F4 /* MarkdownWebRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownWebRenderer.swift; sourceTree = ""; }; A50014F8 /* MarkdownWebSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownWebSupport.swift; sourceTree = ""; }; + A11C0000000000000000F003 /* material-icons.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "material-icons.json"; sourceTree = ""; }; + 599E6D613B9CD389A75BFI01 /* MaterialFileIconResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MaterialFileIconResolver.swift; sourceTree = ""; }; + A11C0000000000000000F001 /* MaterialIcons */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder; path = MaterialIcons; sourceTree = ""; }; A42100010000000000000004 /* MemoryPressureAggregatePolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MemoryPressureAggregatePolicy.swift; sourceTree = ""; }; A42100010000000000000006 /* MemoryPressureAggregateSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MemoryPressureAggregateSampler.swift; sourceTree = ""; }; 7490D0027490D0027490D002 /* MemoryPressureFootprintSampling.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/MemoryPressureFootprintSampling.swift; sourceTree = ""; }; @@ -7643,6 +7651,7 @@ E2558AE4F66A522BE42F1802 /* SidebarCompactStatusGlyph+Resolve.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/SidebarCompactStatusGlyph+Resolve.swift"; sourceTree = ""; }; 28A56F37BE9679A42D1CA606 /* SidebarCompactStatusGlyph.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarCompactStatusGlyph.swift; sourceTree = ""; }; EF059E045A7A63F7202EE198 /* SidebarCompactStatusGlyphView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarCompactStatusGlyphView.swift; sourceTree = ""; }; + 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarCustomizationSettings.swift; sourceTree = ""; }; A12481700000000000000002 /* SidebarDerivedStateScaleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SidebarDerivedStateScaleTests.swift; sourceTree = ""; }; EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = ""; }; B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = ""; }; @@ -7679,6 +7688,7 @@ F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMarkdownRendererTests.swift; sourceTree = ""; }; A6AC73F20000000000000002 /* SidebarMediaActivityIndicators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarMediaActivityIndicators.swift; sourceTree = ""; }; C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMetadataMarkdownRenderer.swift; sourceTree = ""; }; + 0AAA0330CB6FD8B7279F8398 /* SidebarNavigationTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarNavigationTimings.swift; sourceTree = ""; }; 130160000000000000000002 /* SidebarNewLocalWorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarNewLocalWorkspaceTests.swift; sourceTree = ""; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; EA1F00000000000000000002 /* SidebarPathFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPathFormatter.swift; sourceTree = ""; }; @@ -9171,6 +9181,8 @@ A9E010000000000000000007 /* agent-session-solid */, A5001622 /* cmux.sdef */, + A11C0000000000000000F001 /* MaterialIcons */, + A11C0000000000000000F003 /* material-icons.json */, ); path = Resources; sourceTree = ""; @@ -10138,7 +10150,9 @@ 6042D0056042D0056042D005 /* WindowScopedShortcutHintModifierMonitor.swift */, A5001901A1B2C3D4E5F60718 /* WindowBackgroundComposition.swift */, D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */, + 0AAA0330CB6FD8B7279F8398 /* SidebarNavigationTimings.swift */, 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */, + 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */, 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */, A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */, 62AA6EC5A67508F26074B60B /* SidebarFilterController.swift */, @@ -11622,6 +11636,7 @@ A5FB1204 /* WorkspaceConfigActionCapture.swift */, A5001655 /* CmuxActionTrust.swift */, FE5996010000000000000001 /* FileExplorerCellView.swift */, + 599E6D613B9CD389A75BFI01 /* MaterialFileIconResolver.swift */, FE5996080000000000000001 /* FileExplorerExternalOpenMenuItems.swift */, FE5996090000000000000001 /* FileExplorerExternalOpenRequest.swift */, FE5996020000000000000001 /* FileExplorerHeaderView.swift */, @@ -13981,6 +13996,8 @@ A11CE0060000000000000001 /* LICENSE in Resources */, DA7A10CA710E000000000003 /* Localizable.xcstrings in Resources */, A9D9000000000000000F0017 /* markdown-viewer in Resources */, + A11C0000000000000000F004 /* material-icons.json in Resources */, + A11C0000000000000000F002 /* MaterialIcons in Resources */, FEED0000000000000000F007 /* opencode-plugin.js in Resources */, A5002000 /* THIRD_PARTY_LICENSES.md in Resources */, 84E00D47E4584162AE53BC8D /* xterm-ghostty in Resources */, @@ -15531,6 +15548,7 @@ A50014F3 /* MarkdownViewerAssets.swift in Sources */, A50014F5 /* MarkdownWebRenderer.swift in Sources */, A50014F9 /* MarkdownWebSupport.swift in Sources */, + 5C01156CFF64659AE0158FI1 /* MaterialFileIconResolver.swift in Sources */, A42100010000000000000003 /* MemoryPressureAggregatePolicy.swift in Sources */, A42100010000000000000005 /* MemoryPressureAggregateSampler.swift in Sources */, 7490C0027490C0027490C002 /* MemoryPressureFootprintSampling.swift in Sources */, @@ -16005,6 +16023,7 @@ 71D6C8FB21FA9335AC60ECD2 /* SidebarCompactStatusGlyph+Resolve.swift in Sources */, 7CB5B5D87E4CCA04CACEAEB0 /* SidebarCompactStatusGlyph.swift in Sources */, 4653DE3101390738A2CF02E0 /* SidebarCompactStatusGlyphView.swift in Sources */, + 5C01156CFF64659AE0158CU1 /* SidebarCustomizationSettings.swift in Sources */, EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */, 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */, @@ -16027,6 +16046,7 @@ 5732A0125732A0125732A012 /* SidebarLazyContractProbeKey.swift in Sources */, A6AC73F20000000000000001 /* SidebarMediaActivityIndicators.swift in Sources */, C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */, + 72AC501F0A669B401708CCC4 /* SidebarNavigationTimings.swift in Sources */, EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */, 22326FBAA042DDAFA15F818C /* SidebarPeekController.swift in Sources */, F3E95CEBB40A6AA79A9B4CEE /* SidebarPeekEdgeTrackingView.swift in Sources */, From 410ca2f89bacd90d5575fa4384fe633cc251af8c Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 2 Sep 2026 12:47:43 +1200 Subject: [PATCH 11/69] compositor glass sidebar, blur + tint sliders, glass accent, peek flicker fix sidebar ground is now clear window + compositor blur radius instead of an appkit material, so blur is a real slider (1x-5x). tint slider drives a slate grey wash (default 44%). floating peek card gets the same blur only while it is showing so it never blurs the docked rows under it. files/find/vault sidebar stays on a solid terminal-coloured ground. new sidebar accent setting: blue or flat grey glass pill. toggle-button hover no longer pops the card during a hide (300ms window). close button gets a centred hover circle. sidebar defaults baked (hudWindow fallback, behind-window, always-active) and the upstream defaults-stamping migration is defanged. --- .../Appearance/WindowAppearanceResolver.swift | 15 ++- .../Appearance/WindowAppearanceSnapshot.swift | 51 +++++++- ...WindowAppearanceUserSettingsSnapshot.swift | 11 +- .../Backdrop/WindowBackdropController.swift | 4 +- ...WindowBackdropControllerDependencies.swift | 3 + .../Backdrop/WindowBackdropPlan.swift | 10 +- .../SidebarBackdropSettingsSnapshot.swift | 74 +++++++++-- .../WindowChromeSidebarTintDefaults.swift | 4 +- .../WindowBackdropControllerTests.swift | 5 + .../SidebarAppearanceCatalogSection.swift | 28 +++- .../Keys/SidebarCatalogSection.swift | 8 ++ .../Values/SidebarCustomizationOptions.swift | 8 ++ .../CuratedSettingEntry+Default.swift | 2 + .../SidebarSection+Customization.swift | 123 ++++++++++++++++-- .../Sections/SidebarSection.swift | 14 +- .../Peek/UI/SidebarPeekGlassBackdrop.swift | 12 +- .../Peek/UI/SidebarPeekPanelChrome.swift | 20 ++- .../Blur/CompositorBlurController.swift | 9 +- Sources/ContentView+SidebarPeek.swift | 22 +++- Sources/ContentView.swift | 31 ++++- .../Cells/SidebarGroupHeaderRowView.swift | 97 ++++++++++++++ .../Cells/SidebarWorkspaceRowCellView.swift | 1 + .../SidebarWorkspaceTableController.swift | 2 - .../Sidebar/Peek/SidebarPeekPanelHost.swift | 53 ++++++++ .../Peek/SidebarPeekPresentation.swift | 11 +- .../Sidebar/SidebarAppearanceSupport.swift | 29 ++++- Sources/SidebarTabItemSettingsSnapshot.swift | 2 + ...WindowBackdropControllerDependencies.swift | 4 + .../WindowBackgroundComposition.swift | 6 +- Sources/cmuxApp.swift | 35 +++-- 30 files changed, 609 insertions(+), 85 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift index 44e3fabbeb4f..953195cfdd69 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceResolver.swift @@ -32,8 +32,10 @@ public struct WindowAppearanceResolver { cornerRadius: settings.sidebarCornerRadius, blurOpacity: settings.sidebarBlurOpacity, // The snapshot initializer replaces this compatibility value - // with the resolved sidebar scheme. - colorScheme: settings.colorScheme + // with the terminal authority below. + colorScheme: settings.colorScheme, + compositorGlass: settings.sidebarCompositorGlass, + compositorBlurRadius: settings.sidebarGlassBlurRadius ), windowGlassSettings: WindowGlassSettingsSnapshot( sidebarBlendModeRawValue: settings.sidebarBlendMode, @@ -77,9 +79,9 @@ public struct WindowAppearanceResolver { unifySurfaceBackdrops: defaults.object(forKey: "sidebarMatchTerminalBackground") as? Bool ?? WindowChromeSidebarTintDefaults.matchesTerminalBackground, colorScheme: ambientScheme, - sidebarMaterial: defaults.string(forKey: "sidebarMaterial") ?? WindowChromeSidebarMaterialOption.sidebar.rawValue, - sidebarBlendMode: defaults.string(forKey: "sidebarBlendMode") ?? WindowChromeSidebarBlendModeOption.withinWindow.rawValue, - sidebarState: defaults.string(forKey: "sidebarState") ?? WindowChromeSidebarStateOption.followWindow.rawValue, + sidebarMaterial: defaults.string(forKey: "sidebarMaterial") ?? WindowChromeSidebarMaterialOption.hudWindow.rawValue, + sidebarBlendMode: defaults.string(forKey: "sidebarBlendMode") ?? WindowChromeSidebarBlendModeOption.behindWindow.rawValue, + sidebarState: defaults.string(forKey: "sidebarState") ?? WindowChromeSidebarStateOption.active.rawValue, sidebarTintHex: defaults.string(forKey: "sidebarTintHex") ?? tintDefaults.hex, sidebarTintHexLight: defaults.string(forKey: "sidebarTintHexLight"), sidebarTintHexDark: defaults.string(forKey: "sidebarTintHexDark"), @@ -89,6 +91,9 @@ public struct WindowAppearanceResolver { bgGlassEnabled: defaults.object(forKey: "bgGlassEnabled") as? Bool ?? true, bgGlassTintHex: defaults.string(forKey: "bgGlassTintHex") ?? "#000000", bgGlassTintOpacity: defaults.object(forKey: "bgGlassTintOpacity") as? Double ?? 0.03, + sidebarCompositorGlass: defaults.object(forKey: "sidebarCompositorGlass") as? Bool ?? true, + sidebarGlassBlurRadius: defaults.object(forKey: "sidebarGlassBlurRadius") as? Double + ?? SidebarBackdropSettingsSnapshot.compositorBlurRadiusRange.lowerBound, reduceTransparency: reduceTransparency )) } diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift index f5e7aad74007..a8af53a8e7e6 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift @@ -81,7 +81,9 @@ public struct WindowAppearanceSnapshot { tintOpacity: sidebarSettings.tintOpacity, cornerRadius: sidebarSettings.cornerRadius, blurOpacity: sidebarSettings.blurOpacity, - colorScheme: sidebarScheme + colorScheme: resolvedScheme, + compositorGlass: sidebarSettings.compositorGlass, + compositorBlurRadius: sidebarSettings.compositorBlurRadius ) self.windowGlassSettings = windowGlassSettings self.resolvedColorScheme = resolvedScheme @@ -89,6 +91,19 @@ public struct WindowAppearanceSnapshot { self.sidebarColorScheme = sidebarScheme } + /// Whether the window ground is clear glass blurred by the compositor. + /// + /// The workspace card paints its own opaque terminal colour, so the ground + /// can go clear under it. A translucent terminal already has Ghostty's own + /// blur path and keeps the legacy plan. This one answer drives the ground, + /// the sidebar layer, and the window plan, so they can never disagree. + public var usesCompositorGlass: Bool { + sidebarSettings.compositorGlass + && !unifySurfaceBackdrops + && terminalBackgroundOpacity >= 0.999 + && !terminalBackgroundBlur.isMacOSGlassStyle + } + /// Clamps opacity into the visible `0...1` range. public static func clampedOpacity(_ opacity: Double) -> CGFloat { CGFloat(max(0.0, min(1.0, opacity))) @@ -249,14 +264,23 @@ public struct WindowAppearanceSnapshot { public func policy(for role: WindowBackdropRole) -> WindowBackdropPolicy { switch role { case .windowRoot: + // Clear glass ground: the compositor blurs the desktop behind the + // transparent window and the sidebar layer paints the tint over + // it; the workspace card stays the one opaque surface. + if usesCompositorGlass { + return .clear + } return terminalBackdropPolicy() case .terminalCanvas, .bonsplitChrome, .titlebar, .browserSurface: return .clear - case .leftSidebar, .rightSidebar: + case .leftSidebar: if unifySurfaceBackdrops { return .clear } let materialPolicy = sidebarSettings.materialPolicy + if usesCompositorGlass { + return .sidebarMaterial(sidebarSettings.tintOnlyMaterialPolicy) + } guard reducesTransparency else { return .sidebarMaterial(materialPolicy) } @@ -266,6 +290,18 @@ public struct WindowAppearanceSnapshot { ? compositedTerminalBackgroundColor : Self.resolvedColor(.windowBackgroundColor, for: resolvedColorScheme) return .sidebarMaterial(materialPolicy.opaque(over: baseColor)) + case .rightSidebar: + if unifySurfaceBackdrops { + return .clear + } + // The tools sidebar (files, find, vault) is a work surface, not + // chrome: it always sits on a solid terminal-coloured ground and + // never follows the workspace sidebar's glass. + return .ghosttyTerminalBackdrop( + color: terminalBackgroundColor, + opacity: 1.0, + renderingMode: terminalRenderingMode + ) } } @@ -312,6 +348,17 @@ public struct WindowAppearanceSnapshot { if reducesTransparency { return opaqueWindowFillPlan(rootPolicy: rootPolicy) } + if usesCompositorGlass { + return WindowBackdropPlan( + hostingPhase: .transparentRootBackdrop, + windowBackgroundColor: windowBackgroundPolicy.transparentWindowBaseColor, + windowIsOpaque: false, + rootPolicy: .clear, + glass: nil, + shouldApplyGhosttyCompositorBlur: false, + compositorBlurRadius: sidebarSettings.effectiveCompositorBlurRadius + ) + } if windowGlassSettings.shouldApply( glassEffectAvailable: glassEffectAvailable, windowBackgroundPolicy: windowBackgroundPolicy diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift index 38cf3c0c4f45..2c7610b8bff8 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift @@ -46,6 +46,11 @@ public struct WindowAppearanceUserSettingsSnapshot { /// Background glass tint opacity. public let bgGlassTintOpacity: Double + /// Whether the sidebar glass uses the compositor blur path. + public let sidebarCompositorGlass: Bool + + /// Sidebar compositor blur radius in points. + public let sidebarGlassBlurRadius: Double /// The macOS Reduce Transparency setting. public let reduceTransparency: Bool @@ -65,8 +70,12 @@ public struct WindowAppearanceUserSettingsSnapshot { bgGlassEnabled: Bool, bgGlassTintHex: String, bgGlassTintOpacity: Double, - reduceTransparency: Bool = false + sidebarCompositorGlass: Bool = true, + sidebarGlassBlurRadius: Double = SidebarBackdropSettingsSnapshot.compositorBlurRadiusRange.lowerBound, + reduceTransparency: Bool = false ) { + self.sidebarCompositorGlass = sidebarCompositorGlass + self.sidebarGlassBlurRadius = sidebarGlassBlurRadius self.unifySurfaceBackdrops = unifySurfaceBackdrops self.colorScheme = colorScheme self.sidebarMaterial = sidebarMaterial diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropController.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropController.swift index f43b0bad7016..50fa61f51e3a 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropController.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropController.swift @@ -43,7 +43,9 @@ public final class WindowBackdropController { didChangeGlassRoot = dependencies.glassEffect.remove(from: window) window.backgroundColor = plan.windowBackgroundColor window.isOpaque = false - if plan.shouldApplyGhosttyCompositorBlur { + if let radius = plan.compositorBlurRadius { + dependencies.setCompositorBackgroundBlur(windowNumber: window.windowNumber, radius: radius) + } else if plan.shouldApplyGhosttyCompositorBlur { dependencies.applyGhosttyCompositorBlurIfNeeded(to: window) } else { dependencies.resetCompositorBackgroundBlur(windowNumber: window.windowNumber) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropControllerDependencies.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropControllerDependencies.swift index 65523987f15e..ca7753d2c085 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropControllerDependencies.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropControllerDependencies.swift @@ -9,6 +9,9 @@ public protocol WindowBackdropControllerDependencies: AnyObject { /// Resets compositor blur for a window number. func resetCompositorBackgroundBlur(windowNumber: Int) + /// Sets the compositor blur radius, in points, for a window number. + func setCompositorBackgroundBlur(windowNumber: Int, radius: Int) + /// Applies Ghostty's compositor blur to a transparent window when needed. func applyGhosttyCompositorBlurIfNeeded(to window: NSWindow) } diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPlan.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPlan.swift index f073c3c8b5e6..0cc41929a748 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPlan.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Backdrop/WindowBackdropPlan.swift @@ -21,6 +21,11 @@ public struct WindowBackdropPlan { /// Whether Ghostty compositor blur should be applied. public let shouldApplyGhosttyCompositorBlur: Bool + /// Explicit compositor blur radius (points) for the sidebar glass ground, + /// when `hostingPhase` is `.transparentRootBackdrop` and the sidebar owns + /// the blur instead of Ghostty's config. + public let compositorBlurRadius: Int? + /// Creates a window backdrop plan. public init( hostingPhase: WindowBackdropHostingPhase, @@ -28,7 +33,8 @@ public struct WindowBackdropPlan { windowIsOpaque: Bool, rootPolicy: WindowBackdropPolicy, glass: WindowBackdropGlassPlan?, - shouldApplyGhosttyCompositorBlur: Bool + shouldApplyGhosttyCompositorBlur: Bool, + compositorBlurRadius: Int? = nil ) { self.hostingPhase = hostingPhase self.windowBackgroundColor = windowBackgroundColor @@ -36,6 +42,7 @@ public struct WindowBackdropPlan { self.rootPolicy = rootPolicy self.glass = glass self.shouldApplyGhosttyCompositorBlur = shouldApplyGhosttyCompositorBlur + self.compositorBlurRadius = compositorBlurRadius } /// Whether the window should be transparent. @@ -58,6 +65,7 @@ public struct WindowBackdropPlan { glass?.tintColor.hexString(includeAlpha: true) ?? "nil", glass.map { String(describing: $0.style) } ?? "nil", String(shouldApplyGhosttyCompositorBlur), + compositorBlurRadius.map(String.init) ?? "nil", ].joined(separator: "|") } } diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift index 7574818fca77..0be5b2a23f48 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift @@ -34,6 +34,26 @@ public struct SidebarBackdropSettingsSnapshot { /// Color scheme used to pick light/dark tint overrides. public let colorScheme: ColorScheme + /// Whether the sidebar glass is a clear window ground with a compositor + /// blur behind it (tint painted on top) instead of an AppKit material. + /// This is the only path with an adjustable blur radius. + public let compositorGlass: Bool + + /// Compositor blur radius in points, used when `compositorGlass` is on. + /// Read through `effectiveCompositorBlurRadius`, which clamps it. + public let compositorBlurRadius: Double + + /// Blur radius bounds in points. Mirrors the settings catalog's range + /// (this module cannot import it): a floor so the sidebar always reads + /// as glass, a ceiling before blur turns to smear. + public static let compositorBlurRadiusRange: ClosedRange = 12...60 + + /// The blur radius the window actually receives, clamped to the range. + public var effectiveCompositorBlurRadius: Int { + let range = Self.compositorBlurRadiusRange + return Int(min(range.upperBound, max(range.lowerBound, compositorBlurRadius)).rounded()) + } + /// Creates a sidebar backdrop settings snapshot. public init( materialRawValue: String, @@ -45,7 +65,9 @@ public struct SidebarBackdropSettingsSnapshot { tintOpacity: Double, cornerRadius: Double, blurOpacity: Double, - colorScheme: ColorScheme + colorScheme: ColorScheme, + compositorGlass: Bool = true, + compositorBlurRadius: Double = Self.compositorBlurRadiusRange.lowerBound ) { self.materialRawValue = materialRawValue self.blendModeRawValue = blendModeRawValue @@ -57,6 +79,27 @@ public struct SidebarBackdropSettingsSnapshot { self.cornerRadius = cornerRadius self.blurOpacity = blurOpacity self.colorScheme = colorScheme + self.compositorGlass = compositorGlass + self.compositorBlurRadius = compositorBlurRadius + } + + /// Tint-only policy for the compositor-glass path: the window ground is + /// clear and the compositor blurs behind it, so the sidebar layer paints + /// just the tint. No effect view is created for a nil material. + /// + /// Chosen by `WindowAppearanceSnapshot`, which owns the decision, so the + /// layer can never go tint-only over a ground that stayed opaque. + public var tintOnlyMaterialPolicy: SidebarBackdropMaterialPolicy { + SidebarBackdropMaterialPolicy( + material: nil, + blendingMode: .behindWindow, + state: .active, + opacity: 1.0, + tintColor: resolvedTintColor, + cornerRadius: CGFloat(max(0, cornerRadius)), + preferLiquidGlass: false, + usesWindowLevelGlass: false + ) } /// Resolved AppKit material policy for these settings. @@ -64,16 +107,6 @@ public struct SidebarBackdropSettingsSnapshot { let materialOption = WindowChromeSidebarMaterialOption(rawValue: materialRawValue) let blendingMode = WindowChromeSidebarBlendModeOption(rawValue: blendModeRawValue)?.mode ?? .behindWindow let state = WindowChromeSidebarStateOption(rawValue: stateRawValue)?.state ?? .active - let resolvedHex: String - if colorScheme == .dark, let tintHexDark { - resolvedHex = tintHexDark - } else if colorScheme == .light, let tintHexLight { - resolvedHex = tintHexLight - } else { - resolvedHex = tintHex - } - let tintColor = (NSColor(hex: resolvedHex) ?? NSColor(hex: tintHex) ?? .black) - .withAlphaComponent(tintOpacity) let preferLiquidGlass = materialOption?.usesLiquidGlass ?? false let usesWindowLevelGlass = preferLiquidGlass && blendingMode == .behindWindow @@ -82,13 +115,28 @@ public struct SidebarBackdropSettingsSnapshot { blendingMode: blendingMode, state: state, opacity: blurOpacity, - tintColor: tintColor, + tintColor: resolvedTintColor, cornerRadius: CGFloat(max(0, cornerRadius)), preferLiquidGlass: preferLiquidGlass, usesWindowLevelGlass: usesWindowLevelGlass ) } + /// The tint colour with its opacity baked in, honouring the per-scheme + /// overrides. + private var resolvedTintColor: NSColor { + let resolvedHex: String + if colorScheme == .dark, let tintHexDark { + resolvedHex = tintHexDark + } else if colorScheme == .light, let tintHexLight { + resolvedHex = tintHexLight + } else { + resolvedHex = tintHex + } + return (NSColor(hex: resolvedHex) ?? NSColor(hex: tintHex) ?? .black) + .withAlphaComponent(tintOpacity) + } + /// Stable identity for AppKit mutations. public var appKitMutationID: String { [ @@ -102,6 +150,8 @@ public struct SidebarBackdropSettingsSnapshot { identityComponent(cornerRadius), identityComponent(blurOpacity), String(describing: colorScheme), + String(compositorGlass), + identityComponent(compositorBlurRadius), ].joined(separator: "|") } diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift index 587758d51306..7fdae5225aea 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift @@ -13,8 +13,8 @@ public struct WindowChromeSidebarTintDefaults: Sendable { /// Creates sidebar tint defaults. public init( - hex: String = "#000000", - opacity: Double = 0.18 + hex: String = "#2a2e35", + opacity: Double = 0.44 ) { self.hex = hex self.opacity = opacity diff --git a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Backdrop/WindowBackdropControllerTests.swift b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Backdrop/WindowBackdropControllerTests.swift index 95f31164e57c..dfea22e8899a 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Backdrop/WindowBackdropControllerTests.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Backdrop/WindowBackdropControllerTests.swift @@ -107,6 +107,7 @@ private final class FakeBackdropDependencies: WindowBackdropControllerDependenci let glass = FakeGlassEffect() var resetBlurWindowNumbers: [Int] = [] var appliedBlurWindows: [NSWindow] = [] + var setBlurRadii: [(windowNumber: Int, radius: Int)] = [] var glassEffect: any WindowGlassEffectManaging { glass @@ -116,6 +117,10 @@ private final class FakeBackdropDependencies: WindowBackdropControllerDependenci resetBlurWindowNumbers.append(windowNumber) } + func setCompositorBackgroundBlur(windowNumber: Int, radius: Int) { + setBlurRadii.append((windowNumber, radius)) + } + func applyGhosttyCompositorBlurIfNeeded(to window: NSWindow) { appliedBlurWindows.append(window) } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift index dc403a6c58ea..042e1ae8d04b 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift @@ -10,7 +10,7 @@ public struct SidebarAppearanceCatalogSection: SettingCatalogSection { public let tintColorHex = DefaultsKey( id: "sidebarAppearance.tintColor", - defaultValue: "#000000", + defaultValue: "#2a2e35", userDefaultsKey: "sidebarTintHex" ) @@ -28,7 +28,7 @@ public struct SidebarAppearanceCatalogSection: SettingCatalogSection { public let tintOpacity = DefaultsKey( id: "sidebarAppearance.tintOpacity", - defaultValue: 0.18, + defaultValue: 0.44, userDefaultsKey: "sidebarTintOpacity" ) @@ -38,6 +38,24 @@ public struct SidebarAppearanceCatalogSection: SettingCatalogSection { userDefaultsKey: "sidebarBlurOpacity" ) + public let compositorGlass = DefaultsKey( + id: "sidebarAppearance.compositorGlass", + defaultValue: true, + userDefaultsKey: "sidebarCompositorGlass" + ) + + public let glassBlurRadius = DefaultsKey( + id: "sidebarAppearance.glassBlurRadius", + defaultValue: Self.glassBlurRadiusRange.lowerBound, + userDefaultsKey: "sidebarGlassBlurRadius" + ) + + /// Compositor blur radius bounds in points. The floor keeps the sidebar + /// reading as glass (a crisp pane is not the look); the ceiling is where + /// blur stops reading as glass and just smears. Mirrored by the clamp in + /// `SidebarBackdropSettingsSnapshot`, which cannot import this module. + public static let glassBlurRadiusRange: ClosedRange = 12...60 + public let cornerRadius = DefaultsKey( id: "sidebarAppearance.cornerRadius", defaultValue: 0.0, @@ -52,19 +70,19 @@ public struct SidebarAppearanceCatalogSection: SettingCatalogSection { public let material = DefaultsKey( id: "sidebarAppearance.material", - defaultValue: .sidebar, + defaultValue: .hudWindow, userDefaultsKey: "sidebarMaterial" ) public let blendMode = DefaultsKey( id: "sidebarAppearance.blendMode", - defaultValue: .withinWindow, + defaultValue: .behindWindow, userDefaultsKey: "sidebarBlendMode" ) public let state = DefaultsKey( id: "sidebarAppearance.state", - defaultValue: .followWindow, + defaultValue: .active, userDefaultsKey: "sidebarState" ) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift index 7e65795c58e9..3b306437974a 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift @@ -257,5 +257,13 @@ public struct SidebarCatalogSection: SettingCatalogSection { userDefaultsKey: "sidebarDragSwitchDisabled" ) + /// Highlight style for the selected workspace row. A custom selection + /// colour (`workspaceColors.selectionColor`) still wins when set. + public let selectionAccent = DefaultsKey( + id: "sidebar.selectionAccent", + defaultValue: .blue, + userDefaultsKey: "sidebarSelectionAccent" + ) + public init() {} } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift index e2a78c8b9bd2..12438cd82d24 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift @@ -17,3 +17,11 @@ public enum SidebarRowDensity: String, CaseIterable, Sendable, SettingCodable { case cozy case spacious } + +/// How the selected workspace row is highlighted. +public enum SidebarSelectionAccent: String, CaseIterable, Sendable, SettingCodable { + /// The app accent colour as a solid pill. + case blue + /// A translucent grey pill that reads as a lighter patch of the glass. + case glass +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 1b48f3624815..1919ec699507 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -325,6 +325,8 @@ extension Array where Element == CuratedSettingEntry { // Sidebar appearance + sidebar workspace row details .init(section: .sidebarAppearance, id: "match-terminal", title: String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), synonyms: "Match Terminal Background sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), .init(section: .sidebarAppearance, id: "sidebar-glass-tint", title: "Sidebar Glass Tint", synonyms: "sidebarAppearance.tintOpacity sidebarTintOpacity glass glassmorphism tint opacity transparency blur floating docked panel peek clear frosted opaque"), + .init(section: .sidebarAppearance, id: "sidebar-selection-accent", title: "Sidebar Accent", synonyms: "sidebar.selectionAccent sidebarSelectionAccent accent selected workspace highlight pill blue glass grey gray selection colour color"), + .init(section: .sidebarAppearance, id: "sidebar-glass-blur", title: "Sidebar Blur", synonyms: "sidebarAppearance.glassBlurRadius sidebarGlassBlurRadius blur radius glass frosted clear see-through transparency backdrop compositor"), .init(section: .sidebarAppearance, id: "sidebar-peek-reveal", title: "Sidebar Peek Reveal Speed", synonyms: "sidebar.peekReveal peek hover reveal edge dwell instant quick relaxed sensitivity floating sidebar"), .init(section: .sidebarAppearance, id: "sidebar-peek-disabled", title: "Disable Sidebar Peek", synonyms: "sidebar.peekDisabled peek hover reveal disable never auto hide floating sidebar edge"), .init(section: .sidebarAppearance, id: "sidebar-row-density", title: "Row Density", synonyms: "sidebar.rowDensity density compact cozy spacious row height padding workspace list"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift index c12fa6eb7e79..a43c6988f06d 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift @@ -7,38 +7,115 @@ extension SidebarSection { var customizationRows: some View { SettingsCardRow( configurationReview: .json("sidebarAppearance.tintOpacity"), - String(localized: "settings.sidebar.glassTint", defaultValue: "Sidebar Glass Tint"), - subtitle: String(localized: "settings.sidebar.glassTint.subtitle", defaultValue: "How strongly the sidebar tints the glass behind it, docked or floating. Lower is clearer glass."), + String(localized: "settings.sidebar.glassTint", defaultValue: "Sidebar Tint"), + subtitle: String(localized: "settings.sidebar.glassTint.subtitle", defaultValue: "How strongly the sidebar's glass is coloured, docked or floating. 0% is clear glass; 100% is a solid panel."), controlWidth: 250 ) { HStack(spacing: 8) { + let tintValue = glassTintDraft ?? glassTint.current Slider( value: Binding( - get: { glassTint.current }, + get: { tintValue }, // Continuous track (a stepped slider draws tick dots); // store rounded so the percent label stays stable. - set: { glassTint.set(($0 * 100).rounded() / 100) } + set: { newValue in + let rounded = (newValue * 100).rounded() / 100 + glassTintDraft = rounded + debounceGlassWrite("glassTint") { glassTint.set(rounded) } + } ), - in: 0.0...1.0 + in: 0.0...1.0, + onEditingChanged: { editing in + guard !editing, let draft = glassTintDraft else { return } + glassTintDraft = nil + flushGlassWrite("glassTint") { glassTint.set(draft) } + } ) .frame(width: 130) .accessibilityIdentifier("SettingsSidebarGlassTintSlider") - Text("\(Int((glassTint.current * 100).rounded()))%") + Text("\(Int((tintValue * 100).rounded()))%") .cmuxFont(size: 12, weight: .medium, design: .rounded) .monospacedDigit() .frame(width: 44, alignment: .trailing) Button(String(localized: "settings.sidebar.glassTint.reset", defaultValue: "Reset")) { - glassTint.set(0.18) + glassTintDraft = nil + glassTint.set(Self.defaultGlassTint) } .buttonStyle(.bordered) .controlSize(.small) - .disabled(abs(glassTint.current - 0.18) < 0.001) + .disabled(abs(tintValue - Self.defaultGlassTint) < 0.001) } } SettingsCardDivider() + SettingsCardRow( + configurationReview: .json("sidebarAppearance.glassBlurRadius"), + String(localized: "settings.sidebar.glassBlur", defaultValue: "Sidebar Blur"), + subtitle: String(localized: "settings.sidebar.glassBlur.subtitle", defaultValue: "How much the glass blurs what is behind the window. 1× is the lightest glass; higher is frostier."), + controlWidth: 250 + ) { + HStack(spacing: 8) { + // Shown as a multiplier of the lightest blur (1× is the floor, + // 5× the ceiling), which reads more naturally than a + // percentage that cannot start at zero. + let blurRadius = glassBlurDraft ?? glassBlur.current + let floor = Self.glassBlurRange.lowerBound + Slider( + value: Binding( + get: { blurRadius / floor }, + set: { multiplier in + let radius = (multiplier * floor).rounded() + glassBlurDraft = radius + debounceGlassWrite("glassBlur") { glassBlur.set(radius) } + } + ), + in: 1.0...(Self.glassBlurRange.upperBound / floor), + onEditingChanged: { editing in + guard !editing, let draft = glassBlurDraft else { return } + glassBlurDraft = nil + flushGlassWrite("glassBlur") { glassBlur.set(draft) } + } + ) + .frame(width: 130) + .accessibilityIdentifier("SettingsSidebarGlassBlurSlider") + + Text(String(format: "%.1f×", blurRadius / floor)) + .cmuxFont(size: 12, weight: .medium, design: .rounded) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + + Button(String(localized: "settings.sidebar.glassBlur.reset", defaultValue: "Reset")) { + glassBlurDraft = nil + glassBlur.set(floor) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(abs(blurRadius - floor) < 0.5) + } + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebar.selectionAccent"), + String(localized: "settings.sidebar.selectionAccent", defaultValue: "Sidebar Accent"), + subtitle: String(localized: "settings.sidebar.selectionAccent.subtitle", defaultValue: "How the selected workspace is highlighted: the accent colour, or a lighter patch of the glass.") + ) { + Picker("", selection: Binding( + get: { selectionAccent.current }, + set: { selectionAccent.set($0) } + )) { + ForEach(SidebarSelectionAccent.allCases, id: \.self) { accent in + Text(accentLabel(accent)).tag(accent) + } + } + .labelsHidden() + .pickerStyle(.segmented) + .fixedSize() + } + SettingsCardDivider() + SettingsCardRow( configurationReview: .json("sidebar.peekReveal"), String(localized: "settings.sidebar.peekReveal", defaultValue: "Sidebar Peek Reveal Speed"), @@ -105,6 +182,36 @@ extension SidebarSection { SettingsCardDivider() } + /// Compositor blur bounds in points; the floor is also the default. + static let glassBlurRange = SidebarAppearanceCatalogSection.glassBlurRadiusRange + + /// Default tint strength, mirrored from the catalog key. + static let defaultGlassTint = SidebarAppearanceCatalogSection().tintOpacity.defaultValue + + /// Coalesces slider writes: each thumb movement replaces the pending + /// write, so the stored value (and the window re-render it triggers) + /// lands once the thumb pauses instead of on every pixel. + private func debounceGlassWrite(_ key: String, _ write: @escaping @MainActor @Sendable () -> Void) { + tasks.replaceOnMainActor(key) { + guard (try? await Task.sleep(for: .milliseconds(45))) != nil else { return } + write() + } + } + + /// Writes immediately, superseding any pending debounced write. + private func flushGlassWrite(_ key: String, _ write: @escaping @MainActor @Sendable () -> Void) { + tasks.replaceOnMainActor(key) { write() } + } + + private func accentLabel(_ accent: SidebarSelectionAccent) -> String { + switch accent { + case .blue: + String(localized: "settings.sidebar.selectionAccent.blue", defaultValue: "Blue") + case .glass: + String(localized: "settings.sidebar.selectionAccent.glass", defaultValue: "Glass") + } + } + private func peekRevealLabel(_ preset: SidebarPeekRevealPreset) -> String { switch preset { case .instant: diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift index 50ab5e2722c0..81a193a6ef54 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift @@ -9,7 +9,9 @@ public struct SidebarSection: View { private let rightSidebarWidthSettings = RightSidebarWidthSettings() @State private var sidebarFont: SettingsFontSize @State private var fontSaveFailed = false - @State private var tasks = MainActorTaskStore() + // Not `private`: the customization rows debounce their slider writes + // through the same store. + @State var tasks = MainActorTaskStore() @State private var matchTerminal: DefaultsValueModel @State var hideAll: DefaultsValueModel @State private var wrapTitles: DefaultsValueModel @@ -39,10 +41,16 @@ public struct SidebarSection: View { @State private var rightMaxWidth: DefaultsValueModel @State private var rememberedRightMaxWidth: DefaultsValueModel @State var glassTint: DefaultsValueModel + @State var glassBlur: DefaultsValueModel + /// In-flight slider values while the thumb is moving. The stored value + /// is written debounced, so the window is not re-rendered per pixel. + @State var glassTintDraft: Double? + @State var glassBlurDraft: Double? @State var peekReveal: DefaultsValueModel @State var peekDisabled: DefaultsValueModel @State var rowDensity: DefaultsValueModel @State var dragSwitchDisabled: DefaultsValueModel + @State var selectionAccent: DefaultsValueModel public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog, hostActions: SettingsHostActions) { self.catalog = catalog self.hostActions = hostActions @@ -77,10 +85,12 @@ public struct SidebarSection: View { _rightMaxWidth = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.rightMaxWidth)) _rememberedRightMaxWidth = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.rememberedRightMaxWidth)) _glassTint = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.tintOpacity)) + _glassBlur = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.glassBlurRadius)) _peekReveal = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.peekReveal)) _peekDisabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.peekDisabled)) _rowDensity = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.rowDensity)) _dragSwitchDisabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.dragSwitchDisabled)) + _selectionAccent = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.selectionAccent)) } /// The rendered sidebar settings section. public var body: some View { @@ -121,10 +131,12 @@ public struct SidebarSection: View { rightMaxWidth, rememberedRightMaxWidth, glassTint, + glassBlur, peekReveal, peekDisabled, rowDensity, dragSwitchDisabled, + selectionAccent, ] models.forEach { $0.startObserving() } } diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift index dc08f4cc7186..ae45c9aaeadf 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekGlassBackdrop.swift @@ -13,11 +13,19 @@ public struct SidebarPeekGlassBackdrop: NSViewRepresentable { public let cornerRadius: CGFloat /// The material to blur with, matching the docked ground's. public let material: NSVisualEffectView.Material + /// The material's alpha. Below 1.0 the raw backdrop shows through the + /// frost, matching the docked pane's see-through glass. + public let materialOpacity: Double /// Creates the backdrop. - public init(cornerRadius: CGFloat, material: NSVisualEffectView.Material = .popover) { + public init( + cornerRadius: CGFloat, + material: NSVisualEffectView.Material = .popover, + materialOpacity: Double = 1.0 + ) { self.cornerRadius = cornerRadius self.material = material + self.materialOpacity = materialOpacity } public func makeNSView(context: Context) -> NSVisualEffectView { @@ -25,6 +33,7 @@ public struct SidebarPeekGlassBackdrop: NSViewRepresentable { view.blendingMode = .behindWindow view.material = material view.state = .active + view.alphaValue = max(0.0, min(1.0, materialOpacity)) view.wantsLayer = true view.layer?.cornerRadius = cornerRadius view.layer?.cornerCurve = .continuous @@ -34,6 +43,7 @@ public struct SidebarPeekGlassBackdrop: NSViewRepresentable { public func updateNSView(_ view: NSVisualEffectView, context: Context) { view.material = material + view.alphaValue = max(0.0, min(1.0, materialOpacity)) view.layer?.cornerRadius = cornerRadius } } diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift index d516032a3881..955d01c654e4 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/UI/SidebarPeekPanelChrome.swift @@ -17,7 +17,10 @@ public struct SidebarPeekPanelChrome: View { /// so the floating card and the docked pane wear the exact same wash. public let tint: Color /// The AppKit material behind the tint, matching the docked ground's. - public let glassMaterial: NSVisualEffectView.Material + /// Nil draws no effect view: the host window supplies the blur itself. + public let glassMaterial: NSVisualEffectView.Material? + /// The material's alpha, matching the docked ground's frost thickness. + public let glassOpacity: Double /// The panel's contents. @ViewBuilder public let content: () -> Content @@ -33,13 +36,15 @@ public struct SidebarPeekPanelChrome: View { metrics: SidebarPeekPanelMetrics = .default, material: Material = .regularMaterial, tint: Color = Color(nsColor: .windowBackgroundColor).opacity(0.52), - glassMaterial: NSVisualEffectView.Material = .popover, + glassMaterial: NSVisualEffectView.Material? = .popover, + glassOpacity: Double = 1.0, @ViewBuilder content: @escaping () -> Content ) { self.metrics = metrics self.material = material self.tint = tint self.glassMaterial = glassMaterial + self.glassOpacity = glassOpacity self.content = content } @@ -68,10 +73,13 @@ public struct SidebarPeekPanelChrome: View { /// without going opaque, which is the whole glassmorphism effect. private var surface: some View { ZStack { - SidebarPeekGlassBackdrop( - cornerRadius: metrics.cornerRadius, - material: glassMaterial - ) + if let glassMaterial { + SidebarPeekGlassBackdrop( + cornerRadius: metrics.cornerRadius, + material: glassMaterial, + materialOpacity: glassOpacity + ) + } shape.fill(tint) } } diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Window/Blur/CompositorBlurController.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Window/Blur/CompositorBlurController.swift index a4da6447c56c..789408a0f479 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Window/Blur/CompositorBlurController.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Window/Blur/CompositorBlurController.swift @@ -39,11 +39,18 @@ public struct CompositorBlurController: Sendable { /// `window.windowNumber` in its own (main-actor) isolation domain. /// Nonpositive numbers do not identify a realized window and are ignored. public func resetBackgroundBlur(windowNumber: Int) { + setBackgroundBlur(windowNumber: windowNumber, radius: 0) + } + + /// Sets the compositor background blur radius (in points) on the window + /// with the given `windowNumber`. Only transparent pixels of the window + /// blur what lies behind them, so an opaque terminal card is unaffected. + public func setBackgroundBlur(windowNumber: Int, radius: Int) { guard windowNumber > 0 else { return } _ = cmuxCGSSetWindowBackgroundBlurRadius( cmuxCGSDefaultConnectionForThread(), UInt(windowNumber), - 0 + Int32(max(0, radius)) ) } } diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index f464566b3507..a333074c3bdd 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -70,13 +70,20 @@ extension ContentView { /// Zero-sized anchor owning the child panel window that floats the card /// above the terminal. Mounted from `contentAndSidebarLayout`. - @ViewBuilder var sidebarPeekPanelHost: some View { - SidebarWidthReader(layout: sidebarLayout) { width in + let appearance = windowAppearanceSnapshot + return SidebarWidthReader(layout: sidebarLayout) { width in SidebarPeekPanelBridge( contentWidth: width, metrics: sidebarPeekPanelMetrics, acceptsMouse: sidebarPanelCardIsRevealed, + // The card's own window gets the same compositor blur as the + // docked ground, so floating and docked glass match exactly. + // Never while docked: the panel then sits over the docked + // pane's rows and would blur them as they slide in. + glassBlurRadius: appearance.usesCompositorGlass && !sidebarState.occupiesLayout + ? appearance.sidebarSettings.effectiveCompositorBlurRadius + : nil, content: AnyView(sidebarPeekCard(width: width)) ) } @@ -96,12 +103,14 @@ extension ContentView { // as one surface in two positions. The tint-opacity slider moves // both together through this single resolution. var panelTint = Color(nsColor: .windowBackgroundColor).opacity(0.52) - var panelGlassMaterial = NSVisualEffectView.Material.popover + var panelGlassMaterial: NSVisualEffectView.Material? = .popover + var panelGlassOpacity = 1.0 if case let .sidebarMaterial(materialPolicy) = appearance.policy(for: .leftSidebar) { panelTint = Color(nsColor: materialPolicy.tintColor) - if let material = materialPolicy.material { - panelGlassMaterial = material - } + // A nil material is the compositor-glass path: the card's window + // is blurred by the compositor, so no effect view is drawn. + panelGlassMaterial = materialPolicy.material + panelGlassOpacity = materialPolicy.opacity } // `isPresented: true` keeps the panel's list live even while hidden: // suspending it on dismissal blanks the rows the moment the slide-out @@ -120,6 +129,7 @@ extension ContentView { dismissesInstantly: sidebarState.occupiesLayout, panelTint: panelTint, panelGlassMaterial: panelGlassMaterial, + panelGlassOpacity: panelGlassOpacity, panelMetrics: sidebarPeekPanelMetrics, onPanelHoverChange: { isInside in if isInside { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 995aa62f3164..9db2fd8e5e19 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2048,10 +2048,14 @@ struct ContentView: View { @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults().hex @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? - @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue - @AppStorage("sidebarState") private var sidebarStateSetting = SidebarStateOption.followWindow.rawValue + @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue + @AppStorage("sidebarState") private var sidebarStateSetting = SidebarStateOption.active.rawValue @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 + // Fork glass: clear window ground + compositor blur, so the blur radius is + // a real slider instead of whatever an AppKit material bakes in. + @AppStorage("sidebarCompositorGlass") private var sidebarCompositorGlass = true + @AppStorage("sidebarGlassBlurRadius") private var sidebarGlassBlurRadius = SidebarAppearanceCatalogSection.glassBlurRadiusRange.lowerBound // Background glass settings @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @@ -2062,6 +2066,13 @@ struct ContentView: View { // the material's flat frost). @AppStorage("bgGlassEnabled") private var bgGlassEnabled = true @State private var titlebarLeadingInset: CGFloat = 12 + /// Toggle-hover pre-reveals are ignored until this instant. Set when the + /// sidebar hides: the toggle button re-fires its hover as it re-renders + /// under the stationary pointer, which would pop the peek card over the + /// closing pane. A time window rather than a wait-for-exit latch, because + /// SwiftUI's onHover can drop the exit event across that re-render and a + /// latch would then never clear. + @State private var sidebarToggleHoverSuppressedUntil: Date? private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } // Not `private`: the peek panel card in `ContentView+SidebarPeek` derives // its content colour scheme from the same snapshot the docked sidebar uses. @@ -2083,6 +2094,8 @@ struct ContentView: View { bgGlassEnabled: bgGlassEnabled, bgGlassTintHex: bgGlassTintHex, bgGlassTintOpacity: bgGlassTintOpacity, + sidebarCompositorGlass: sidebarCompositorGlass, + sidebarGlassBlurRadius: sidebarGlassBlurRadius, reduceTransparency: displayAccessibility.reduceTransparency ) ) @@ -2645,11 +2658,25 @@ struct ContentView: View { guard observedWindow?.isKeyWindow == true else { return } guard !sidebarState.isVisible, sidebarPeek.policy.isEnabled else { return } if hovering { + // A hover re-fired by the hide click itself is not a + // request to show; only an arrival after the window + // pre-reveals. + if let until = sidebarToggleHoverSuppressedUntil, Date() < until { + return + } sidebarPeek.pointerEnteredActivationControl() } else { sidebarPeek.pointerExitedEdge() } } + .onChange(of: sidebarState.isVisible) { visible in + guard !visible else { return } + // Hiding retires any peek in flight and opens the hover + // suppression window, so the card cannot flash over the + // closing pane. The show direction is untouched. + sidebarPeek.sidebarCollapsed() + sidebarToggleHoverSuppressedUntil = Date().addingTimeInterval(0.3) + } .background( // Zero-sized anchor that owns the floating card's child // window. The card cannot live in this tree: the portal diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 12a9a0991d9e..70188bc3c825 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -689,16 +689,59 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { } } +/// Button cell that centres its image on the bounds exactly. AppKit's default +/// image rect for a borderless button sits a point off, which shows the +/// moment a hover circle is drawn around the glyph. +@MainActor +final class SidebarCenteredGlyphButtonCell: NSButtonCell { + override func imageRect(forBounds rect: NSRect) -> NSRect { + guard let image else { return super.imageRect(forBounds: rect) } + let size = image.size + return NSRect( + x: rect.midX - size.width / 2, + y: rect.midY - size.height / 2, + width: size.width, + height: size.height + ) + } +} + /// Borderless glyph button used for the header chevron and plus controls. @MainActor final class SidebarHeaderGlyphButton: NSButton { + override class var cellClass: AnyClass? { + get { SidebarCenteredGlyphButtonCell.self } + set {} + } + var onClick: (() -> Void)? var menuProvider: (() -> NSMenu?)? + /// Opt-in pointer feedback: a soft circle behind the glyph and the glyph + /// lifted to the full label colour while the pointer is on it. + var highlightsOnHover = false { + didSet { if !highlightsOnHover { setHovering(false) } } + } + var glyphImage: NSImage? { didSet { image = glyphImage } } + /// The tint the owner asked for; hover lifts the glyph away from it and + /// must come back to exactly this, not to whatever it lifted to. + private var restingTintColor: NSColor? + private var isApplyingHoverTint = false + private var isHovering = false + private var hoverTrackingArea: NSTrackingArea? + + override var contentTintColor: NSColor? { + didSet { + guard !isApplyingHoverTint else { return } + restingTintColor = contentTintColor + if isHovering { applyHoverAppearance() } + } + } + init() { super.init(frame: .zero) isBordered = false @@ -717,6 +760,60 @@ final class SidebarHeaderGlyphButton: NSButton { onClick?() } + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let hoverTrackingArea { + removeTrackingArea(hoverTrackingArea) + } + let area = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + hoverTrackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + guard highlightsOnHover, isEnabled else { return } + setHovering(true) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + setHovering(false) + } + + override func layout() { + super.layout() + if highlightsOnHover { + layer?.cornerRadius = min(bounds.width, bounds.height) / 2 + } + } + + private func setHovering(_ hovering: Bool) { + guard hovering != isHovering else { return } + isHovering = hovering + applyHoverAppearance() + } + + private func applyHoverAppearance() { + wantsLayer = true + layer?.cornerRadius = min(bounds.width, bounds.height) / 2 + // Dynamic colours resolve against the drawing appearance, so the + // circle and glyph pick up the sidebar's own light/dark scheme. + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.backgroundColor = isHovering + ? NSColor.labelColor.withAlphaComponent(0.14).cgColor + : NSColor.clear.cgColor + } + isApplyingHoverTint = true + contentTintColor = isHovering ? .labelColor : restingTintColor + isApplyingHoverTint = false + } + override func menu(for event: NSEvent) -> NSMenu? { menuProvider?() ?? super.menu(for: event) } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index b07ae974b22c..192e806ac336 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -343,6 +343,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { closeButton.setAccessibilityIdentifier("sidebarWorkspaceCloseButton") closeButton.setAccessibilityElement(false) closeButton.concealImmediately() + closeButton.highlightsOnHover = true contentContainer.addSubview(closeButton) contentContainer.addSubview(descriptionView) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index 734f5e219ec8..b185c7595427 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -151,8 +151,6 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } previewBailoutTask?.cancel() } -<<<<<<< HEAD - private func clearPendingWorkspaceDragWriters( preserving preservedWriter: SidebarWorkspaceDragPasteboardWriter? = nil ) { diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift index 0f908ba31306..408bd6545b42 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift @@ -62,6 +62,11 @@ final class SidebarPeekPanelWindowController { /// false still lands so the exit animation runs. private var lastRevealed = false private var hasPushedContent = false + /// Compositor blur radius last pushed to the panel window, so the CGS + /// call only fires when the value (or the panel) changes. + private var appliedBlurRadius: Int? + /// Pending blur removal after the card hides; see `syncCompositorBlur`. + private var blurResetTask: Task? /// Trailing-edge coalescing for content pushes. ContentView can evaluate /// its body many times inside one runloop turn (worst during a workspace /// switch); the first push of a turn lands synchronously so clicks @@ -102,6 +107,7 @@ final class SidebarPeekPanelWindowController { contentWidth: CGFloat, metrics: SidebarPeekPanelMetrics, acceptsMouse: Bool, + glassBlurRadius: Int?, content: AnyView ) { guard let parent, parent.isVisible else { @@ -112,6 +118,7 @@ final class SidebarPeekPanelWindowController { lastMetrics = metrics ensurePanel(parent: parent) guard let panel, let hostingView else { return } + syncCompositorBlur(glassBlurRadius, revealed: acceptsMouse, panel: panel) // Push content only while the card shows (or on the hide transition, // which the slide-out animation needs). Pushing on every ContentView // update while hidden re-diffed the whole sidebar subtree during @@ -149,6 +156,44 @@ final class SidebarPeekPanelWindowController { layoutPanel() } + /// Mirrors the docked ground's compositor blur onto the card's own window, + /// but only while the card is showing. The panel stays attached over the + /// leading column even when hidden, and a transparent window with a blur + /// radius blurs everything beneath it: with the card away that would be + /// the docked sidebar's own rows. Removal waits for the exit slide so the + /// card does not go clear mid-flight. + private func syncCompositorBlur(_ radius: Int?, revealed: Bool, panel: NSWindow) { + if radius == nil { + // No blur wanted at all (docked, or glass off): drop it now rather + // than after the exit delay, or the docked rows underneath start + // out blurred. + blurResetTask?.cancel() + blurResetTask = nil + applyCompositorBlur(nil, to: panel) + } else if revealed { + blurResetTask?.cancel() + blurResetTask = nil + applyCompositorBlur(radius, to: panel) + } else if appliedBlurRadius != nil, blurResetTask == nil { + blurResetTask = Task { @MainActor [weak self] in + guard (try? await Task.sleep(for: .milliseconds(320))) != nil else { return } + guard let self, let panel = self.panel else { return } + self.blurResetTask = nil + self.applyCompositorBlur(nil, to: panel) + } + } + } + + private func applyCompositorBlur(_ radius: Int?, to panel: NSWindow) { + guard radius != appliedBlurRadius else { return } + guard panel.windowNumber > 0 else { return } + WindowBackgroundComposition.blurController.setBackgroundBlur( + windowNumber: panel.windowNumber, + radius: radius ?? 0 + ) + appliedBlurRadius = radius + } + private func ensurePanel(parent: NSWindow) { if let panel, parentWindow === parent, panel.parent === parent { return @@ -234,6 +279,9 @@ final class SidebarPeekPanelWindowController { panel = nil hostingView = nil parentWindow = nil + appliedBlurRadius = nil + blurResetTask?.cancel() + blurResetTask = nil } } @@ -289,6 +337,9 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { let contentWidth: CGFloat let metrics: SidebarPeekPanelMetrics let acceptsMouse: Bool + /// Compositor blur for the card's window; nil when the card blurs + /// through its own material instead. + let glassBlurRadius: Int? let content: AnyView @MainActor @@ -318,6 +369,7 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { let contentWidth = contentWidth let metrics = metrics let acceptsMouse = acceptsMouse + let glassBlurRadius = glassBlurRadius let content = content let push: @MainActor () -> Void = { [weak view] in controller.update( @@ -325,6 +377,7 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { contentWidth: contentWidth, metrics: metrics, acceptsMouse: acceptsMouse, + glassBlurRadius: glassBlurRadius, content: content ) } diff --git a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift index 3b76dd7eba17..d4597c895ddc 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift @@ -30,8 +30,12 @@ struct SidebarPeekPresentation: ViewModifier { /// The card's legibility tint (alpha included), resolved from the same /// appearance policy that paints the docked ground. var panelTint: Color = Color(nsColor: .windowBackgroundColor).opacity(0.52) - /// The card's glass material, matching the docked ground's. - var panelGlassMaterial: NSVisualEffectView.Material = .popover + /// The card's glass material, matching the docked ground's. Nil when the + /// card's window is blurred by the compositor instead. + var panelGlassMaterial: NSVisualEffectView.Material? = .popover + /// The material's alpha, matching the docked ground's frost thickness so + /// the floating card is exactly as see-through as the docked pane. + var panelGlassOpacity: Double = 1.0 /// Card geometry for floating mode. let panelMetrics: SidebarPeekPanelMetrics /// Acquires and releases the pointer hold as the pointer crosses the panel. @@ -48,7 +52,8 @@ struct SidebarPeekPresentation: ViewModifier { SidebarPeekPanelChrome( metrics: panelMetrics, tint: panelTint, - glassMaterial: panelGlassMaterial + glassMaterial: panelGlassMaterial, + glassOpacity: panelGlassOpacity ) { content.frame(width: width, alignment: .leading) } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index bef9ab406bf5..e68f747cb2a4 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -349,13 +349,17 @@ func sidebarSelectedWorkspaceBackgroundNSColor( subtleSelection: Bool = false, isEmphasized: Bool = true, increaseContrast: Bool = false, - accent: CmuxAccentColor = CmuxAccentColor() + accent: CmuxAccentColor = CmuxAccentColor(), + selectionAccent: SidebarSelectionAccent = .blue ) -> NSColor { if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { return parsed } if activeTabIndicatorStyle == .solidFill || !subtleSelection { + if selectionAccent == .glass { + return sidebarGlassPillNSColor(for: colorScheme).withAlphaComponent(colorScheme == .dark ? 0.24 : 0.14) + } return accent.nsColor(for: colorScheme) } let surface = NSColor(white: colorScheme == .dark ? 0.16 : 0.93, alpha: 1) @@ -365,6 +369,16 @@ func sidebarSelectedWorkspaceBackgroundNSColor( increaseContrast: increaseContrast ) return cmuxCompositedNSColor(fill.color, over: surface) + } +} + +/// The glass accent's base hue: a slate grey, not white, so the pill reads as +/// grey glass over the pane rather than a bright patch. Alpha is applied by +/// the caller; the rim and sheen reuse the hue at their own strengths. +func sidebarGlassPillNSColor(for colorScheme: ColorScheme) -> NSColor { + colorScheme == .dark + ? NSColor(srgbRed: 0.58, green: 0.62, blue: 0.68, alpha: 1) + : NSColor(srgbRed: 0.22, green: 0.24, blue: 0.28, alpha: 1) } func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { @@ -379,6 +393,13 @@ func sidebarSelectedWorkspaceForegroundNSColor( opacity: CGFloat ) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) + // A translucent pill (the glass accent) is a tint on the pane, not a + // surface: text keeps the pane's own label colour, which the pill's + // hue encodes (white pill on dark glass, black pill on light). + if backgroundColor.alphaComponent < 0.5 { + let brightness = backgroundColor.usingColorSpace(.deviceRGB)?.brightnessComponent ?? 1 + return (brightness > 0.5 ? NSColor.white : NSColor.black).withAlphaComponent(clampedOpacity) + } let whiteContrast = cmuxContrastRatio(foreground: .white, background: backgroundColor) guard whiteContrast < 2.75 else { return NSColor.white.withAlphaComponent(clampedOpacity) @@ -457,7 +478,8 @@ func sidebarWorkspaceRowBackgroundStyle( brightenInDarkMode: Bool = true, isEmphasized: Bool = true, increaseContrast: Bool = false, - accent: CmuxAccentColor = CmuxAccentColor() + accent: CmuxAccentColor = CmuxAccentColor(), + selectionAccent: SidebarSelectionAccent = .blue ) -> SidebarWorkspaceRowBackgroundStyle { // Increase Contrast: the multi-selection wash is otherwise too faint to // read against the sidebar material. @@ -469,7 +491,8 @@ func sidebarWorkspaceRowBackgroundStyle( subtleSelection: subtleSelection, isEmphasized: isEmphasized, increaseContrast: increaseContrast, - accent: accent + accent: accent, + selectionAccent: selectionAccent ) let accentBackground = accent.nsColor(for: colorScheme) let usesSubtleSelection = sidebarUsesSubtleSelection( diff --git a/Sources/SidebarTabItemSettingsSnapshot.swift b/Sources/SidebarTabItemSettingsSnapshot.swift index fad993cc4e51..76a8e0c6aa90 100644 --- a/Sources/SidebarTabItemSettingsSnapshot.swift +++ b/Sources/SidebarTabItemSettingsSnapshot.swift @@ -33,6 +33,7 @@ struct SidebarTabItemSettingsSnapshot: Equatable { let selectionColorHex: String? let subtleSelection: Bool let brightenInDarkMode: Bool + let selectionAccent: SidebarSelectionAccent let notificationBadgeColorHex: String? let visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility let iMessageModeEnabled: Bool @@ -106,6 +107,7 @@ struct SidebarTabItemSettingsSnapshot: Equatable { selectionColorHex = settings.value(for: workspaceColors.selectionColorHex).nilIfEmpty subtleSelection = settings.value(for: workspaceColors.subtleSelection) brightenInDarkMode = settings.value(for: workspaceColors.brightenInDarkMode) + selectionAccent = settings.value(for: sidebar.selectionAccent) notificationBadgeColorHex = settings.value(for: workspaceColors.notificationBadgeColorHex).nilIfEmpty iMessageModeEnabled = IMessageModeSettings.isEnabled(defaults: defaults) workspaceTodoChecklistStyle = settings.value(for: betaFeatures.workspaceTodosChecklistStyle) diff --git a/Sources/Windowing/AppWindowBackdropControllerDependencies.swift b/Sources/Windowing/AppWindowBackdropControllerDependencies.swift index 4a9a534237f9..bbdb6cd4ad3f 100644 --- a/Sources/Windowing/AppWindowBackdropControllerDependencies.swift +++ b/Sources/Windowing/AppWindowBackdropControllerDependencies.swift @@ -14,6 +14,10 @@ final class AppWindowBackdropControllerDependencies: WindowBackdropControllerDep WindowBackgroundComposition.blurController.resetBackgroundBlur(windowNumber: windowNumber) } + func setCompositorBackgroundBlur(windowNumber: Int, radius: Int) { + WindowBackgroundComposition.blurController.setBackgroundBlur(windowNumber: windowNumber, radius: radius) + } + func applyGhosttyCompositorBlurIfNeeded(to window: NSWindow) { GhosttyApp.shared.applyWindowBlurIfNeeded(window) } diff --git a/Sources/Windowing/WindowBackgroundComposition.swift b/Sources/Windowing/WindowBackgroundComposition.swift index 76bac6ceba50..6e4d374bd35a 100644 --- a/Sources/Windowing/WindowBackgroundComposition.swift +++ b/Sources/Windowing/WindowBackgroundComposition.swift @@ -2,8 +2,8 @@ import AppKit import CmuxWorkspaces /// App-side `UserDefaults`-backed conformance to the window-background settings -/// seam. The default values match the legacy god-file reads byte-for-byte: -/// `sidebarBlendMode` -> `"withinWindow"`, `bgGlassEnabled` -> `false`. +/// seam. Fork defaults: `sidebarBlendMode` -> `"behindWindow"` and +/// `bgGlassEnabled` -> `true`, so the glass look holds on a fresh domain. struct UserDefaultsWindowBackgroundSettings: WindowBackgroundSettingsReading { // `UserDefaults` accessors are documented as thread-safe; the seam requires // `Sendable`, so suppress the non-Sendable stored-property check here rather @@ -15,7 +15,7 @@ struct UserDefaultsWindowBackgroundSettings: WindowBackgroundSettingsReading { } var sidebarBlendModeRawValue: String { - defaults.string(forKey: "sidebarBlendMode") ?? "withinWindow" + defaults.string(forKey: "sidebarBlendMode") ?? "behindWindow" } var isBackgroundGlassEnabled: Bool { diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 7d3719baf459..e95674de7458 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -473,17 +473,12 @@ struct cmuxApp: App { approximatelyEqual(blurOpacity, 0.79) && approximatelyEqual(cornerRadius, 0.0) - if usesLegacyDefaults { - let preset = SidebarPresetOption.nativeSidebar - defaults.set(preset.rawValue, forKey: "sidebarPreset") - defaults.set(preset.material.rawValue, forKey: "sidebarMaterial") - defaults.set(preset.blendMode.rawValue, forKey: "sidebarBlendMode") - defaults.set(preset.state.rawValue, forKey: "sidebarState") - defaults.set(preset.tintHex, forKey: "sidebarTintHex") - defaults.set(preset.tintOpacity, forKey: "sidebarTintOpacity") - defaults.set(preset.blurOpacity, forKey: "sidebarBlurOpacity") - defaults.set(preset.cornerRadius, forKey: "sidebarCornerRadius") - } + // Fork: upstream stamped the nativeSidebar preset here, which stores + // sidebarBlendMode = withinWindow and tintOpacity = 0 into every fresh + // defaults domain. Stored values beat the code defaults, so that stamp + // silently killed the fork's behind-window glass on first launch. + // Legacy-default users now simply stay on the code defaults. + _ = usesLegacyDefaults defaults.set(targetVersion, forKey: migrationKey) } @@ -1769,11 +1764,11 @@ private enum DebugWindowConfigSnapshot { static func combinedPayload(defaults: UserDefaults = .standard) -> String { let sidebarPayload = """ sidebarPreset=\(stringValue(defaults, key: "sidebarPreset", fallback: SidebarPresetOption.nativeSidebar.rawValue)) - sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.sidebar.rawValue)) - sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.withinWindow.rawValue)) - sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.followWindow.rawValue)) + sidebarMaterial=\(stringValue(defaults, key: "sidebarMaterial", fallback: SidebarMaterialOption.hudWindow.rawValue)) + sidebarBlendMode=\(stringValue(defaults, key: "sidebarBlendMode", fallback: SidebarBlendModeOption.behindWindow.rawValue)) + sidebarState=\(stringValue(defaults, key: "sidebarState", fallback: SidebarStateOption.active.rawValue)) sidebarBlurOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarBlurOpacity", fallback: 1.0))) - sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: "#000000")) + sidebarTintHex=\(stringValue(defaults, key: "sidebarTintHex", fallback: SidebarTintDefaults().hex)) sidebarTintHexLight=\(stringValue(defaults, key: "sidebarTintHexLight", fallback: "(nil)")) sidebarTintHexDark=\(stringValue(defaults, key: "sidebarTintHexDark", fallback: "(nil)")) sidebarTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "sidebarTintOpacity", fallback: 0.18))) @@ -3415,9 +3410,9 @@ private struct SidebarDebugView: View { @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults().hex @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? @AppStorage("sidebarTintHexDark") private var sidebarTintHexDark: String? - @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.sidebar.rawValue - @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue - @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.followWindow.rawValue + @AppStorage("sidebarMaterial") private var sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue + @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue + @AppStorage("sidebarState") private var sidebarState = SidebarStateOption.active.rawValue @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 @AppStorage(SidebarCatalogSection().branchVerticalLayout.userDefaultsKey) @@ -3575,9 +3570,9 @@ private struct SidebarDebugView: View { } Button("Reset Blur") { sidebarMaterial = SidebarMaterialOption.hudWindow.rawValue - sidebarBlendMode = SidebarBlendModeOption.withinWindow.rawValue + sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue sidebarState = SidebarStateOption.active.rawValue - sidebarBlurOpacity = 0.98 + sidebarBlurOpacity = 1.0 } Button("Reset Shape") { sidebarCornerRadius = 0.0 From 5577cd07cd92897045109f13e304dae552379457 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sun, 6 Sep 2026 17:00:40 +1200 Subject: [PATCH 12/69] group drags lift the whole block, not just the header dragging a workspace group moved the header row alone while its members stayed put. the freeform lift now carries the header plus its visible member rows as one block, other rows and groups part around the block's real height, and a group can never land inside another group mid-drag. single row drags are unchanged; the layout math is now exact for mixed row heights. commit path is upstream's existing whole-group move. --- .../SidebarWorkspaceTableController.swift | 128 ++++++++++++++---- 1 file changed, 98 insertions(+), 30 deletions(-) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index b185c7595427..f1e3a2319021 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -2123,12 +2123,67 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// layer transforms over an unchanged table. private struct ReorderLiftSession { let workspaceId: UUID - let sourceIndex: Int - let rowHeight: CGFloat + /// Rows travelling with the pointer: one row, or a group header plus + /// its visible members. A group's header mints its anchor's id (see + /// `pasteboardWriterForRow`), so an anchor drag is a whole-group move + /// and the block is what the hand is holding. + let sourceRange: Range + /// Every other row, grouped into the units that shift as one. A row + /// drag parts single rows (a row can slot inside a group); a group + /// drag parts whole groups (a group only reorders among top-level + /// units), so a header never separates from its members mid-drag. + let otherUnits: [Range] let frames: [CGRect] + /// Vertical gap between adjacent rows, so re-laid units keep it. + let rowSpacing: CGFloat /// The applied translation target per row index, so a row only /// animates when its target actually flips. var appliedTargets: [CGFloat] + + func height(of unit: Range) -> CGFloat { + frames[unit].reduce(0) { $0 + $1.height } + rowSpacing * CGFloat(max(0, unit.count - 1)) + } + } + + /// The row range a drag of `workspaceId` carries: a group header plus + /// its visible members when the id is a group anchor, else the one row. + private func reorderSourceRange(for workspaceId: UUID) -> Range? { + guard let first = rows.firstIndex(where: { $0.workspaceId == workspaceId }) else { return nil } + guard rows[first].isGroupHeader, let groupId = rows[first].groupId else { + return first..<(first + 1) + } + return groupBlockRange(headerIndex: first, groupId: groupId) + } + + /// Header row plus the contiguous member rows below it. A collapsed + /// group shows no members, so its block is the header alone. + private func groupBlockRange(headerIndex: Int, groupId: UUID) -> Range { + var end = headerIndex + 1 + while end < rows.count, !rows[end].isGroupHeader, rows[end].groupId == groupId { + end += 1 + } + return headerIndex.., wholeGroups: Bool) -> [Range] { + var units: [Range] = [] + var index = 0 + while index < rows.count { + if source.contains(index) { + index = source.upperBound + continue + } + if wholeGroups, rows[index].isGroupHeader, let groupId = rows[index].groupId { + let block = groupBlockRange(headerIndex: index, groupId: groupId) + units.append(block) + index = block.upperBound + } else { + units.append(index..<(index + 1)) + index += 1 + } + } + return units } private var reorderLiftSession: ReorderLiftSession? @@ -2170,58 +2225,71 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS if reorderLiftSession?.workspaceId != workspaceId { endReorderLift(animated: false) - guard let sourceIndex = rows.firstIndex(where: { $0.workspaceId == workspaceId }) else { return } + guard let sourceRange = reorderSourceRange(for: workspaceId) else { return } let frames = (0.. 0 else { return } + guard frames.indices.contains(sourceRange.lowerBound), + frames[sourceRange.lowerBound].height > 0 else { return } + let isGroupDrag = rows[sourceRange.lowerBound].isGroupHeader reorderLiftSession = ReorderLiftSession( workspaceId: workspaceId, - sourceIndex: sourceIndex, - rowHeight: frames[sourceIndex].height, + sourceRange: sourceRange, + otherUnits: reorderUnits(excluding: sourceRange, wholeGroups: isGroupDrag), frames: frames, + rowSpacing: frames.count > 1 ? max(0, frames[1].minY - frames[0].maxY) : 0, appliedTargets: Array(repeating: 0, count: rows.count) ) } guard var session = reorderLiftSession else { return } - // The dragged row's visual centre: the pointer, clamped so the row - // cannot leave the list. The top clamp is the first row's slot (a - // tab cannot go above the new-workspace row); the bottom is the - // full extent of the list area, so a tab can be carried all the way + // The dragged block's visual centre: the pointer, clamped so the + // block cannot leave the list. The top clamp is the first slot (a + // tab cannot go above the new-workspace row); the bottom is the full + // extent of the list area, so a block can be carried all the way // down past the last row. - let minCenter = session.frames[0].midY - let maxCenter = max(minCenter, table.bounds.maxY - session.rowHeight / 2) + let blockHeight = session.height(of: session.sourceRange) + let blockTop = session.frames[session.sourceRange.lowerBound].minY + let minCenter = session.frames[0].minY + blockHeight / 2 + let maxCenter = max(minCenter, table.bounds.maxY - blockHeight / 2) let draggedCenter = min(max(point.y, minCenter), maxCenter) - - // Insertion slot from frozen midpoints: how many other rows sit - // above the dragged row's centre. - var insertion = 0 - for index in session.frames.indices where index != session.sourceIndex { - if session.frames[index].midY < draggedCenter { insertion += 1 } + let blockShift = draggedCenter - (blockTop + blockHeight / 2) + + // Insertion slot from frozen midpoints: how many other units sit + // above the dragged block's centre. + let insertion = session.otherUnits.filter { unit in + let top = session.frames[unit.lowerBound].minY + return top + session.height(of: unit) / 2 < draggedCenter + }.count + + // Re-lay every unit in drop order and read each row's offset from + // where it would land. Exact for mixed heights (a header is not a + // row) and for blocks, where a plain one-row-height shift is wrong. + var order = session.otherUnits + order.insert(session.sourceRange, at: insertion) + var targets = Array(repeating: CGFloat(0), count: session.frames.count) + var nextTop = session.frames[0].minY + for unit in order { + if unit != session.sourceRange { + let delta = nextTop - session.frames[unit.lowerBound].minY + for row in unit { targets[row] = delta } + } + nextTop += session.height(of: unit) + session.rowSpacing } table.enumerateAvailableRowViews { rowView, row in guard session.frames.indices.contains(row) else { return } rowView.wantsLayer = true guard let layer = rowView.layer else { return } - if row == session.sourceIndex { + if session.sourceRange.contains(row) { // Pointer-driven, never animated: any smoothing here reads - // as the row lagging the hand. + // as the block lagging the hand. CATransaction.begin() CATransaction.setDisableActions(true) layer.zPosition = 100 - layer.transform = CATransform3DMakeTranslation( - 0, draggedCenter - session.frames[row].midY, 0 - ) + layer.transform = CATransform3DMakeTranslation(0, blockShift, 0) CATransaction.commit() return } - // Final position of this row if dropped now: removing the - // dragged row shifts later rows up one; inserting at the slot - // pushes rows at or past it down one. The offset is always - // exactly one dragged-row height or zero. - var finalPosition = row - (row > session.sourceIndex ? 1 : 0) - if finalPosition >= insertion { finalPosition += 1 } - let target = CGFloat(finalPosition - row) * session.rowHeight + let target = targets[row] if session.appliedTargets[row] != target { session.appliedTargets[row] = target layer.zPosition = 0 From 24589aa2500ec7560d68a2e35eeb534984ea09a9 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Sun, 6 Sep 2026 20:37:41 +1200 Subject: [PATCH 13/69] drop the sidebar filter and the render gallery i removed a filter engine i added as it was mostly for testing, and removed a preview tool that rendered sidebar states to png images without launching the app, which i used as a dev tool for visual iteration. the fuzzy matcher goes back to the command palette where it lived. --- .gitignore | 3 - .../CommandPaletteSearchOrchestrator.swift | 1 - .../Search/CommandPaletteFuzzyMatcher.swift | 1093 ++++++++++++++++- .../CommandPaletteSearchCorpusEntry.swift | 1 - .../Search/CommandPaletteSearchEngine.swift | 1 - .../Search/CommandPaletteSearchWordText.swift | 1 - .../CommandPaletteNucleoSearchIndex.swift | 1 - .../CmuxFoundation/Search/FuzzyMatcher.swift | 1091 ---------------- .../Filter/SidebarFilterCandidate.swift | 37 - .../Filter/SidebarFilterCandidateField.swift | 39 - .../Filter/SidebarFilterField.swift | 70 -- .../Filter/SidebarFilterGroup.swift | 30 - .../Filter/SidebarFilterIndex.swift | 152 --- .../Filter/SidebarFilterMatch.swift | 93 -- .../Filter/SidebarFilterOutcome.swift | 81 -- .../Filter/SidebarFilterQuery.swift | 74 -- .../UI/SidebarFilterEmptyStateView.swift | 88 -- .../Filter/UI/SidebarFilterFieldChrome.swift | 117 -- .../Filter/UI/SidebarFilterFieldModel.swift | 56 - .../Filter/UI/SidebarFilterFieldView.swift | 63 - .../UI/SidebarFilterHighlightStyle.swift | 84 -- .../UI/SidebarFilterHighlightedText.swift | 70 -- .../Filter/UI/SidebarFilterMetrics.swift | 37 - .../Filter/UI/SidebarFilterScopeLabel.swift | 25 - .../SidebarFilterHighlightTests.swift | 80 -- .../SidebarFilterHighlightedTextTests.swift | 71 -- .../SidebarFilterIndexTests.swift | 223 ---- .../SidebarFilterQueryTests.swift | 65 - .../SidebarFilterScaleTests.swift | 143 --- .../macOS/CmuxSidebarGallery/Package.resolved | 15 - .../macOS/CmuxSidebarGallery/Package.swift | 29 - .../CmuxSidebarGallery/GalleryCatalog.swift | 191 --- .../CmuxSidebarGallery/GalleryMockRow.swift | 67 - .../CmuxSidebarGallery/GalleryRenderer.swift | 60 - .../CmuxSidebarGallery/GalleryScene.swift | 14 - .../GalleryTerminalBackdrop.swift | 55 - .../Sources/CmuxSidebarGallery/main.swift | 31 - .../Filter/SidebarFilterController.swift | 129 -- .../Filter/SidebarFilterCorpusKey.swift | 53 - cmux.xcodeproj/project.pbxproj | 8 - 40 files changed, 1085 insertions(+), 3457 deletions(-) delete mode 100644 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Search/FuzzyMatcher.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidate.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidateField.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterField.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterGroup.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterIndex.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterMatch.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterOutcome.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterQuery.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterEmptyStateView.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldChrome.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldModel.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldView.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightStyle.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightedText.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterMetrics.swift delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterScopeLabel.swift delete mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightTests.swift delete mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightedTextTests.swift delete mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterIndexTests.swift delete mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterQueryTests.swift delete mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterScaleTests.swift delete mode 100644 Packages/macOS/CmuxSidebarGallery/Package.resolved delete mode 100644 Packages/macOS/CmuxSidebarGallery/Package.swift delete mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift delete mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryMockRow.swift delete mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryRenderer.swift delete mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryScene.swift delete mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryTerminalBackdrop.swift delete mode 100644 Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/main.swift delete mode 100644 Sources/Sidebar/Filter/SidebarFilterController.swift delete mode 100644 Sources/Sidebar/Filter/SidebarFilterCorpusKey.swift diff --git a/.gitignore b/.gitignore index 6cfd6cb80770..1e2e490f2db1 100644 --- a/.gitignore +++ b/.gitignore @@ -78,6 +78,3 @@ tmux-*.log # Remote-only Blacksmith cmux-tui benchmark output /testbox-benchmark/ /.cmux-scratch/ - -# CmuxSidebarGallery render output (dev-only, see Packages/macOS/CmuxSidebarGallery) -.gallery/ diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift index 36fb0055fa12..853821ff5723 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Orchestration/CommandPaletteSearchOrchestrator.swift @@ -1,4 +1,3 @@ -public import CmuxFoundation public import Foundation /// Orchestrates one palette search across both engines: prefers the nucleo diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift index 5bfaced186c8..8ee5a91ae390 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteFuzzyMatcher.swift @@ -1,10 +1,1087 @@ -public import CmuxFoundation +import Foundation -/// The command palette's name for the shared ``FuzzyMatcher``. +/// The Swift fuzzy matcher behind command-palette ranking: token scoring over +/// word segments with exact/prefix/contains/initialism/stitched-prefix and +/// single-edit fallbacks. Pure logic; scores are deterministic for a given +/// query/candidate pair. /// -/// The matcher moved down to `CmuxFoundation` when the sidebar workspace -/// filter needed the same scoring: two domain packages may not depend on each -/// other, so the shared primitive lifts to the common lower package. This -/// alias keeps every palette call site (and its reference-scoring tests) -/// spelled as before. -public typealias CommandPaletteFuzzyMatcher = FuzzyMatcher +/// A matcher is a value object bound to one prepared query: construct it with +/// `init(query:)` (or an already-prepared query) and score candidates through +/// the instance methods. The preparation/normalization building blocks remain +/// `static` factories on the value types they produce, per the foundation +/// helper conventions. +public struct CommandPaletteFuzzyMatcher { + /// The prepared query this matcher scores candidates against. + public let preparedQuery: PreparedQuery + + /// Prepares `query` for matching. + public init(query: String) { self.preparedQuery = Self.preparedQuery(query) } + + /// Binds an already-prepared query. + public init(preparedQuery: PreparedQuery) { self.preparedQuery = preparedQuery } + + /// Scores `candidate` against this matcher's query; nil when it does not + /// match. + public func score(candidate: String) -> Int? { + Self.score(query: preparedQuery.normalizedText, candidate: candidate) + } + + /// Scores `preparedCandidate` against this matcher's query. + public func score(preparedCandidate: PreparedCandidateText) -> Int? { + Self.score(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) + } + + /// Candidate character indices matched by this matcher's query. + public func matchCharacterIndices(in candidate: String) -> Set { + Self.matchCharacterIndices(preparedQuery: preparedQuery, candidate: candidate) + } + + /// Candidate character indices matched against a prepared candidate. + public func matchCharacterIndices(in preparedCandidate: PreparedCandidateText) -> Set { + Self.matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) + } + + private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] + + /// Half-open `[start, end)` character range of one word in a candidate. + public struct WordSegment: Hashable, Sendable { + /// Index of the first character of the word. + public let start: Int + /// Index one past the last character of the word. + public let end: Int + } + + /// 128-bit presence mask of ASCII scalars used to cheaply prune + /// candidates that cannot contain a token's characters. + public struct ASCIIScalarMask: Equatable, Sendable { + /// Bits for scalars 0-63. + public let low: UInt64 + /// Bits for scalars 64-127. + public let high: UInt64 + + /// Creates a mask from raw bit halves. + public init(low: UInt64, high: UInt64) { + self.low = low + self.high = high + } + + /// Builds the mask from the ASCII scalars of `text`. + public init(_ text: String) { + var low: UInt64 = 0 + var high: UInt64 = 0 + for scalar in text.unicodeScalars where scalar.isASCII { + let value = Int(scalar.value) + if value < 64 { + low |= UInt64(1) << UInt64(value) + } else { + high |= UInt64(1) << UInt64(value - 64) + } + } + self.low = low + self.high = high + } + + /// Number of scalars present here but absent from `candidate`. + public func missingBitCount(from candidate: ASCIIScalarMask) -> Int { + (low & ~candidate.low).nonzeroBitCount + (high & ~candidate.high).nonzeroBitCount + } + } + + /// One normalized query token with precomputed characters, ASCII mask, + /// and score bounds. + public struct PreparedToken: Equatable, Sendable { + /// The normalized token text. + public let normalizedText: String + /// The token's characters in order. + public let characters: [Character] + /// ASCII presence mask for fast pruning. + public let asciiMask: ASCIIScalarMask + /// Whether single-edit (typo) fallback matching applies (length >= 4). + public let allowsSingleEdit: Bool + /// Whether the token contains a word-boundary character. + public let containsTokenBoundaryCharacter: Bool + /// Maximum achievable score for this token. + public let scoreUpperBound: Int + /// Maximum achievable score excluding an exact whole-text match. + public let scoreUpperBoundWithoutExactMatch: Int + + /// Prepares a normalized token for matching. + public init(_ normalizedText: String) { + self.normalizedText = normalizedText + self.characters = Array(normalizedText) + self.asciiMask = ASCIIScalarMask(normalizedText) + self.allowsSingleEdit = characters.count >= 4 + self.containsTokenBoundaryCharacter = characters.contains { + CommandPaletteFuzzyMatcher.tokenBoundaryChars.contains($0) + } + self.scoreUpperBound = max(8000, 3500 + (characters.count * 300)) + self.scoreUpperBoundWithoutExactMatch = max(6799, 3500 + (characters.count * 300)) + } + + /// Fast pre-check: whether `candidate` could possibly match this token + /// within the allowed edit budget. + public func couldMatch(_ candidate: PreparedCandidateText) -> Bool { + let missingCharacters = asciiMask.missingBitCount(from: candidate.asciiMask) + return missingCharacters <= (allowsSingleEdit ? 1 : 0) + } + } + + /// One normalized candidate string with precomputed characters, word + /// segments, and ASCII mask. + public struct PreparedCandidateText: Sendable { + /// The normalized candidate text. + public let normalizedText: String + /// The candidate's characters in order. + public let characters: [Character] + /// Word segments split on token-boundary characters. + public let wordSegments: [WordSegment] + /// ASCII presence mask for fast pruning. + public let asciiMask: ASCIIScalarMask + + /// Prepares a normalized candidate for matching. + public init(normalizedText: String) { + self.normalizedText = normalizedText + self.characters = Array(normalizedText) + self.wordSegments = CommandPaletteFuzzyMatcher.wordSegments(characters) + self.asciiMask = ASCIIScalarMask(normalizedText) + } + } + + private enum SingleEditWordPrefixEditKind { + case candidateExtraCharacter + case tokenExtraCharacter + case substitutedCharacter + case transposedCharacters + + var basePenalty: Int { + switch self { + case .candidateExtraCharacter: + return 0 + case .tokenExtraCharacter: + return 240 + case .transposedCharacters: + return 24 + case .substitutedCharacter: + return 40 + } + } + } + + private struct SingleEditWordPrefixMatch { + let matchedIndices: Set + let segmentStart: Int + let segmentLength: Int + let prefixLength: Int + let editPosition: Int + let editKind: SingleEditWordPrefixEditKind + } + + /// A normalized query split into prepared tokens. + public struct PreparedQuery { + /// The full normalized query text. + public let normalizedText: String + /// Normalized query tokens joined by single spaces. + public let normalizedTokenText: String + /// The prepared tokens, in query order. + public let tokens: [PreparedToken] + + /// Whether the query has no tokens. + public var isEmpty: Bool { + tokens.isEmpty + } + } + + /// Normalizes and tokenizes `query` for matching. + public static func preparedQuery(_ query: String) -> PreparedQuery { + let normalizedQuery = normalizeForSearch(query) + let tokens = normalizedQuery + .split(separator: " ") + .map(String.init) + .filter { !$0.isEmpty } + .map(PreparedToken.init) + return PreparedQuery( + normalizedText: normalizedQuery, + normalizedTokenText: tokens.map(\.normalizedText).joined(separator: " "), + tokens: tokens + ) + } + + /// Canonical search normalization: trim, diacritic-fold, case-fold. + public static func normalizeForSearch(_ text: String) -> String { + text + .trimmingCharacters(in: .whitespacesAndNewlines) + .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + .lowercased() + } + + /// Normalizes and prepares `candidate`, or nil when it normalizes to empty. + public static func prepareCandidateText(_ candidate: String) -> PreparedCandidateText? { + let normalizedCandidate = normalizeForSearch(candidate) + guard !normalizedCandidate.isEmpty else { return nil } + return PreparedCandidateText(normalizedText: normalizedCandidate) + } + + /// Prepares an already-normalized candidate, or nil when empty. + public static func prepareNormalizedCandidateText(_ normalizedCandidate: String) -> PreparedCandidateText? { + guard !normalizedCandidate.isEmpty else { return nil } + return PreparedCandidateText(normalizedText: normalizedCandidate) + } + + /// Scores `query` against a single candidate; nil when it does not match. + public static func score(query: String, candidate: String) -> Int? { + score(query: query, candidates: [candidate]) + } + + /// Scores `query` against multiple candidate texts; nil when any token fails. + public static func score(query: String, candidates: [String]) -> Int? { + let preparedQuery = preparedQuery(query) + var normalizedCandidates: [String] = [] + normalizedCandidates.reserveCapacity(candidates.count) + for candidate in candidates { + let normalizedCandidate = normalizeForSearch(candidate) + guard !normalizedCandidate.isEmpty else { continue } + normalizedCandidates.append(normalizedCandidate) + } + return score( + preparedQuery: preparedQuery, + normalizedCandidates: normalizedCandidates + ) + } + + /// Scores a prepared query against normalized candidate texts. + public static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { + score( + preparedQuery: preparedQuery, + preparedCandidates: normalizedCandidates.compactMap(prepareNormalizedCandidateText), + exactCandidateTexts: Set(normalizedCandidates) + ) + } + + /// Scores a prepared query against prepared candidates. + public static func score(preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText]) -> Int? { + score( + preparedQuery: preparedQuery, + preparedCandidates: preparedCandidates, + exactCandidateTexts: nil + ) + } + + /// Scores a prepared query against one prepared candidate. + public static func score(preparedQuery: PreparedQuery, preparedCandidate: PreparedCandidateText) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + + var totalScore = 0 + for token in preparedQuery.tokens { + guard token.couldMatch(preparedCandidate) else { return nil } + guard let tokenScore = scoreToken(token, in: preparedCandidate) else { return nil } + totalScore += tokenScore + } + return totalScore + } + + /// Full scoring entry point with exact-text and prefix-score fast paths. + public static func score( + preparedQuery: PreparedQuery, + preparedCandidates: [PreparedCandidateText], + exactCandidateTexts: Set?, + wholeCandidatePrefixScoreByToken: [String: Int]? = nil + ) -> Int? { + guard !preparedQuery.isEmpty else { return 0 } + guard !preparedCandidates.isEmpty else { return nil } + + var totalScore = 0 + for token in preparedQuery.tokens { + let hasExactCandidateText = exactCandidateTexts?.contains(token.normalizedText) == true + if token.scoreUpperBound == 8000, hasExactCandidateText { + totalScore += 8000 + continue + } + if exactCandidateTexts != nil, + !hasExactCandidateText, + let prefixScore = wholeCandidatePrefixScoreByToken?[token.normalizedText] + ?? bestWholeCandidatePrefixScore(token: token, preparedCandidates: preparedCandidates), + prefixScore >= token.scoreUpperBoundWithoutExactMatch { + totalScore += prefixScore + continue + } + + var bestTokenScore: Int? + for candidate in preparedCandidates { + guard token.couldMatch(candidate) else { continue } + guard let candidateScore = scoreToken(token, in: candidate) else { continue } + bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) + if bestTokenScore ?? 0 >= token.scoreUpperBound { + break + } + } + guard let bestTokenScore else { return nil } + totalScore += bestTokenScore + } + return totalScore + } + + private static func bestWholeCandidatePrefixScore( + token: PreparedToken, + preparedCandidates: [PreparedCandidateText] + ) -> Int? { + var bestScore: Int? + for candidate in preparedCandidates where candidate.normalizedText.hasPrefix(token.normalizedText) { + let score = 6800 - max(0, candidate.characters.count - token.characters.count) + bestScore = max(bestScore ?? score, score) + } + return bestScore + } + + /// Precomputes best whole-candidate prefix scores keyed by prefix text. + public static func wholeCandidatePrefixScoreByToken( + preparedCandidates: [PreparedCandidateText], + maxPrefixLength: Int = 16 + ) -> [String: Int] { + var scores: [String: Int] = [:] + for candidate in preparedCandidates { + let prefixLimit = min(candidate.characters.count, maxPrefixLength) + guard prefixLimit > 0 else { continue } + + for prefixLength in 1...prefixLimit { + let prefix = String(candidate.characters.prefix(prefixLength)) + let score = 6800 - max(0, candidate.characters.count - prefixLength) + if score > (scores[prefix] ?? Int.min) { + scores[prefix] = score + } + } + } + return scores + } + + /// Candidate character indices matched by `query`, for highlight rendering. + public static func matchCharacterIndices(query: String, candidate: String) -> Set { + matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) + } + + /// Candidate character indices matched by a prepared query. + public static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { + guard !preparedQuery.isEmpty else { return [] } + + guard let preparedCandidate = prepareCandidateText(candidate) else { return [] } + return matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) + } + + /// Candidate character indices matched by a prepared query against a + /// prepared candidate. + public static func matchCharacterIndices( + preparedQuery: PreparedQuery, + preparedCandidate: PreparedCandidateText + ) -> Set { + guard !preparedQuery.isEmpty else { return [] } + + let loweredCandidate = preparedCandidate.normalizedText + let candidateChars = preparedCandidate.characters + var matched: Set = [] + + for token in preparedQuery.tokens { + guard token.couldMatch(preparedCandidate) else { continue } + + if token.normalizedText == loweredCandidate { + matched.formUnion(0.. Bool { + guard !token.normalizedText.isEmpty else { return true } + + let candidateText = candidate.normalizedText + if token.normalizedText == candidateText { + return true + } + if candidateText.hasPrefix(token.normalizedText) { + return true + } + if candidateText.range(of: token.normalizedText) != nil { + return true + } + + guard !token.containsTokenBoundaryCharacter else { + return token.characters.count <= 3 && subsequenceScore(token: token, candidate: candidate) != nil + } + + if bestWordScore(tokenChars: token.characters, candidate: candidate) != nil { + return true + } + if initialismScore(tokenChars: token.characters, candidate: candidate) != nil { + return true + } + if stitchedWordPrefixScore(tokenChars: token.characters, candidate: candidate) != nil { + return true + } + if token.characters.count <= 3, subsequenceScore(token: token, candidate: candidate) != nil { + return true + } + return false + } + + /// Whether any token only matches these candidates via the single-edit + /// (typo) word-prefix fallback. + public static func usesSingleEditWordPrefix( + preparedQuery: PreparedQuery, + preparedCandidates: [PreparedCandidateText] + ) -> Bool { + for token in preparedQuery.tokens where token.allowsSingleEdit && !token.containsTokenBoundaryCharacter { + for candidate in preparedCandidates { + guard !tokenCanMatchWithoutSingleEdit(token, preparedCandidate: candidate) else { continue } + if singleEditWordPrefixMatch( + tokenChars: token.characters, + candidateChars: candidate.characters, + segments: candidate.wordSegments + ) != nil { + return true + } + } + } + return false + } + + private static func scoreToken(_ token: PreparedToken, in candidate: PreparedCandidateText) -> Int? { + guard !token.normalizedText.isEmpty else { return 0 } + + let candidateText = candidate.normalizedText + let candidateChars = candidate.characters + let tokenChars = token.characters + guard tokenChars.count <= candidateChars.count else { return nil } + + if token.normalizedText == candidateText { + return 8000 + } + if candidateText.hasPrefix(token.normalizedText) { + return 6800 - max(0, candidateChars.count - tokenChars.count) + } + + var bestScore: Int? + if !token.containsTokenBoundaryCharacter { + if let wordScore = bestWordScore(tokenChars: tokenChars, candidate: candidate) { + bestScore = max(bestScore ?? wordScore, wordScore) + } + if let singleEditPrefixScore = singleEditWordPrefixScore( + tokenChars: tokenChars, + candidate: candidate + ) { + bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) + } + } + + if let range = candidateText.range(of: token.normalizedText) { + let distance = candidateText.distance(from: candidateText.startIndex, to: range.lowerBound) + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + let boundaryBoost: Int = { + guard distance > 0 else { return 220 } + let prior = candidateChars[distance - 1] + return tokenBoundaryChars.contains(prior) ? 180 : 0 + }() + let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty + bestScore = max(bestScore ?? containsScore, containsScore) + } + + if !token.containsTokenBoundaryCharacter { + if let initialismScore = initialismScore(tokenChars: tokenChars, candidate: candidate) { + bestScore = max(bestScore ?? initialismScore, initialismScore) + } + + if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidate: candidate) { + bestScore = max(bestScore ?? stitchedScore, stitchedScore) + } + } + + if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { + bestScore = max(bestScore ?? subsequence, subsequence) + } + + guard let bestScore else { return nil } + return max(1, bestScore) + } + + private static func bestWordScore( + tokenChars: [Character], + candidate: PreparedCandidateText + ) -> Int? { + guard !tokenChars.isEmpty else { return nil } + + let candidateChars = candidate.characters + var best: Int? + for segment in candidate.wordSegments { + let wordLength = segment.end - segment.start + guard tokenChars.count <= wordLength else { continue } + + var matchesPrefix = true + for offset in 0.. Int? { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidate.characters, + segments: candidate.wordSegments + ) else { + return nil + } + return singleEditWordPrefixScore(match: match, candidateLength: candidate.characters.count) + } + + private static func singleEditWordPrefixScore( + match: SingleEditWordPrefixMatch, + candidateLength: Int + ) -> Int { + let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 + let distancePenalty = match.segmentStart * 8 + let trailingPenalty = max(0, candidateLength - match.segmentLength) + let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 + return 5000 + - match.editKind.basePenalty + - distancePenalty + - lengthPenalty + - trailingPenalty + - editPositionPenalty + } + + private static func initialismScore(tokenChars: [Character], candidate: PreparedCandidateText) -> Int? { + guard !tokenChars.isEmpty else { return nil } + let candidateChars = candidate.characters + let segments = candidate.wordSegments + guard tokenChars.count <= segments.count else { return nil } + + var matchedStarts: [Int] = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matchedStarts.append(segment.start) + found = true + break + } + } + if !found { return nil } + } + + let firstStart = matchedStarts.first ?? 0 + let skippedWords = max(0, segments.count - tokenChars.count) + return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) + } + + private static func tokenPrefixMatches( + tokenChars: [Character], + tokenStart: Int, + length: Int, + candidateChars: [Character], + candidateStart: Int + ) -> Bool { + guard length >= 0 else { return false } + guard tokenStart + length <= tokenChars.count else { return false } + guard candidateStart + length <= candidateChars.count else { return false } + guard length > 0 else { return true } + + for offset in 0.. Int? { + guard tokenChars.count >= 4 else { return nil } + let candidateChars = candidate.characters + let segments = candidate.wordSegments + guard segments.count >= 2 else { return nil } + + struct StitchState: Hashable { + let tokenIndex: Int + let wordIndex: Int + let usedWords: Int + } + + var memo: [StitchState: Int?] = [:] + + func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { + if tokenIndex == tokenChars.count { + return usedWords >= 2 ? 0 : nil + } + guard wordIndex < segments.count else { return nil } + + let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) + if let cached = memo[state] { + return cached + } + + var best: Int? + let remainingChars = tokenChars.count - tokenIndex + for segmentIndex in wordIndex.. 0 else { continue } + + let skippedWords = max(0, segmentIndex - wordIndex) + let skipPenalty = skippedWords * 120 + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + guard let suffixScore = dfs( + tokenIndex: tokenIndex + chunkLength, + wordIndex: segmentIndex + 1, + usedWords: min(2, usedWords + 1) + ) else { + continue + } + + let chunkCoverage = chunkLength * 220 + let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 + let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 + let distancePenalty = segment.start * 4 + let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty + let totalScore = suffixScore + chunkScore + best = max(best ?? totalScore, totalScore) + } + } + + memo[state] = best + return best + } + + guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } + let lengthPenalty = max(0, candidateChars.count - tokenChars.count) + return 3500 + stitchedScore - lengthPenalty + } + + private static func stitchedWordPrefixMatchIndices( + token: PreparedToken, + candidate: PreparedCandidateText + ) -> Set? { + let tokenChars = token.characters + let candidateChars = candidate.characters + guard tokenChars.count >= 4 else { return nil } + + let segments = candidate.wordSegments + guard segments.count >= 2 else { return nil } + + var tokenIndex = 0 + var nextWordIndex = 0 + var usedWords = 0 + var matchedIndices: Set = [] + + while tokenIndex < tokenChars.count { + let remainingChars = tokenChars.count - tokenIndex + var foundMatch = false + + for segmentIndex in nextWordIndex.. 0 else { continue } + + for chunkLength in stride(from: maxChunk, through: 1, by: -1) { + guard tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: tokenIndex, + length: chunkLength, + candidateChars: candidateChars, + candidateStart: segment.start + ) else { + continue + } + + matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) + tokenIndex += chunkLength + nextWordIndex = segmentIndex + 1 + usedWords += 1 + foundMatch = true + break + } + + if foundMatch { break } + } + + if !foundMatch { return nil } + } + + guard usedWords >= 2 else { return nil } + return matchedIndices + } + + private static func singleEditWordPrefixMatch( + token: String, + candidate: String + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: Array(token), + candidateChars: Array(candidate) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character] + ) -> SingleEditWordPrefixMatch? { + singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segments: wordSegments(candidateChars) + ) + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segments: [WordSegment] + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + var bestMatch: SingleEditWordPrefixMatch? + var bestScore: Int? + + for segment in segments { + guard let match = singleEditWordPrefixMatch( + tokenChars: tokenChars, + candidateChars: candidateChars, + segment: segment + ) else { + continue + } + + let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) + if let bestScore, score <= bestScore { + continue + } + bestScore = score + bestMatch = match + } + + return bestMatch + } + + private static func singleEditWordPrefixMatch( + tokenChars: [Character], + candidateChars: [Character], + segment: WordSegment + ) -> SingleEditWordPrefixMatch? { + guard tokenChars.count >= 4 else { return nil } + + let segmentLength = segment.end - segment.start + guard segmentLength + 1 >= tokenChars.count else { return nil } + + let exactPrefixLength = min(tokenChars.count, segmentLength) + var mismatchOffset = 0 + while mismatchOffset < exactPrefixLength, + candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] + { + mismatchOffset += 1 + } + + if mismatchOffset == tokenChars.count { + let prefixLength = tokenChars.count + 1 + guard segmentLength >= prefixLength else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + tokenChars.count, + editKind: .candidateExtraCharacter + ) + } + + if mismatchOffset == segmentLength { + let prefixLength = tokenChars.count - 1 + guard prefixLength > 0 else { return nil } + guard tokenChars.count == segmentLength + 1 else { return nil } + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + prefixLength)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: prefixLength, + editPosition: segment.start + prefixLength, + editKind: .tokenExtraCharacter + ) + } + + let mismatchCandidateIndex = segment.start + mismatchOffset + + if segmentLength >= tokenChars.count + 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset, + length: tokenChars.count - mismatchOffset, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count + 1, + editPosition: mismatchCandidateIndex, + editKind: .candidateExtraCharacter + ) + } + + if tokenChars.count >= 2, + segmentLength >= tokenChars.count - 1, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count - 1, + editPosition: mismatchCandidateIndex, + editKind: .tokenExtraCharacter + ) + } + + if segmentLength >= tokenChars.count, + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 1, + length: tokenChars.count - mismatchOffset - 1, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 1 + ) + { + var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) + matchedIndices.remove(mismatchCandidateIndex) + return SingleEditWordPrefixMatch( + matchedIndices: matchedIndices, + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .substitutedCharacter + ) + } + + if segmentLength >= tokenChars.count, + mismatchOffset + 1 < tokenChars.count, + mismatchCandidateIndex + 1 < segment.end, + tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], + tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], + tokenPrefixMatches( + tokenChars: tokenChars, + tokenStart: mismatchOffset + 2, + length: tokenChars.count - mismatchOffset - 2, + candidateChars: candidateChars, + candidateStart: mismatchCandidateIndex + 2 + ) + { + return SingleEditWordPrefixMatch( + matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), + segmentStart: segment.start, + segmentLength: segmentLength, + prefixLength: tokenChars.count, + editPosition: mismatchCandidateIndex, + editKind: .transposedCharacters + ) + } + + return nil + } + + private static func wordSegments(_ candidateChars: [Character]) -> [WordSegment] { + var segments: [WordSegment] = [] + var index = 0 + + while index < candidateChars.count { + while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + guard index < candidateChars.count else { break } + let start = index + while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { + index += 1 + } + segments.append(WordSegment(start: start, end: index)) + } + + return segments + } + + private static func subsequenceScore(token: PreparedToken, candidate: PreparedCandidateText) -> Int? { + let tokenChars = token.characters + let candidateChars = candidate.characters + guard tokenChars.count <= candidateChars.count else { return nil } + + var searchIndex = 0 + var previousMatch = -1 + var consecutiveRun = 0 + var score = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchedIndex = foundIndex else { return nil } + + score += 90 + if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { + score += 140 + } + if matchedIndex == previousMatch + 1 { + consecutiveRun += 1 + score += min(200, consecutiveRun * 45) + } else { + consecutiveRun = 0 + score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) + } + + previousMatch = matchedIndex + searchIndex = matchedIndex + 1 + } + + score -= max(0, candidateChars.count - tokenChars.count) + return max(1, score) + } + + private static func subsequenceMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { + let tokenChars = token.characters + let candidateChars = candidate.characters + guard tokenChars.count <= candidateChars.count else { return nil } + + var indices: Set = [] + var searchIndex = 0 + + for tokenChar in tokenChars { + var foundIndex: Int? + while searchIndex < candidateChars.count { + if candidateChars[searchIndex] == tokenChar { + foundIndex = searchIndex + break + } + searchIndex += 1 + } + guard let matchIndex = foundIndex else { return nil } + indices.insert(matchIndex) + searchIndex = matchIndex + 1 + } + + return indices + } + + private static func initialismMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { + let tokenChars = token.characters + let candidateChars = candidate.characters + guard !tokenChars.isEmpty else { return nil } + + let segments = candidate.wordSegments + guard tokenChars.count <= segments.count else { return nil } + + var matched: Set = [] + var searchWordIndex = 0 + + for tokenChar in tokenChars { + var found = false + while searchWordIndex < segments.count { + let segment = segments[searchWordIndex] + searchWordIndex += 1 + if candidateChars[segment.start] == tokenChar { + matched.insert(segment.start) + found = true + break + } + } + if !found { return nil } + } + + return matched + } +} diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift index b45b1fa71e5c..5ed290f136a6 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchCorpusEntry.swift @@ -1,4 +1,3 @@ -public import CmuxFoundation import Foundation /// One searchable palette entry: a payload plus precomputed normalized diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift index fcb2e6ca25b3..e2e5e8aa8fda 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchEngine.swift @@ -1,4 +1,3 @@ -public import CmuxFoundation import Foundation /// Pure Swift ranking engine over a prepared corpus: scores entries with diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchWordText.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchWordText.swift index 5fc7d00f0fb0..f0e35ffabef8 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchWordText.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/CommandPaletteSearchWordText.swift @@ -1,4 +1,3 @@ -public import CmuxFoundation import Foundation func commandPaletteNormalizedSearchWordText( diff --git a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift index 131864e7a5de..08483aaba540 100644 --- a/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift +++ b/Packages/macOS/CmuxCommandPalette/Sources/CmuxCommandPalette/Search/Nucleo/CommandPaletteNucleoSearchIndex.swift @@ -1,4 +1,3 @@ -public import CmuxFoundation import Foundation // Sendable is safe here because the Swift payload entries are immutable, the diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Search/FuzzyMatcher.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Search/FuzzyMatcher.swift deleted file mode 100644 index 44975498d376..000000000000 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Search/FuzzyMatcher.swift +++ /dev/null @@ -1,1091 +0,0 @@ -import Foundation - -/// The shared Swift fuzzy matcher: token scoring over word segments with -/// exact/prefix/contains/initialism/stitched-prefix and single-edit -/// fallbacks. Pure logic; scores are deterministic for a given -/// query/candidate pair. -/// -/// Lives in `CmuxFoundation` because more than one domain ranks text against -/// a typed query - command-palette results and the sidebar workspace filter -/// both score through it, and neither may depend on the other. -/// -/// A matcher is a value object bound to one prepared query: construct it with -/// `init(query:)` (or an already-prepared query) and score candidates through -/// the instance methods. The preparation/normalization building blocks remain -/// `static` factories on the value types they produce, per the foundation -/// helper conventions. -public struct FuzzyMatcher { - /// The prepared query this matcher scores candidates against. - public let preparedQuery: PreparedQuery - - /// Prepares `query` for matching. - public init(query: String) { self.preparedQuery = Self.preparedQuery(query) } - - /// Binds an already-prepared query. - public init(preparedQuery: PreparedQuery) { self.preparedQuery = preparedQuery } - - /// Scores `candidate` against this matcher's query; nil when it does not - /// match. - public func score(candidate: String) -> Int? { - Self.score(query: preparedQuery.normalizedText, candidate: candidate) - } - - /// Scores `preparedCandidate` against this matcher's query. - public func score(preparedCandidate: PreparedCandidateText) -> Int? { - Self.score(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) - } - - /// Candidate character indices matched by this matcher's query. - public func matchCharacterIndices(in candidate: String) -> Set { - Self.matchCharacterIndices(preparedQuery: preparedQuery, candidate: candidate) - } - - /// Candidate character indices matched against a prepared candidate. - public func matchCharacterIndices(in preparedCandidate: PreparedCandidateText) -> Set { - Self.matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) - } - - private static let tokenBoundaryChars: Set = [" ", "-", "_", "/", ".", ":"] - - /// Half-open `[start, end)` character range of one word in a candidate. - public struct WordSegment: Hashable, Sendable { - /// Index of the first character of the word. - public let start: Int - /// Index one past the last character of the word. - public let end: Int - } - - /// 128-bit presence mask of ASCII scalars used to cheaply prune - /// candidates that cannot contain a token's characters. - public struct ASCIIScalarMask: Equatable, Sendable { - /// Bits for scalars 0-63. - public let low: UInt64 - /// Bits for scalars 64-127. - public let high: UInt64 - - /// Creates a mask from raw bit halves. - public init(low: UInt64, high: UInt64) { - self.low = low - self.high = high - } - - /// Builds the mask from the ASCII scalars of `text`. - public init(_ text: String) { - var low: UInt64 = 0 - var high: UInt64 = 0 - for scalar in text.unicodeScalars where scalar.isASCII { - let value = Int(scalar.value) - if value < 64 { - low |= UInt64(1) << UInt64(value) - } else { - high |= UInt64(1) << UInt64(value - 64) - } - } - self.low = low - self.high = high - } - - /// Number of scalars present here but absent from `candidate`. - public func missingBitCount(from candidate: ASCIIScalarMask) -> Int { - (low & ~candidate.low).nonzeroBitCount + (high & ~candidate.high).nonzeroBitCount - } - } - - /// One normalized query token with precomputed characters, ASCII mask, - /// and score bounds. - public struct PreparedToken: Equatable, Sendable { - /// The normalized token text. - public let normalizedText: String - /// The token's characters in order. - public let characters: [Character] - /// ASCII presence mask for fast pruning. - public let asciiMask: ASCIIScalarMask - /// Whether single-edit (typo) fallback matching applies (length >= 4). - public let allowsSingleEdit: Bool - /// Whether the token contains a word-boundary character. - public let containsTokenBoundaryCharacter: Bool - /// Maximum achievable score for this token. - public let scoreUpperBound: Int - /// Maximum achievable score excluding an exact whole-text match. - public let scoreUpperBoundWithoutExactMatch: Int - - /// Prepares a normalized token for matching. - public init(_ normalizedText: String) { - self.normalizedText = normalizedText - self.characters = Array(normalizedText) - self.asciiMask = ASCIIScalarMask(normalizedText) - self.allowsSingleEdit = characters.count >= 4 - self.containsTokenBoundaryCharacter = characters.contains { - FuzzyMatcher.tokenBoundaryChars.contains($0) - } - self.scoreUpperBound = max(8000, 3500 + (characters.count * 300)) - self.scoreUpperBoundWithoutExactMatch = max(6799, 3500 + (characters.count * 300)) - } - - /// Fast pre-check: whether `candidate` could possibly match this token - /// within the allowed edit budget. - public func couldMatch(_ candidate: PreparedCandidateText) -> Bool { - let missingCharacters = asciiMask.missingBitCount(from: candidate.asciiMask) - return missingCharacters <= (allowsSingleEdit ? 1 : 0) - } - } - - /// One normalized candidate string with precomputed characters, word - /// segments, and ASCII mask. - public struct PreparedCandidateText: Sendable { - /// The normalized candidate text. - public let normalizedText: String - /// The candidate's characters in order. - public let characters: [Character] - /// Word segments split on token-boundary characters. - public let wordSegments: [WordSegment] - /// ASCII presence mask for fast pruning. - public let asciiMask: ASCIIScalarMask - - /// Prepares a normalized candidate for matching. - public init(normalizedText: String) { - self.normalizedText = normalizedText - self.characters = Array(normalizedText) - self.wordSegments = FuzzyMatcher.wordSegments(characters) - self.asciiMask = ASCIIScalarMask(normalizedText) - } - } - - private enum SingleEditWordPrefixEditKind { - case candidateExtraCharacter - case tokenExtraCharacter - case substitutedCharacter - case transposedCharacters - - var basePenalty: Int { - switch self { - case .candidateExtraCharacter: - return 0 - case .tokenExtraCharacter: - return 240 - case .transposedCharacters: - return 24 - case .substitutedCharacter: - return 40 - } - } - } - - private struct SingleEditWordPrefixMatch { - let matchedIndices: Set - let segmentStart: Int - let segmentLength: Int - let prefixLength: Int - let editPosition: Int - let editKind: SingleEditWordPrefixEditKind - } - - /// A normalized query split into prepared tokens. - public struct PreparedQuery { - /// The full normalized query text. - public let normalizedText: String - /// Normalized query tokens joined by single spaces. - public let normalizedTokenText: String - /// The prepared tokens, in query order. - public let tokens: [PreparedToken] - - /// Whether the query has no tokens. - public var isEmpty: Bool { - tokens.isEmpty - } - } - - /// Normalizes and tokenizes `query` for matching. - public static func preparedQuery(_ query: String) -> PreparedQuery { - let normalizedQuery = normalizeForSearch(query) - let tokens = normalizedQuery - .split(separator: " ") - .map(String.init) - .filter { !$0.isEmpty } - .map(PreparedToken.init) - return PreparedQuery( - normalizedText: normalizedQuery, - normalizedTokenText: tokens.map(\.normalizedText).joined(separator: " "), - tokens: tokens - ) - } - - /// Canonical search normalization: trim, diacritic-fold, case-fold. - public static func normalizeForSearch(_ text: String) -> String { - text - .trimmingCharacters(in: .whitespacesAndNewlines) - .folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) - .lowercased() - } - - /// Normalizes and prepares `candidate`, or nil when it normalizes to empty. - public static func prepareCandidateText(_ candidate: String) -> PreparedCandidateText? { - let normalizedCandidate = normalizeForSearch(candidate) - guard !normalizedCandidate.isEmpty else { return nil } - return PreparedCandidateText(normalizedText: normalizedCandidate) - } - - /// Prepares an already-normalized candidate, or nil when empty. - public static func prepareNormalizedCandidateText(_ normalizedCandidate: String) -> PreparedCandidateText? { - guard !normalizedCandidate.isEmpty else { return nil } - return PreparedCandidateText(normalizedText: normalizedCandidate) - } - - /// Scores `query` against a single candidate; nil when it does not match. - public static func score(query: String, candidate: String) -> Int? { - score(query: query, candidates: [candidate]) - } - - /// Scores `query` against multiple candidate texts; nil when any token fails. - public static func score(query: String, candidates: [String]) -> Int? { - let preparedQuery = preparedQuery(query) - var normalizedCandidates: [String] = [] - normalizedCandidates.reserveCapacity(candidates.count) - for candidate in candidates { - let normalizedCandidate = normalizeForSearch(candidate) - guard !normalizedCandidate.isEmpty else { continue } - normalizedCandidates.append(normalizedCandidate) - } - return score( - preparedQuery: preparedQuery, - normalizedCandidates: normalizedCandidates - ) - } - - /// Scores a prepared query against normalized candidate texts. - public static func score(preparedQuery: PreparedQuery, normalizedCandidates: [String]) -> Int? { - score( - preparedQuery: preparedQuery, - preparedCandidates: normalizedCandidates.compactMap(prepareNormalizedCandidateText), - exactCandidateTexts: Set(normalizedCandidates) - ) - } - - /// Scores a prepared query against prepared candidates. - public static func score(preparedQuery: PreparedQuery, preparedCandidates: [PreparedCandidateText]) -> Int? { - score( - preparedQuery: preparedQuery, - preparedCandidates: preparedCandidates, - exactCandidateTexts: nil - ) - } - - /// Scores a prepared query against one prepared candidate. - public static func score(preparedQuery: PreparedQuery, preparedCandidate: PreparedCandidateText) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - - var totalScore = 0 - for token in preparedQuery.tokens { - guard token.couldMatch(preparedCandidate) else { return nil } - guard let tokenScore = scoreToken(token, in: preparedCandidate) else { return nil } - totalScore += tokenScore - } - return totalScore - } - - /// Full scoring entry point with exact-text and prefix-score fast paths. - public static func score( - preparedQuery: PreparedQuery, - preparedCandidates: [PreparedCandidateText], - exactCandidateTexts: Set?, - wholeCandidatePrefixScoreByToken: [String: Int]? = nil - ) -> Int? { - guard !preparedQuery.isEmpty else { return 0 } - guard !preparedCandidates.isEmpty else { return nil } - - var totalScore = 0 - for token in preparedQuery.tokens { - let hasExactCandidateText = exactCandidateTexts?.contains(token.normalizedText) == true - if token.scoreUpperBound == 8000, hasExactCandidateText { - totalScore += 8000 - continue - } - if exactCandidateTexts != nil, - !hasExactCandidateText, - let prefixScore = wholeCandidatePrefixScoreByToken?[token.normalizedText] - ?? bestWholeCandidatePrefixScore(token: token, preparedCandidates: preparedCandidates), - prefixScore >= token.scoreUpperBoundWithoutExactMatch { - totalScore += prefixScore - continue - } - - var bestTokenScore: Int? - for candidate in preparedCandidates { - guard token.couldMatch(candidate) else { continue } - guard let candidateScore = scoreToken(token, in: candidate) else { continue } - bestTokenScore = max(bestTokenScore ?? candidateScore, candidateScore) - if bestTokenScore ?? 0 >= token.scoreUpperBound { - break - } - } - guard let bestTokenScore else { return nil } - totalScore += bestTokenScore - } - return totalScore - } - - private static func bestWholeCandidatePrefixScore( - token: PreparedToken, - preparedCandidates: [PreparedCandidateText] - ) -> Int? { - var bestScore: Int? - for candidate in preparedCandidates where candidate.normalizedText.hasPrefix(token.normalizedText) { - let score = 6800 - max(0, candidate.characters.count - token.characters.count) - bestScore = max(bestScore ?? score, score) - } - return bestScore - } - - /// Precomputes best whole-candidate prefix scores keyed by prefix text. - public static func wholeCandidatePrefixScoreByToken( - preparedCandidates: [PreparedCandidateText], - maxPrefixLength: Int = 16 - ) -> [String: Int] { - var scores: [String: Int] = [:] - for candidate in preparedCandidates { - let prefixLimit = min(candidate.characters.count, maxPrefixLength) - guard prefixLimit > 0 else { continue } - - for prefixLength in 1...prefixLimit { - let prefix = String(candidate.characters.prefix(prefixLength)) - let score = 6800 - max(0, candidate.characters.count - prefixLength) - if score > (scores[prefix] ?? Int.min) { - scores[prefix] = score - } - } - } - return scores - } - - /// Candidate character indices matched by `query`, for highlight rendering. - public static func matchCharacterIndices(query: String, candidate: String) -> Set { - matchCharacterIndices(preparedQuery: preparedQuery(query), candidate: candidate) - } - - /// Candidate character indices matched by a prepared query. - public static func matchCharacterIndices(preparedQuery: PreparedQuery, candidate: String) -> Set { - guard !preparedQuery.isEmpty else { return [] } - - guard let preparedCandidate = prepareCandidateText(candidate) else { return [] } - return matchCharacterIndices(preparedQuery: preparedQuery, preparedCandidate: preparedCandidate) - } - - /// Candidate character indices matched by a prepared query against a - /// prepared candidate. - public static func matchCharacterIndices( - preparedQuery: PreparedQuery, - preparedCandidate: PreparedCandidateText - ) -> Set { - guard !preparedQuery.isEmpty else { return [] } - - let loweredCandidate = preparedCandidate.normalizedText - let candidateChars = preparedCandidate.characters - var matched: Set = [] - - for token in preparedQuery.tokens { - guard token.couldMatch(preparedCandidate) else { continue } - - if token.normalizedText == loweredCandidate { - matched.formUnion(0.. Bool { - guard !token.normalizedText.isEmpty else { return true } - - let candidateText = candidate.normalizedText - if token.normalizedText == candidateText { - return true - } - if candidateText.hasPrefix(token.normalizedText) { - return true - } - if candidateText.range(of: token.normalizedText) != nil { - return true - } - - guard !token.containsTokenBoundaryCharacter else { - return token.characters.count <= 3 && subsequenceScore(token: token, candidate: candidate) != nil - } - - if bestWordScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if initialismScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if stitchedWordPrefixScore(tokenChars: token.characters, candidate: candidate) != nil { - return true - } - if token.characters.count <= 3, subsequenceScore(token: token, candidate: candidate) != nil { - return true - } - return false - } - - /// Whether any token only matches these candidates via the single-edit - /// (typo) word-prefix fallback. - public static func usesSingleEditWordPrefix( - preparedQuery: PreparedQuery, - preparedCandidates: [PreparedCandidateText] - ) -> Bool { - for token in preparedQuery.tokens where token.allowsSingleEdit && !token.containsTokenBoundaryCharacter { - for candidate in preparedCandidates { - guard !tokenCanMatchWithoutSingleEdit(token, preparedCandidate: candidate) else { continue } - if singleEditWordPrefixMatch( - tokenChars: token.characters, - candidateChars: candidate.characters, - segments: candidate.wordSegments - ) != nil { - return true - } - } - } - return false - } - - private static func scoreToken(_ token: PreparedToken, in candidate: PreparedCandidateText) -> Int? { - guard !token.normalizedText.isEmpty else { return 0 } - - let candidateText = candidate.normalizedText - let candidateChars = candidate.characters - let tokenChars = token.characters - guard tokenChars.count <= candidateChars.count else { return nil } - - if token.normalizedText == candidateText { - return 8000 - } - if candidateText.hasPrefix(token.normalizedText) { - return 6800 - max(0, candidateChars.count - tokenChars.count) - } - - var bestScore: Int? - if !token.containsTokenBoundaryCharacter { - if let wordScore = bestWordScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? wordScore, wordScore) - } - if let singleEditPrefixScore = singleEditWordPrefixScore( - tokenChars: tokenChars, - candidate: candidate - ) { - bestScore = max(bestScore ?? singleEditPrefixScore, singleEditPrefixScore) - } - } - - if let range = candidateText.range(of: token.normalizedText) { - let distance = candidateText.distance(from: candidateText.startIndex, to: range.lowerBound) - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) - let boundaryBoost: Int = { - guard distance > 0 else { return 220 } - let prior = candidateChars[distance - 1] - return tokenBoundaryChars.contains(prior) ? 180 : 0 - }() - let containsScore = 4200 + boundaryBoost - (distance * 9) - lengthPenalty - bestScore = max(bestScore ?? containsScore, containsScore) - } - - if !token.containsTokenBoundaryCharacter { - if let initialismScore = initialismScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? initialismScore, initialismScore) - } - - if let stitchedScore = stitchedWordPrefixScore(tokenChars: tokenChars, candidate: candidate) { - bestScore = max(bestScore ?? stitchedScore, stitchedScore) - } - } - - if tokenChars.count <= 3, let subsequence = subsequenceScore(token: token, candidate: candidate) { - bestScore = max(bestScore ?? subsequence, subsequence) - } - - guard let bestScore else { return nil } - return max(1, bestScore) - } - - private static func bestWordScore( - tokenChars: [Character], - candidate: PreparedCandidateText - ) -> Int? { - guard !tokenChars.isEmpty else { return nil } - - let candidateChars = candidate.characters - var best: Int? - for segment in candidate.wordSegments { - let wordLength = segment.end - segment.start - guard tokenChars.count <= wordLength else { continue } - - var matchesPrefix = true - for offset in 0.. Int? { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidate.characters, - segments: candidate.wordSegments - ) else { - return nil - } - return singleEditWordPrefixScore(match: match, candidateLength: candidate.characters.count) - } - - private static func singleEditWordPrefixScore( - match: SingleEditWordPrefixMatch, - candidateLength: Int - ) -> Int { - let lengthPenalty = max(0, match.segmentLength - match.prefixLength) * 6 - let distancePenalty = match.segmentStart * 8 - let trailingPenalty = max(0, candidateLength - match.segmentLength) - let editPositionPenalty = max(0, match.editPosition - match.segmentStart) * 10 - return 5000 - - match.editKind.basePenalty - - distancePenalty - - lengthPenalty - - trailingPenalty - - editPositionPenalty - } - - private static func initialismScore(tokenChars: [Character], candidate: PreparedCandidateText) -> Int? { - guard !tokenChars.isEmpty else { return nil } - let candidateChars = candidate.characters - let segments = candidate.wordSegments - guard tokenChars.count <= segments.count else { return nil } - - var matchedStarts: [Int] = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matchedStarts.append(segment.start) - found = true - break - } - } - if !found { return nil } - } - - let firstStart = matchedStarts.first ?? 0 - let skippedWords = max(0, segments.count - tokenChars.count) - return 3000 + (tokenChars.count * 160) - (firstStart * 5) - (skippedWords * 30) - } - - private static func tokenPrefixMatches( - tokenChars: [Character], - tokenStart: Int, - length: Int, - candidateChars: [Character], - candidateStart: Int - ) -> Bool { - guard length >= 0 else { return false } - guard tokenStart + length <= tokenChars.count else { return false } - guard candidateStart + length <= candidateChars.count else { return false } - guard length > 0 else { return true } - - for offset in 0.. Int? { - guard tokenChars.count >= 4 else { return nil } - let candidateChars = candidate.characters - let segments = candidate.wordSegments - guard segments.count >= 2 else { return nil } - - struct StitchState: Hashable { - let tokenIndex: Int - let wordIndex: Int - let usedWords: Int - } - - var memo: [StitchState: Int?] = [:] - - func dfs(tokenIndex: Int, wordIndex: Int, usedWords: Int) -> Int? { - if tokenIndex == tokenChars.count { - return usedWords >= 2 ? 0 : nil - } - guard wordIndex < segments.count else { return nil } - - let state = StitchState(tokenIndex: tokenIndex, wordIndex: wordIndex, usedWords: usedWords) - if let cached = memo[state] { - return cached - } - - var best: Int? - let remainingChars = tokenChars.count - tokenIndex - for segmentIndex in wordIndex.. 0 else { continue } - - let skippedWords = max(0, segmentIndex - wordIndex) - let skipPenalty = skippedWords * 120 - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - guard let suffixScore = dfs( - tokenIndex: tokenIndex + chunkLength, - wordIndex: segmentIndex + 1, - usedWords: min(2, usedWords + 1) - ) else { - continue - } - - let chunkCoverage = chunkLength * 220 - let contiguityBonus = segmentIndex == wordIndex ? 80 : 0 - let segmentRemainderPenalty = max(0, segmentLength - chunkLength) * 9 - let distancePenalty = segment.start * 4 - let chunkScore = chunkCoverage + contiguityBonus - segmentRemainderPenalty - distancePenalty - skipPenalty - let totalScore = suffixScore + chunkScore - best = max(best ?? totalScore, totalScore) - } - } - - memo[state] = best - return best - } - - guard let stitchedScore = dfs(tokenIndex: 0, wordIndex: 0, usedWords: 0) else { return nil } - let lengthPenalty = max(0, candidateChars.count - tokenChars.count) - return 3500 + stitchedScore - lengthPenalty - } - - private static func stitchedWordPrefixMatchIndices( - token: PreparedToken, - candidate: PreparedCandidateText - ) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters - guard tokenChars.count >= 4 else { return nil } - - let segments = candidate.wordSegments - guard segments.count >= 2 else { return nil } - - var tokenIndex = 0 - var nextWordIndex = 0 - var usedWords = 0 - var matchedIndices: Set = [] - - while tokenIndex < tokenChars.count { - let remainingChars = tokenChars.count - tokenIndex - var foundMatch = false - - for segmentIndex in nextWordIndex.. 0 else { continue } - - for chunkLength in stride(from: maxChunk, through: 1, by: -1) { - guard tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: tokenIndex, - length: chunkLength, - candidateChars: candidateChars, - candidateStart: segment.start - ) else { - continue - } - - matchedIndices.formUnion(segment.start..<(segment.start + chunkLength)) - tokenIndex += chunkLength - nextWordIndex = segmentIndex + 1 - usedWords += 1 - foundMatch = true - break - } - - if foundMatch { break } - } - - if !foundMatch { return nil } - } - - guard usedWords >= 2 else { return nil } - return matchedIndices - } - - private static func singleEditWordPrefixMatch( - token: String, - candidate: String - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: Array(token), - candidateChars: Array(candidate) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character] - ) -> SingleEditWordPrefixMatch? { - singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segments: wordSegments(candidateChars) - ) - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segments: [WordSegment] - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - var bestMatch: SingleEditWordPrefixMatch? - var bestScore: Int? - - for segment in segments { - guard let match = singleEditWordPrefixMatch( - tokenChars: tokenChars, - candidateChars: candidateChars, - segment: segment - ) else { - continue - } - - let score = singleEditWordPrefixScore(match: match, candidateLength: candidateChars.count) - if let bestScore, score <= bestScore { - continue - } - bestScore = score - bestMatch = match - } - - return bestMatch - } - - private static func singleEditWordPrefixMatch( - tokenChars: [Character], - candidateChars: [Character], - segment: WordSegment - ) -> SingleEditWordPrefixMatch? { - guard tokenChars.count >= 4 else { return nil } - - let segmentLength = segment.end - segment.start - guard segmentLength + 1 >= tokenChars.count else { return nil } - - let exactPrefixLength = min(tokenChars.count, segmentLength) - var mismatchOffset = 0 - while mismatchOffset < exactPrefixLength, - candidateChars[segment.start + mismatchOffset] == tokenChars[mismatchOffset] - { - mismatchOffset += 1 - } - - if mismatchOffset == tokenChars.count { - let prefixLength = tokenChars.count + 1 - guard segmentLength >= prefixLength else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + tokenChars.count, - editKind: .candidateExtraCharacter - ) - } - - if mismatchOffset == segmentLength { - let prefixLength = tokenChars.count - 1 - guard prefixLength > 0 else { return nil } - guard tokenChars.count == segmentLength + 1 else { return nil } - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + prefixLength)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: prefixLength, - editPosition: segment.start + prefixLength, - editKind: .tokenExtraCharacter - ) - } - - let mismatchCandidateIndex = segment.start + mismatchOffset - - if segmentLength >= tokenChars.count + 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset, - length: tokenChars.count - mismatchOffset, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count + 1)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count + 1, - editPosition: mismatchCandidateIndex, - editKind: .candidateExtraCharacter - ) - } - - if tokenChars.count >= 2, - segmentLength >= tokenChars.count - 1, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count - 1)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count - 1, - editPosition: mismatchCandidateIndex, - editKind: .tokenExtraCharacter - ) - } - - if segmentLength >= tokenChars.count, - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 1, - length: tokenChars.count - mismatchOffset - 1, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 1 - ) - { - var matchedIndices = Set(segment.start..<(segment.start + tokenChars.count)) - matchedIndices.remove(mismatchCandidateIndex) - return SingleEditWordPrefixMatch( - matchedIndices: matchedIndices, - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .substitutedCharacter - ) - } - - if segmentLength >= tokenChars.count, - mismatchOffset + 1 < tokenChars.count, - mismatchCandidateIndex + 1 < segment.end, - tokenChars[mismatchOffset] == candidateChars[mismatchCandidateIndex + 1], - tokenChars[mismatchOffset + 1] == candidateChars[mismatchCandidateIndex], - tokenPrefixMatches( - tokenChars: tokenChars, - tokenStart: mismatchOffset + 2, - length: tokenChars.count - mismatchOffset - 2, - candidateChars: candidateChars, - candidateStart: mismatchCandidateIndex + 2 - ) - { - return SingleEditWordPrefixMatch( - matchedIndices: Set(segment.start..<(segment.start + tokenChars.count)), - segmentStart: segment.start, - segmentLength: segmentLength, - prefixLength: tokenChars.count, - editPosition: mismatchCandidateIndex, - editKind: .transposedCharacters - ) - } - - return nil - } - - private static func wordSegments(_ candidateChars: [Character]) -> [WordSegment] { - var segments: [WordSegment] = [] - var index = 0 - - while index < candidateChars.count { - while index < candidateChars.count, tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - guard index < candidateChars.count else { break } - let start = index - while index < candidateChars.count, !tokenBoundaryChars.contains(candidateChars[index]) { - index += 1 - } - segments.append(WordSegment(start: start, end: index)) - } - - return segments - } - - private static func subsequenceScore(token: PreparedToken, candidate: PreparedCandidateText) -> Int? { - let tokenChars = token.characters - let candidateChars = candidate.characters - guard tokenChars.count <= candidateChars.count else { return nil } - - var searchIndex = 0 - var previousMatch = -1 - var consecutiveRun = 0 - var score = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchedIndex = foundIndex else { return nil } - - score += 90 - if matchedIndex == 0 || tokenBoundaryChars.contains(candidateChars[matchedIndex - 1]) { - score += 140 - } - if matchedIndex == previousMatch + 1 { - consecutiveRun += 1 - score += min(200, consecutiveRun * 45) - } else { - consecutiveRun = 0 - score -= min(120, max(0, matchedIndex - previousMatch - 1) * 4) - } - - previousMatch = matchedIndex - searchIndex = matchedIndex + 1 - } - - score -= max(0, candidateChars.count - tokenChars.count) - return max(1, score) - } - - private static func subsequenceMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters - guard tokenChars.count <= candidateChars.count else { return nil } - - var indices: Set = [] - var searchIndex = 0 - - for tokenChar in tokenChars { - var foundIndex: Int? - while searchIndex < candidateChars.count { - if candidateChars[searchIndex] == tokenChar { - foundIndex = searchIndex - break - } - searchIndex += 1 - } - guard let matchIndex = foundIndex else { return nil } - indices.insert(matchIndex) - searchIndex = matchIndex + 1 - } - - return indices - } - - private static func initialismMatchIndices(token: PreparedToken, candidate: PreparedCandidateText) -> Set? { - let tokenChars = token.characters - let candidateChars = candidate.characters - guard !tokenChars.isEmpty else { return nil } - - let segments = candidate.wordSegments - guard tokenChars.count <= segments.count else { return nil } - - var matched: Set = [] - var searchWordIndex = 0 - - for tokenChar in tokenChars { - var found = false - while searchWordIndex < segments.count { - let segment = segments[searchWordIndex] - searchWordIndex += 1 - if candidateChars[segment.start] == tokenChar { - matched.insert(segment.start) - found = true - break - } - } - if !found { return nil } - } - - return matched - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidate.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidate.swift deleted file mode 100644 index adc81b9f5381..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidate.swift +++ /dev/null @@ -1,37 +0,0 @@ -public import Foundation - -/// One workspace's searchable text, prepared for repeated matching. -/// -/// Built once per corpus revision by the owner of the workspace list and -/// re-scored on every keystroke. Carries only value types: no `Workspace` -/// reference crosses into the filter, so scoring stays pure and testable. -public struct SidebarFilterCandidate: Sendable, Identifiable { - /// The workspace this candidate represents. - public let id: UUID - /// The group the workspace belongs to, if any. - public let groupId: UUID? - /// Whether this workspace is its group's anchor (rendered as the header). - public let isGroupAnchor: Bool - /// Every prepared field this workspace can be matched on. - public let fields: [SidebarFilterCandidateField] - - /// Creates a candidate for one workspace. - /// - /// - Parameters: - /// - id: The workspace id. - /// - groupId: The workspace's group, or nil when ungrouped. - /// - isGroupAnchor: Whether the workspace renders as its group's header. - /// - fields: Prepared searchable fields; empty fields should be omitted - /// by the caller rather than passed as empty strings. - public init( - id: UUID, - groupId: UUID? = nil, - isGroupAnchor: Bool = false, - fields: [SidebarFilterCandidateField] - ) { - self.id = id - self.groupId = groupId - self.isGroupAnchor = isGroupAnchor - self.fields = fields - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidateField.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidateField.swift deleted file mode 100644 index 5603aa821ec2..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterCandidateField.swift +++ /dev/null @@ -1,39 +0,0 @@ -public import CmuxFoundation - -/// One searchable field of one sidebar row, prepared for repeated matching. -/// -/// Preparation (normalize, split into characters and word segments, build the -/// ASCII mask) is the expensive half of fuzzy matching, and it depends only on -/// the candidate - not on the query. Building these once per corpus revision -/// and reusing them across keystrokes is what keeps per-character filtering off -/// the main thread's critical path. -public struct SidebarFilterCandidateField: Sendable { - /// Which row field this text came from. - public let field: SidebarFilterField - /// The text as the row displays it, used to map match indices back to UI. - public let displayText: String - /// The normalized, pre-segmented form the matcher scores against. - public let prepared: FuzzyMatcher.PreparedCandidateText - /// Whether match offsets in normalized coordinates also address - /// `displayText`. - /// - /// Search normalization case-folds and strips diacritics, which can change - /// a string's character count. When it does, a matched offset no longer - /// points at the same character in the displayed string, so the engine - /// drops that field's highlight data rather than underlining the wrong - /// characters. Scoring is unaffected either way. - public let isDisplayIndexAligned: Bool - - /// Prepares `displayText` for matching as `field`. - /// - /// - Parameters: - /// - field: The row field this text is displayed in. - /// - displayText: The text exactly as the row renders it. - public init(field: SidebarFilterField, displayText: String) { - self.field = field - self.displayText = displayText - let normalizedText = FuzzyMatcher.normalizeForSearch(displayText) - self.prepared = FuzzyMatcher.PreparedCandidateText(normalizedText: normalizedText) - self.isDisplayIndexAligned = normalizedText.count == displayText.count - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterField.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterField.swift deleted file mode 100644 index b3f35a28e78c..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterField.swift +++ /dev/null @@ -1,70 +0,0 @@ -/// One searchable text field on a sidebar workspace row. -/// -/// The sidebar filter scores a query against the fields a row can display, so -/// typing a branch name finds a workspace whose title says nothing about the -/// branch. Fields are tried in ``matchPriority`` order and the first hit wins. -public enum SidebarFilterField: String, CaseIterable, Sendable, Hashable { - /// The workspace's displayed title (custom or auto-derived). - case title - /// The workspace's git branch. - case branch - /// The workspace's working directory path. - case directory - /// The name of the group the workspace belongs to. - case group - /// The linked pull request's number and title. - case pullRequest - /// A port the workspace is listening on. - case port - - /// Priority of this field when a row matches on more than one. - /// - /// Fields are scored in descending priority and the first hit wins, so a - /// row that matches on its title never pays to score its directory. That - /// is both faster and more predictable than taking the maximum score - /// across fields: "the title matched" always beats "the path happened to - /// contain those letters", regardless of how the fuzzy scores compare. - /// - /// Ranking between rows is lexicographic on `(matchPriority, fuzzyScore)`. - public var matchPriority: Int { - switch self { - case .title: - return 60 - case .branch: - return 50 - case .directory: - return 40 - case .group: - return 30 - case .pullRequest: - return 20 - case .port: - return 10 - } - } - - /// Every field in the order the filter scores them. - public static var scoringOrder: [SidebarFilterField] { - allCases.sorted { $0.matchPriority > $1.matchPriority } - } - - /// The single-character prefix that restricts a query to this field, if any. - /// - /// Typing `@main` searches branches only; `#infra` searches group names; - /// `:3000` searches ports; `/repos` searches directories. Titles have no - /// sigil because an unprefixed query already searches every field. - public var querySigil: Character? { - switch self { - case .branch: - return "@" - case .group: - return "#" - case .port: - return ":" - case .directory: - return "/" - case .title, .pullRequest: - return nil - } - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterGroup.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterGroup.swift deleted file mode 100644 index 945412b162db..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterGroup.swift +++ /dev/null @@ -1,30 +0,0 @@ -public import CmuxFoundation -public import Foundation - -/// One workspace group's searchable name plus the membership the filter needs -/// to decide whether the group's header survives filtering. -public struct SidebarFilterGroup: Sendable, Identifiable { - /// The group id. - public let id: UUID - /// The workspace rendered as this group's header row. - public let anchorWorkspaceId: UUID - /// The group's display name. - public let name: String - /// The normalized, pre-segmented group name. - public let prepared: FuzzyMatcher.PreparedCandidateText - - /// Creates a searchable group. - /// - /// - Parameters: - /// - id: The group id. - /// - anchorWorkspaceId: The workspace whose row is the group header. - /// - name: The group's display name. - public init(id: UUID, anchorWorkspaceId: UUID, name: String) { - self.id = id - self.anchorWorkspaceId = anchorWorkspaceId - self.name = name - self.prepared = FuzzyMatcher.PreparedCandidateText( - normalizedText: FuzzyMatcher.normalizeForSearch(name) - ) - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterIndex.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterIndex.swift deleted file mode 100644 index 472edff4f515..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterIndex.swift +++ /dev/null @@ -1,152 +0,0 @@ -public import CmuxFoundation -public import Foundation - -/// The prepared corpus the sidebar filter scores against. -/// -/// Built once per corpus revision (a workspace added, renamed, moved group, or -/// its branch/directory changed) and re-scored on every keystroke. Holding the -/// index across keystrokes is the whole performance story: preparation is -/// O(text) and scoring is O(query), so typing costs only the latter. -/// -/// ```swift -/// let index = SidebarFilterIndex(candidates: candidates, groups: groups) -/// let outcome = index.outcome(for: SidebarFilterQuery("@drag")) -/// ``` -public struct SidebarFilterIndex: Sendable { - /// Every workspace's prepared searchable text, in sidebar order. - public let candidates: [SidebarFilterCandidate] - /// Every group's prepared name, keyed for membership lookups. - public let groupsById: [UUID: SidebarFilterGroup] - - /// Builds an index over a workspace list and its groups. - /// - /// - Parameters: - /// - candidates: Prepared workspaces in the order the sidebar renders - /// them; the order is preserved in the outcome. - /// - groups: Prepared groups; order is irrelevant. - public init(candidates: [SidebarFilterCandidate], groups: [SidebarFilterGroup]) { - self.candidates = candidates - self.groupsById = Dictionary(uniqueKeysWithValues: groups.map { ($0.id, $0) }) - } - - /// Scores every candidate against `query`. - /// - /// A group survives when its own name matches (which keeps every member - /// with it, so a group is browsable by name) or when any member matches - /// (which keeps the header for context and forces the group open). - /// - /// Cost is one fuzzy score per candidate in the common case: scoring stops - /// at the highest-priority field that matches, and the matcher's ASCII mask - /// prunes non-matching candidates before any token work. - /// - /// - Parameter query: The parsed filter query. - /// - Returns: The rows to show, the groups to force open, and per-row match - /// detail. Returns ``SidebarFilterOutcome/inactive`` for an empty query. - public func outcome(for query: SidebarFilterQuery) -> SidebarFilterOutcome { - guard let matcher = query.makeMatcher() else { return .inactive } - let scoredFields = Set(query.fields) - - var matchedGroupIds: Set = [] - if scoredFields.contains(.group) { - for group in groupsById.values - where matcher.score(preparedCandidate: group.prepared) != nil { - matchedGroupIds.insert(group.id) - } - } - - var matchesByWorkspaceId: [UUID: SidebarFilterMatch] = [:] - var orderedMatchWorkspaceIds: [UUID] = [] - var visibleWorkspaceIds: Set = [] - var expandedGroupIds: Set = [] - var bestMatch: SidebarFilterMatch? - - for candidate in candidates { - let inMatchedGroup = candidate.groupId.map(matchedGroupIds.contains) ?? false - let match = self.match(candidate: candidate, matcher: matcher, fields: scoredFields) - - if let match { - matchesByWorkspaceId[candidate.id] = match - orderedMatchWorkspaceIds.append(candidate.id) - if bestMatch.map(match.outranks) ?? true { - bestMatch = match - } - } - - guard match != nil || inMatchedGroup else { continue } - visibleWorkspaceIds.insert(candidate.id) - guard let groupId = candidate.groupId, let group = groupsById[groupId] else { continue } - // Keep the header so a surviving member is not stranded under a - // group that filtered itself away. - visibleWorkspaceIds.insert(group.anchorWorkspaceId) - // Open the group only when something *inside* it needs reaching. An - // anchor matching on its own title is the header itself, and - // force-opening a group because the user typed its header's name - // would fight the collapse they chose. - if !candidate.isGroupAnchor { - expandedGroupIds.insert(groupId) - } - } - - // A group whose name matched stays visible even with no members, so - // typing a group name never blanks the sidebar mid-word, and it opens: - // asking for a group by name is asking to see what is in it. - for groupId in matchedGroupIds { - guard let group = groupsById[groupId] else { continue } - visibleWorkspaceIds.insert(group.anchorWorkspaceId) - expandedGroupIds.insert(groupId) - } - - return SidebarFilterOutcome( - isFiltering: true, - visibleWorkspaceIds: visibleWorkspaceIds, - expandedGroupIds: expandedGroupIds, - matchesByWorkspaceId: matchesByWorkspaceId, - orderedMatchWorkspaceIds: orderedMatchWorkspaceIds, - bestMatchWorkspaceId: bestMatch?.workspaceId - ) - } - - /// Scores one candidate, stopping at the highest-priority field that hits. - /// - /// Fields are visited in ``SidebarFilterField/matchPriority`` order, so a - /// row whose title matches never pays to score its branch, directory, and - /// path. Only the winning field's highlight offsets are computed; nothing - /// downstream can render a highlight on a field it is not showing as the - /// match. - private func match( - candidate: SidebarFilterCandidate, - matcher: FuzzyMatcher, - fields: Set - ) -> SidebarFilterMatch? { - for field in SidebarFilterField.scoringOrder where fields.contains(field) { - var bestScore: Int? - var bestCandidateField: SidebarFilterCandidateField? - // A row can carry several values for one field (two listening - // ports, say); keep the strongest. - for candidateField in candidate.fields where candidateField.field == field { - guard let score = matcher.score(preparedCandidate: candidateField.prepared) else { - continue - } - if bestScore.map({ score > $0 }) ?? true { - bestScore = score - bestCandidateField = candidateField - } - } - guard let bestScore, let bestCandidateField else { continue } - var matchedIndicesByField: [SidebarFilterField: Set] = [:] - if bestCandidateField.isDisplayIndexAligned { - let indices = matcher.matchCharacterIndices(in: bestCandidateField.prepared) - if !indices.isEmpty { - matchedIndicesByField[field] = indices - } - } - return SidebarFilterMatch( - workspaceId: candidate.id, - score: bestScore, - field: field, - matchedIndicesByField: matchedIndicesByField - ) - } - return nil - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterMatch.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterMatch.swift deleted file mode 100644 index 06f0db7ccb75..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterMatch.swift +++ /dev/null @@ -1,93 +0,0 @@ -public import Foundation - -/// The result of scoring one sidebar row against a filter query. -/// -/// Carries both the ranking score and the character positions that matched, so -/// the row can highlight exactly the characters the user typed instead of just -/// appearing in a filtered list. -public struct SidebarFilterMatch: Sendable, Equatable { - /// The workspace this match belongs to. - public let workspaceId: UUID - /// The fuzzy score of the winning field, unmixed with field priority. - public let score: Int - /// The highest-priority field that matched. - public let field: SidebarFilterField - /// Matched character offsets per field. - /// - /// Only fields whose normalization is index-aligned with their display text - /// appear here (see ``SidebarFilterCandidateField/isDisplayIndexAligned``), - /// so every offset addresses the displayed string directly. - public let matchedIndicesByField: [SidebarFilterField: Set] - - /// Creates a match. - /// - /// - Parameters: - /// - workspaceId: The matched workspace. - /// - score: The winning field's fuzzy score. - /// - field: The highest-priority field that matched. - /// - matchedIndicesByField: Matched offsets for every field that hit. - public init( - workspaceId: UUID, - score: Int, - field: SidebarFilterField, - matchedIndicesByField: [SidebarFilterField: Set] - ) { - self.workspaceId = workspaceId - self.score = score - self.field = field - self.matchedIndicesByField = matchedIndicesByField - } - - /// Whether this match ranks above `other`. - /// - /// Lexicographic on `(field priority, fuzzy score)`: which field matched - /// dominates, and the fuzzy score only separates rows that matched on the - /// same field. Comparing raw scores across fields would let a long path - /// coincidentally out-score a direct title hit. - /// - /// - Parameter other: The match to compare against. - /// - Returns: `true` when this match should win. - public func outranks(_ other: SidebarFilterMatch) -> Bool { - if field.matchPriority != other.field.matchPriority { - return field.matchPriority > other.field.matchPriority - } - return score > other.score - } - - /// Contiguous highlight ranges for `field` against `displayText`. - /// - /// Fields whose normalization changed their character count carry no - /// offsets at all, so the only guard here is a bounds check against the - /// string actually being drawn. - /// - /// - Parameters: - /// - field: The field being rendered. - /// - displayText: The exact string the row draws. - /// - Returns: Ascending, non-overlapping character ranges to highlight. - public func highlightRanges( - for field: SidebarFilterField, - in displayText: String - ) -> [Range] { - guard let indices = matchedIndicesByField[field], !indices.isEmpty else { return [] } - let displayCount = displayText.count - guard indices.allSatisfy({ $0 >= 0 && $0 < displayCount }) else { return [] } - var ranges: [Range] = [] - var runStart: Int? - var previous: Int? - for index in indices.sorted() { - if let last = previous, index == last + 1 { - previous = index - continue - } - if let start = runStart, let last = previous { - ranges.append(start..<(last + 1)) - } - runStart = index - previous = index - } - if let start = runStart, let last = previous { - ranges.append(start..<(last + 1)) - } - return ranges - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterOutcome.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterOutcome.swift deleted file mode 100644 index a62943017dc1..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterOutcome.swift +++ /dev/null @@ -1,81 +0,0 @@ -public import Foundation - -/// Everything the sidebar needs to render one filter query's result. -/// -/// The outcome deliberately does not reorder rows. A sidebar is a spatial -/// surface: users reach for a workspace by where it sits, and re-sorting by -/// score on every keystroke destroys that. Rows are hidden, never moved, and -/// ``bestMatchWorkspaceId`` carries the ranking separately so Return can still -/// jump to the strongest hit. -public struct SidebarFilterOutcome: Sendable, Equatable { - /// Whether a query was active at all. - /// - /// `false` means the filter is off and every row renders untouched; callers - /// use this to skip filtering work entirely rather than comparing sets. - public let isFiltering: Bool - /// Workspaces whose rows survive the filter, including group anchors kept - /// only to give a surviving member its header. - public let visibleWorkspaceIds: Set - /// Groups that must render expanded so their matching members are reachable. - /// - /// Transient: the filter never writes a group's persisted collapse state, - /// so clearing the query restores exactly the shape the user had. - public let expandedGroupIds: Set - /// Match detail per workspace, for highlighting. - public let matchesByWorkspaceId: [UUID: SidebarFilterMatch] - /// Matching workspaces in model order, for arrow-key navigation. - public let orderedMatchWorkspaceIds: [UUID] - /// The highest-scoring workspace, for Return. - public let bestMatchWorkspaceId: UUID? - - /// The outcome for an inactive filter: everything visible, nothing matched. - public static let inactive = SidebarFilterOutcome( - isFiltering: false, - visibleWorkspaceIds: [], - expandedGroupIds: [], - matchesByWorkspaceId: [:], - orderedMatchWorkspaceIds: [], - bestMatchWorkspaceId: nil - ) - - /// Whether the query matched nothing, so the sidebar should show its empty - /// state instead of a blank list. - public var isEmptyResult: Bool { - isFiltering && orderedMatchWorkspaceIds.isEmpty - } - - /// Whether the row for `workspaceId` should render. - /// - /// - Parameter workspaceId: The workspace whose row is being placed. - /// - Returns: `true` when the filter is off or the workspace survived it. - public func includesWorkspace(_ workspaceId: UUID) -> Bool { - guard isFiltering else { return true } - return visibleWorkspaceIds.contains(workspaceId) - } - - /// Whether `groupId` should render expanded regardless of its stored - /// collapse state. - /// - /// - Parameter groupId: The group being placed. - /// - Returns: `true` only while a filter is forcing the group open. - public func forcesGroupExpanded(_ groupId: UUID) -> Bool { - isFiltering && expandedGroupIds.contains(groupId) - } - - /// Creates an outcome. - public init( - isFiltering: Bool, - visibleWorkspaceIds: Set, - expandedGroupIds: Set, - matchesByWorkspaceId: [UUID: SidebarFilterMatch], - orderedMatchWorkspaceIds: [UUID], - bestMatchWorkspaceId: UUID? - ) { - self.isFiltering = isFiltering - self.visibleWorkspaceIds = visibleWorkspaceIds - self.expandedGroupIds = expandedGroupIds - self.matchesByWorkspaceId = matchesByWorkspaceId - self.orderedMatchWorkspaceIds = orderedMatchWorkspaceIds - self.bestMatchWorkspaceId = bestMatchWorkspaceId - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterQuery.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterQuery.swift deleted file mode 100644 index 16a2988aaa19..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/SidebarFilterQuery.swift +++ /dev/null @@ -1,74 +0,0 @@ -public import CmuxFoundation - -/// A sidebar filter query: the raw text the user typed, split into the text to -/// match and the optional field it was restricted to. -/// -/// A leading sigil scopes the search to one field (`@` branch, `#` group, `:` -/// port, `/` directory); everything else searches every field. The sigil is -/// stripped from ``searchText`` so `/repos` matches the directory text -/// `~/repos/cmux` rather than requiring a literal leading slash. -/// -/// ```swift -/// let query = SidebarFilterQuery("@fix-drag") -/// query.restrictedField // .branch -/// query.searchText // "fix-drag" -/// ``` -/// -/// A directory query keeps its slash when the user typed a rooted path, since -/// `/Users/me` reads as a path fragment rather than a scoped search. -public struct SidebarFilterQuery: Sendable, Equatable { - /// The unmodified text the user typed. - public let rawText: String - /// The text to fuzzy-match, with any scoping sigil removed. - public let searchText: String - /// The field this query was scoped to, or nil to search every field. - public let restrictedField: SidebarFilterField? - - /// Whether this query selects everything (nothing to match against). - public var isEmpty: Bool { - searchText.isEmpty - } - - /// The fields this query should be scored against, in declaration order. - public var fields: [SidebarFilterField] { - guard let restrictedField else { return SidebarFilterField.allCases } - return [restrictedField] - } - - /// Parses `rawText` into a scoped query. - /// - /// - Parameter rawText: Text straight from the filter field. - public init(_ rawText: String) { - self.rawText = rawText - let trimmed = rawText.trimmingCharacters(in: .whitespacesAndNewlines) - guard let sigil = trimmed.first, - let field = SidebarFilterField.allCases.first(where: { $0.querySigil == sigil }), - Self.sigilScopesQuery(sigil: sigil, trimmed: trimmed) else { - self.searchText = trimmed - self.restrictedField = nil - return - } - self.searchText = String(trimmed.dropFirst()) - .trimmingCharacters(in: .whitespacesAndNewlines) - self.restrictedField = field - } - - /// Whether a leading sigil should be read as a scope rather than as part of - /// the search text. - /// - /// `/Users/me/src` is a path the user is searching for, not a directory - /// scope followed by `Users/me/src` - a rooted path keeps its slash. Every - /// other sigil always scopes. - private static func sigilScopesQuery(sigil: Character, trimmed: String) -> Bool { - guard sigil == "/" else { return true } - return !trimmed.dropFirst().contains("/") - } - - /// The fuzzy matcher for this query, or nil when the query is empty. - public func makeMatcher() -> FuzzyMatcher? { - guard !isEmpty else { return nil } - let matcher = FuzzyMatcher(query: searchText) - guard !matcher.preparedQuery.isEmpty else { return nil } - return matcher - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterEmptyStateView.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterEmptyStateView.swift deleted file mode 100644 index a5edbe13c34f..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterEmptyStateView.swift +++ /dev/null @@ -1,88 +0,0 @@ -public import SwiftUI - -/// What the workspace list shows when a filter matches nothing. -/// -/// An empty list is ambiguous: it reads the same as "you have no workspaces". -/// This says which query came up empty and offers the way out, so clearing the -/// filter never requires remembering a shortcut. -public struct SidebarFilterEmptyStateView: View { - /// The query that matched nothing. - public let queryText: String - /// The field the query was scoped to, if any. - public let scopeField: SidebarFilterField? - /// The sidebar's accent color. - public let accent: Color - /// Geometry, scaled to the sidebar's font size. - public let metrics: SidebarFilterMetrics - /// Clears the filter. - public let onClear: () -> Void - - /// Creates the empty state. - /// - /// - Parameters: - /// - queryText: The text that matched nothing. - /// - scopeField: The field the query was scoped to, if any. - /// - accent: The sidebar accent color. - /// - metrics: Geometry for the current font scale. - /// - onClear: Called when the user chooses to clear the filter. - public init( - queryText: String, - scopeField: SidebarFilterField? = nil, - accent: Color, - metrics: SidebarFilterMetrics = SidebarFilterMetrics(), - onClear: @escaping () -> Void - ) { - self.queryText = queryText - self.scopeField = scopeField - self.accent = accent - self.metrics = metrics - self.onClear = onClear - } - - public var body: some View { - VStack(alignment: .leading, spacing: 4) { - Text(headline) - .font(.system(size: 11 * metrics.fontScale)) - .foregroundStyle(.secondary) - .lineLimit(2) - - // Plain rather than `.link`: the link style paints AppKit's link - // blue, which is a different blue from the sidebar accent sitting - // right above it. - Button(action: onClear) { - Text(String( - localized: "sidebar.filter.empty.clear", - defaultValue: "Clear filter" - )) - .font(.system(size: 10.5 * metrics.fontScale, weight: .medium)) - .foregroundStyle(accent) - } - .buttonStyle(.plain) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, metrics.horizontalInset + metrics.fieldPadding) - .padding(.vertical, 10) - .accessibilityIdentifier("SidebarFilterEmptyState") - } - - /// The query as it appears in the message, quoted so a query made of - /// spaces or punctuation is still visible. - private var quoted: String { - "\u{201C}\(queryText)\u{201D}" - } - - /// The message, naming the scope when the query was scoped so a fruitless - /// `@` search does not look like the workspace simply is not there. - private var headline: String { - guard let scopeField else { - return String( - localized: "sidebar.filter.empty.unscoped", - defaultValue: "No workspace matches \(quoted)" - ) - } - return String( - localized: "sidebar.filter.empty.scoped", - defaultValue: "No \(SidebarFilterScopeLabel.text(for: scopeField)) matches \(quoted)" - ) - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldChrome.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldChrome.swift deleted file mode 100644 index 25d4da222c79..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldChrome.swift +++ /dev/null @@ -1,117 +0,0 @@ -public import SwiftUI - -/// The filter field's frame: leading glyph, scope chip, match count, clear -/// button, and the rounded fill behind them. -/// -/// The text input is injected rather than owned, so the same chrome hosts the -/// live `TextField` in the app and a plain `Text` anywhere the field has to be -/// drawn without being editable (the settings preview, the render gallery). -/// Keeping the chrome input-agnostic also means the surrounding layout is -/// exercised by everything that draws it, not only by the interactive path. -public struct SidebarFilterFieldChrome: View { - /// Everything the chrome draws from. - public let model: SidebarFilterFieldModel - /// Geometry, scaled to the sidebar's font size. - public let metrics: SidebarFilterMetrics - /// The sidebar's accent color. - public let accent: Color - /// Whether to draw the focused accent ring. - public let isFocused: Bool - /// Invoked when the user clears the field. - public let onCancel: () -> Void - /// The text input to host. - @ViewBuilder public let input: () -> Input - - /// Creates the chrome around `input`. - /// - /// - Parameters: - /// - model: The render snapshot. - /// - metrics: Geometry for the current font scale. - /// - accent: The sidebar accent color. - /// - isFocused: Whether to draw the focus ring. - /// - onCancel: Called when the clear button is pressed. - /// - input: The text input, editable or otherwise. - public init( - model: SidebarFilterFieldModel, - metrics: SidebarFilterMetrics = SidebarFilterMetrics(), - accent: Color, - isFocused: Bool, - onCancel: @escaping () -> Void, - @ViewBuilder input: @escaping () -> Input - ) { - self.model = model - self.metrics = metrics - self.accent = accent - self.isFocused = isFocused - self.onCancel = onCancel - self.input = input - } - - public var body: some View { - HStack(spacing: metrics.itemSpacing) { - Image(systemName: "magnifyingglass") - .font(.system(size: metrics.accessoryFontSize, weight: .medium)) - .foregroundStyle(.secondary) - - if let scopeField = model.scopeField { - scopeChip(scopeField) - } - - input() - .frame(maxWidth: .infinity, alignment: .leading) - - if model.showsMatchCount { - // Zero is shown, not hidden. It is the calmest possible "no - // matches" signal: the number the user is already watching - // simply reaches 0, with no colour change anywhere. - Text(verbatim: "\(model.matchCount)") - .font(.system(size: metrics.accessoryFontSize, weight: .medium)) - .monospacedDigit() - .foregroundStyle(model.isEmptyResult ? AnyShapeStyle(.tertiary) : AnyShapeStyle(.secondary)) - } - - if model.hasQuery { - Button(action: onCancel) { - Image(systemName: "xmark.circle.fill") - .font(.system(size: metrics.accessoryFontSize)) - .foregroundStyle(.tertiary) - } - .buttonStyle(.plain) - .accessibilityLabel(String( - localized: "sidebar.filter.clear", - defaultValue: "Clear filter" - )) - } - } - .padding(.horizontal, metrics.fieldPadding) - .frame(height: metrics.fieldHeight) - .background(background) - .padding(.horizontal, metrics.horizontalInset) - } - - private func scopeChip(_ field: SidebarFilterField) -> some View { - Text(SidebarFilterScopeLabel.text(for: field)) - .font(.system(size: metrics.scopeFontSize, weight: .semibold)) - .foregroundStyle(accent) - .padding(.horizontal, 5) - .padding(.vertical, 1) - .background( - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(accent.opacity(0.15)) - ) - .fixedSize() - } - - /// Focus reads as a slightly brighter fill and a soft accent ring rather - /// than a hard outline. The sidebar is a resting surface that happens to - /// contain a field; a system-weight focus border makes the field shout - /// over the workspace list it belongs to. - private var background: some View { - RoundedRectangle(cornerRadius: metrics.cornerRadius, style: .continuous) - .fill(Color.primary.opacity(isFocused ? 0.09 : 0.06)) - .overlay( - RoundedRectangle(cornerRadius: metrics.cornerRadius, style: .continuous) - .strokeBorder(accent.opacity(isFocused ? 0.3 : 0), lineWidth: 1) - ) - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldModel.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldModel.swift deleted file mode 100644 index 798c676ee064..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldModel.swift +++ /dev/null @@ -1,56 +0,0 @@ -public import Foundation - -/// Immutable render input for the sidebar's filter field. -/// -/// A value snapshot, not a store: the field sits above a virtualized list, and -/// the sidebar's snapshot-boundary rule keeps observable references out of -/// everything in that subtree. -public struct SidebarFilterFieldModel: Sendable, Equatable { - /// The text currently in the field. - public let queryText: String - /// How many workspaces match. - public let matchCount: Int - /// How many workspaces exist in total. - public let totalCount: Int - /// The field the query is scoped to by a sigil, if any. - public let scopeField: SidebarFilterField? - - /// Whether the field has anything in it. - public var hasQuery: Bool { - !queryText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - /// Whether a real query matched nothing. - public var isEmptyResult: Bool { - hasQuery && matchCount == 0 - } - - /// Whether to show the match count. - /// - /// Hidden when the query matches everything, because "42 of 42" is noise; - /// the count earns its place once it is narrowing something. Zero counts - /// as narrowing: watching the number fall to 0 is how the field reports a - /// miss, with no colour change needed anywhere. - public var showsMatchCount: Bool { - hasQuery && matchCount < totalCount - } - - /// Creates a snapshot. - /// - /// - Parameters: - /// - queryText: Text currently in the field. - /// - matchCount: Number of matching workspaces. - /// - totalCount: Number of workspaces in the sidebar. - /// - scopeField: Field the query is scoped to, if a sigil was typed. - public init( - queryText: String, - matchCount: Int, - totalCount: Int, - scopeField: SidebarFilterField? = nil - ) { - self.queryText = queryText - self.matchCount = matchCount - self.totalCount = totalCount - self.scopeField = scopeField - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldView.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldView.swift deleted file mode 100644 index 1eeaea8fb41b..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterFieldView.swift +++ /dev/null @@ -1,63 +0,0 @@ -public import SwiftUI - -/// The sidebar's live filter field: ``SidebarFilterFieldChrome`` hosting an -/// editable text field. -/// -/// Renders from a value snapshot plus an action bundle, never from a store, so -/// it satisfies the same snapshot-boundary rule as the rows below it. -public struct SidebarFilterFieldView: View { - /// Everything the field draws from. - public let model: SidebarFilterFieldModel - /// Geometry, scaled to the sidebar's font size. - public let metrics: SidebarFilterMetrics - /// The sidebar's accent color. - public let accent: Color - /// Live binding for the text field's contents. - @Binding public var queryText: String - /// Invoked when the user clears the field or presses Escape. - public let onCancel: () -> Void - - @FocusState private var isFocused: Bool - - /// Creates a filter field. - /// - /// - Parameters: - /// - model: The render snapshot. - /// - metrics: Geometry for the current font scale. - /// - accent: The sidebar accent color. - /// - queryText: Binding to the field's text. - /// - onCancel: Called on clear or Escape. - public init( - model: SidebarFilterFieldModel, - metrics: SidebarFilterMetrics = SidebarFilterMetrics(), - accent: Color, - queryText: Binding, - onCancel: @escaping () -> Void - ) { - self.model = model - self.metrics = metrics - self.accent = accent - self._queryText = queryText - self.onCancel = onCancel - } - - public var body: some View { - SidebarFilterFieldChrome( - model: model, - metrics: metrics, - accent: accent, - isFocused: isFocused, - onCancel: onCancel - ) { - TextField( - String(localized: "sidebar.filter.placeholder", defaultValue: "Filter workspaces"), - text: $queryText - ) - .textFieldStyle(.plain) - .font(.system(size: metrics.queryFontSize)) - .focused($isFocused) - .onExitCommand(perform: onCancel) - } - .accessibilityIdentifier("SidebarFilterField") - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightStyle.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightStyle.swift deleted file mode 100644 index 25315699e5eb..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightStyle.swift +++ /dev/null @@ -1,84 +0,0 @@ -public import SwiftUI - -/// How a filter match is emphasised inside a row label. -/// -/// Matched characters get a tinted pill behind them rather than a coloured -/// foreground. Recolouring the text would put a second blue next to the -/// selection fill, which is already cmux's accent, and the two blues then -/// compete to mean different things. A background tint reads as "the search -/// found this here" without borrowing the colour that means "this row is -/// selected". -/// -/// On a selected row the tint is drawn from white instead of the accent, -/// because the accent is the background there, and unmatched text steps back -/// so the match wins on contrast rather than on weight alone. -public struct SidebarFilterHighlightStyle: Sendable { - /// The sidebar accent, used for the tint on unselected rows. - public let accent: Color - /// Whether the row is the selected one. - public let isActiveRow: Bool - /// Whether the sidebar is drawing in its dark scheme. - public let isDark: Bool - - /// Creates a style. - /// - /// - Parameters: - /// - accent: The sidebar accent color. - /// - isActiveRow: Whether the row being drawn is selected. - /// - isDark: Whether the sidebar is in its dark scheme. - public init(accent: Color, isActiveRow: Bool, isDark: Bool) { - self.accent = accent - self.isActiveRow = isActiveRow - self.isDark = isDark - } - - /// Background tint painted behind a matched run. - /// - /// The accent needs more alpha on a near-black ground than on a light one - /// to reach the same apparent contrast, so the tint is scheme-aware rather - /// than one constant that is right in exactly one theme. - public var matchBackground: Color { - if isActiveRow { - return Color.white.opacity(0.28) - } - return accent.opacity(isDark ? 0.34 : 0.22) - } - - /// Foreground for a matched run. - public var matchForeground: Color { - isActiveRow ? .white : .primary - } - - /// Foreground for the rest of the label. - /// - /// Stepped back on a selected row so the match reads as emphasis; left - /// alone otherwise, since the tint is already doing the work. - public var restForeground: Color { - isActiveRow ? Color.white.opacity(0.72) : .primary - } - - /// Builds the attributed label for `displayText` with `ranges` emphasised. - /// - /// - Parameters: - /// - displayText: The label exactly as the row draws it. - /// - ranges: Match ranges from ``SidebarFilterMatch/highlightRanges(for:in:)``. - /// - Returns: An attributed string whose plain text equals `displayText`. - public func attributedLabel( - displayText: String, - ranges: [Range] - ) -> AttributedString { - let runs = SidebarFilterHighlightedText.runs(displayText: displayText, ranges: ranges) - var result = AttributedString() - for run in runs { - var piece = AttributedString(run.text) - if run.isMatch { - piece.foregroundColor = matchForeground - piece.backgroundColor = matchBackground - } else { - piece.foregroundColor = restForeground - } - result.append(piece) - } - return result - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightedText.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightedText.swift deleted file mode 100644 index 3f38dbb68df9..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Filter/UI/SidebarFilterHighlightedText.swift +++ /dev/null @@ -1,70 +0,0 @@ -public import Foundation - -/// Builds the attributed form of a row label with the filter's matched -/// characters emphasised. -/// -/// Pure string work, deliberately separate from any view: the sidebar draws -/// rows through AppKit and SwiftUI in different places, and both need the same -/// emphasis decisions from the same match data. -public struct SidebarFilterHighlightedText: Sendable { - /// One run of the label, and whether the filter matched it. - public struct Run: Sendable, Equatable { - /// The run's text. - public let text: String - /// Whether this run should be emphasised as a match. - public let isMatch: Bool - - /// Creates a run. - public init(text: String, isMatch: Bool) { - self.text = text - self.isMatch = isMatch - } - } - - /// Splits `displayText` into alternating unmatched and matched runs. - /// - /// Adjacent matched characters collapse into one run, so a label renders as - /// few attribute changes as possible rather than one per character. - /// - /// - Parameters: - /// - displayText: The label exactly as the row draws it. - /// - ranges: Ascending, non-overlapping match ranges in character offsets, - /// as returned by ``SidebarFilterMatch/highlightRanges(for:in:)``. - /// - Returns: Runs in order; concatenating their text reproduces - /// `displayText` exactly. An empty or out-of-bounds range set yields a - /// single unmatched run. - public static func runs( - displayText: String, - ranges: [Range] - ) -> [Run] { - let characters = Array(displayText) - guard !ranges.isEmpty else { - return characters.isEmpty ? [] : [Run(text: displayText, isMatch: false)] - } - guard ranges.allSatisfy({ $0.lowerBound >= 0 && $0.upperBound <= characters.count }) - else { - return [Run(text: displayText, isMatch: false)] - } - - var runs: [Run] = [] - var cursor = 0 - for range in ranges { - // A caller that hands over unsorted or overlapping ranges would - // otherwise duplicate or drop characters; skip rather than corrupt - // the label. - guard range.lowerBound >= cursor else { continue } - if range.lowerBound > cursor { - runs.append(Run( - text: String(characters[cursor.. String { - switch field { - case .title: - return String(localized: "sidebar.filter.scope.title", defaultValue: "title") - case .branch: - return String(localized: "sidebar.filter.scope.branch", defaultValue: "branch") - case .directory: - return String(localized: "sidebar.filter.scope.directory", defaultValue: "path") - case .group: - return String(localized: "sidebar.filter.scope.group", defaultValue: "group") - case .pullRequest: - return String(localized: "sidebar.filter.scope.pullRequest", defaultValue: "pr") - case .port: - return String(localized: "sidebar.filter.scope.port", defaultValue: "port") - } - } -} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightTests.swift deleted file mode 100644 index 19332d5ee75f..000000000000 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightTests.swift +++ /dev/null @@ -1,80 +0,0 @@ -import Foundation -import Testing -@testable import CmuxSidebar - -@Suite("SidebarFilterMatch highlighting") -struct SidebarFilterHighlightTests { - private func match(indices: Set, field: SidebarFilterField = .title) -> SidebarFilterMatch { - SidebarFilterMatch( - workspaceId: UUID(), - score: 1, - field: field, - matchedIndicesByField: [field: indices] - ) - } - - @Test func adjacentOffsetsCollapseIntoOneRange() { - let ranges = match(indices: [0, 1, 2]).highlightRanges(for: .title, in: "cmux-web") - #expect(ranges == [0..<3]) - } - - @Test func gapsSplitIntoSeparateRanges() { - let ranges = match(indices: [0, 1, 5, 6, 7]).highlightRanges(for: .title, in: "cmux-web") - #expect(ranges == [0..<2, 5..<8]) - } - - @Test func singleOffsetsBecomeSingleCharacterRanges() { - let ranges = match(indices: [0, 2, 4]).highlightRanges(for: .title, in: "cmux-web") - #expect(ranges == [0..<1, 2..<3, 4..<5]) - } - - @Test func rangesAreAscendingRegardlessOfSetIteration() { - // Set iteration order is unspecified; the ranges must not be. - let ranges = match(indices: [7, 1, 4, 0, 5]).highlightRanges(for: .title, in: "cmux-web") - #expect(ranges == [0..<2, 4..<6, 7..<8]) - } - - @Test func fieldWithoutMatchesHighlightsNothing() { - let ranges = match(indices: [0, 1]).highlightRanges(for: .branch, in: "main") - #expect(ranges.isEmpty) - } - - @Test func offsetsPastTheDisplayStringHighlightNothing() { - // Defence in depth against a stale match outliving a renamed row: - // highlighting out of bounds would trap in the row's text layout. - let ranges = match(indices: [0, 1, 99]).highlightRanges(for: .title, in: "cmux") - #expect(ranges.isEmpty) - } - - @Test func nonLengthPreservingNormalizationCarriesNoHighlights() { - // Diacritic folding can change the character count, which invalidates - // the offsets against the displayed string. The field still scores; it - // just renders unhighlighted rather than underlining the wrong glyphs. - let field = SidebarFilterCandidateField(field: .title, displayText: "café") - if !field.isDisplayIndexAligned { - let index = SidebarFilterIndex( - candidates: [SidebarFilterCandidate(id: UUID(), fields: [field])], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("cafe")) - let match = outcome.matchesByWorkspaceId.values.first - #expect(match != nil) - #expect(match?.matchedIndicesByField.isEmpty == true) - } - } - - @Test func alignedAsciiFieldKeepsItsHighlights() { - let id = UUID() - let index = SidebarFilterIndex( - candidates: [SidebarFilterCandidate( - id: id, - fields: [SidebarFilterCandidateField(field: .title, displayText: "cmux-web")] - )], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("cmux")) - let ranges = outcome.matchesByWorkspaceId[id]? - .highlightRanges(for: .title, in: "cmux-web") - #expect(ranges == [0..<4]) - } -} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightedTextTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightedTextTests.swift deleted file mode 100644 index 1c12ab549640..000000000000 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterHighlightedTextTests.swift +++ /dev/null @@ -1,71 +0,0 @@ -import Foundation -import Testing -@testable import CmuxSidebar - -@Suite("SidebarFilterHighlightedText") -struct SidebarFilterHighlightedTextTests { - private func concatenated(_ runs: [SidebarFilterHighlightedText.Run]) -> String { - runs.map(\.text).joined() - } - - @Test func noRangesYieldOneUnmatchedRun() { - let runs = SidebarFilterHighlightedText.runs(displayText: "cmux-web", ranges: []) - #expect(runs == [.init(text: "cmux-web", isMatch: false)]) - } - - @Test func emptyTextYieldsNoRuns() { - #expect(SidebarFilterHighlightedText.runs(displayText: "", ranges: []).isEmpty) - } - - @Test func aLeadingMatchSplitsIntoTwoRuns() { - let runs = SidebarFilterHighlightedText.runs(displayText: "cmux-web", ranges: [0..<4]) - #expect(runs == [ - .init(text: "cmux", isMatch: true), - .init(text: "-web", isMatch: false), - ]) - } - - @Test func anInteriorMatchSplitsIntoThreeRuns() { - let runs = SidebarFilterHighlightedText.runs(displayText: "cmux-web", ranges: [5..<8]) - #expect(runs == [ - .init(text: "cmux-", isMatch: false), - .init(text: "web", isMatch: true), - ]) - } - - @Test func severalRangesAlternate() { - let runs = SidebarFilterHighlightedText.runs( - displayText: "sidebar-rail", - ranges: [0..<2, 8..<12] - ) - #expect(runs.map(\.isMatch) == [true, false, true]) - #expect(concatenated(runs) == "sidebar-rail") - } - - @Test func runsAlwaysReproduceTheOriginalLabel() { - // The label the user reads must never change because of highlighting. - let text = "feature/sidebar-rail-42" - for ranges in [[0..<7], [8..<15], [0..<1, 4..<6, 20..<23]] { - let runs = SidebarFilterHighlightedText.runs(displayText: text, ranges: ranges) - #expect(concatenated(runs) == text) - } - } - - @Test func outOfBoundsRangesFallBackToAPlainLabel() { - let runs = SidebarFilterHighlightedText.runs(displayText: "cmux", ranges: [0..<99]) - #expect(runs == [.init(text: "cmux", isMatch: false)]) - } - - @Test func overlappingRangesAreSkippedRatherThanDuplicatingCharacters() { - let runs = SidebarFilterHighlightedText.runs( - displayText: "cmux-web", - ranges: [0..<4, 2..<6] - ) - #expect(concatenated(runs) == "cmux-web") - } - - @Test func aFullyMatchedLabelIsOneMatchedRun() { - let runs = SidebarFilterHighlightedText.runs(displayText: "cmux", ranges: [0..<4]) - #expect(runs == [.init(text: "cmux", isMatch: true)]) - } -} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterIndexTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterIndexTests.swift deleted file mode 100644 index c9712bd46bfa..000000000000 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterIndexTests.swift +++ /dev/null @@ -1,223 +0,0 @@ -import Foundation -import Testing -@testable import CmuxSidebar - -@Suite("SidebarFilterIndex") -struct SidebarFilterIndexTests { - // MARK: - Fixtures - - private static func workspace( - _ id: UUID, - title: String, - branch: String? = nil, - directory: String? = nil, - pullRequest: String? = nil, - ports: [String] = [], - groupId: UUID? = nil, - isGroupAnchor: Bool = false - ) -> SidebarFilterCandidate { - var fields: [SidebarFilterCandidateField] = [ - SidebarFilterCandidateField(field: .title, displayText: title) - ] - if let branch { - fields.append(SidebarFilterCandidateField(field: .branch, displayText: branch)) - } - if let directory { - fields.append(SidebarFilterCandidateField(field: .directory, displayText: directory)) - } - if let pullRequest { - fields.append(SidebarFilterCandidateField(field: .pullRequest, displayText: pullRequest)) - } - for port in ports { - fields.append(SidebarFilterCandidateField(field: .port, displayText: port)) - } - return SidebarFilterCandidate( - id: id, - groupId: groupId, - isGroupAnchor: isGroupAnchor, - fields: fields - ) - } - - // MARK: - Passthrough - - @Test func emptyQueryReturnsInactiveOutcome() { - let index = SidebarFilterIndex( - candidates: [Self.workspace(UUID(), title: "cmux")], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery(" ")) - #expect(outcome == .inactive) - #expect(!outcome.isFiltering) - #expect(!outcome.isEmptyResult) - } - - @Test func inactiveOutcomeIncludesEveryWorkspace() { - // Passthrough must not depend on the visible set being populated: an - // inactive filter carries no ids at all and still shows every row. - #expect(SidebarFilterOutcome.inactive.includesWorkspace(UUID())) - #expect(!SidebarFilterOutcome.inactive.forcesGroupExpanded(UUID())) - } - - // MARK: - Field matching - - @Test func matchesOnBranchWhenTitleSaysNothingAboutIt() { - let id = UUID() - let index = SidebarFilterIndex( - candidates: [Self.workspace(id, title: "api", branch: "fix-drag-failsafe")], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("failsafe")) - #expect(outcome.orderedMatchWorkspaceIds == [id]) - #expect(outcome.matchesByWorkspaceId[id]?.field == .branch) - } - - @Test func matchesOnDirectory() { - let id = UUID() - let index = SidebarFilterIndex( - candidates: [Self.workspace(id, title: "api", directory: "~/repos/manaflow")], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("manaflow")) - #expect(outcome.matchesByWorkspaceId[id]?.field == .directory) - } - - @Test func titleOutranksDirectoryForTheSameText() { - let titled = UUID() - let pathed = UUID() - let index = SidebarFilterIndex( - candidates: [ - Self.workspace(pathed, title: "unrelated", directory: "~/src/ghostty"), - Self.workspace(titled, title: "ghostty"), - ], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("ghostty")) - #expect(outcome.bestMatchWorkspaceId == titled) - } - - @Test func scopedQueryIgnoresOtherFields() { - let branchHit = UUID() - let titleHit = UUID() - let index = SidebarFilterIndex( - candidates: [ - Self.workspace(titleHit, title: "main"), - Self.workspace(branchHit, title: "worker", branch: "main"), - ], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("@main")) - #expect(outcome.orderedMatchWorkspaceIds == [branchHit]) - #expect(outcome.matchesByWorkspaceId[titleHit] == nil) - } - - @Test func portQueryFindsAListeningWorkspace() { - let id = UUID() - let index = SidebarFilterIndex( - candidates: [Self.workspace(id, title: "web", ports: ["3000", "9229"])], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery(":9229")) - #expect(outcome.orderedMatchWorkspaceIds == [id]) - } - - // MARK: - Ordering - - @Test func rowsKeepModelOrderRatherThanScoreOrder() { - // A sidebar is spatial. Re-sorting by score on every keystroke would - // move rows under the pointer mid-type, so the outcome preserves the - // order the sidebar renders in and exposes ranking separately. - let weak = UUID() - let strong = UUID() - let index = SidebarFilterIndex( - candidates: [ - Self.workspace(weak, title: "unrelated", directory: "~/src/cmux"), - Self.workspace(strong, title: "cmux"), - ], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("cmux")) - #expect(outcome.orderedMatchWorkspaceIds == [weak, strong]) - #expect(outcome.bestMatchWorkspaceId == strong) - } - - // MARK: - Groups - - @Test func matchingMemberKeepsItsHeaderAndForcesTheGroupOpen() { - let groupId = UUID() - let anchor = UUID() - let member = UUID() - let index = SidebarFilterIndex( - candidates: [ - Self.workspace(anchor, title: "infra", groupId: groupId, isGroupAnchor: true), - Self.workspace(member, title: "deploy-runner", groupId: groupId), - ], - groups: [SidebarFilterGroup(id: groupId, anchorWorkspaceId: anchor, name: "infra")] - ) - let outcome = index.outcome(for: SidebarFilterQuery("runner")) - #expect(outcome.visibleWorkspaceIds == [anchor, member]) - #expect(outcome.expandedGroupIds == [groupId]) - // The header is context, not a hit. - #expect(outcome.orderedMatchWorkspaceIds == [member]) - } - - @Test func matchingGroupNameKeepsEveryMemberAndOpensTheGroup() { - let groupId = UUID() - let anchor = UUID() - let first = UUID() - let second = UUID() - let index = SidebarFilterIndex( - candidates: [ - Self.workspace(anchor, title: "anchor", groupId: groupId, isGroupAnchor: true), - Self.workspace(first, title: "alpha", groupId: groupId), - Self.workspace(second, title: "beta", groupId: groupId), - ], - groups: [SidebarFilterGroup(id: groupId, anchorWorkspaceId: anchor, name: "infra")] - ) - let outcome = index.outcome(for: SidebarFilterQuery("#infra")) - #expect(outcome.visibleWorkspaceIds == [anchor, first, second]) - #expect(outcome.expandedGroupIds == [groupId]) - } - - @Test func anchorMatchingOnItsOwnTitleDoesNotForceTheGroupOpen() { - // The anchor *is* the header. Typing its name is not a request to - // expand the group the user deliberately collapsed. - let groupId = UUID() - let anchor = UUID() - let member = UUID() - let index = SidebarFilterIndex( - candidates: [ - Self.workspace(anchor, title: "zephyr", groupId: groupId, isGroupAnchor: true), - Self.workspace(member, title: "unrelated", groupId: groupId), - ], - groups: [SidebarFilterGroup(id: groupId, anchorWorkspaceId: anchor, name: "other")] - ) - let outcome = index.outcome(for: SidebarFilterQuery("zephyr")) - #expect(outcome.orderedMatchWorkspaceIds == [anchor]) - #expect(outcome.expandedGroupIds.isEmpty) - } - - @Test func ungroupedWorkspacesNeverProduceGroupExpansion() { - let id = UUID() - let index = SidebarFilterIndex( - candidates: [Self.workspace(id, title: "solo")], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("solo")) - #expect(outcome.expandedGroupIds.isEmpty) - } - - // MARK: - Empty results - - @Test func unmatchedQueryReportsAnEmptyResultRatherThanPassthrough() { - let index = SidebarFilterIndex( - candidates: [Self.workspace(UUID(), title: "cmux")], - groups: [] - ) - let outcome = index.outcome(for: SidebarFilterQuery("zzzzqqq")) - #expect(outcome.isFiltering) - #expect(outcome.isEmptyResult) - #expect(outcome.visibleWorkspaceIds.isEmpty) - #expect(outcome.bestMatchWorkspaceId == nil) - } -} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterQueryTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterQueryTests.swift deleted file mode 100644 index 01c7382d58f7..000000000000 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterQueryTests.swift +++ /dev/null @@ -1,65 +0,0 @@ -import Foundation -import Testing -@testable import CmuxSidebar - -@Suite("SidebarFilterQuery") -struct SidebarFilterQueryTests { - @Test func blankQueryIsEmptyAndBuildsNoMatcher() { - for raw in ["", " ", "\n\t"] { - let query = SidebarFilterQuery(raw) - #expect(query.isEmpty) - #expect(query.makeMatcher() == nil) - } - } - - @Test func unprefixedQuerySearchesEveryField() { - let query = SidebarFilterQuery("cmux") - #expect(query.restrictedField == nil) - #expect(query.searchText == "cmux") - #expect(Set(query.fields) == Set(SidebarFilterField.allCases)) - } - - @Test(arguments: [ - ("@fix-drag", SidebarFilterField.branch, "fix-drag"), - ("#infra", SidebarFilterField.group, "infra"), - (":3000", SidebarFilterField.port, "3000"), - ]) - func sigilScopesQueryToOneField( - raw: String, - expectedField: SidebarFilterField, - expectedText: String - ) { - let query = SidebarFilterQuery(raw) - #expect(query.restrictedField == expectedField) - #expect(query.searchText == expectedText) - #expect(query.fields == [expectedField]) - } - - @Test func bareSlashScopesToDirectory() { - let query = SidebarFilterQuery("/repos") - #expect(query.restrictedField == .directory) - #expect(query.searchText == "repos") - } - - @Test func rootedPathKeepsItsSlashAndSearchesEveryField() { - // A user typing an absolute path is searching for that path, not - // scoping a search to directories. Stripping the leading slash here - // would make `/Users/me` fail to match `/Users/me`. - let query = SidebarFilterQuery("/Users/me/src") - #expect(query.restrictedField == nil) - #expect(query.searchText == "/Users/me/src") - } - - @Test func sigilAloneMatchesNothingRatherThanEverything() { - let query = SidebarFilterQuery("@") - #expect(query.restrictedField == .branch) - #expect(query.isEmpty) - #expect(query.makeMatcher() == nil) - } - - @Test func surroundingWhitespaceIsTrimmed() { - let query = SidebarFilterQuery(" @ main ") - #expect(query.restrictedField == .branch) - #expect(query.searchText == "main") - } -} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterScaleTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterScaleTests.swift deleted file mode 100644 index 7df78eb77c95..000000000000 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarFilterScaleTests.swift +++ /dev/null @@ -1,143 +0,0 @@ -import Foundation -import Testing -@testable import CmuxSidebar - -/// Scale coverage for the sidebar filter's two performance claims: -/// -/// 1. Candidate preparation is paid once per corpus revision, so a keystroke -/// costs only scoring. A regression that rebuilds the index per keystroke -/// passes every behavioural suite and only shows up here. -/// 2. Scoring stops at the highest-priority field that matches, so a row whose -/// title matches never pays to score its branch, directory, and ports. -/// -/// Bounds are deliberately loose. These exist to fail on an order-of-magnitude -/// regression, not to pin a number to one machine's clock. -@Suite("SidebarFilterIndex scale") -struct SidebarFilterScaleTests { - private static func makeIndex( - workspaceCount: Int, - matchingEvery: Int - ) -> SidebarFilterIndex { - let groupCount = max(1, workspaceCount / 25) - let groups = (0.. SidebarFilterCandidate in - let group = groups[index % groupCount] - let stem = index % matchingEvery == 0 ? "manaflow" : "zephyr" - return SidebarFilterCandidate( - id: UUID(), - groupId: group.id, - isGroupAnchor: false, - fields: [ - SidebarFilterCandidateField( - field: .title, - displayText: "workspace-\(index)-\(stem)" - ), - SidebarFilterCandidateField( - field: .branch, - displayText: "feature/sidebar-rail-\(index)" - ), - SidebarFilterCandidateField( - field: .directory, - displayText: "~/repos/\(stem)/cmux/pkg-\(index)" - ), - SidebarFilterCandidateField(field: .port, displayText: "\(3000 + index)"), - ] - ) - } - return SidebarFilterIndex(candidates: candidates, groups: groups) - } - - /// Types `query` one character at a time and returns the worst and total - /// wall time, the way a filter field drives the index. - private static func measureTyping( - index: SidebarFilterIndex, - query: String - ) -> (worstMs: Double, totalMs: Double, matches: Int) { - var worstMs = 0.0 - var totalMs = 0.0 - var matches = 0 - for length in 1...query.count { - let prefix = String(query.prefix(length)) - let start = DispatchTime.now().uptimeNanoseconds - let outcome = index.outcome(for: SidebarFilterQuery(prefix)) - let elapsedMs = Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 - worstMs = max(worstMs, elapsedMs) - totalMs += elapsedMs - matches = outcome.orderedMatchWorkspaceIds.count - } - return (worstMs, totalMs, matches) - } - - @Test func typingStaysWellInsideAFrameOnARealisticCorpus() { - // 200 workspaces is already far past what a sidebar shows; one in - // twelve matching is a normal narrowing search. - let index = Self.makeIndex(workspaceCount: 200, matchingEvery: 12) - let result = Self.measureTyping(index: index, query: "manaflow") - print( - "BENCH sidebar-filter realistic workspaces=200" - + " worstKeystroke=\(String(format: "%.2f", result.worstMs))ms" - + " typing=\(String(format: "%.2f", result.totalMs))ms" - + " matches=\(result.matches)" - ) - #expect(result.matches > 0) - // One 60Hz frame is 16.6ms; a realistic corpus must not come close. - #expect(result.worstMs < 8.0) - } - - @Test func preparationIsPaidOncePerCorpusRevisionNotPerKeystroke() { - let query = "manaflow" - let prepareStart = DispatchTime.now().uptimeNanoseconds - let index = Self.makeIndex(workspaceCount: 500, matchingEvery: 1) - let prepareMs = Double(DispatchTime.now().uptimeNanoseconds - prepareStart) / 1_000_000 - - let result = Self.measureTyping(index: index, query: query) - let averageKeystrokeMs = result.totalMs / Double(query.count) - - print( - "BENCH sidebar-filter adversarial workspaces=500 allMatching" - + " prepare=\(String(format: "%.2f", prepareMs))ms" - + " worstKeystroke=\(String(format: "%.2f", result.worstMs))ms" - + " avgKeystroke=\(String(format: "%.2f", averageKeystrokeMs))ms" - ) - - #expect(result.matches == 500) - // Every one of 500 rows matching on its title is not a real corpus; it - // is the worst case the scorer can be handed. Even here a keystroke - // stays inside a few frames, and human typing leaves >60ms between - // them. The bound is a tripwire for a per-keystroke re-preparation - // regression, which would cost `prepare` on top of every character. - #expect(result.worstMs < prepareMs * 4) - #expect(result.worstMs < 40.0) - } - - @Test func aMissPrunesCheaplyThroughTheASCIIMask() { - let index = Self.makeIndex(workspaceCount: 500, matchingEvery: 1) - let start = DispatchTime.now().uptimeNanoseconds - let outcome = index.outcome(for: SidebarFilterQuery("qqqzzzxxx")) - let elapsedMs = Double(DispatchTime.now().uptimeNanoseconds - start) / 1_000_000 - print("BENCH sidebar-filter miss workspaces=500 elapsed=\(String(format: "%.2f", elapsedMs))ms") - #expect(outcome.isEmptyResult) - #expect(elapsedMs < 16.0) - } - - @Test func aTitleHitNeverPaysToScoreTheRemainingFields() { - // Same corpus, same query text, scoped to the lowest-priority field so - // every higher-priority field is skipped outright. If early exit ever - // regresses into scoring every field, the unscoped run stops being - // comparable to this one. - let index = Self.makeIndex(workspaceCount: 500, matchingEvery: 1) - let outcome = index.outcome(for: SidebarFilterQuery("manaflow")) - #expect(outcome.matchesByWorkspaceId.values.allSatisfy { $0.field == .title }) - // The directory also contains "manaflow"; it must not appear as the - // match, and it must not carry highlight offsets it never earned. - #expect(outcome.matchesByWorkspaceId.values.allSatisfy { - $0.matchedIndicesByField[.directory] == nil - }) - } -} diff --git a/Packages/macOS/CmuxSidebarGallery/Package.resolved b/Packages/macOS/CmuxSidebarGallery/Package.resolved deleted file mode 100644 index 763a67302633..000000000000 --- a/Packages/macOS/CmuxSidebarGallery/Package.resolved +++ /dev/null @@ -1,15 +0,0 @@ -{ - "originHash" : "9af779e5749178840b9e12e3787009efd7e9756e1dafac375fcbbb2a3066978c", - "pins" : [ - { - "identity" : "swift-syntax", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-syntax.git", - "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" - } - } - ], - "version" : 3 -} diff --git a/Packages/macOS/CmuxSidebarGallery/Package.swift b/Packages/macOS/CmuxSidebarGallery/Package.swift deleted file mode 100644 index 2adb1d84b2fa..000000000000 --- a/Packages/macOS/CmuxSidebarGallery/Package.swift +++ /dev/null @@ -1,29 +0,0 @@ -// swift-tools-version: 6.0 - -import PackageDescription - -// Development-only. Deliberately NOT wired into cmux.xcodeproj: it exists so -// sidebar UI can be iterated on in seconds by rendering to PNG, instead of -// paying a ~15 minute app build to look at a panel. Nothing ships from here. -let package = Package( - name: "CmuxSidebarGallery", - platforms: [ - .macOS(.v14), - ], - dependencies: [ - .package(path: "../CmuxSidebar"), - .package(path: "../CmuxFoundation"), - ], - targets: [ - .executableTarget( - name: "CmuxSidebarGallery", - dependencies: [ - .product(name: "CmuxSidebar", package: "CmuxSidebar"), - .product(name: "CmuxFoundation", package: "CmuxFoundation"), - ], - swiftSettings: [ - .swiftLanguageMode(.v6), - ] - ), - ] -) diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift deleted file mode 100644 index 2b87b471ce7e..000000000000 --- a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryCatalog.swift +++ /dev/null @@ -1,191 +0,0 @@ -import CmuxSidebar -import SwiftUI - -/// The scenes the gallery renders. -/// -/// Fields are drawn through ``SidebarFilterFieldChrome`` with a static `Text` -/// standing in for the live `TextField`: `ImageRenderer` cannot rasterise -/// AppKit-backed controls, and the chrome is what is being reviewed here -/// anyway. -@MainActor -struct GalleryCatalog { - /// cmux's sidebar accent, matching `cmuxAccentNSColor(for:)`. - static let accent = Color(.sRGB, red: 0, green: 145.0 / 255.0, blue: 1, opacity: 1) - - /// A non-editable stand-in for the live text field. - @ViewBuilder - static func staticInput(_ text: String, metrics: SidebarFilterMetrics) -> some View { - if text.isEmpty { - Text(verbatim: "Filter workspaces") - .font(.system(size: metrics.queryFontSize)) - .foregroundStyle(.tertiary) - } else { - Text(verbatim: text) - .font(.system(size: metrics.queryFontSize)) - .foregroundStyle(.primary) - } - } - - static func field( - _ model: SidebarFilterFieldModel, - isFocused: Bool = false - ) -> some View { - let metrics = SidebarFilterMetrics() - // A sigil scopes the query, so the chip replaces it in the visible text. - let visible = model.scopeField == nil - ? model.queryText - : String(model.queryText.dropFirst()) - return SidebarFilterFieldChrome( - model: model, - metrics: metrics, - accent: accent, - isFocused: isFocused, - onCancel: {} - ) { - staticInput(visible, metrics: metrics) - } - } - - // MARK: - Fixtures - - static let workspaces: [(String, String, String)] = [ - ("cmux", "main", "~/repos/cmux"), - ("sidebar drag fix", "fix/sidebar-drag-failsafe", "~/repos/cmux"), - ("ghostty", "cmux-fork", "~/repos/ghostty"), - ("dragnet", "feat/drag-reorder", "~/repos/dragnet"), - ] - - @ViewBuilder - static func rail( - @ViewBuilder header: () -> Header, - @ViewBuilder content: () -> Body - ) -> some View { - VStack(alignment: .leading, spacing: 8) { - header() - VStack(alignment: .leading, spacing: 2) { - content() - } - } - } - - /// The full rail contents: filter field, rows, footer action. - @ViewBuilder - static func railContents( - mode: SidebarPresentationMode, - filter: SidebarFilterFieldModel, - activeIndex: Int = 1 - ) -> some View { - VStack(alignment: .leading, spacing: 0) { - HStack(spacing: 4) { - field(filter, isFocused: filter.hasQuery) - SidebarPresentationToggleButton(mode: mode, accent: accent, onToggle: {}) - .padding(.trailing, 8) - } - .padding(.top, 10) - - VStack(alignment: .leading, spacing: 2) { - ForEach(Array(workspaces.enumerated()), id: \.offset) { index, workspace in - GalleryMockRow( - title: workspace.0, - branch: workspace.1, - directory: workspace.2, - isActive: index == activeIndex - ) - } - } - .padding(.top, 8) - - Spacer(minLength: 8) - Divider().opacity(0.4).padding(.horizontal, 8) - SidebarNewWorkspaceButton(accent: accent, onCreate: {}) - .padding(.vertical, 6) - } - } - - static let sceneWidth: CGFloat = 620 - static let sceneHeight: CGFloat = 400 - static let railWidth: CGFloat = 260 - - /// The floating panel over mock terminal content, as it appears on reveal. - static func floatingOverContent(filter: SidebarFilterFieldModel) -> some View { - ZStack(alignment: .topLeading) { - GalleryTerminalBackdrop() - SidebarPeekPanelChrome { - railContents(mode: .floating, filter: filter) - .frame(maxHeight: .infinity, alignment: .top) - } - .frame(width: railWidth + SidebarPeekPanelMetrics.default.leadingInset) - } - .frame(width: sceneWidth, height: sceneHeight) - .clipped() - } - - /// The docked sidebar beside the same content, for the mode comparison. - static func dockedBesideContent(filter: SidebarFilterFieldModel) -> some View { - HStack(spacing: 0) { - railContents(mode: .docked, filter: filter) - .frame(width: railWidth) - .frame(maxHeight: .infinity, alignment: .top) - .background(Color.primary.opacity(0.04)) - Divider().opacity(0.5) - GalleryTerminalBackdrop() - } - .frame(width: sceneWidth, height: sceneHeight) - .clipped() - } - - static func scenes() -> [GalleryScene] { - let resting = SidebarFilterFieldModel(queryText: "", matchCount: 6, totalCount: 6) - return [ - GalleryScene(name: "filter-field-scoped") { - field( - SidebarFilterFieldModel( - queryText: "@fix-drag", - matchCount: 2, - totalCount: 24, - scopeField: .branch - ), - isFocused: true - ) - }, - GalleryScene(name: "rail-filtering", width: 260) { - rail { - field( - SidebarFilterFieldModel(queryText: "drag", matchCount: 2, totalCount: 6), - isFocused: true - ) - } content: { - GalleryMockRow( - title: "sidebar drag fix", - branch: "fix/sidebar-drag-failsafe", - directory: "~/repos/cmux", - isActive: true, - titleRanges: [8..<12] - ) - GalleryMockRow( - title: "dragnet", - branch: "feat/drag-reorder", - directory: "~/repos/dragnet", - titleRanges: [0..<4] - ) - } - }, - GalleryScene(name: "rail-empty-result", width: 260) { - rail { - field( - SidebarFilterFieldModel(queryText: "zzzz", matchCount: 0, totalCount: 6), - isFocused: true - ) - } content: { - SidebarFilterEmptyStateView(queryText: "zzzz", accent: accent, onClear: {}) - } - }, - GalleryScene(name: "peek-floating", width: sceneWidth) { - floatingOverContent(filter: resting) - }, - GalleryScene(name: "peek-docked", width: sceneWidth) { - dockedBesideContent(filter: resting) - }, - ] - } -} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryMockRow.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryMockRow.swift deleted file mode 100644 index 87797d76911a..000000000000 --- a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryMockRow.swift +++ /dev/null @@ -1,67 +0,0 @@ -import CmuxSidebar -import SwiftUI - -/// A stand-in for cmux's AppKit workspace row. -/// -/// The real row is an AppKit cell in the app target and cannot be reached from -/// a package. This mirrors its metrics exactly (12.5pt semibold title, 10.5pt -/// details, 8pt leading inset, 6pt corner radius, 8/4 vertical rhythm) so the -/// new chrome is reviewed in a list that lays out like the real one. It is -/// gallery-only and ships nowhere. -struct GalleryMockRow: View { - let title: String - let branch: String? - let directory: String? - var isActive: Bool = false - var titleRanges: [Range] = [] - var branchRanges: [Range] = [] - var isDimmed: Bool = false - - @Environment(\.colorScheme) private var colorScheme - - private let accent = GalleryCatalog.accent - - var body: some View { - VStack(alignment: .leading, spacing: 4) { - highlighted(title, ranges: titleRanges, size: 12.5, weight: .semibold) - if let branch { - HStack(spacing: 4) { - Image(systemName: "arrow.triangle.branch") - .font(.system(size: 9)) - .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.7)) : AnyShapeStyle(.secondary)) - highlighted(branch, ranges: branchRanges, size: 10.5, weight: .regular) - .opacity(isActive ? 1 : 0.72) - } - } - if let directory { - Text(verbatim: directory) - .font(.system(size: 10)) - .foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.7)) : AnyShapeStyle(.secondary)) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(isActive ? accent : .clear) - ) - .padding(.horizontal, 8) - .opacity(isDimmed ? 0.35 : 1) - } - - private func highlighted( - _ text: String, - ranges: [Range], - size: CGFloat, - weight: Font.Weight - ) -> some View { - let style = SidebarFilterHighlightStyle( - accent: accent, - isActiveRow: isActive, - isDark: colorScheme == .dark - ) - return Text(style.attributedLabel(displayText: text, ranges: ranges)) - .font(.system(size: size, weight: weight)) - } -} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryRenderer.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryRenderer.swift deleted file mode 100644 index 500f327e9079..000000000000 --- a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryRenderer.swift +++ /dev/null @@ -1,60 +0,0 @@ -import AppKit -import SwiftUI - -/// Renders scenes to PNG files offscreen. -/// -/// `ImageRenderer` rasterises a SwiftUI view without a window, so this needs no -/// Screen Recording permission and produces byte-identical output run to run. -@MainActor -struct GalleryRenderer { - let outputDirectory: URL - let scale: CGFloat - - func render(_ scenes: [GalleryScene]) throws { - try FileManager.default.createDirectory( - at: outputDirectory, - withIntermediateDirectories: true - ) - for scene in scenes { - for scheme in [ColorScheme.dark, .light] { - let suffix = scheme == .dark ? "dark" : "light" - let url = outputDirectory.appendingPathComponent("\(scene.name)-\(suffix).png") - try write(scene: scene, scheme: scheme, to: url) - print("rendered \(url.lastPathComponent)") - } - } - } - - private func write(scene: GalleryScene, scheme: ColorScheme, to url: URL) throws { - let background = scheme == .dark - ? Color(nsColor: NSColor(srgbRed: 0.106, green: 0.110, blue: 0.118, alpha: 1)) - : Color(nsColor: NSColor(srgbRed: 0.945, green: 0.945, blue: 0.953, alpha: 1)) - - let wrapped = scene.content - .frame(width: scene.width, alignment: .leading) - .padding(.vertical, 12) - .background(background) - .environment(\.colorScheme, scheme) - - let renderer = ImageRenderer(content: wrapped) - renderer.scale = scale - guard let image = renderer.nsImage, - let tiff = image.tiffRepresentation, - let rep = NSBitmapImageRep(data: tiff), - let png = rep.representation(using: .png, properties: [:]) else { - throw GalleryError.renderFailed(scene.name) - } - try png.write(to: url) - } -} - -enum GalleryError: Error, CustomStringConvertible { - case renderFailed(String) - - var description: String { - switch self { - case .renderFailed(let name): - return "failed to rasterise scene '\(name)'" - } - } -} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryScene.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryScene.swift deleted file mode 100644 index 46611a946c13..000000000000 --- a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryScene.swift +++ /dev/null @@ -1,14 +0,0 @@ -import SwiftUI - -/// One named view to render, at a fixed width, in both color schemes. -struct GalleryScene { - let name: String - let width: CGFloat - let content: AnyView - - init(name: String, width: CGFloat = 260, @ViewBuilder content: () -> Content) { - self.name = name - self.width = width - self.content = AnyView(content()) - } -} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryTerminalBackdrop.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryTerminalBackdrop.swift deleted file mode 100644 index 3ee32af4d687..000000000000 --- a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/GalleryTerminalBackdrop.swift +++ /dev/null @@ -1,55 +0,0 @@ -import SwiftUI - -/// Mock terminal content for the floating panel to sit over. -/// -/// The panel's material and rim only mean anything against real content, so -/// the gallery draws something with the texture of a terminal (monospaced -/// lines of varying length and colour) rather than a flat fill. -struct GalleryTerminalBackdrop: View { - @Environment(\.colorScheme) private var colorScheme - - private let lines: [(String, Int)] = [ - ("$ swift test --filter SidebarFilter", 0), - ("Test run started.", 1), - ("BENCH sidebar-filter realistic workspaces=200", 2), - (" worstKeystroke=2.79ms typing=14.43ms matches=17", 1), - ("✔ Suite \"SidebarFilterIndex\" passed", 3), - ("✔ Test run with 34 tests in 4 suites passed", 3), - ("", 1), - ("$ git status --short", 0), - (" M Packages/macOS/CmuxSidebar/Sources/Filter", 2), - ("?? Packages/macOS/CmuxSidebarGallery", 4), - ("", 1), - ("$ ./scripts/reload.sh --tag rail", 0), - ("==> reload succeeded in 936s", 3), - ("", 1), - ("$ ", 0), - ] - - var body: some View { - VStack(alignment: .leading, spacing: 3) { - ForEach(Array(lines.enumerated()), id: \.offset) { _, line in - Text(verbatim: line.0) - .font(.system(size: 10.5, design: .monospaced)) - .foregroundStyle(color(for: line.1)) - .frame(maxWidth: .infinity, alignment: .leading) - } - Spacer(minLength: 0) - } - .padding(14) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(colorScheme == .dark - ? Color(.sRGB, red: 0.055, green: 0.059, blue: 0.067, opacity: 1) - : Color(.sRGB, red: 0.99, green: 0.99, blue: 0.985, opacity: 1)) - } - - private func color(for kind: Int) -> Color { - switch kind { - case 0: return colorScheme == .dark ? .white.opacity(0.85) : .black.opacity(0.8) - case 2: return Color(.sRGB, red: 0.42, green: 0.62, blue: 0.95, opacity: 1) - case 3: return Color(.sRGB, red: 0.35, green: 0.75, blue: 0.5, opacity: 1) - case 4: return Color(.sRGB, red: 0.92, green: 0.68, blue: 0.3, opacity: 1) - default: return colorScheme == .dark ? .white.opacity(0.45) : .black.opacity(0.45) - } - } -} diff --git a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/main.swift b/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/main.swift deleted file mode 100644 index b902c74aa2d6..000000000000 --- a/Packages/macOS/CmuxSidebarGallery/Sources/CmuxSidebarGallery/main.swift +++ /dev/null @@ -1,31 +0,0 @@ -import AppKit -import CmuxSidebar -import Foundation -import SwiftUI - -@MainActor -func run() { - var outputPath = NSString(string: "~/Developer/lab/cmux-rail/.gallery").expandingTildeInPath - var arguments = Array(CommandLine.arguments.dropFirst()) - while let flag = arguments.first { - arguments.removeFirst() - if flag == "--output", let value = arguments.first { - outputPath = value - arguments.removeFirst() - } - } - - let renderer = GalleryRenderer( - outputDirectory: URL(fileURLWithPath: outputPath), - scale: 2 - ) - do { - try renderer.render(GalleryCatalog.scenes()) - print("gallery written to \(outputPath)") - } catch { - print("gallery failed: \(error)") - exit(1) - } -} - -run() diff --git a/Sources/Sidebar/Filter/SidebarFilterController.swift b/Sources/Sidebar/Filter/SidebarFilterController.swift deleted file mode 100644 index f7290e3ad5d0..000000000000 --- a/Sources/Sidebar/Filter/SidebarFilterController.swift +++ /dev/null @@ -1,129 +0,0 @@ -import CmuxSidebar -import CmuxWorkspaces -import Combine -import Foundation - -/// Owns the sidebar filter for one window: the query, the prepared index, and -/// the outcome the row projection reads. -/// -/// The index is rebuilt only when the searchable text actually changes, keyed -/// by ``SidebarFilterCorpusKey``. Everything else (scoring, group expansion, -/// highlight offsets) is pure logic in `CmuxSidebar`; this type is the cache -/// and the published surface around it. -@MainActor -final class SidebarFilterController: ObservableObject { - /// The text in the filter field. - @Published var queryText: String = "" { - didSet { - guard queryText != oldValue else { return } - recomputeOutcome() - } - } - - /// Whether the filter field is mounted. - /// - /// Separate from `queryText` being empty: the field can be open and empty, - /// which shows every row but keeps focus for the next keystroke. - @Published private(set) var isFieldPresented = false - - /// The current filter result. ``SidebarFilterOutcome/inactive`` when off. - @Published private(set) var outcome: SidebarFilterOutcome = .inactive - - private var index: SidebarFilterIndex? - private var corpusKey: SidebarFilterCorpusKey? - private var totalWorkspaceCount = 0 - - /// The snapshot the filter field renders from. - var fieldModel: SidebarFilterFieldModel { - SidebarFilterFieldModel( - queryText: queryText, - matchCount: outcome.isFiltering ? outcome.orderedMatchWorkspaceIds.count : totalWorkspaceCount, - totalCount: totalWorkspaceCount, - scopeField: SidebarFilterQuery(queryText).restrictedField - ) - } - - /// Opens the field and focuses it. - func presentField() { - isFieldPresented = true - } - - /// Closes the field and clears the query. - /// - /// Clearing on dismiss is deliberate: a filter left applied behind a hidden - /// field is a sidebar that is silently lying about how many workspaces - /// exist. - func dismissField() { - isFieldPresented = false - if !queryText.isEmpty { - queryText = "" - } - } - - /// Rebuilds the prepared index if the searchable text changed. - /// - /// Called from the sidebar's render-context construction, which runs on - /// every sidebar invalidation; the corpus key is what keeps that cheap. - /// - /// - Parameters: - /// - entries: Searchable text per workspace, in sidebar order. - /// - groups: The workspace groups. - func updateCorpus(entries: [SidebarFilterCorpusEntry], groups: [WorkspaceGroup]) { - totalWorkspaceCount = entries.count - let key = SidebarFilterCorpusKey(entries: entries, groups: groups) - guard key != corpusKey else { return } - corpusKey = key - index = SidebarFilterIndex( - candidates: entries.map(Self.candidate(from:)), - groups: groups.map { - SidebarFilterGroup( - id: $0.id, - anchorWorkspaceId: $0.anchorWorkspaceId, - name: $0.name - ) - } - ) - recomputeOutcome() - } - - private func recomputeOutcome() { - guard let index, isFieldPresented else { - if outcome.isFiltering { outcome = .inactive } - return - } - let next = index.outcome(for: SidebarFilterQuery(queryText)) - // Republishing an equal outcome would invalidate the sidebar on every - // keystroke that changes nothing, such as typing into an already-empty - // result. - guard next != outcome else { return } - outcome = next - } - - private static func candidate( - from entry: SidebarFilterCorpusEntry - ) -> SidebarFilterCandidate { - var fields: [SidebarFilterCandidateField] = [ - SidebarFilterCandidateField(field: .title, displayText: entry.title) - ] - if let branch = entry.branch, !branch.isEmpty { - fields.append(SidebarFilterCandidateField(field: .branch, displayText: branch)) - } - if let directory = entry.directory, !directory.isEmpty { - fields.append(SidebarFilterCandidateField(field: .directory, displayText: directory)) - } - if let pullRequest = entry.pullRequest, !pullRequest.isEmpty { - fields.append( - SidebarFilterCandidateField(field: .pullRequest, displayText: pullRequest) - ) - } - for port in entry.ports where !port.isEmpty { - fields.append(SidebarFilterCandidateField(field: .port, displayText: port)) - } - return SidebarFilterCandidate( - id: entry.workspaceId, - groupId: entry.groupId, - isGroupAnchor: entry.isGroupAnchor, - fields: fields - ) - } -} diff --git a/Sources/Sidebar/Filter/SidebarFilterCorpusKey.swift b/Sources/Sidebar/Filter/SidebarFilterCorpusKey.swift deleted file mode 100644 index 07ee9d46e61d..000000000000 --- a/Sources/Sidebar/Filter/SidebarFilterCorpusKey.swift +++ /dev/null @@ -1,53 +0,0 @@ -import CmuxWorkspaces -import Foundation - -/// A cheap identity for the text the sidebar filter searches. -/// -/// The prepared index is the filter's whole performance story, so it must be -/// rebuilt when the searchable text changes and never merely because a -/// workspace published something unrelated (a notification, a port, a spinner -/// tick). This key hashes exactly the fields the index reads, so an unrelated -/// change compares equal and the index survives. -struct SidebarFilterCorpusKey: Hashable { - private let hash: Int - - /// Builds the key from the current workspaces and groups. - /// - /// - Parameters: - /// - entries: One entry per workspace, in sidebar order. - /// - groups: The workspace groups. - init(entries: [SidebarFilterCorpusEntry], groups: [WorkspaceGroup]) { - var hasher = Hasher() - for entry in entries { - hasher.combine(entry.workspaceId) - hasher.combine(entry.title) - hasher.combine(entry.branch) - hasher.combine(entry.directory) - hasher.combine(entry.pullRequest) - hasher.combine(entry.ports) - hasher.combine(entry.groupId) - hasher.combine(entry.isGroupAnchor) - } - for group in groups { - hasher.combine(group.id) - hasher.combine(group.name) - hasher.combine(group.anchorWorkspaceId) - } - self.hash = hasher.finalize() - } -} - -/// The searchable text of one workspace, extracted from the live model. -/// -/// A value snapshot taken at the boundary: no `Workspace` reference reaches the -/// filter, which keeps the index pure and lets the corpus key be a plain hash. -struct SidebarFilterCorpusEntry: Hashable { - let workspaceId: UUID - let title: String - let branch: String? - let directory: String? - let pullRequest: String? - let ports: [String] - let groupId: UUID? - let isGroupAnchor: Bool -} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 96bd24a7fc55..129b23b3650f 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3245,8 +3245,6 @@ 9212A0069212A0069212A006 /* SidebarEmptyAreaWindowDragTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0069212B0069212B006 /* SidebarEmptyAreaWindowDragTests.swift */; }; 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */; }; A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A7FD1001 /* SidebarFileDropFindRoutingTests.swift */; }; - 3D6E1C0362B56791490C8135 /* SidebarFilterController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62AA6EC5A67508F26074B60B /* SidebarFilterController.swift */; }; - 4BB8A42E2371AC7FB155ADF5 /* SidebarFilterCorpusKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0FA48771BCBE1F596B455835 /* SidebarFilterCorpusKey.swift */; }; B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; }; A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; }; @@ -7662,8 +7660,6 @@ 9212B0069212B0069212B006 /* SidebarEmptyAreaWindowDragTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarEmptyAreaWindowDragTests.swift; sourceTree = ""; }; 9212B0059212B0059212B005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragTrackingEvent.swift; sourceTree = ""; }; A7FD1001 /* SidebarFileDropFindRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFileDropFindRoutingTests.swift; sourceTree = ""; }; - 62AA6EC5A67508F26074B60B /* SidebarFilterController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Filter/SidebarFilterController.swift; sourceTree = ""; }; - 0FA48771BCBE1F596B455835 /* SidebarFilterCorpusKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Filter/SidebarFilterCorpusKey.swift; sourceTree = ""; }; B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = ""; }; A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = ""; }; @@ -10155,8 +10151,6 @@ 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */, 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */, A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */, - 62AA6EC5A67508F26074B60B /* SidebarFilterController.swift */, - 0FA48771BCBE1F596B455835 /* SidebarFilterCorpusKey.swift */, B954AEE8BD37A8D69AA13832 /* SidebarPeekController.swift */, 2C733A9B22B4426FB8C1CD4F /* SidebarPeekEdgeTrackingView.swift */, 327A9185ED75125180313AF0 /* SidebarPeekPresentation.swift */, @@ -16029,8 +16023,6 @@ 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */, 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */, 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */, - 3D6E1C0362B56791490C8135 /* SidebarFilterController.swift in Sources */, - 4BB8A42E2371AC7FB155ADF5 /* SidebarFilterCorpusKey.swift in Sources */, A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */, F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */, B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */, From 2f3fe091059b3353ae868f6927adea138c3c1bff Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 22:23:35 +1300 Subject: [PATCH 14/69] sidebar drag: header stays under the cursor, drops settle instead of snapping - grab offset captured at native drag start so a group header doesn't jump - slot decided by the lifted block's leading edge, planner fed the same probe, so a tall group can reach the last slots - dragged rows pinned via prepareContent and drawn as a static snapshot so autoscroll can't unload or blank them - no-op drop glides home, real drops settle from where the block was with an ease-out, a re-grab clears any in-flight settle - single workspace drag indents live (top level vs inside a group) - optional active look on a dragged group header (sidebarDragHeaderActiveLook) --- .../Cells/SidebarWorkspaceRowCellView.swift | 22 ++ .../SidebarWorkspaceTableController.swift | 315 +++++++++++++++++- .../SidebarWorkspaceTableViewImpl.swift | 15 + cmuxTests/SidebarWorkspaceTableTests.swift | 58 +++- 4 files changed, 391 insertions(+), 19 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 192e806ac336..4fd46d1a072f 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -209,6 +209,28 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { return false } + /// The row's fill (selection/hover) as a standalone copy in `view`'s + /// coordinates, for the reorder lift, which moves it apart from the + /// content. Nil when the row has no visible fill. + func makeLiftFillLayer(in view: NSView) -> CALayer? { + guard let source = backgroundView.layer, !backgroundView.isHidden, + (source.backgroundColor?.alpha ?? 0) > 0 || source.borderWidth > 0 else { return nil } + let fill = CALayer() + let frame = backgroundView.convert(backgroundView.bounds, to: view) + fill.frame = CGRect(x: frame.minX, y: 0, width: frame.width, height: view.bounds.height) + fill.backgroundColor = source.backgroundColor + fill.cornerRadius = source.cornerRadius + fill.cornerCurve = source.cornerCurve + fill.borderWidth = source.borderWidth + fill.borderColor = source.borderColor + return fill + } + + /// Hides the fill so the lift can snapshot the content alone. + func setLiftFillHidden(_ hidden: Bool) { + backgroundView.isHidden = hidden + } + private func applyBackgroundStyle(_ style: SidebarWorkspaceRowBackgroundStyle) { backgroundView.layer?.backgroundColor = (style.color ?? .clear) .withAlphaComponent((style.color == nil ? 0 : style.opacity) * ((style.color?.alphaComponent) ?? 1)).cgColor diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index f1e3a2319021..682093b9f654 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -870,9 +870,31 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } #endif let handsOffReorderTransforms = clearsReorderTransformsOnNextApply && hasStructuralChanges + // Where the dragged block stands on screen at release: it follows the + // pointer, so it is usually between slots. Read it before the commit + // drops the lift, then settle it from there (`settleReorderedBlock`) + // instead of snapping it into place. + let reorderSettleOrigin: (ids: [SidebarWorkspaceRenderItemID], visualTop: CGFloat)? = { + guard handsOffReorderTransforms, + let session = reorderLiftSession, + session.sourceRange.upperBound <= previousRows.count else { return nil } + let table = containerView.tableView + let layer = table.rowView(atRow: session.sourceRange.lowerBound, makeIfNecessary: false)?.layer + let shift = (layer?.presentation() ?? layer)? + .value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 + return ( + session.sourceRange.map { previousRows[$0].id }, + session.frames[session.sourceRange.lowerBound].minY + shift + ) + }() if handsOffReorderTransforms { clearsReorderTransformsOnNextApply = false } + if hasStructuralChanges && !handsOffReorderTransforms { + // Rows are about to move or be reused under a live drag: put the + // real cells back before the table can hand them to other rows. + removeReorderLiftSnapshots() + } if hasStructuralChanges { if forceTableReload { let table = containerView.tableView @@ -991,6 +1013,9 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } mutationScheduler.stagePostUpdateActions(postUpdateActions) } + } + if let reorderSettleOrigin { + settleReorderedBlock(ids: reorderSettleOrigin.ids, fromVisualTop: reorderSettleOrigin.visualTop) } else if forceTableReload { let table = containerView.tableView let postUpdateActions = detachLoadedCells() @@ -1364,7 +1389,6 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS willBeginAt screenPoint: NSPoint, forRowIndexes rowIndexes: IndexSet ) { - _ = screenPoint let draggedRows = Array(rowIndexes) let provisionalWriter = pendingWorkspaceDragWriter let sourceWriter: SidebarWorkspaceDragPasteboardWriter? = { @@ -1414,6 +1438,19 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS clearPendingWorkspaceDragWriters() return } + // Capture the pointer's offset from the source header before any + // selection/state update can rebuild the table. The first visual poll + // may arrive after that rebuild, which otherwise makes a group header + // slip away from the point where the drag started. + reorderDragStartWorkspaceId = workspaceId + if let sourceRow = draggedRows.first, + rows.indices.contains(sourceRow), + let windowPoint = tableView.window?.convertPoint(fromScreen: screenPoint) { + let tablePoint = tableView.convert(windowPoint, from: nil) + reorderDragStartPointerOffsetY = tablePoint.y - tableView.rect(ofRow: sourceRow).minY + } else { + reorderDragStartPointerOffsetY = nil + } if isWorkspaceDragSourceActive { // A second begin callback with the same session is AppKit's // reconstruction duplicate. Any distinct session is an @@ -1827,6 +1864,8 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS pendingWorkspaceDragWorkspaceId = nil reorderDragWindowPoint = nil reorderDragPayloadWorkspaceId = nil + reorderDragStartWorkspaceId = nil + reorderDragStartPointerOffsetY = nil retireReorderIndicator() let tableView = activeWorkspaceDragTableView @@ -1921,6 +1960,12 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// a session when the native source has already completed. private var reorderDragPayloadWorkspaceId: UUID? + /// Source-header pointer offset captured at AppKit drag start. This is + /// stable across the selection update that can rebuild the table before + /// the first lift poll arrives. + private var reorderDragStartWorkspaceId: UUID? + private var reorderDragStartPointerOffsetY: CGFloat? + /// The plan whose indicator is currently painted. The drop commits this /// plan verbatim so the outcome always matches the line the user saw; /// re-resolving at release time could pick a different gap (pointer @@ -1963,12 +2008,31 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS "source=\(commitSource) performed=\(performed ? 1 : 0)" ) #endif - if performed { + // A handled drop on the block's own slot moves nothing, so no + // structural apply will come to take the transforms over. The lift + // knows: no other row is displaced. Glide home now, rather than + // waiting (non-structural applies can land first after any drop). + let liftShowsMove = reorderLiftSession?.appliedTargets.contains { $0 != 0 } ?? false + if performed && !liftShowsMove { + suppressSelectedScrollAfterLocalDrop = true + endReorderLift(animated: true) + } else if performed { suppressSelectedScrollAfterLocalDrop = true // The rows already stand at their new positions via transforms; // the apply that lands the committed order clears them in the // same update, so the real frames take over in place. clearsReorderTransformsOnNextApply = true + // Safety net if the committed order never arrives; don't leave + // the rows frozen where the lift drew them. + reorderDropCommitGeneration &+= 1 + let generation = reorderDropCommitGeneration + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in + guard let self, + self.reorderDropCommitGeneration == generation, + self.clearsReorderTransformsOnNextApply else { return } + self.clearsReorderTransformsOnNextApply = false + self.endReorderLift(animated: true) + } } else { endReorderLift(animated: true) } @@ -2026,9 +2090,19 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS SidebarNavigationTimings.recordSampledTick("reorder.tick", startUptime: reorderTickStart) } reorderDragPayloadWorkspaceId = payloadWorkspaceId + // Plan at the lifted block's slot probe, not the raw pointer, so the + // committed gap is the one the displaced rows are showing. + var plannerPoint = point + if let session = reorderLiftSession, let containerView { + let table = containerView.tableView + let tablePoint = table.convert(windowPoint, from: nil) + let placement = reorderLiftPlacement(session, pointY: tablePoint.y, tableMaxY: table.bounds.maxY) + let probeWindowPoint = table.convert(NSPoint(x: tablePoint.x, y: placement.slotProbeY), to: nil) + plannerPoint = containerView.reorderDropView.convert(probeWindowPoint, from: nil) + } guard !targets.isEmpty, let update = actions.updateWorkspaceDrag( - point, + plannerPoint, targets, payloadWorkspaceId ) @@ -2047,6 +2121,9 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS // it), but it is drawn as displacement, not indicator lines: the // rows shifting around the lifted row are the drop preview. lastAcceptedReorderDropPlan = update.plan + if case .reorder(_, _, let explicitGroupId)? = update.plan?.action { + updateReorderLiftIndent(isGrouped: explicitGroupId != nil) + } reorderDragWindowPoint = windowPoint #if DEBUG cmuxDebugLog("sidebar.reorder.tick y=\(Int(point.y))") @@ -2136,6 +2213,10 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS let frames: [CGRect] /// Vertical gap between adjacent rows, so re-laid units keep it. let rowSpacing: CGFloat + /// Pointer offset from the top of the dragged block at pickup. Keep + /// this stable so a multi-row group does not jump to center itself + /// under the pointer when the lift starts. + let pointerOffsetY: CGFloat /// The applied translation target per row index, so a row only /// animates when its target actually flips. var appliedTargets: [CGFloat] @@ -2191,6 +2272,8 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// order clears every drag transform in the same update, so the rows' /// real frames take over exactly where the transforms left them. private var clearsReorderTransformsOnNextApply = false + /// Identifies the latest committed drop for its no-apply fallback. + private var reorderDropCommitGeneration = 0 /// Glides a row's vertical shift to `target` with a soft spring. /// @@ -2218,13 +2301,26 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } /// Drives the freeform drag visuals for the pointer at `windowPoint`. - private func updateReorderLift(windowPoint: NSPoint, workspaceId: UUID) { + func updateReorderLift(windowPoint: NSPoint, workspaceId: UUID) { guard let containerView else { return } let table = containerView.tableView let point = table.convert(windowPoint, from: nil) if reorderLiftSession?.workspaceId != workspaceId { endReorderLift(animated: false) + // A quick re-grab can land while the last drop is still settling. + // Finish those glides first: an in-flight animation overrides the + // transforms the lift sets, so the new drag would lag or snap. + table.enumerateAvailableRowViews { rowView, _ in + guard let layer = rowView.layer, + layer.animation(forKey: "cmux.reorderShift") != nil else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.removeAnimation(forKey: "cmux.reorderShift") + layer.transform = CATransform3DIdentity + layer.zPosition = 0 + CATransaction.commit() + } guard let sourceRange = reorderSourceRange(for: workspaceId) else { return } let frames = (0.. 1 ? max(0, frames[1].minY - frames[0].maxY) : 0, + pointerOffsetY: reorderDragStartWorkspaceId == workspaceId + ? (reorderDragStartPointerOffsetY ?? point.y - frames[sourceRange.lowerBound].minY) + : point.y - frames[sourceRange.lowerBound].minY, appliedTargets: Array(repeating: 0, count: rows.count) ) + table.reorderPinnedRowsRect = sourceRange.reduce(CGRect.null) { $0.union(frames[$1]) } + installReorderLiftSnapshots(table: table, sourceRange: sourceRange) } guard var session = reorderLiftSession else { return } - // The dragged block's visual centre: the pointer, clamped so the - // block cannot leave the list. The top clamp is the first slot (a - // tab cannot go above the new-workspace row); the bottom is the full - // extent of the list area, so a block can be carried all the way - // down past the last row. - let blockHeight = session.height(of: session.sourceRange) + // Keep the same pointer-to-block offset captured at pickup. Centering + // a multi-row block under the pointer makes a group jump by roughly + // half its height as soon as the lift begins. Clamp the block top so + // it stays within the list while preserving that offset everywhere + // else. let blockTop = session.frames[session.sourceRange.lowerBound].minY - let minCenter = session.frames[0].minY + blockHeight / 2 - let maxCenter = max(minCenter, table.bounds.maxY - blockHeight / 2) - let draggedCenter = min(max(point.y, minCenter), maxCenter) - let blockShift = draggedCenter - (blockTop + blockHeight / 2) + let blockFrame = session.sourceRange.reduce(CGRect.null) { $0.union(session.frames[$1]) } + table.reorderPinnedRowsRect = blockFrame + let placement = reorderLiftPlacement(session, pointY: point.y, tableMaxY: table.bounds.maxY) + let draggedTop = placement.draggedTop + let blockShift = draggedTop - blockTop // Insertion slot from frozen midpoints: how many other units sit - // above the dragged block's centre. + // above the block's slot probe (see `reorderLiftPlacement`). let insertion = session.otherUnits.filter { unit in let top = session.frames[unit.lowerBound].minY - return top + session.height(of: unit) / 2 < draggedCenter + return top + session.height(of: unit) / 2 < placement.slotProbeY }.count // Re-lay every unit in drop order and read each row's offset from @@ -2308,14 +2409,196 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS reorderLiftSession = session } + /// Glides the just-dropped block from where it stood under the pointer + /// into its committed slot. The other rows already stand at their new + /// places (the lift showed them there), so only the block moves. + private func settleReorderedBlock(ids: [SidebarWorkspaceRenderItemID], fromVisualTop visualTop: CGFloat) { + guard let table = containerView?.tableView, + let firstId = ids.first, + let firstIndex = rows.firstIndex(where: { $0.id == firstId }) else { return } + let delta = visualTop - table.rect(ofRow: firstIndex).minY + guard abs(delta) > 0.5 else { return } + let idSet = Set(ids) + for (index, row) in rows.enumerated() where idSet.contains(row.id) { + guard let layer = table.rowView(atRow: index, makeIfNecessary: false)?.layer else { continue } + // Explicit start: the presentation layer still holds the lift's + // old offset (relative to the row's old frame) until the next + // frame, so reading it, as `glideRowShift` does, would start the + // glide from the wrong place and the block would jump first. + // A plain ease-out, not a spring: the block can land up to a row + // away from its slot, and a spring's fast start and long tail + // read as a snap over that distance. Longer travel, longer glide. + let settle = CABasicAnimation(keyPath: "transform.translation.y") + settle.fromValue = delta + settle.toValue = 0 + settle.duration = min(0.3, 0.18 + abs(delta) / 700) + settle.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, 0.8, 0.25, 1) + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.removeAnimation(forKey: "cmux.reorderShift") + layer.transform = CATransform3DIdentity + layer.zPosition = 100 + layer.add(settle, forKey: "cmux.reorderShift") + CATransaction.commit() + } + } + + /// Static images of the lifted rows, shown in place of the live cells for + /// the whole drag. AppKit does not redraw views outside the visible rect, + /// so once autoscroll moves a lifted row's real frame out of view, any + /// redraw (a reconfigure, a hover sweep) leaves it blank. The images are + /// taken at pickup, while the rows are drawn, and never need redrawing. + private var reorderLiftSnapshots: [(rowView: NSTableRowView, cell: NSView?, layer: CALayer)] = [] + + /// A lifted single workspace row, split into its fill and its content so + /// the indent can follow the planned drop: full width when it would land + /// outside a group, indented when inside one. + private var reorderLiftIndent: ( + fill: CALayer?, content: CALayer, fillFrame: CGRect, wasGrouped: Bool, isGrouped: Bool + )? + + private func installReorderLiftSnapshots(table: NSTableView, sourceRange: Range) { + removeReorderLiftSnapshots() + let scale = table.window?.backingScaleFactor ?? 2 + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { return } + for row in sourceRange { + guard let rowView = table.rowView(atRow: row, makeIfNecessary: false), + let rowLayer = rowView.layer else { continue } + // Optionally, a lifted group header wears its active (selected) + // look for the whole drag. Off by default; try it live with + // `defaults write sidebarDragHeaderActiveLook -bool true`. + if UserDefaults.standard.bool(forKey: "sidebarDragHeaderActiveLook"), + let header = rowView.view(atColumn: 0) as? SidebarGroupHeaderTableCellView { + header.showOptimisticAnchorActive() + header.displayIfNeeded() + } + let size = rowLayer.bounds.size + guard size.width > 0, size.height > 0, + let context = CGContext( + data: nil, + width: Int((size.width * scale).rounded(.up)), + height: Int((size.height * scale).rounded(.up)), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { continue } + context.scaleBy(x: scale, y: scale) + // A single workspace row: lift its fill out as its own layer and + // snapshot the content without it (see `reorderLiftIndent`). + let workspaceCell = sourceRange.count == 1 + ? rowView.view(atColumn: 0) as? SidebarWorkspaceRowTableCellView + : nil + let fill = workspaceCell?.makeLiftFillLayer(in: rowView) + if let workspaceCell { + workspaceCell.setLiftFillHidden(true) + workspaceCell.displayIfNeeded() + } + if rowView.isFlipped { + // Row views are flipped; render upright into the bitmap. + context.translateBy(x: 0, y: size.height) + context.scaleBy(x: 1, y: -1) + } + rowLayer.render(in: context) + workspaceCell?.setLiftFillHidden(false) + guard let image = context.makeImage() else { continue } + let snapshot = CALayer() + snapshot.frame = rowLayer.bounds + snapshot.zPosition = 1000 + let content = CALayer() + content.contents = image + content.contentsScale = scale + content.contentsGravity = .resize + content.frame = snapshot.bounds + if let fill { snapshot.addSublayer(fill) } + snapshot.addSublayer(content) + if workspaceCell != nil { + let wasGrouped = rows.indices.contains(row) && rows[row].groupId != nil + reorderLiftIndent = (fill, content, fill?.frame ?? .zero, wasGrouped, wasGrouped) + } + let cell = rowView.view(atColumn: 0) as? NSView + CATransaction.begin() + CATransaction.setDisableActions(true) + rowLayer.addSublayer(snapshot) + cell?.isHidden = true + CATransaction.commit() + reorderLiftSnapshots.append((rowView, cell, snapshot)) + } + } + + /// Moves the lifted row's indent to match where it would land. + private func updateReorderLiftIndent(isGrouped: Bool) { + guard var lift = reorderLiftIndent, lift.isGrouped != isGrouped else { return } + lift.isGrouped = isGrouped + reorderLiftIndent = lift + let indent = SidebarWorkspaceGroupingMetrics.memberIndent + let shift = (isGrouped ? indent : 0) - (lift.wasGrouped ? indent : 0) + CATransaction.begin() + CATransaction.setAnimationDuration(0.18) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.25, 0.8, 0.25, 1)) + lift.content.transform = CATransform3DMakeTranslation(shift, 0, 0) + if let fill = lift.fill { + // The right edge stays put; only the leading edge follows. + var frame = lift.fillFrame + frame.origin.x += shift + frame.size.width -= shift + fill.frame = frame + } + CATransaction.commit() + } + + private func removeReorderLiftSnapshots() { + reorderLiftIndent = nil + guard !reorderLiftSnapshots.isEmpty else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + for entry in reorderLiftSnapshots { + entry.layer.removeFromSuperlayer() + entry.cell?.isHidden = false + // Drop the lift-only active look; the stored model decides again. + (entry.cell as? SidebarGroupHeaderTableCellView)?.restoreStoredModelPaint() + } + CATransaction.commit() + reorderLiftSnapshots.removeAll() + } + + /// Where the lifted block sits for a pointer at `pointY` (table space), + /// and the y that decides its slot. The probe is the block's leading edge + /// in the direction it moved, not its centre: the block is clamped to the + /// list, so a block taller than the rows below it could never carry its + /// centre past their midpoints and the last slots were unreachable. The + /// drop planner is fed the same probe, so the commit matches the preview. + private func reorderLiftPlacement( + _ session: ReorderLiftSession, + pointY: CGFloat, + tableMaxY: CGFloat + ) -> (draggedTop: CGFloat, slotProbeY: CGFloat) { + let blockHeight = session.height(of: session.sourceRange) + let blockTop = session.frames[session.sourceRange.lowerBound].minY + let minTop = session.frames[0].minY + let maxTop = max(minTop, tableMaxY - blockHeight) + let draggedTop = min(max(pointY - session.pointerOffsetY, minTop), maxTop) + let slotProbeY: CGFloat + if draggedTop > blockTop + 0.5 { + slotProbeY = draggedTop + blockHeight - 1 + } else if draggedTop < blockTop - 0.5 { + slotProbeY = draggedTop + 1 + } else { + slotProbeY = draggedTop + blockHeight / 2 + } + return (draggedTop, slotProbeY) + } + /// Ends the visual session. Animated: rows glide back to their real /// frames (a cancelled drag). Not animated: transforms drop instantly /// (the structural apply is about to redraw the true order). private func endReorderLift(animated: Bool) { guard reorderLiftSession != nil else { return } reorderLiftSession = nil + removeReorderLiftSnapshots() guard let containerView else { return } let table = containerView.tableView + table.reorderPinnedRowsRect = nil table.enumerateAvailableRowViews { [self] rowView, _ in guard let layer = rowView.layer else { return } if animated { diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift index 4bf58228990c..42626ad449be 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableViewImpl.swift @@ -12,6 +12,21 @@ final class SidebarWorkspaceTableViewImpl: NSTableView { private var pointerTrackingArea: NSTrackingArea? private(set) var lastPointerWindowLocation: NSPoint? + /// Rows a reorder drag is carrying. They must stay loaded even when + /// autoscroll moves their real frames out of view: the lift draws them + /// by transform, so an unloaded row would vanish from under the pointer + /// and leave only its gap behind. + var reorderPinnedRowsRect: NSRect? { + didSet { + guard oldValue != reorderPinnedRowsRect else { return } + prepareContent(in: visibleRect) + } + } + + override func prepareContent(in rect: NSRect) { + super.prepareContent(in: reorderPinnedRowsRect.map { rect.union($0) } ?? rect) + } + /// Pointer location for hover recomputes that no event drove (content /// applies, menu close, viewport changes). Tracking events stop while a /// context menu or drag session runs, so the cached point can be where diff --git a/cmuxTests/SidebarWorkspaceTableTests.swift b/cmuxTests/SidebarWorkspaceTableTests.swift index 72e090e984be..0f71d32e0183 100644 --- a/cmuxTests/SidebarWorkspaceTableTests.swift +++ b/cmuxTests/SidebarWorkspaceTableTests.swift @@ -46,6 +46,56 @@ struct SidebarWorkspaceTableTests { } #if DEBUG + @Test(arguments: [0, 1, 3]) + @MainActor + func groupDragPreservesHeaderGrabOffset(memberCount: Int) async throws { + let controller = SidebarWorkspaceTableController() + let container = controller.makeContainerView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 600), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = container + defer { controller.dismantleContainerView(container) } + let groupId = UUID() + let header = makeRowConfiguration(groupId: groupId, isGroupHeader: true, fixedHeight: 28) + let members = (0.. Date: Wed, 7 Oct 2026 22:23:41 +1300 Subject: [PATCH 15/69] sidebar selection is always glass, tint defaults to #393939 at 72% neutral translucent fill with a hairline edge, no blue accent; multi-selection is a weaker glass. fill and edge strength tunable via sidebarGlassSelectionFillOpacity / sidebarGlassSelectionEdgeOpacity. tint measured from aside. drops the unused glass pill color. --- .../WindowChromeSidebarTintDefaults.swift | 4 +- .../SidebarAppearanceCatalogSection.swift | 4 +- Sources/ContentView.swift | 8 +-- .../Cells/SidebarWorkspaceRowCellView.swift | 3 +- .../SidebarWorkspaceRowSupportViews.swift | 3 +- ...rticalTabsSidebar+PresentationToggle.swift | 3 +- .../Sidebar/SidebarAppearanceSupport.swift | 50 +++++++++++-------- .../VerticalTabsSidebar+WorkspaceGroups.swift | 6 ++- 8 files changed, 47 insertions(+), 34 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift index 7fdae5225aea..61d0bf25d679 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift @@ -13,8 +13,8 @@ public struct WindowChromeSidebarTintDefaults: Sendable { /// Creates sidebar tint defaults. public init( - hex: String = "#2a2e35", - opacity: Double = 0.44 + hex: String = "#393939", + opacity: Double = 0.72 ) { self.hex = hex self.opacity = opacity diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift index 042e1ae8d04b..49889672a9fa 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift @@ -10,7 +10,7 @@ public struct SidebarAppearanceCatalogSection: SettingCatalogSection { public let tintColorHex = DefaultsKey( id: "sidebarAppearance.tintColor", - defaultValue: "#2a2e35", + defaultValue: "#393939", userDefaultsKey: "sidebarTintHex" ) @@ -28,7 +28,7 @@ public struct SidebarAppearanceCatalogSection: SettingCatalogSection { public let tintOpacity = DefaultsKey( id: "sidebarAppearance.tintOpacity", - defaultValue: 0.44, + defaultValue: 0.72, userDefaultsKey: "sidebarTintOpacity" ) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9db2fd8e5e19..ff976bfe5bbe 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -16159,7 +16159,8 @@ struct TabItemView: View, Equatable { sidebarSelectionColorHex: sidebarSelectionColorHex, activeTabIndicatorStyle: activeTabIndicatorStyle, subtleSelection: settings.subtleSelection, - accent: settings.accentColor + accent: settings.accentColor, + selectionAccent: settings.selectionAccent ) } @@ -16937,7 +16938,7 @@ struct TabItemView: View, Equatable { workspaceSnapshot: SidebarWorkspaceSnapshotBuilder.Snapshot, railColor: Color ) -> some View { - RoundedRectangle(cornerRadius: 6) + return RoundedRectangle(cornerRadius: 6) .fill(style.color.map { Color(nsColor: $0).opacity(style.opacity) } ?? .clear) .overlay { RoundedRectangle(cornerRadius: 6) @@ -16973,7 +16974,8 @@ struct TabItemView: View, Equatable { brightenInDarkMode: settings.brightenInDarkMode, isEmphasized: isEmphasized, increaseContrast: colorSchemeContrast == .increased, - accent: settings.accentColor + accent: settings.accentColor, + selectionAccent: settings.selectionAccent ) } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 4fd46d1a072f..4e4efe73ad9d 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -251,7 +251,8 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { brightenInDarkMode: settings.brightenInDarkMode, isEmphasized: palette.isSelectionEmphasized, increaseContrast: palette.increasesSelectionContrast, - accent: palette.accent + accent: palette.accent, + selectionAccent: settings.selectionAccent ) applyBackgroundStyle(style) if let edgeColor = style.edgeColor { diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift index d229bf711f77..6bf4617d5ee2 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift @@ -28,7 +28,8 @@ struct SidebarRowPalette { subtleSelection: model.settings.subtleSelection, isEmphasized: isSelectionEmphasized, increaseContrast: increasesSelectionContrast, - accent: accent + accent: accent, + selectionAccent: model.settings.selectionAccent ) } diff --git a/Sources/Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift b/Sources/Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift index efddcfdd845e..20ff63061940 100644 --- a/Sources/Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift +++ b/Sources/Sidebar/Peek/VerticalTabsSidebar+PresentationToggle.swift @@ -1,4 +1,5 @@ import CmuxSidebar +import CmuxFoundation import SwiftUI extension VerticalTabsSidebar { @@ -13,7 +14,7 @@ extension VerticalTabsSidebar { var sidebarPresentationToggleOverlay: some View { SidebarPresentationToggleButton( mode: presentationMode, - accent: Color(nsColor: cmuxAccentNSColor(for: sidebarChromeColorScheme)), + accent: Color(nsColor: (AppDelegate.shared?.accentColor ?? CmuxAccentColor()).nsColor(for: sidebarChromeColorScheme)), onToggle: onTogglePresentationMode ) .padding(.trailing, 8) diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index e68f747cb2a4..e0785500c926 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -357,10 +357,14 @@ func sidebarSelectedWorkspaceBackgroundNSColor( return parsed } if activeTabIndicatorStyle == .solidFill || !subtleSelection { - if selectionAccent == .glass { - return sidebarGlassPillNSColor(for: colorScheme).withAlphaComponent(colorScheme == .dark ? 0.24 : 0.14) - } - return accent.nsColor(for: colorScheme) + // The selection is always glass: a light translucent patch, never + // the blue accent. + return SidebarAppearanceColorResolver().resolvedColor( + .labelColor, + for: colorScheme, + opacity: UserDefaults.standard.object(forKey: "sidebarGlassSelectionFillOpacity") as? Double + ?? (colorScheme == .dark ? 0.13 : 0.07) + ) } let surface = NSColor(white: colorScheme == .dark ? 0.16 : 0.93, alpha: 1) let fill = CmuxSelectionFill.resolve( @@ -369,16 +373,6 @@ func sidebarSelectedWorkspaceBackgroundNSColor( increaseContrast: increaseContrast ) return cmuxCompositedNSColor(fill.color, over: surface) - } -} - -/// The glass accent's base hue: a slate grey, not white, so the pill reads as -/// grey glass over the pane rather than a bright patch. Alpha is applied by -/// the caller; the rim and sheen reuse the hue at their own strengths. -func sidebarGlassPillNSColor(for colorScheme: ColorScheme) -> NSColor { - colorScheme == .dark - ? NSColor(srgbRed: 0.58, green: 0.62, blue: 0.68, alpha: 1) - : NSColor(srgbRed: 0.22, green: 0.24, blue: 0.28, alpha: 1) } func sidebarSelectedWorkspaceForegroundNSColor(opacity: CGFloat) -> NSColor { @@ -481,9 +475,6 @@ func sidebarWorkspaceRowBackgroundStyle( accent: CmuxAccentColor = CmuxAccentColor(), selectionAccent: SidebarSelectionAccent = .blue ) -> SidebarWorkspaceRowBackgroundStyle { - // Increase Contrast: the multi-selection wash is otherwise too faint to - // read against the sidebar material. - let multiSelectionOpacity = increaseContrast ? 0.45 : 0.25 let selectedBackground = sidebarSelectedWorkspaceBackgroundNSColor( for: colorScheme, sidebarSelectionColorHex: sidebarSelectionColorHex, @@ -494,7 +485,20 @@ func sidebarWorkspaceRowBackgroundStyle( accent: accent, selectionAccent: selectionAccent ) - let accentBackground = accent.nsColor(for: colorScheme) + // Glass selection: a light translucent fill with a hairline edge, so the + // selected row reads as a lighter patch of the glass (Aside-style). + let glassEdge: NSColor? = sidebarSelectionColorHex.flatMap({ NSColor(hex: $0) }) == nil + ? SidebarAppearanceColorResolver().resolvedColor( + .labelColor, + for: colorScheme, + opacity: UserDefaults.standard.object(forKey: "sidebarGlassSelectionEdgeOpacity") as? Double + ?? (colorScheme == .dark ? 0.14 : 0.12) + ) + : nil + // Multi-selection members: the same glass at roughly half strength, so + // the active row stays the clear anchor. + let multiSelectionBackground = selectedBackground + let multiSelectionEdge = glassEdge.map { $0.withAlphaComponent($0.alphaComponent * 0.55) } let usesSubtleSelection = sidebarUsesSubtleSelection( activeTabIndicatorStyle: activeTabIndicatorStyle, subtleSelection: subtleSelection, @@ -524,12 +528,13 @@ func sidebarWorkspaceRowBackgroundStyle( if usesSubtleSelection { return calmFill(isSecondary: false) } return SidebarWorkspaceRowBackgroundStyle( color: selectedBackground, - opacity: 1 + opacity: 1, + edgeColor: glassEdge ) } if isMultiSelected { if usesSubtleSelection { return calmFill(isSecondary: true) } - return SidebarWorkspaceRowBackgroundStyle(color: accentBackground, opacity: multiSelectionOpacity) + return SidebarWorkspaceRowBackgroundStyle(color: multiSelectionBackground, opacity: 0.55, edgeColor: multiSelectionEdge) } return .clear @@ -537,7 +542,8 @@ func sidebarWorkspaceRowBackgroundStyle( if isActive { return SidebarWorkspaceRowBackgroundStyle( color: selectedBackground, - opacity: 1 + opacity: 1, + edgeColor: glassEdge ) } if let customBackground { @@ -547,7 +553,7 @@ func sidebarWorkspaceRowBackgroundStyle( ) } if isMultiSelected { - return SidebarWorkspaceRowBackgroundStyle(color: accentBackground, opacity: multiSelectionOpacity) + return SidebarWorkspaceRowBackgroundStyle(color: multiSelectionBackground, opacity: 0.55, edgeColor: multiSelectionEdge) } return .clear } diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 9797dac1fff8..efe3ac10a48d 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -41,7 +41,8 @@ extension VerticalTabsSidebar { subtleSelection: settings.subtleSelection, brightenInDarkMode: settings.brightenInDarkMode, increaseContrast: renderContext.environment.displayAccessibility.increaseContrast, - accent: settings.accentColor + accent: settings.accentColor, + selectionAccent: settings.selectionAccent ) let anchorActiveEdgeColor = sidebarGroupHeaderAnchorActiveEdgeNSColor( activeTabIndicatorStyle: settings.activeTabIndicatorStyle, @@ -227,7 +228,8 @@ extension VerticalTabsSidebar { subtleSelection: settings.subtleSelection, brightenInDarkMode: settings.brightenInDarkMode, increaseContrast: renderContext.environment.displayAccessibility.increaseContrast, - accent: settings.accentColor + accent: settings.accentColor, + selectionAccent: settings.selectionAccent ) let anchorActiveEdgeColor = sidebarGroupHeaderAnchorActiveEdgeNSColor( activeTabIndicatorStyle: settings.activeTabIndicatorStyle, From aa23a95e7edf131e6103914726bd814c1d8290fc Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 22:23:48 +1300 Subject: [PATCH 16/69] settings > sidebar: liquid glass toggle, blur opacity, tint color + opacity rows match terminal background moves into the same card, the sidebar accent row goes (selection is always glass now), search entries follow. --- .../CuratedSettingEntry+Default.swift | 7 +- .../SidebarSection+Customization.swift | 159 ++++++++++-------- .../Sections/SidebarSection.swift | 23 +-- 3 files changed, 104 insertions(+), 85 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 1919ec699507..9c0ac142be53 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -324,9 +324,10 @@ extension Array where Element == CuratedSettingEntry { // Sidebar appearance + sidebar workspace row details .init(section: .sidebarAppearance, id: "match-terminal", title: String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), synonyms: "Match Terminal Background sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), - .init(section: .sidebarAppearance, id: "sidebar-glass-tint", title: "Sidebar Glass Tint", synonyms: "sidebarAppearance.tintOpacity sidebarTintOpacity glass glassmorphism tint opacity transparency blur floating docked panel peek clear frosted opaque"), - .init(section: .sidebarAppearance, id: "sidebar-selection-accent", title: "Sidebar Accent", synonyms: "sidebar.selectionAccent sidebarSelectionAccent accent selected workspace highlight pill blue glass grey gray selection colour color"), - .init(section: .sidebarAppearance, id: "sidebar-glass-blur", title: "Sidebar Blur", synonyms: "sidebarAppearance.glassBlurRadius sidebarGlassBlurRadius blur radius glass frosted clear see-through transparency backdrop compositor"), + .init(section: .sidebarAppearance, id: "sidebar-glass-tint", title: "Tint Opacity", synonyms: "sidebarAppearance.tintOpacity sidebarTintOpacity glass glassmorphism tint opacity transparency blur floating docked panel peek clear frosted opaque"), + .init(section: .sidebarAppearance, id: "sidebar-liquid-glass", title: "Liquid Glass", synonyms: "sidebarAppearance.compositorGlass sidebarCompositorGlass liquid glass blur compositor frosted transparency see-through backdrop"), + .init(section: .sidebarAppearance, id: "sidebar-tint-color", title: "Tint Color", synonyms: "sidebarAppearance.tintColor sidebarTintHex tint colour color glass hex grey gray"), + .init(section: .sidebarAppearance, id: "sidebar-glass-blur", title: "Blur Opacity", synonyms: "blur opacity sidebarAppearance.glassBlurRadius sidebarGlassBlurRadius blur radius glass frosted clear see-through transparency backdrop compositor"), .init(section: .sidebarAppearance, id: "sidebar-peek-reveal", title: "Sidebar Peek Reveal Speed", synonyms: "sidebar.peekReveal peek hover reveal edge dwell instant quick relaxed sensitivity floating sidebar"), .init(section: .sidebarAppearance, id: "sidebar-peek-disabled", title: "Disable Sidebar Peek", synonyms: "sidebar.peekDisabled peek hover reveal disable never auto hide floating sidebar edge"), .init(section: .sidebarAppearance, id: "sidebar-row-density", title: "Row Density", synonyms: "sidebar.rowDensity density compact cozy spacious row height padding workspace list"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift index a43c6988f06d..8cb4883d1c69 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift @@ -6,60 +6,26 @@ extension SidebarSection { @ViewBuilder var customizationRows: some View { SettingsCardRow( - configurationReview: .json("sidebarAppearance.tintOpacity"), - String(localized: "settings.sidebar.glassTint", defaultValue: "Sidebar Tint"), - subtitle: String(localized: "settings.sidebar.glassTint.subtitle", defaultValue: "How strongly the sidebar's glass is coloured, docked or floating. 0% is clear glass; 100% is a solid panel."), - controlWidth: 250 + configurationReview: .json("sidebarAppearance.compositorGlass"), + String(localized: "settings.sidebar.liquidGlass", defaultValue: "Liquid Glass"), + subtitle: String(localized: "settings.sidebar.liquidGlass.subtitle", defaultValue: "Blur what is behind the window through the sidebar.") ) { - HStack(spacing: 8) { - let tintValue = glassTintDraft ?? glassTint.current - Slider( - value: Binding( - get: { tintValue }, - // Continuous track (a stepped slider draws tick dots); - // store rounded so the percent label stays stable. - set: { newValue in - let rounded = (newValue * 100).rounded() / 100 - glassTintDraft = rounded - debounceGlassWrite("glassTint") { glassTint.set(rounded) } - } - ), - in: 0.0...1.0, - onEditingChanged: { editing in - guard !editing, let draft = glassTintDraft else { return } - glassTintDraft = nil - flushGlassWrite("glassTint") { glassTint.set(draft) } - } - ) - .frame(width: 130) - .accessibilityIdentifier("SettingsSidebarGlassTintSlider") - - Text("\(Int((tintValue * 100).rounded()))%") - .cmuxFont(size: 12, weight: .medium, design: .rounded) - .monospacedDigit() - .frame(width: 44, alignment: .trailing) - - Button(String(localized: "settings.sidebar.glassTint.reset", defaultValue: "Reset")) { - glassTintDraft = nil - glassTint.set(Self.defaultGlassTint) - } - .buttonStyle(.bordered) + Toggle("", isOn: Binding(get: { liquidGlass.current }, set: { liquidGlass.set($0) })) + .labelsHidden() + .toggleStyle(.switch) .controlSize(.small) - .disabled(abs(tintValue - Self.defaultGlassTint) < 0.001) - } } SettingsCardDivider() SettingsCardRow( configurationReview: .json("sidebarAppearance.glassBlurRadius"), - String(localized: "settings.sidebar.glassBlur", defaultValue: "Sidebar Blur"), - subtitle: String(localized: "settings.sidebar.glassBlur.subtitle", defaultValue: "How much the glass blurs what is behind the window. 1× is the lightest glass; higher is frostier."), + String(localized: "settings.sidebar.blurOpacity", defaultValue: "Blur Opacity"), + subtitle: String(localized: "settings.sidebar.blurOpacity.subtitle", defaultValue: "How much the glass blurs what is behind the window. 0% is the lightest blur; higher is frostier."), controlWidth: 250 ) { HStack(spacing: 8) { - // Shown as a multiplier of the lightest blur (1× is the floor, - // 5× the ceiling), which reads more naturally than a - // percentage that cannot start at zero. + // Shown as a percentage of the blur range: 0% is the lightest + // blur (the floor), 100% the frostiest. let blurRadius = glassBlurDraft ?? glassBlur.current let floor = Self.glassBlurRange.lowerBound Slider( @@ -81,7 +47,7 @@ extension SidebarSection { .frame(width: 130) .accessibilityIdentifier("SettingsSidebarGlassBlurSlider") - Text(String(format: "%.1f×", blurRadius / floor)) + Text("\(Int(((blurRadius - floor) / (Self.glassBlurRange.upperBound - floor) * 100).rounded()))%") .cmuxFont(size: 12, weight: .medium, design: .rounded) .monospacedDigit() .frame(width: 44, alignment: .trailing) @@ -95,24 +61,91 @@ extension SidebarSection { .disabled(abs(blurRadius - floor) < 0.5) } } + .disabled(!liquidGlass.current) SettingsCardDivider() SettingsCardRow( - configurationReview: .json("sidebar.selectionAccent"), - String(localized: "settings.sidebar.selectionAccent", defaultValue: "Sidebar Accent"), - subtitle: String(localized: "settings.sidebar.selectionAccent.subtitle", defaultValue: "How the selected workspace is highlighted: the accent colour, or a lighter patch of the glass.") + configurationReview: .json("sidebarAppearance.tintColor"), + String(localized: "settings.sidebar.tintColor", defaultValue: "Tint Color"), + subtitle: String(localized: "settings.sidebar.tintColor.subtitle", defaultValue: "The color laid over the glass.") ) { - Picker("", selection: Binding( - get: { selectionAccent.current }, - set: { selectionAccent.set($0) } - )) { - ForEach(SidebarSelectionAccent.allCases, id: \.self) { accent in - Text(accentLabel(accent)).tag(accent) + HStack(spacing: 8) { + if glassTintHex.current != Self.defaultGlassTintHex { + Button(String(localized: "settings.sidebar.tintColor.reset", defaultValue: "Reset")) { + glassTintHex.reset() + } + .buttonStyle(.bordered) + .controlSize(.small) + } + HexColorPicker( + storedHex: glassTintHex.current, + fallback: Color.gray, + reconcileRevision: glassTintHex.revision + ) { hex in + glassTintHex.set(hex) } + Text(glassTintHex.current) + .cmuxFont(size: 12, weight: .medium, design: .monospaced) + .foregroundStyle(.secondary) + .frame(width: 76, alignment: .trailing) } - .labelsHidden() - .pickerStyle(.segmented) - .fixedSize() + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebarAppearance.tintOpacity"), + String(localized: "settings.sidebar.tintOpacity", defaultValue: "Tint Opacity"), + subtitle: String(localized: "settings.sidebar.tintOpacity.subtitle", defaultValue: "How strongly the tint color covers the glass. 0% is clear glass; 100% is a solid panel."), + controlWidth: 250 + ) { + HStack(spacing: 8) { + let tintValue = glassTintDraft ?? glassTint.current + Slider( + value: Binding( + get: { tintValue }, + // Continuous track (a stepped slider draws tick dots); + // store rounded so the percent label stays stable. + set: { newValue in + let rounded = (newValue * 100).rounded() / 100 + glassTintDraft = rounded + debounceGlassWrite("glassTint") { glassTint.set(rounded) } + } + ), + in: 0.0...1.0, + onEditingChanged: { editing in + guard !editing, let draft = glassTintDraft else { return } + glassTintDraft = nil + flushGlassWrite("glassTint") { glassTint.set(draft) } + } + ) + .frame(width: 130) + .accessibilityIdentifier("SettingsSidebarGlassTintSlider") + + Text("\(Int((tintValue * 100).rounded()))%") + .cmuxFont(size: 12, weight: .medium, design: .rounded) + .monospacedDigit() + .frame(width: 44, alignment: .trailing) + + Button(String(localized: "settings.sidebar.glassTint.reset", defaultValue: "Reset")) { + glassTintDraft = nil + glassTint.set(Self.defaultGlassTint) + } + .buttonStyle(.bordered) + .controlSize(.small) + .disabled(abs(tintValue - Self.defaultGlassTint) < 0.001) + } + } + SettingsCardDivider() + + SettingsCardRow( + configurationReview: .json("sidebarAppearance.matchTerminalBackground"), + String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), + subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") + ) { + Toggle("", isOn: Binding(get: { matchTerminal.current }, set: { matchTerminal.set($0) })) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) } SettingsCardDivider() @@ -188,6 +221,9 @@ extension SidebarSection { /// Default tint strength, mirrored from the catalog key. static let defaultGlassTint = SidebarAppearanceCatalogSection().tintOpacity.defaultValue + /// Default tint colour, mirrored from the catalog key. + static let defaultGlassTintHex = SidebarAppearanceCatalogSection().tintColorHex.defaultValue + /// Coalesces slider writes: each thumb movement replaces the pending /// write, so the stored value (and the window re-render it triggers) /// lands once the thumb pauses instead of on every pixel. @@ -203,15 +239,6 @@ extension SidebarSection { tasks.replaceOnMainActor(key) { write() } } - private func accentLabel(_ accent: SidebarSelectionAccent) -> String { - switch accent { - case .blue: - String(localized: "settings.sidebar.selectionAccent.blue", defaultValue: "Blue") - case .glass: - String(localized: "settings.sidebar.selectionAccent.glass", defaultValue: "Glass") - } - } - private func peekRevealLabel(_ preset: SidebarPeekRevealPreset) -> String { switch preset { case .instant: diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift index 81a193a6ef54..c5779096a9ef 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift @@ -12,7 +12,7 @@ public struct SidebarSection: View { // Not `private`: the customization rows debounce their slider writes // through the same store. @State var tasks = MainActorTaskStore() - @State private var matchTerminal: DefaultsValueModel + @State var matchTerminal: DefaultsValueModel @State var hideAll: DefaultsValueModel @State private var wrapTitles: DefaultsValueModel @State private var showDesc: DefaultsValueModel @@ -42,6 +42,8 @@ public struct SidebarSection: View { @State private var rememberedRightMaxWidth: DefaultsValueModel @State var glassTint: DefaultsValueModel @State var glassBlur: DefaultsValueModel + @State var liquidGlass: DefaultsValueModel + @State var glassTintHex: DefaultsValueModel /// In-flight slider values while the thumb is moving. The stored value /// is written debounced, so the window is not re-rendered per pixel. @State var glassTintDraft: Double? @@ -50,7 +52,6 @@ public struct SidebarSection: View { @State var peekDisabled: DefaultsValueModel @State var rowDensity: DefaultsValueModel @State var dragSwitchDisabled: DefaultsValueModel - @State var selectionAccent: DefaultsValueModel public init(defaultsStore: UserDefaultsSettingsStore, catalog: SettingCatalog, hostActions: SettingsHostActions) { self.catalog = catalog self.hostActions = hostActions @@ -86,11 +87,12 @@ public struct SidebarSection: View { _rememberedRightMaxWidth = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.rememberedRightMaxWidth)) _glassTint = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.tintOpacity)) _glassBlur = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.glassBlurRadius)) + _liquidGlass = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.compositorGlass)) + _glassTintHex = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebarAppearance.tintColorHex)) _peekReveal = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.peekReveal)) _peekDisabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.peekDisabled)) _rowDensity = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.rowDensity)) _dragSwitchDisabled = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.dragSwitchDisabled)) - _selectionAccent = State(initialValue: DefaultsValueModel(store: defaultsStore, key: catalog.sidebar.selectionAccent)) } /// The rendered sidebar settings section. public var body: some View { @@ -132,11 +134,12 @@ public struct SidebarSection: View { rememberedRightMaxWidth, glassTint, glassBlur, + liquidGlass, + glassTintHex, peekReveal, peekDisabled, rowDensity, dragSwitchDisabled, - selectionAccent, ] models.forEach { $0.startObserving() } } @@ -204,18 +207,6 @@ public struct SidebarSection: View { @ViewBuilder private var mainCard: some View { SettingsCard { - SettingsCardRow( - configurationReview: .json("sidebarAppearance.matchTerminalBackground"), - String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), - subtitle: String(localized: "settings.sidebarAppearance.matchTerminalBackground.subtitle", defaultValue: "Use the same background color and transparency as the terminal.") - ) { - Toggle("", isOn: Binding(get: { matchTerminal.current }, set: { matchTerminal.set($0) })) - .labelsHidden() - .toggleStyle(.switch) - .controlSize(.small) - } - SettingsCardDivider() - customizationRows SettingsCardRow( From f881849e491cdec039b31a90de0e6d6b407fc80e Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 22:23:49 +1300 Subject: [PATCH 17/69] group color submenu on the group header's right-click menu clear, custom color prompt, then the named palette, same as the workspace row's color menu. new defaulted onSetColor action routes to TabManager.setWorkspaceGroupColor. --- .../Cells/SidebarGroupHeaderRowModel.swift | 2 + .../Cells/SidebarGroupHeaderRowView.swift | 44 +++++++++++++++++++ .../VerticalTabsSidebar+WorkspaceGroups.swift | 3 ++ 3 files changed, 49 insertions(+) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift index 743fe79f96cb..b6db481acc8c 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift @@ -78,4 +78,6 @@ struct SidebarGroupHeaderRowActions { let onOpenDocs: () -> Void /// Resolves current notification availability for retained rows. var notificationState: () -> NotificationState = { .unavailable } + /// Sets the group's own color (nil clears it back to any config color). + var onSetColor: (String?) -> Void = { _ in } } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 70188bc3c825..ef42a604e680 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -623,6 +623,49 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { )) } + /// Group Color submenu, built like the workspace row's color menu: clear, + /// a custom color prompt, then the named palette with swatches. + private func makeColorMenuItem( + model: SidebarGroupHeaderRowModel, + actions: SidebarGroupHeaderRowActions + ) -> NSMenuItem { + let submenu = NSMenu() + submenu.autoenablesItems = false + let palette = WorkspaceTabColorSettings.palette() + if model.tintHex != nil { + let clearItem = menuItem(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color")) { + actions.onSetColor(nil) + } + clearItem.image = RenderableSystemSymbol.configuredAppKitImage( + systemName: "xmark.circle", pointSize: 13, weight: nil + ) + submenu.addItem(clearItem) + } + let customItem = menuItem(String(localized: "contextMenu.chooseCustomColor", defaultValue: "Choose Custom Color…")) { [weak self] in + guard let hex = WorkspaceCustomColorPrompt.run( + currentHex: model.tintHex, + presentingWindow: self?.window + ) else { return } + actions.onSetColor(hex) + } + customItem.image = RenderableSystemSymbol.configuredAppKitImage( + systemName: "paintpalette", pointSize: 13, weight: nil + ) + submenu.addItem(customItem) + if !palette.isEmpty { + submenu.addItem(.separator()) + } + SidebarWorkspaceRowColorMenu( + currentColorHex: model.tintHex, + colorScheme: model.colorSchemeIsDark ? .dark : .light + ).addPaletteItems(to: submenu, palette: palette) { hex in + actions.onSetColor(hex) + } + let parent = menuItem(String(localized: "workspaceGroup.contextMenu.groupColor", defaultValue: "Group Color")) {} + parent.submenu = submenu + return parent + } + private func makeHeaderMenu() -> NSMenu { guard let model, let actions else { return NSMenu() } let menu = trackedMenu() @@ -645,6 +688,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { : String(localized: "workspaceGroup.contextMenu.pin", defaultValue: "Pin Group"), action: actions.onTogglePinned )) + menu.addItem(makeColorMenuItem(model: model, actions: actions)) menu.addItem(.separator()) menu.addItem(menuItem( String(localized: "workspaceGroup.contextMenu.markRead", defaultValue: "Mark Group as Read"), diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index efe3ac10a48d..214f127f01b4 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -579,6 +579,9 @@ extension VerticalTabsSidebar { } ) actions.notificationState = resolveNotificationState + actions.onSetColor = { [weak tabManager] hex in + tabManager?.setWorkspaceGroupColor(groupId: groupId, hex: hex) + } return actions } From 027e2479c6a5d184052aab623ee78ee54ad786c5 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 22:25:04 +1300 Subject: [PATCH 18/69] drop the sidebarSelectionAccent setting, the selection is always glass now --- .../CmuxSettings/Keys/SidebarCatalogSection.swift | 9 --------- .../Values/SidebarCustomizationOptions.swift | 8 -------- Sources/ContentView.swift | 6 ++---- .../AppKitList/Cells/SidebarWorkspaceRowCellView.swift | 3 +-- .../Cells/SidebarWorkspaceRowSupportViews.swift | 3 +-- Sources/Sidebar/SidebarAppearanceSupport.swift | 9 +++------ Sources/SidebarTabItemSettingsSnapshot.swift | 2 -- Sources/VerticalTabsSidebar+WorkspaceGroups.swift | 6 ++---- 8 files changed, 9 insertions(+), 37 deletions(-) diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift index 3b306437974a..0220f6ed4882 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarCatalogSection.swift @@ -256,14 +256,5 @@ public struct SidebarCatalogSection: SettingCatalogSection { defaultValue: false, userDefaultsKey: "sidebarDragSwitchDisabled" ) - - /// Highlight style for the selected workspace row. A custom selection - /// colour (`workspaceColors.selectionColor`) still wins when set. - public let selectionAccent = DefaultsKey( - id: "sidebar.selectionAccent", - defaultValue: .blue, - userDefaultsKey: "sidebarSelectionAccent" - ) - public init() {} } diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift index 12438cd82d24..e2a78c8b9bd2 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Values/SidebarCustomizationOptions.swift @@ -17,11 +17,3 @@ public enum SidebarRowDensity: String, CaseIterable, Sendable, SettingCodable { case cozy case spacious } - -/// How the selected workspace row is highlighted. -public enum SidebarSelectionAccent: String, CaseIterable, Sendable, SettingCodable { - /// The app accent colour as a solid pill. - case blue - /// A translucent grey pill that reads as a lighter patch of the glass. - case glass -} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index ff976bfe5bbe..50bd1d9a3c51 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -16159,8 +16159,7 @@ struct TabItemView: View, Equatable { sidebarSelectionColorHex: sidebarSelectionColorHex, activeTabIndicatorStyle: activeTabIndicatorStyle, subtleSelection: settings.subtleSelection, - accent: settings.accentColor, - selectionAccent: settings.selectionAccent + accent: settings.accentColor ) } @@ -16974,8 +16973,7 @@ struct TabItemView: View, Equatable { brightenInDarkMode: settings.brightenInDarkMode, isEmphasized: isEmphasized, increaseContrast: colorSchemeContrast == .increased, - accent: settings.accentColor, - selectionAccent: settings.selectionAccent + accent: settings.accentColor ) } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 4e4efe73ad9d..4fd46d1a072f 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -251,8 +251,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { brightenInDarkMode: settings.brightenInDarkMode, isEmphasized: palette.isSelectionEmphasized, increaseContrast: palette.increasesSelectionContrast, - accent: palette.accent, - selectionAccent: settings.selectionAccent + accent: palette.accent ) applyBackgroundStyle(style) if let edgeColor = style.edgeColor { diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift index 6bf4617d5ee2..d229bf711f77 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift @@ -28,8 +28,7 @@ struct SidebarRowPalette { subtleSelection: model.settings.subtleSelection, isEmphasized: isSelectionEmphasized, increaseContrast: increasesSelectionContrast, - accent: accent, - selectionAccent: model.settings.selectionAccent + accent: accent ) } diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index e0785500c926..482f5bca80cf 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -349,8 +349,7 @@ func sidebarSelectedWorkspaceBackgroundNSColor( subtleSelection: Bool = false, isEmphasized: Bool = true, increaseContrast: Bool = false, - accent: CmuxAccentColor = CmuxAccentColor(), - selectionAccent: SidebarSelectionAccent = .blue + accent: CmuxAccentColor = CmuxAccentColor() ) -> NSColor { if let hex = sidebarSelectionColorHex, let parsed = NSColor(hex: hex) { @@ -472,8 +471,7 @@ func sidebarWorkspaceRowBackgroundStyle( brightenInDarkMode: Bool = true, isEmphasized: Bool = true, increaseContrast: Bool = false, - accent: CmuxAccentColor = CmuxAccentColor(), - selectionAccent: SidebarSelectionAccent = .blue + accent: CmuxAccentColor = CmuxAccentColor() ) -> SidebarWorkspaceRowBackgroundStyle { let selectedBackground = sidebarSelectedWorkspaceBackgroundNSColor( for: colorScheme, @@ -482,8 +480,7 @@ func sidebarWorkspaceRowBackgroundStyle( subtleSelection: subtleSelection, isEmphasized: isEmphasized, increaseContrast: increaseContrast, - accent: accent, - selectionAccent: selectionAccent + accent: accent ) // Glass selection: a light translucent fill with a hairline edge, so the // selected row reads as a lighter patch of the glass (Aside-style). diff --git a/Sources/SidebarTabItemSettingsSnapshot.swift b/Sources/SidebarTabItemSettingsSnapshot.swift index 76a8e0c6aa90..fad993cc4e51 100644 --- a/Sources/SidebarTabItemSettingsSnapshot.swift +++ b/Sources/SidebarTabItemSettingsSnapshot.swift @@ -33,7 +33,6 @@ struct SidebarTabItemSettingsSnapshot: Equatable { let selectionColorHex: String? let subtleSelection: Bool let brightenInDarkMode: Bool - let selectionAccent: SidebarSelectionAccent let notificationBadgeColorHex: String? let visibleAuxiliaryDetails: SidebarWorkspaceAuxiliaryDetailVisibility let iMessageModeEnabled: Bool @@ -107,7 +106,6 @@ struct SidebarTabItemSettingsSnapshot: Equatable { selectionColorHex = settings.value(for: workspaceColors.selectionColorHex).nilIfEmpty subtleSelection = settings.value(for: workspaceColors.subtleSelection) brightenInDarkMode = settings.value(for: workspaceColors.brightenInDarkMode) - selectionAccent = settings.value(for: sidebar.selectionAccent) notificationBadgeColorHex = settings.value(for: workspaceColors.notificationBadgeColorHex).nilIfEmpty iMessageModeEnabled = IMessageModeSettings.isEnabled(defaults: defaults) workspaceTodoChecklistStyle = settings.value(for: betaFeatures.workspaceTodosChecklistStyle) diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 214f127f01b4..4448fc75c22d 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -41,8 +41,7 @@ extension VerticalTabsSidebar { subtleSelection: settings.subtleSelection, brightenInDarkMode: settings.brightenInDarkMode, increaseContrast: renderContext.environment.displayAccessibility.increaseContrast, - accent: settings.accentColor, - selectionAccent: settings.selectionAccent + accent: settings.accentColor ) let anchorActiveEdgeColor = sidebarGroupHeaderAnchorActiveEdgeNSColor( activeTabIndicatorStyle: settings.activeTabIndicatorStyle, @@ -228,8 +227,7 @@ extension VerticalTabsSidebar { subtleSelection: settings.subtleSelection, brightenInDarkMode: settings.brightenInDarkMode, increaseContrast: renderContext.environment.displayAccessibility.increaseContrast, - accent: settings.accentColor, - selectionAccent: settings.selectionAccent + accent: settings.accentColor ) let anchorActiveEdgeColor = sidebarGroupHeaderAnchorActiveEdgeNSColor( activeTabIndicatorStyle: settings.activeTabIndicatorStyle, From b8c8c0e7ac75257f9e474037acd0cec2f0617db4 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 22:32:46 +1300 Subject: [PATCH 19/69] localize the new sidebar settings, group color and peek strings (app catalog, 19 locales) settings search titles use the same keys as their rows. the blur and tint subtitles drop the 0%/100% wording, a bare % reads as a format specifier. --- .../CuratedSettingEntry+Default.swift | 16 +- .../SidebarSection+Customization.swift | 4 +- Resources/Localizable.xcstrings | 3689 +++++++++++++++++ scripts/localization-allowed-omissions.json | 4 + 4 files changed, 3703 insertions(+), 10 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index 9c0ac142be53..dfbc9ef8a9c3 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -324,14 +324,14 @@ extension Array where Element == CuratedSettingEntry { // Sidebar appearance + sidebar workspace row details .init(section: .sidebarAppearance, id: "match-terminal", title: String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), synonyms: "Match Terminal Background sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), - .init(section: .sidebarAppearance, id: "sidebar-glass-tint", title: "Tint Opacity", synonyms: "sidebarAppearance.tintOpacity sidebarTintOpacity glass glassmorphism tint opacity transparency blur floating docked panel peek clear frosted opaque"), - .init(section: .sidebarAppearance, id: "sidebar-liquid-glass", title: "Liquid Glass", synonyms: "sidebarAppearance.compositorGlass sidebarCompositorGlass liquid glass blur compositor frosted transparency see-through backdrop"), - .init(section: .sidebarAppearance, id: "sidebar-tint-color", title: "Tint Color", synonyms: "sidebarAppearance.tintColor sidebarTintHex tint colour color glass hex grey gray"), - .init(section: .sidebarAppearance, id: "sidebar-glass-blur", title: "Blur Opacity", synonyms: "blur opacity sidebarAppearance.glassBlurRadius sidebarGlassBlurRadius blur radius glass frosted clear see-through transparency backdrop compositor"), - .init(section: .sidebarAppearance, id: "sidebar-peek-reveal", title: "Sidebar Peek Reveal Speed", synonyms: "sidebar.peekReveal peek hover reveal edge dwell instant quick relaxed sensitivity floating sidebar"), - .init(section: .sidebarAppearance, id: "sidebar-peek-disabled", title: "Disable Sidebar Peek", synonyms: "sidebar.peekDisabled peek hover reveal disable never auto hide floating sidebar edge"), - .init(section: .sidebarAppearance, id: "sidebar-row-density", title: "Row Density", synonyms: "sidebar.rowDensity density compact cozy spacious row height padding workspace list"), - .init(section: .sidebarAppearance, id: "sidebar-drag-switch", title: "Disable Switch on Drag", synonyms: "sidebar.dragSwitchDisabled drag reorder switch selection workspace pick up"), + .init(section: .sidebarAppearance, id: "sidebar-glass-tint", title: String(localized: "settings.sidebar.tintOpacity", defaultValue: "Tint Opacity"), synonyms: "sidebarAppearance.tintOpacity sidebarTintOpacity glass glassmorphism tint opacity transparency blur floating docked panel peek clear frosted opaque"), + .init(section: .sidebarAppearance, id: "sidebar-liquid-glass", title: String(localized: "settings.sidebar.liquidGlass", defaultValue: "Liquid Glass"), synonyms: "sidebarAppearance.compositorGlass sidebarCompositorGlass liquid glass blur compositor frosted transparency see-through backdrop"), + .init(section: .sidebarAppearance, id: "sidebar-tint-color", title: String(localized: "settings.sidebar.tintColor", defaultValue: "Tint Color"), synonyms: "sidebarAppearance.tintColor sidebarTintHex tint colour color glass hex grey gray"), + .init(section: .sidebarAppearance, id: "sidebar-glass-blur", title: String(localized: "settings.sidebar.blurOpacity", defaultValue: "Blur Opacity"), synonyms: "blur opacity sidebarAppearance.glassBlurRadius sidebarGlassBlurRadius blur radius glass frosted clear see-through transparency backdrop compositor"), + .init(section: .sidebarAppearance, id: "sidebar-peek-reveal", title: String(localized: "settings.sidebar.peekReveal", defaultValue: "Sidebar Peek Reveal Speed"), synonyms: "sidebar.peekReveal peek hover reveal edge dwell instant quick relaxed sensitivity floating sidebar"), + .init(section: .sidebarAppearance, id: "sidebar-peek-disabled", title: String(localized: "settings.sidebar.peekDisabled", defaultValue: "Disable Sidebar Peek"), synonyms: "sidebar.peekDisabled peek hover reveal disable never auto hide floating sidebar edge"), + .init(section: .sidebarAppearance, id: "sidebar-row-density", title: String(localized: "settings.sidebar.rowDensity", defaultValue: "Row Density"), synonyms: "sidebar.rowDensity density compact cozy spacious row height padding workspace list"), + .init(section: .sidebarAppearance, id: "sidebar-drag-switch", title: String(localized: "settings.sidebar.dragSwitchDisabled", defaultValue: "Disable Switch on Drag"), synonyms: "sidebar.dragSwitchDisabled drag reorder switch selection workspace pick up"), .init(section: .sidebarAppearance, id: "hide-sidebar-details", title: String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), synonyms: "Hide All Sidebar Details sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), .init(section: .sidebarAppearance, id: "wrap-workspace-titles", title: String(localized: "settings.app.wrapWorkspaceTitles", defaultValue: "Wrap Workspace Titles in Sidebar"), synonyms: "Wrap Workspace Titles in Sidebar sidebar.wrapWorkspaceTitles workspace title wrap multiline pr pull request"), .init(section: .sidebarAppearance, id: "show-workspace-description", title: String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"), synonyms: "Show Workspace Description in Sidebar sidebar.showWorkspaceDescription workspace description notes markdown sidebar"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift index 8cb4883d1c69..c8a4b189f019 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift @@ -20,7 +20,7 @@ extension SidebarSection { SettingsCardRow( configurationReview: .json("sidebarAppearance.glassBlurRadius"), String(localized: "settings.sidebar.blurOpacity", defaultValue: "Blur Opacity"), - subtitle: String(localized: "settings.sidebar.blurOpacity.subtitle", defaultValue: "How much the glass blurs what is behind the window. 0% is the lightest blur; higher is frostier."), + subtitle: String(localized: "settings.sidebar.blurOpacity.subtitle", defaultValue: "How much the glass blurs what is behind the window. Lower is lighter; higher is frostier."), controlWidth: 250 ) { HStack(spacing: 8) { @@ -95,7 +95,7 @@ extension SidebarSection { SettingsCardRow( configurationReview: .json("sidebarAppearance.tintOpacity"), String(localized: "settings.sidebar.tintOpacity", defaultValue: "Tint Opacity"), - subtitle: String(localized: "settings.sidebar.tintOpacity.subtitle", defaultValue: "How strongly the tint color covers the glass. 0% is clear glass; 100% is a solid panel."), + subtitle: String(localized: "settings.sidebar.tintOpacity.subtitle", defaultValue: "How strongly the tint color covers the glass, from clear glass to a solid panel."), controlWidth: 250 ) { HStack(spacing: 8) { diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c74ba744c725..ffb11f389d1e 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -366003,6 +366003,3695 @@ } } }, + "settings.sidebar.blurOpacity": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "عتامة التمويه" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Neprozirnost zamućenja" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Sløringsopacitet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Unschärfe-Deckkraft" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Blur Opacity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Opacidad del desenfoque" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Opacité du flou" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Opacità sfocatura" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ぼかしの不透明度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "흐림 불투명도" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Uskarphet-opasitet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Krycie rozmycia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Opacidade do Desfoque" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Непрозрачность размытия" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ความทึบของการเบลอ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bulanıklık Opaklığı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Непрозорість розмиття" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "模糊不透明度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "模糊不透明度" + } + } + } + }, + "settings.sidebar.blurOpacity.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مدى تمويه الزجاج لما خلف النافذة. القيمة الأقل أخف، والأعلى أكثر ضبابية." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koliko staklo zamućuje ono što je iza prozora. Niže je lakše; više je mutnije." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hvor meget glasset slører det, der er bag vinduet. Lavere er lettere; højere er mere frostet." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie stark das Glas den Bereich hinter dem Fenster weichzeichnet. Niedriger ist leichter, höher ist milchiger." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "How much the glass blurs what is behind the window. Lower is lighter; higher is frostier." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuánto desenfoca el cristal lo que hay detrás de la ventana. Menos es más ligero; más es más esmerilado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intensité du flou appliqué par le verre à ce qui se trouve derrière la fenêtre. Plus bas, c'est plus léger ; plus haut, plus dépoli." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quanto il vetro sfoca ciò che si trova dietro la finestra. Più basso è più leggero; più alto è più smerigliato." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガラスがウインドウの背後をどれだけぼかすか。低いほど軽く、高いほどすりガラス状になります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유리가 창 뒤의 내용을 얼마나 흐리게 할지 정합니다. 낮을수록 가볍고, 높을수록 더 뿌옇게 보입니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hvor mye glasset gjør det bak vinduet uskarpt. Lavere er lettere; høyere er mer frostet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jak mocno szkło rozmywa to, co znajduje się za oknem. Niżej jest lżej, wyżej bardziej matowo." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Quanto o vidro desfoca o que está atrás da janela. Menor é mais leve; maior é mais fosco." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Насколько сильно стекло размывает то, что за окном. Чем ниже, тем легче; чем выше, тем матовее." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระดับที่กระจกเบลอสิ่งที่อยู่ด้านหลังหน้าต่าง ค่าต่ำจะเบากว่า ค่าสูงจะขุ่นกว่า" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Camın pencerenin arkasındakini ne kadar bulanıklaştıracağı. Düşük değer daha hafif, yüksek değer daha buzlu görünür." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Наскільки сильно скло розмиває те, що за вікном. Чим нижче, тим легше; чим вище, тим матовіше." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "玻璃对窗口后方内容的模糊程度。越低越轻,越高越磨砂。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "玻璃對視窗後方內容的模糊程度。越低越輕,越高越霧面。" + } + } + } + }, + "settings.sidebar.dragSwitchDisabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعطيل التبديل عند السحب" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Onemogući prebacivanje pri prevlačenju" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå skift ved træk fra" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wechsel beim Ziehen deaktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable Switch on Drag" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivar cambio al arrastrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver le changement au glissement" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disattiva il cambio al trascinamento" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ドラッグ時の切り替えを無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "드래그 시 전환 끄기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå av bytte ved draing" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyłącz przełączanie przy przeciąganiu" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desativar Troca ao Arrastar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключить переключение при перетаскивании" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดการสลับเมื่อลาก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sürüklerken Geçişi Kapat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вимкнути перемикання під час перетягування" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停用拖动时切换" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用拖曳時切換" + } + } + } + }, + "settings.sidebar.dragSwitchDisabled.subtitleOff": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "التقاط مساحة عمل لست فيها ينقلك إليها، لذا يكون الصف الذي تسحبه هو الذي تستخدمه." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Podizanje radnog prostora u kojem niste prebacuje na njega, pa je red koji držite onaj koji koristite." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Når du samler et arbejdsområde op, som du ikke er i, skiftes der til det, så rækken i din hånd er den, du bruger." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wenn du einen Arbeitsbereich aufnimmst, in dem du gerade nicht bist, wird zu ihm gewechselt, sodass die Zeile in deiner Hand die ist, die du verwendest." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Picking up a workspace you are not on switches to it, so the row in your hand is the one you are using." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Al tomar un espacio de trabajo en el que no estás, se cambia a él, así que la fila que llevas es la que estás usando." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Saisir un espace de travail sur lequel vous n'êtes pas y bascule, de sorte que la ligne que vous tenez est celle que vous utilisez." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Prendere un'area di lavoro in cui non ti trovi passa a quella, così la riga che tieni è quella che stai usando." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "表示中でないワークスペースをつかむとそのワークスペースに切り替わるため、手にしている行が使用中の行になります。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "현재 위치하지 않은 작업 공간을 집으면 그 작업 공간으로 전환되므로, 끌고 있는 행이 사용 중인 행이 됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når du plukker opp et arbeidsområde du ikke er i, byttes det til det, så raden du holder er den du bruker." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podniesienie przestrzeni roboczej, w której nie jesteś, przełącza na nią, więc trzymany wiersz jest tym, którego używasz." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Pegar uma área de trabalho em que você não está muda para ela, então a linha na sua mão é a que você está usando." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Если взять рабочее пространство, в котором вы не находитесь, произойдёт переключение на него, и строка в руке будет той, которой вы пользуетесь." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การหยิบเวิร์กสเปซที่คุณไม่ได้อยู่จะสลับไปยังเวิร์กสเปซนั้น แถวที่คุณถืออยู่จึงเป็นแถวที่คุณกำลังใช้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Üzerinde olmadığınız bir çalışma alanını tuttuğunuzda ona geçilir, böylece elinizdeki satır kullandığınız satır olur." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Якщо взяти робочу область, у якій ви не перебуваєте, відбудеться перемикання на неї, тож рядок у руці буде тим, яким ви користуєтеся." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拿起一个当前未处于的工作区会切换到该工作区,因此手中的行就是正在使用的那个。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拿起一個目前未處於的工作區會切換到該工作區,因此手中的列就是正在使用的那個。" + } + } + } + }, + "settings.sidebar.dragSwitchDisabled.subtitleOn": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سحب مساحة عمل يعيد ترتيبها فقط؛ وتبقى مساحة العمل الحالية محددة." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prevlačenje radnog prostora ga samo premješta; vaš trenutni radni prostor ostaje odabran." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Når du trækker et arbejdsområde, flyttes det kun; dit nuværende arbejdsområde forbliver valgt." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Das Ziehen eines Arbeitsbereichs ordnet ihn nur neu an; dein aktueller Arbeitsbereich bleibt ausgewählt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Dragging a workspace only reorders it; your current workspace stays selected." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Arrastrar un espacio de trabajo solo lo reordena; tu espacio de trabajo actual sigue seleccionado." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Faire glisser un espace de travail le réordonne seulement ; votre espace de travail actuel reste sélectionné." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Trascinare un'area di lavoro la riordina soltanto; l'area di lavoro corrente resta selezionata." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ワークスペースをドラッグしても並べ替えるだけで、現在のワークスペースは選択されたままです。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "작업 공간을 드래그하면 순서만 바뀌고 현재 작업 공간은 선택된 상태로 유지됩니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Når du drar et arbeidsområde, flyttes det bare; det nåværende arbeidsområdet forblir valgt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przeciągnięcie przestrzeni roboczej tylko zmienia jej kolejność; bieżąca przestrzeń robocza pozostaje zaznaczona." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Arrastar uma área de trabalho apenas a reordena; sua área de trabalho atual continua selecionada." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Перетаскивание рабочего пространства только меняет его порядок; текущее рабочее пространство остаётся выбранным." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "การลากเวิร์กสเปซจะจัดลำดับใหม่เท่านั้น เวิร์กสเปซปัจจุบันของคุณจะยังคงถูกเลือกอยู่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bir çalışma alanını sürüklemek yalnızca sırasını değiştirir; geçerli çalışma alanınız seçili kalır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перетягування робочої області лише змінює її порядок; поточна робоча область залишається вибраною." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拖动工作区只会调整其顺序;当前工作区保持选中。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拖曳工作區只會調整其順序;目前的工作區保持選取。" + } + } + } + }, + "settings.sidebar.glassBlur.reset": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재설정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + } + } + }, + "settings.sidebar.glassTint.reset": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재설정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + } + } + }, + "settings.sidebar.liquidGlass": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "液态玻璃" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "Liquid Glass" + } + } + } + }, + "settings.sidebar.liquidGlass.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمويه ما خلف النافذة عبر الشريط الجانبي." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Zamuti ono što je iza prozora kroz bočnu traku." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slør det, der er bag vinduet, gennem sidebjælken." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeichnet den Bereich hinter dem Fenster durch die Seitenleiste unscharf." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Blur what is behind the window through the sidebar." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desenfoca lo que hay detrás de la ventana a través de la barra lateral." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Floute ce qui se trouve derrière la fenêtre à travers la barre latérale." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Sfoca ciò che si trova dietro la finestra attraverso la barra laterale." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバー越しに、ウインドウの背後にあるものをぼかします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바를 통해 창 뒤에 있는 내용을 흐리게 표시합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjør det som ligger bak vinduet uskarpt gjennom sidefeltet." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Rozmywa to, co znajduje się za oknem, przez pasek boczny." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desfoca o que está atrás da janela através da barra lateral." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Размывает то, что находится за окном, сквозь боковую панель." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เบลอสิ่งที่อยู่ด้านหลังหน้าต่างผ่านแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar çubuğu üzerinden pencerenin arkasındakini bulanıklaştırır." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розмиває те, що знаходиться за вікном, крізь бічну панель." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "透过侧边栏模糊窗口后方的内容。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "透過側邊欄模糊視窗後方的內容。" + } + } + } + }, + "settings.sidebar.peekDisabled": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعطيل لمحة الشريط الجانبي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Onemogući pojavljivanje bočne trake" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Slå kig frem af sidebjælken fra" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleisten-Vorschau deaktivieren" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Disable Sidebar Peek" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Desactivar aparición de la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Désactiver l'aperçu de la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Disattiva comparsa della barra laterale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのプレビューを無効化" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 미리 보기 끄기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Slå av titt fram av sidefeltet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wyłącz wysuwanie paska bocznego" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Desativar Revelação da Barra Lateral" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отключить появление боковой панели" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปิดการแอบดูแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğu Önizlemesini Kapat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вимкнути появу бічної панелі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停用侧边栏预览" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停用側邊欄預覽" + } + } + } + }, + "settings.sidebar.peekDisabled.subtitleOff": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "يظهر الشريط الجانبي المخفي عندما يستقر المؤشر عند الحافة اليسرى." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Skrivena bočna traka se pojavi kada pokazivač miruje na lijevoj ivici." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Den skjulte sidebjælke kigger frem, når markøren hviler ved venstre kant." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die ausgeblendete Seitenleiste kommt hervor, wenn der Zeiger am linken Rand verweilt." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The hidden sidebar peeks out when the pointer rests at the left edge." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La barra lateral oculta asoma cuando el puntero se detiene en el borde izquierdo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La barre latérale masquée apparaît lorsque le pointeur reste sur le bord gauche." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La barra laterale nascosta fa capolino quando il puntatore si ferma sul bordo sinistro." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポインタが左端にとどまると、非表示のサイドバーが現れます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포인터가 왼쪽 가장자리에 머물면 숨겨진 사이드바가 나타납니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Det skjulte sidefeltet titter fram når pekeren hviler ved venstre kant." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ukryty pasek boczny wysuwa się, gdy wskaźnik zatrzyma się przy lewej krawędzi." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A barra lateral oculta aparece quando o ponteiro para na borda esquerda." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрытая боковая панель выглядывает, когда указатель задерживается у левого края." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แถบด้านข้างที่ซ่อนอยู่จะโผล่ออกมาเมื่อตัวชี้หยุดอยู่ที่ขอบซ้าย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İşaretçi sol kenarda beklediğinde gizli kenar çubuğu görünür." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Прихована бічна панель виглядає, коли вказівник зупиняється біля лівого краю." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "指针停留在左侧边缘时,隐藏的侧边栏会探出。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "指標停留在左側邊緣時,隱藏的側邊欄會探出。" + } + } + } + }, + "settings.sidebar.peekDisabled.subtitleOn": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لا يظهر الشريط الجانبي المخفي أبدًا؛ أعده باستخدام الاختصار أو زر الشريط الجانبي." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Skrivena bočna traka se nikada ne pojavljuje; vratite je prečicom ili dugmetom bočne trake." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Den skjulte sidebjælke kigger aldrig frem; hent den tilbage med genvejen eller sidebjælkeknappen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die ausgeblendete Seitenleiste kommt nie hervor; hole sie mit dem Kurzbefehl oder der Seitenleistentaste zurück." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The hidden sidebar never peeks out; bring it back with the shortcut or the sidebar button." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "La barra lateral oculta nunca asoma; recupérala con el atajo o con el botón de la barra lateral." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La barre latérale masquée n'apparaît jamais ; rappelez-la avec le raccourci ou le bouton de la barre latérale." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "La barra laterale nascosta non fa mai capolino; richiamala con la scorciatoia o con il pulsante della barra laterale." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "非表示のサイドバーは現れません。ショートカットまたはサイドバーボタンで戻します。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "숨겨진 사이드바가 나타나지 않습니다. 단축키나 사이드바 버튼으로 다시 표시하세요." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Det skjulte sidefeltet titter aldri fram; hent det tilbake med snarveien eller sidefeltknappen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ukryty pasek boczny nigdy się nie wysuwa; przywróć go skrótem lub przyciskiem paska bocznego." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A barra lateral oculta nunca aparece; traga-a de volta com o atalho ou com o botão da barra lateral." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скрытая боковая панель никогда не выглядывает; верните её сочетанием клавиш или кнопкой боковой панели." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "แถบด้านข้างที่ซ่อนอยู่จะไม่โผล่ออกมา ใช้ปุ่มลัดหรือปุ่มแถบด้านข้างเพื่อแสดงอีกครั้ง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gizli kenar çubuğu hiçbir zaman görünmez; kısayolla veya kenar çubuğu düğmesiyle geri getirin." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Прихована бічна панель ніколи не виглядає; поверніть її сполученням клавіш або кнопкою бічної панелі." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "隐藏的侧边栏不会探出;使用快捷键或侧边栏按钮将其恢复。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "隱藏的側邊欄不會探出;使用快速鍵或側邊欄按鈕將其恢復。" + } + } + } + }, + "settings.sidebar.peekReveal": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سرعة إظهار لمحة الشريط الجانبي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Brzina otkrivanja bočne trake" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hastighed for kig frem af sidebjælken" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Geschwindigkeit der Seitenleisten-Vorschau" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Sidebar Peek Reveal Speed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Velocidad de aparición de la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Vitesse d'apparition de la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Velocità di comparsa della barra laterale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーのプレビュー表示速度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 미리 보기 표시 속도" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hastighet for titt fram av sidefeltet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szybkość wysuwania paska bocznego" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Velocidade de Revelação da Barra Lateral" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Скорость появления боковой панели" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ความเร็วในการแสดงแถบด้านข้างแบบแอบดู" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar Çubuğu Gösterme Hızı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Швидкість появи бічної панелі" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "侧边栏预览显示速度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "側邊欄預覽顯示速度" + } + } + } + }, + "settings.sidebar.peekReveal.instant": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فوري" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Trenutno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Øjeblikkelig" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sofort" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Instant" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Inmediato" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Instantané" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Immediato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "即時" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "즉시" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Umiddelbart" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Natychmiast" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Instantâneo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Мгновенно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทันที" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Anında" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Миттєво" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "即时" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "即時" + } + } + } + }, + "settings.sidebar.peekReveal.quick": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "سريع" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Brzo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hurtig" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Schnell" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Quick" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Rápido" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rapide" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rapido" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "速い" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "빠르게" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Rask" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Szybko" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Rápido" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Быстро" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เร็ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Hızlı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Швидко" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "快速" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "快速" + } + } + } + }, + "settings.sidebar.peekReveal.relaxed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "هادئ" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Opušteno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Afslappet" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Entspannt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Relaxed" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Relajado" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Détendu" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rilassato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "のんびり" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "여유롭게" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avslappet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Spokojnie" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tranquilo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Спокойно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ผ่อนคลาย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sakin" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Спокійно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从容" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從容" + } + } + } + }, + "settings.sidebar.peekReveal.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مدى سرعة ظهور الشريط الجانبي المخفي عندما يصل المؤشر إلى الحافة اليسرى أو زر الشريط الجانبي." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koliko brzo se skrivena bočna traka pojavi kada pokazivač dođe do lijeve ivice ili dugmeta bočne trake." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hvor ivrigt den skjulte sidebjælke kigger frem, når markøren når venstre kant eller sidebjælkeknappen." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie schnell die ausgeblendete Seitenleiste hervorkommt, wenn der Zeiger den linken Rand oder die Seitenleistentaste erreicht." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "How eagerly the hidden sidebar peeks out when the pointer reaches the left edge or the sidebar button." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Con qué rapidez asoma la barra lateral oculta cuando el puntero llega al borde izquierdo o al botón de la barra lateral." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rapidité avec laquelle la barre latérale masquée apparaît lorsque le pointeur atteint le bord gauche ou le bouton de la barre latérale." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quanto rapidamente la barra laterale nascosta fa capolino quando il puntatore raggiunge il bordo sinistro o il pulsante della barra laterale." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポインタが左端またはサイドバーボタンに届いたときに、非表示のサイドバーがどれだけ素早く現れるか。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포인터가 왼쪽 가장자리나 사이드바 버튼에 닿았을 때 숨겨진 사이드바가 얼마나 빨리 나타날지 정합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hvor raskt det skjulte sidefeltet titter fram når pekeren når venstre kant eller sidefeltknappen." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jak chętnie ukryty pasek boczny wysuwa się, gdy wskaźnik dotrze do lewej krawędzi lub przycisku paska bocznego." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Com que rapidez a barra lateral oculta aparece quando o ponteiro chega à borda esquerda ou ao botão da barra lateral." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Насколько быстро скрытая боковая панель выглядывает, когда указатель достигает левого края или кнопки боковой панели." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ความไวที่แถบด้านข้างที่ซ่อนอยู่จะโผล่ออกมาเมื่อตัวชี้ไปถึงขอบซ้ายหรือปุ่มแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Gizli kenar çubuğunun, işaretçi sol kenara veya kenar çubuğu düğmesine ulaştığında ne kadar hızlı görüneceği." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Наскільки швидко прихована бічна панель виглядає, коли вказівник досягає лівого краю або кнопки бічної панелі." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "指针到达左侧边缘或侧边栏按钮时,隐藏的侧边栏探出的灵敏程度。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "指標到達左側邊緣或側邊欄按鈕時,隱藏的側邊欄探出的靈敏程度。" + } + } + } + }, + "settings.sidebar.rowDensity": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "كثافة الصفوف" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Gustoća redova" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Rækketæthed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeilendichte" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Row Density" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Densidad de filas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Densité des lignes" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Densità delle righe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行の密度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "행 밀도" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Radtetthet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Gęstość wierszy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Densidade das Linhas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Плотность строк" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ความหนาแน่นของแถว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Satır Yoğunluğu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Щільність рядків" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "行密度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "列密度" + } + } + } + }, + "settings.sidebar.rowDensity.compact": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مضغوط" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Kompaktno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kompakt" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Kompakt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Compact" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Compacta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Compacte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Compatta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "コンパクト" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "컴팩트" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Kompakt" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kompaktowa" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Compacta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Компактно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กะทัดรัด" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kompakt" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Компактно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "紧凑" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "緊湊" + } + } + } + }, + "settings.sidebar.rowDensity.cozy": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مريح" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Udobno" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Behagelig" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Komfortabel" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Cozy" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cómoda" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Confortable" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Comoda" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ほどよい" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "보통" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Komfortabel" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Wygodna" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Confortável" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удобно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สบายตา" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Rahat" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Зручно" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "适中" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "適中" + } + } + } + }, + "settings.sidebar.rowDensity.spacious": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "واسع" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prostrano" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Rummelig" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Großzügig" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Spacious" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Amplia" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Spacieuse" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Ampia" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ゆったり" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "넓게" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Romslig" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przestronna" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Espaçosa" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Просторно" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "กว้างขวาง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Ferah" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Просторо" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "宽松" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "寬鬆" + } + } + } + }, + "settings.sidebar.rowDensity.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "المساحة العمودية داخل كل صف من صفوف مساحات العمل." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Vertikalni prostor unutar svakog reda radnog prostora." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Lodret luft i hver arbejdsområderække." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vertikaler Freiraum in jeder Arbeitsbereichszeile." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Vertical breathing room inside each workspace row." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Espacio vertical dentro de cada fila de espacio de trabajo." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Espace vertical à l'intérieur de chaque ligne d'espace de travail." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Spazio verticale all'interno di ogni riga dell'area di lavoro." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "各ワークスペース行の縦方向の余白。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "각 작업 공간 행 안의 세로 여백입니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Loddrett luft i hver arbeidsområderad." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Pionowy odstęp wewnątrz każdego wiersza przestrzeni roboczej." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Espaço vertical dentro de cada linha de área de trabalho." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вертикальный отступ внутри каждой строки рабочего пространства." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระยะห่างแนวตั้งภายในแต่ละแถวของเวิร์กสเปซ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Her çalışma alanı satırının içindeki dikey boşluk." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вертикальний відступ усередині кожного рядка робочої області." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "每个工作区行内部的纵向留白。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "每個工作區列內部的縱向留白。" + } + } + } + }, + "settings.sidebar.tintColor": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون التظليل" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boja nijanse" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Farvetone" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tönungsfarbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Tint Color" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color del tinte" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur de teinte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore tinta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ティントの色" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "색조 색상" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargetone" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolor odcienia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor da Tonalidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет оттенка" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีโทน" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renk Tonu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Колір відтінку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "色调颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "色調顏色" + } + } + } + }, + "settings.sidebar.tintColor.reset": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة تعيين" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zurücksetzen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Restablecer" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Réinitialiser" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リセット" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "재설정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Reset" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重置" + } + } + } + }, + "settings.sidebar.tintColor.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "اللون المطبّق فوق الزجاج." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boja položena preko stakla." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Farven, der lægges over glasset." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Die Farbe, die über das Glas gelegt wird." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "The color laid over the glass." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "El color que se aplica sobre el cristal." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "La couleur appliquée sur le verre." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Il colore applicato sopra il vetro." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ガラスに重ねる色。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "유리 위에 덧입히는 색상입니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargen som legges over glasset." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolor nakładany na szkło." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "A cor aplicada sobre o vidro." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет, накладываемый поверх стекла." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีที่ทาทับบนกระจก" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Camın üzerine uygulanan renk." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Колір, що накладається поверх скла." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "覆盖在玻璃上的颜色。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "覆蓋在玻璃上的顏色。" + } + } + } + }, + "settings.sidebar.tintOpacity": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "شفافية اللون" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prozirnost nijanse" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Farvetone gennemsigtighed" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Farbton-Deckkraft" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Tint Opacity" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Opacidad del tinte" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Opacité de la teinte" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Opacità tinta" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ティントの不透明度" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "색조 불투명도" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fargetone-opasitet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Krycie odcienia" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Opacidade da Tonalidade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Непрозрачность оттенка" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ความทึบของโทนสี" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renk Tonu Opaklığı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Непрозорість відтінку" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "色调不透明度" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "色調不透明度" + } + } + } + }, + "settings.sidebar.tintOpacity.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مدى تغطية لون التظليل للزجاج، من زجاج شفاف إلى لوح مصمت." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Koliko snažno boja nijanse prekriva staklo, od prozirnog stakla do pune ploče." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Hvor kraftigt farvetonen dækker glasset, fra klart glas til et massivt panel." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Wie stark die Tönung das Glas bedeckt, von klarem Glas bis zu einer deckenden Fläche." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "How strongly the tint color covers the glass, from clear glass to a solid panel." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Cuánto cubre el color del tinte el cristal, desde cristal transparente hasta un panel sólido." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Intensité avec laquelle la teinte couvre le verre, du verre transparent à un panneau opaque." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Quanto il colore della tinta copre il vetro, dal vetro trasparente a un pannello pieno." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ティントの色がガラスをどれだけ覆うか。透明なガラスから不透明なパネルまで調整できます。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "색조가 유리를 얼마나 덮을지 정합니다. 투명한 유리부터 불투명한 패널까지 조절할 수 있습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Hvor kraftig fargetonen dekker glasset, fra klart glass til et heldekkende panel." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Jak mocno kolor odcienia pokrywa szkło, od przezroczystego szkła po pełny panel." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Quanto a cor da tonalidade cobre o vidro, de vidro transparente a um painel sólido." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Насколько сильно оттенок покрывает стекло, от прозрачного стекла до сплошной панели." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ระดับที่สีโทนปกคลุมกระจก ตั้งแต่กระจกใสไปจนถึงแผงทึบ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Renk tonunun camı ne kadar kaplayacağı, şeffaf camdan opak bir panele kadar." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Наскільки сильно відтінок покриває скло, від прозорого скла до суцільної панелі." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "色调颜色覆盖玻璃的程度,从透明玻璃到不透明面板。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "色調顏色覆蓋玻璃的程度,從透明玻璃到不透明面板。" + } + } + } + }, + "sidebar.newWorkspace": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "مساحة عمل جديدة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Novi radni prostor" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nyt arbejdsområde" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Arbeitsbereich" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "New workspace" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nuevo espacio de trabajo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouvel espace de travail" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nuova area di lavoro" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規ワークスペース" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 작업 공간" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nytt arbeidsområde" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nowa przestrzeń robocza" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nova Área de Trabalho" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новое рабочее пространство" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "เวิร์กสเปซใหม่" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni Çalışma Alanı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Нова робоча область" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建工作区" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增工作區" + } + } + } + }, + "sidebar.presentation.dock": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تثبيت الشريط الجانبي" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Usidri bočnu traku" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fastgør sidebjælken" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste andocken" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Dock sidebar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Acoplar barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ancrer la barre latérale" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Aggancia la barra laterale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーを固定" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 고정" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fest sidefeltet" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zadokuj pasek boczny" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Fixar barra lateral" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Закрепить боковую панель" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปักหมุดแถบด้านข้าง" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar çubuğunu sabitle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Закріпити бічну панель" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "停靠侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "停靠側邊欄" + } + } + } + }, + "sidebar.presentation.float": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "جعل الشريط الجانبي عائمًا" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Učini bočnu traku plutajućom" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gør sidebjælken svævende" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Seitenleiste schweben lassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Float sidebar" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hacer flotante la barra lateral" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rendre la barre latérale flottante" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rendi fluttuante la barra laterale" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "サイドバーをフローティング表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "사이드바 띄우기" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gjør sidefeltet flytende" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Ustaw pasek boczny jako pływający" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Tornar barra lateral flutuante" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сделать боковую панель плавающей" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ทำให้แถบด้านข้างลอย" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kenar çubuğunu yüzer yap" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Зробити бічну панель плаваючою" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "浮动侧边栏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "浮動側邊欄" + } + } + } + }, + "workspaceGroup.contextMenu.groupColor": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لون المجموعة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Boja grupe" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Gruppefarve" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Gruppenfarbe" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Group Color" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Color del grupo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Couleur du groupe" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Colore gruppo" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グループカラー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그룹 색상" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Gruppefarge" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Kolor grupy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Cor do Grupo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Цвет группы" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "สีกลุ่ม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Grup Rengi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Колір групи" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "分组颜色" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "群組顏色" + } + } + } + }, "settings.sidebarAppearance.reset": { "extractionState": "manual", "localizations": { diff --git a/scripts/localization-allowed-omissions.json b/scripts/localization-allowed-omissions.json index b63df6edc44b..1e2ae2f7ef94 100644 --- a/scripts/localization-allowed-omissions.json +++ b/scripts/localization-allowed-omissions.json @@ -3181,6 +3181,10 @@ "de": "Reviewed shared native developer term, abbreviation, or borrowed technical noun. The translated entry is still required." } }, + "settings.sidebar.liquidGlass": { + "source": "Liquid Glass", + "class": "brand" + }, "simulator.appearance.liquidGlass": { "source": "Liquid Glass", "class": "brand" From c9b3b667241c1882fd3c796146e86b3af5fe38b1 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 22:50:25 +1300 Subject: [PATCH 20/69] schema parity: new tint defaults in the schema and docs, UI-only sidebar keys listed as not in cmux.json --- .../ConfigValidation/CmuxConfigSchema.generated.swift | 4 ++-- docs/customizing-appearance.md | 2 +- skills/cmux-settings/references/all-keys.md | 4 ++-- tests/test_cmux_schema_parity.py | 8 ++++++++ web/data/cmux.schema.json | 4 ++-- 5 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift index 8f844c5ac4bc..91d1701b435f 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift @@ -1846,7 +1846,7 @@ enum CmuxEmbeddedConfigSchema { }, "tintColor": { "$ref": "#/$defs/colorHex", - "default": "#000000", + "default": "#393939", "description": "Base sidebar tint color used when light/dark overrides are not set." }, "lightModeTintColor": { @@ -1863,7 +1863,7 @@ enum CmuxEmbeddedConfigSchema { "type": "number", "minimum": 0, "maximum": 1, - "default": 0.18, + "default": 0.72, "description": "Sidebar tint opacity from 0 to 1. Note: this only controls the sidebar tint, not terminal/window transparency. For terminal background transparency or blur, set `background-opacity` and `background-blur` in `~/.config/ghostty/config` and run `cmux reload-config`." } } diff --git a/docs/customizing-appearance.md b/docs/customizing-appearance.md index 54161fb012d6..180321018667 100644 --- a/docs/customizing-appearance.md +++ b/docs/customizing-appearance.md @@ -185,7 +185,7 @@ skipped, and the error goes to the log rather than showing as a config error. In `cmux.json`: - `sidebarAppearance.tintColor`, `lightModeTintColor`, `darkModeTintColor`, and - `tintOpacity` (0 to 1, default 0.18) tint the sidebar. + `tintOpacity` (0 to 1, default 0.72) tint the sidebar. - `sidebarAppearance.matchTerminalBackground` (default `true`) uses the terminal background instead of a tint. Set it to `false` to use the tint. - `sidebar.*` keys pick which rows appear (branch, PRs, ports, logs, progress, diff --git a/skills/cmux-settings/references/all-keys.md b/skills/cmux-settings/references/all-keys.md index 4201ad337169..ab776da0d55b 100644 --- a/skills/cmux-settings/references/all-keys.md +++ b/skills/cmux-settings/references/all-keys.md @@ -178,10 +178,10 @@ Sidebar tint settings from Settings > Sidebar Appearance. | Key | Type | Default | Description | |---|---|---|---| | `sidebarAppearance.matchTerminalBackground` | boolean | `true` | Use the terminal background instead of the sidebar tint. | -| `sidebarAppearance.tintColor` | colorHex | `"#000000"` | Base sidebar tint color used when light/dark overrides are not set. | +| `sidebarAppearance.tintColor` | colorHex | `"#393939"` | Base sidebar tint color used when light/dark overrides are not set. | | `sidebarAppearance.lightModeTintColor` | colorHexOrNull | `null` | Sidebar tint override for light appearance. | | `sidebarAppearance.darkModeTintColor` | colorHexOrNull | `null` | Sidebar tint override for dark appearance. | -| `sidebarAppearance.tintOpacity` | number | `0.18` | Sidebar tint opacity from 0 to 1. Note: this only controls the sidebar tint, not terminal/window transparency. For terminal background transparency or blur, set `background-opacity` and `background-blur` in `~/.config/ghostty/config` and run `cmux reload-config`. | +| `sidebarAppearance.tintOpacity` | number | `0.72` | Sidebar tint opacity from 0 to 1. Note: this only controls the sidebar tint, not terminal/window transparency. For terminal background transparency or blur, set `background-opacity` and `background-blur` in `~/.config/ghostty/config` and run `cmux reload-config`. | ## automation diff --git a/tests/test_cmux_schema_parity.py b/tests/test_cmux_schema_parity.py index 94644528a68d..80f6bba391c5 100644 --- a/tests/test_cmux_schema_parity.py +++ b/tests/test_cmux_schema_parity.py @@ -89,6 +89,14 @@ "sidebarAppearance.material", "sidebarAppearance.preset", "sidebarAppearance.state", + # Sidebar customization from Settings > Sidebar (peek, row density, drag + # switching, compositor glass); no cmux.json parser maps these yet. + "sidebar.dragSwitchDisabled", + "sidebar.peekDisabled", + "sidebar.peekReveal", + "sidebar.rowDensity", + "sidebarAppearance.compositorGlass", + "sidebarAppearance.glassBlurRadius", # Terminal remembered UI state remains UserDefaults-only. "terminal.sessionContentMaxWidth.remembered", "workspaceGroups.anchorCloseSuppressed", diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index f187fa0f305b..e9a94757dad4 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1836,7 +1836,7 @@ }, "tintColor": { "$ref": "#/$defs/colorHex", - "default": "#000000", + "default": "#393939", "description": "Base sidebar tint color used when light/dark overrides are not set." }, "lightModeTintColor": { @@ -1853,7 +1853,7 @@ "type": "number", "minimum": 0, "maximum": 1, - "default": 0.18, + "default": 0.72, "description": "Sidebar tint opacity from 0 to 1. Note: this only controls the sidebar tint, not terminal/window transparency. For terminal background transparency or blur, set `background-opacity` and `background-blur` in `~/.config/ghostty/config` and run `cmux reload-config`." } } From 8b472a7b15dcc7aaf03ad523241601f5c94ded0c Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:16:19 +1300 Subject: [PATCH 21/69] settings: peek, density, drag switch and glass rows are settings-only, not cmux.json paths fixes test_every_advertised_path_is_supported --- .../Sections/SidebarSection+Customization.swift | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift index c8a4b189f019..b41a32541c57 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift @@ -6,7 +6,7 @@ extension SidebarSection { @ViewBuilder var customizationRows: some View { SettingsCardRow( - configurationReview: .json("sidebarAppearance.compositorGlass"), + configurationReview: .settingsOnly, String(localized: "settings.sidebar.liquidGlass", defaultValue: "Liquid Glass"), subtitle: String(localized: "settings.sidebar.liquidGlass.subtitle", defaultValue: "Blur what is behind the window through the sidebar.") ) { @@ -18,7 +18,7 @@ extension SidebarSection { SettingsCardDivider() SettingsCardRow( - configurationReview: .json("sidebarAppearance.glassBlurRadius"), + configurationReview: .settingsOnly, String(localized: "settings.sidebar.blurOpacity", defaultValue: "Blur Opacity"), subtitle: String(localized: "settings.sidebar.blurOpacity.subtitle", defaultValue: "How much the glass blurs what is behind the window. Lower is lighter; higher is frostier."), controlWidth: 250 @@ -150,7 +150,7 @@ extension SidebarSection { SettingsCardDivider() SettingsCardRow( - configurationReview: .json("sidebar.peekReveal"), + configurationReview: .settingsOnly, String(localized: "settings.sidebar.peekReveal", defaultValue: "Sidebar Peek Reveal Speed"), subtitle: String(localized: "settings.sidebar.peekReveal.subtitle", defaultValue: "How eagerly the hidden sidebar peeks out when the pointer reaches the left edge or the sidebar button.") ) { @@ -170,7 +170,7 @@ extension SidebarSection { SettingsCardDivider() SettingsCardRow( - configurationReview: .json("sidebar.peekDisabled"), + configurationReview: .settingsOnly, String(localized: "settings.sidebar.peekDisabled", defaultValue: "Disable Sidebar Peek"), subtitle: peekDisabled.current ? String(localized: "settings.sidebar.peekDisabled.subtitleOn", defaultValue: "The hidden sidebar never peeks out; bring it back with the shortcut or the sidebar button.") @@ -183,7 +183,7 @@ extension SidebarSection { SettingsCardDivider() SettingsCardRow( - configurationReview: .json("sidebar.rowDensity"), + configurationReview: .settingsOnly, String(localized: "settings.sidebar.rowDensity", defaultValue: "Row Density"), subtitle: String(localized: "settings.sidebar.rowDensity.subtitle", defaultValue: "Vertical breathing room inside each workspace row.") ) { @@ -202,7 +202,7 @@ extension SidebarSection { SettingsCardDivider() SettingsCardRow( - configurationReview: .json("sidebar.dragSwitchDisabled"), + configurationReview: .settingsOnly, String(localized: "settings.sidebar.dragSwitchDisabled", defaultValue: "Disable Switch on Drag"), subtitle: dragSwitchDisabled.current ? String(localized: "settings.sidebar.dragSwitchDisabled.subtitleOn", defaultValue: "Dragging a workspace only reorders it; your current workspace stays selected.") From 7d33fe9659ab1b80141a82379ec791a455ef3676 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:16:36 +1300 Subject: [PATCH 22/69] sidebar defaults agree: match terminal background off everywhere, drop fork wording ContentView rendered with matchTerminalBackground off while the catalog, Settings and the other readers said on. the catalog, schema and docs now say off too, which is what the glass sidebar needs. the dead legacy preset migration goes and the diagnostics report the real bgGlassEnabled default. --- .../WindowChromeSidebarTintDefaults.swift | 2 +- .../CmuxConfigSchema.generated.swift | 2 +- .../SidebarAppearanceCatalogSection.swift | 2 +- .../WindowBackgroundSettingsReading.swift | 4 +- Sources/ContentView.swift | 6 +-- .../WindowBackgroundComposition.swift | 2 +- Sources/cmuxApp.swift | 41 +++---------------- docs/customizing-appearance.md | 4 +- skills/cmux-settings/references/all-keys.md | 2 +- web/data/cmux.schema.json | 2 +- 10 files changed, 18 insertions(+), 49 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift index 61d0bf25d679..b3abaf69a1fe 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift @@ -9,7 +9,7 @@ public struct WindowChromeSidebarTintDefaults: Sendable { /// Whether the sidebar uses the terminal background instead of the tint /// when the user has not chosen. Mirrors the /// `sidebarAppearance.matchTerminalBackground` catalog default. - public static let matchesTerminalBackground = true + public static let matchesTerminalBackground = false /// Creates sidebar tint defaults. public init( diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift index 91d1701b435f..6a767866cbe5 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/ConfigValidation/CmuxConfigSchema.generated.swift @@ -1841,7 +1841,7 @@ enum CmuxEmbeddedConfigSchema { "properties": { "matchTerminalBackground": { "type": "boolean", - "default": true, + "default": false, "description": "Use the terminal background instead of the sidebar tint." }, "tintColor": { diff --git a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift index 49889672a9fa..3756428f5489 100644 --- a/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift +++ b/Packages/macOS/CmuxSettings/Sources/CmuxSettings/Keys/SidebarAppearanceCatalogSection.swift @@ -4,7 +4,7 @@ import Foundation public struct SidebarAppearanceCatalogSection: SettingCatalogSection { public let matchTerminalBackground = DefaultsKey( id: "sidebarAppearance.matchTerminalBackground", - defaultValue: true, + defaultValue: false, userDefaultsKey: "sidebarMatchTerminalBackground" ) diff --git a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Window/Settings/WindowBackgroundSettingsReading.swift b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Window/Settings/WindowBackgroundSettingsReading.swift index e250cea040c4..fd0301863013 100644 --- a/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Window/Settings/WindowBackgroundSettingsReading.swift +++ b/Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Window/Settings/WindowBackgroundSettingsReading.swift @@ -8,9 +8,9 @@ /// match the legacy god-file reads byte-for-byte: `sidebarBlendMode` defaults /// to `"withinWindow"` and `bgGlassEnabled` defaults to `false`. public protocol WindowBackgroundSettingsReading: Sendable { - /// The raw `sidebarBlendMode` value (legacy default `"withinWindow"`). + /// The raw `sidebarBlendMode` value (default `"behindWindow"`). var sidebarBlendModeRawValue: String { get } - /// Whether the background-glass effect is enabled (legacy default `false`). + /// Whether the background-glass effect is enabled (default `true`). var isBackgroundGlassEnabled: Bool { get } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 218b0daebc24..e76c09408938 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -2043,7 +2043,7 @@ struct ContentView: View { // sidebar lives directly on it, with the workspace card as the one // opaque surface on top (the Aside model). @AppStorage("sidebarBlendMode") private var sidebarBlendMode = SidebarBlendModeOption.behindWindow.rawValue - @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = false + @AppStorage("sidebarMatchTerminalBackground") private var sidebarMatchTerminalBackground = SidebarAppearanceCatalogSection().matchTerminalBackground.defaultValue @AppStorage("sidebarTintOpacity") private var sidebarTintOpacity = SidebarTintDefaults().opacity @AppStorage("sidebarTintHex") private var sidebarTintHex = SidebarTintDefaults().hex @AppStorage("sidebarTintHexLight") private var sidebarTintHexLight: String? @@ -2052,7 +2052,7 @@ struct ContentView: View { @AppStorage("sidebarState") private var sidebarStateSetting = SidebarStateOption.active.rawValue @AppStorage("sidebarCornerRadius") private var sidebarCornerRadius = 0.0 @AppStorage("sidebarBlurOpacity") private var sidebarBlurOpacity = 1.0 - // Fork glass: clear window ground + compositor blur, so the blur radius is + // Compositor glass: clear window ground + compositor blur, so the blur radius is // a real slider instead of whatever an AppKit material bakes in. @AppStorage("sidebarCompositorGlass") private var sidebarCompositorGlass = true @AppStorage("sidebarGlassBlurRadius") private var sidebarGlassBlurRadius = SidebarAppearanceCatalogSection.glassBlurRadiusRange.lowerBound @@ -2060,7 +2060,7 @@ struct ContentView: View { // Background glass settings @AppStorage("bgGlassTintHex") private var bgGlassTintHex = "#000000" @AppStorage("bgGlassTintOpacity") private var bgGlassTintOpacity = 0.03 - // Fork default: window glass on. With behindWindow blending this keeps + // Window glass on by default. With behindWindow blending this keeps // the window transparent so the sidebar's vibrancy samples the actual // desktop (an opaque window kills behind-window sampling and leaves only // the material's flat frost). diff --git a/Sources/Windowing/WindowBackgroundComposition.swift b/Sources/Windowing/WindowBackgroundComposition.swift index 6e4d374bd35a..b09839abac0a 100644 --- a/Sources/Windowing/WindowBackgroundComposition.swift +++ b/Sources/Windowing/WindowBackgroundComposition.swift @@ -2,7 +2,7 @@ import AppKit import CmuxWorkspaces /// App-side `UserDefaults`-backed conformance to the window-background settings -/// seam. Fork defaults: `sidebarBlendMode` -> `"behindWindow"` and +/// seam. Defaults: `sidebarBlendMode` -> `"behindWindow"` and /// `bgGlassEnabled` -> `true`, so the glass look holds on a fresh domain. struct UserDefaultsWindowBackgroundSettings: WindowBackgroundSettingsReading { // `UserDefaults` accessors are documented as thread-safe; the seam requires diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index e95674de7458..352e20cee61e 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -445,41 +445,10 @@ struct cmuxApp: App { let targetVersion = 1 guard defaults.integer(forKey: migrationKey) < targetVersion else { return } - func normalizeHex(_ value: String) -> String { - value - .trimmingCharacters(in: .whitespacesAndNewlines) - .replacingOccurrences(of: "#", with: "") - .uppercased() - } - - func approximatelyEqual(_ lhs: Double, _ rhs: Double, tolerance: Double = 0.0001) -> Bool { - abs(lhs - rhs) <= tolerance - } - - let material = defaults.string(forKey: "sidebarMaterial") ?? SidebarMaterialOption.sidebar.rawValue - let blendMode = defaults.string(forKey: "sidebarBlendMode") ?? SidebarBlendModeOption.behindWindow.rawValue - let state = defaults.string(forKey: "sidebarState") ?? SidebarStateOption.followWindow.rawValue - let tintHex = defaults.string(forKey: "sidebarTintHex") ?? "#101010" - let tintOpacity = defaults.object(forKey: "sidebarTintOpacity") as? Double ?? 0.54 - let blurOpacity = defaults.object(forKey: "sidebarBlurOpacity") as? Double ?? 0.79 - let cornerRadius = defaults.object(forKey: "sidebarCornerRadius") as? Double ?? 0.0 - - let usesLegacyDefaults = - material == SidebarMaterialOption.sidebar.rawValue && - blendMode == SidebarBlendModeOption.behindWindow.rawValue && - state == SidebarStateOption.followWindow.rawValue && - normalizeHex(tintHex) == "101010" && - approximatelyEqual(tintOpacity, 0.54) && - approximatelyEqual(blurOpacity, 0.79) && - approximatelyEqual(cornerRadius, 0.0) - - // Fork: upstream stamped the nativeSidebar preset here, which stores - // sidebarBlendMode = withinWindow and tintOpacity = 0 into every fresh - // defaults domain. Stored values beat the code defaults, so that stamp - // silently killed the fork's behind-window glass on first launch. - // Legacy-default users now simply stay on the code defaults. - _ = usesLegacyDefaults - + // This migration used to stamp the native-sidebar preset over legacy + // defaults. Stored values beat code defaults, so the stamp would pin + // fresh installs to the old within-window look; everyone now stays on + // the code defaults, and only the version marker is still written. defaults.set(targetVersion, forKey: migrationKey) } @@ -1782,7 +1751,7 @@ private enum DebugWindowConfigSnapshot { """ let backgroundPayload = """ - bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: false)) + bgGlassEnabled=\(boolValue(defaults, key: "bgGlassEnabled", fallback: true)) bgGlassMaterial=\(stringValue(defaults, key: "bgGlassMaterial", fallback: "hudWindow")) bgGlassTintHex=\(stringValue(defaults, key: "bgGlassTintHex", fallback: "#000000")) bgGlassTintOpacity=\(String(format: "%.2f", doubleValue(defaults, key: "bgGlassTintOpacity", fallback: 0.03))) diff --git a/docs/customizing-appearance.md b/docs/customizing-appearance.md index 180321018667..7bfe3d044e53 100644 --- a/docs/customizing-appearance.md +++ b/docs/customizing-appearance.md @@ -186,8 +186,8 @@ In `cmux.json`: - `sidebarAppearance.tintColor`, `lightModeTintColor`, `darkModeTintColor`, and `tintOpacity` (0 to 1, default 0.72) tint the sidebar. -- `sidebarAppearance.matchTerminalBackground` (default `true`) uses the - terminal background instead of a tint. Set it to `false` to use the tint. +- `sidebarAppearance.matchTerminalBackground` (default `false`) uses the + terminal background instead of a tint. Set it to `true` to match the terminal. - `sidebar.*` keys pick which rows appear (branch, PRs, ports, logs, progress, notification text, and so on), and `sidebar.workspaceDescriptionColor` recolors workspace descriptions. See the diff --git a/skills/cmux-settings/references/all-keys.md b/skills/cmux-settings/references/all-keys.md index ab776da0d55b..5ebb30a93fdb 100644 --- a/skills/cmux-settings/references/all-keys.md +++ b/skills/cmux-settings/references/all-keys.md @@ -177,7 +177,7 @@ Sidebar tint settings from Settings > Sidebar Appearance. | Key | Type | Default | Description | |---|---|---|---| -| `sidebarAppearance.matchTerminalBackground` | boolean | `true` | Use the terminal background instead of the sidebar tint. | +| `sidebarAppearance.matchTerminalBackground` | boolean | `false` | Use the terminal background instead of the sidebar tint. | | `sidebarAppearance.tintColor` | colorHex | `"#393939"` | Base sidebar tint color used when light/dark overrides are not set. | | `sidebarAppearance.lightModeTintColor` | colorHexOrNull | `null` | Sidebar tint override for light appearance. | | `sidebarAppearance.darkModeTintColor` | colorHexOrNull | `null` | Sidebar tint override for dark appearance. | diff --git a/web/data/cmux.schema.json b/web/data/cmux.schema.json index e9a94757dad4..5d5304d6d3ff 100644 --- a/web/data/cmux.schema.json +++ b/web/data/cmux.schema.json @@ -1831,7 +1831,7 @@ "properties": { "matchTerminalBackground": { "type": "boolean", - "default": true, + "default": false, "description": "Use the terminal background instead of the sidebar tint." }, "tintColor": { From a4b2031fa12610f87eaf3f3d7222e2dd908de326 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:17:05 +1300 Subject: [PATCH 23/69] sidebar drag: lift cleanup on every drop path, split reorder code out of the controller review fixes: - the apply's forced-reload / reconfigure tail hung off the settle check and ran after every structural apply; it is back on the structural chain and the settle runs after it - every structural apply ends the lift (snapshots, hidden cells, transforms, pinned rows), not just the reload and pure-reorder branches, so a drop into a collapsed group or one that changes heights no longer leaves blank cells - a new drag cancels a pending drop hand-off and ends any leftover lift; the grab offset is captured after the duplicate-begin check - the drop commit fallback uses the clock + cancellable task pattern instead of asyncAfter; dismantle clears it and the lift - settle raises rows to zPosition 100 and now drops them back after the glide - the animated insert/remove branches were unreachable behind the pure-edit path; small pure edits now animate there (3 rows or fewer, no height change) - multi-selection glass keeps an Increase Contrast boost file length: the reorder lift and drop code move into SidebarWorkspaceTableController+ReorderLift/+ReorderDrop, the glyph button into its own file, the glass selection colors into SidebarGlassSelection, the row cell's lift helpers into an extension. --- .../Cells/SidebarGroupHeaderRowView.swift | 148 --- .../Cells/SidebarHeaderGlyphButton.swift | 151 +++ ...ebarWorkspaceRowCellView+ReorderLift.swift | 26 + .../Cells/SidebarWorkspaceRowCellView.swift | 29 +- ...WorkspaceTableController+ReorderDrop.swift | 310 ++++++ ...WorkspaceTableController+ReorderLift.swift | 457 +++++++++ .../SidebarWorkspaceTableController.swift | 943 ++---------------- .../Sidebar/SidebarAppearanceSupport.swift | 52 +- Sources/Sidebar/SidebarGlassSelection.swift | 36 + cmux.xcodeproj/project.pbxproj | 20 + 10 files changed, 1089 insertions(+), 1083 deletions(-) create mode 100644 Sources/Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift create mode 100644 Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift create mode 100644 Sources/Sidebar/SidebarGlassSelection.swift diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index ef42a604e680..3b85abdefc7e 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -733,154 +733,6 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { } } -/// Button cell that centres its image on the bounds exactly. AppKit's default -/// image rect for a borderless button sits a point off, which shows the -/// moment a hover circle is drawn around the glyph. -@MainActor -final class SidebarCenteredGlyphButtonCell: NSButtonCell { - override func imageRect(forBounds rect: NSRect) -> NSRect { - guard let image else { return super.imageRect(forBounds: rect) } - let size = image.size - return NSRect( - x: rect.midX - size.width / 2, - y: rect.midY - size.height / 2, - width: size.width, - height: size.height - ) - } -} - -/// Borderless glyph button used for the header chevron and plus controls. -@MainActor -final class SidebarHeaderGlyphButton: NSButton { - override class var cellClass: AnyClass? { - get { SidebarCenteredGlyphButtonCell.self } - set {} - } - - var onClick: (() -> Void)? - var menuProvider: (() -> NSMenu?)? - - /// Opt-in pointer feedback: a soft circle behind the glyph and the glyph - /// lifted to the full label colour while the pointer is on it. - var highlightsOnHover = false { - didSet { if !highlightsOnHover { setHovering(false) } } - } - - var glyphImage: NSImage? { - didSet { image = glyphImage } - } - - /// The tint the owner asked for; hover lifts the glyph away from it and - /// must come back to exactly this, not to whatever it lifted to. - private var restingTintColor: NSColor? - private var isApplyingHoverTint = false - private var isHovering = false - private var hoverTrackingArea: NSTrackingArea? - - override var contentTintColor: NSColor? { - didSet { - guard !isApplyingHoverTint else { return } - restingTintColor = contentTintColor - if isHovering { applyHoverAppearance() } - } - } - - init() { - super.init(frame: .zero) - isBordered = false - bezelStyle = .regularSquare - imagePosition = .imageOnly - setButtonType(.momentaryChange) - target = self - action = #selector(didClick) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - @objc private func didClick() { - onClick?() - } - - override func updateTrackingAreas() { - super.updateTrackingAreas() - if let hoverTrackingArea { - removeTrackingArea(hoverTrackingArea) - } - let area = NSTrackingArea( - rect: .zero, - options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], - owner: self, - userInfo: nil - ) - addTrackingArea(area) - hoverTrackingArea = area - } - - override func mouseEntered(with event: NSEvent) { - super.mouseEntered(with: event) - guard highlightsOnHover, isEnabled else { return } - setHovering(true) - } - - override func mouseExited(with event: NSEvent) { - super.mouseExited(with: event) - setHovering(false) - } - - override func layout() { - super.layout() - if highlightsOnHover { - layer?.cornerRadius = min(bounds.width, bounds.height) / 2 - } - } - - private func setHovering(_ hovering: Bool) { - guard hovering != isHovering else { return } - isHovering = hovering - applyHoverAppearance() - } - - private func applyHoverAppearance() { - wantsLayer = true - layer?.cornerRadius = min(bounds.width, bounds.height) / 2 - // Dynamic colours resolve against the drawing appearance, so the - // circle and glyph pick up the sidebar's own light/dark scheme. - effectiveAppearance.performAsCurrentDrawingAppearance { - layer?.backgroundColor = isHovering - ? NSColor.labelColor.withAlphaComponent(0.14).cgColor - : NSColor.clear.cgColor - } - isApplyingHoverTint = true - contentTintColor = isHovering ? .labelColor : restingTintColor - isApplyingHoverTint = false - } - - override func menu(for event: NSEvent) -> NSMenu? { - menuProvider?() ?? super.menu(for: event) - } - - /// Starting state for hover-revealed buttons, and the reset on cell - /// reuse. NSButton is born visible, so without this every fresh or - /// recycled cell painted an X on its first unhovered configure, which - /// flashed the close buttons on all rows at once after a workspace close. - func concealImmediately() { - setRevealed(false) - } - - /// Hover reveal lands in the same frame as the hover change. The row - /// swaps its trailing badge or spinner for this button synchronously, so - /// a fade here left the slot blank on hover-in and doubled up on - /// hover-out. - func setRevealed(_ revealed: Bool) { - isEnabled = revealed - alphaValue = revealed ? 1 : 0 - isHidden = !revealed - } -} - /// AppKit rendition of the sidebar shortcut-hint capsule. The outer view owns /// the shadow while the inner view clips its fill to the capsule; putting both /// on one unclipped layer squares it off. Where `NSGlassEffectView` exists diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift b/Sources/Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift new file mode 100644 index 000000000000..365fcab6778c --- /dev/null +++ b/Sources/Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift @@ -0,0 +1,151 @@ +import AppKit +import CmuxFoundation +import SwiftUI + +/// Button cell that centres its image on the bounds exactly. AppKit's default +/// image rect for a borderless button sits a point off, which shows the +/// moment a hover circle is drawn around the glyph. +@MainActor +final class SidebarCenteredGlyphButtonCell: NSButtonCell { + override func imageRect(forBounds rect: NSRect) -> NSRect { + guard let image else { return super.imageRect(forBounds: rect) } + let size = image.size + return NSRect( + x: rect.midX - size.width / 2, + y: rect.midY - size.height / 2, + width: size.width, + height: size.height + ) + } +} + +/// Borderless glyph button used for the header chevron and plus controls. +@MainActor +final class SidebarHeaderGlyphButton: NSButton { + override class var cellClass: AnyClass? { + get { SidebarCenteredGlyphButtonCell.self } + set {} + } + + var onClick: (() -> Void)? + var menuProvider: (() -> NSMenu?)? + + /// Opt-in pointer feedback: a soft circle behind the glyph and the glyph + /// lifted to the full label colour while the pointer is on it. + var highlightsOnHover = false { + didSet { if !highlightsOnHover { setHovering(false) } } + } + + var glyphImage: NSImage? { + didSet { image = glyphImage } + } + + /// The tint the owner asked for; hover lifts the glyph away from it and + /// must come back to exactly this, not to whatever it lifted to. + private var restingTintColor: NSColor? + private var isApplyingHoverTint = false + private var isHovering = false + private var hoverTrackingArea: NSTrackingArea? + + override var contentTintColor: NSColor? { + didSet { + guard !isApplyingHoverTint else { return } + restingTintColor = contentTintColor + if isHovering { applyHoverAppearance() } + } + } + + init() { + super.init(frame: .zero) + isBordered = false + bezelStyle = .regularSquare + imagePosition = .imageOnly + setButtonType(.momentaryChange) + target = self + action = #selector(didClick) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func didClick() { + onClick?() + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let hoverTrackingArea { + removeTrackingArea(hoverTrackingArea) + } + let area = NSTrackingArea( + rect: .zero, + options: [.mouseEnteredAndExited, .activeInActiveApp, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + hoverTrackingArea = area + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + guard highlightsOnHover, isEnabled else { return } + setHovering(true) + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + setHovering(false) + } + + override func layout() { + super.layout() + if highlightsOnHover { + layer?.cornerRadius = min(bounds.width, bounds.height) / 2 + } + } + + private func setHovering(_ hovering: Bool) { + guard hovering != isHovering else { return } + isHovering = hovering + applyHoverAppearance() + } + + private func applyHoverAppearance() { + wantsLayer = true + layer?.cornerRadius = min(bounds.width, bounds.height) / 2 + // Dynamic colours resolve against the drawing appearance, so the + // circle and glyph pick up the sidebar's own light/dark scheme. + effectiveAppearance.performAsCurrentDrawingAppearance { + layer?.backgroundColor = isHovering + ? NSColor.labelColor.withAlphaComponent(0.14).cgColor + : NSColor.clear.cgColor + } + isApplyingHoverTint = true + contentTintColor = isHovering ? .labelColor : restingTintColor + isApplyingHoverTint = false + } + + override func menu(for event: NSEvent) -> NSMenu? { + menuProvider?() ?? super.menu(for: event) + } + + /// Starting state for hover-revealed buttons, and the reset on cell + /// reuse. NSButton is born visible, so without this every fresh or + /// recycled cell painted an X on its first unhovered configure, which + /// flashed the close buttons on all rows at once after a workspace close. + func concealImmediately() { + setRevealed(false) + } + + /// Hover reveal lands in the same frame as the hover change. The row + /// swaps its trailing badge or spinner for this button synchronously, so + /// a fade here left the slot blank on hover-in and doubled up on + /// hover-out. + func setRevealed(_ revealed: Bool) { + isEnabled = revealed + alphaValue = revealed ? 1 : 0 + isHidden = !revealed + } +} diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift new file mode 100644 index 000000000000..4721cb166f91 --- /dev/null +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift @@ -0,0 +1,26 @@ +import AppKit + +/// Reorder lift support: the lift moves the row's fill apart from its content. +extension SidebarWorkspaceRowTableCellView { + /// The row's fill (selection/hover) as a standalone copy in `view`'s + /// coordinates, for the reorder lift, which moves it apart from the + /// content. Nil when the row has no visible fill. + func makeLiftFillLayer(in view: NSView) -> CALayer? { + guard let source = backgroundView.layer, !backgroundView.isHidden, + (source.backgroundColor?.alpha ?? 0) > 0 || source.borderWidth > 0 else { return nil } + let fill = CALayer() + let frame = backgroundView.convert(backgroundView.bounds, to: view) + fill.frame = CGRect(x: frame.minX, y: 0, width: frame.width, height: view.bounds.height) + fill.backgroundColor = source.backgroundColor + fill.cornerRadius = source.cornerRadius + fill.cornerCurve = source.cornerCurve + fill.borderWidth = source.borderWidth + fill.borderColor = source.borderColor + return fill + } + + /// Hides the fill so the lift can snapshot the content alone. + func setLiftFillHidden(_ hidden: Bool) { + backgroundView.isHidden = hidden + } +} diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 4fd46d1a072f..b464f448fbb7 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -2,7 +2,6 @@ import AppKit import CmuxAppKitSupportUI import Combine import CmuxFoundation -import CmuxSettings import CmuxSidebar import CmuxWorkspaces import SwiftUI @@ -16,7 +15,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { static let reuseIdentifier = NSUserInterfaceItemIdentifier("SidebarWorkspaceRowTableCellView") // Chrome - private let backgroundView = NSView() + let backgroundView = NSView() private let railView = NSView() private let topDropIndicator = SidebarReorderIndicatorView() private let bottomDropIndicator = SidebarReorderIndicatorView() @@ -209,28 +208,6 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { return false } - /// The row's fill (selection/hover) as a standalone copy in `view`'s - /// coordinates, for the reorder lift, which moves it apart from the - /// content. Nil when the row has no visible fill. - func makeLiftFillLayer(in view: NSView) -> CALayer? { - guard let source = backgroundView.layer, !backgroundView.isHidden, - (source.backgroundColor?.alpha ?? 0) > 0 || source.borderWidth > 0 else { return nil } - let fill = CALayer() - let frame = backgroundView.convert(backgroundView.bounds, to: view) - fill.frame = CGRect(x: frame.minX, y: 0, width: frame.width, height: view.bounds.height) - fill.backgroundColor = source.backgroundColor - fill.cornerRadius = source.cornerRadius - fill.cornerCurve = source.cornerCurve - fill.borderWidth = source.borderWidth - fill.borderColor = source.borderColor - return fill - } - - /// Hides the fill so the lift can snapshot the content alone. - func setLiftFillHidden(_ hidden: Bool) { - backgroundView.isHidden = hidden - } - private func applyBackgroundStyle(_ style: SidebarWorkspaceRowBackgroundStyle) { backgroundView.layer?.backgroundColor = (style.color ?? .clear) .withAlphaComponent((style.color == nil ? 0 : style.opacity) * ((style.color?.alphaComponent) ?? 1)).cgColor @@ -367,7 +344,6 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { closeButton.concealImmediately() closeButton.highlightsOnHover = true contentContainer.addSubview(closeButton) - contentContainer.addSubview(descriptionView) descriptionView.onOpenLink = { [weak self] url in guard let self else { return } @@ -765,9 +741,6 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { bottomDropIndicator.layer?.backgroundColor = palette.accentColor.cgColor topDropIndicator.isHidden = !model.topDropIndicatorVisible bottomDropIndicator.isHidden = !model.bottomDropIndicatorVisible - // Full opacity while dragged: the freeform reorder suppresses the - // floating ghost, so the row itself is the drag visual and must keep - // its exact resting appearance (the dim was a ghost-era cue). alphaValue = 1 // Done rows read as settled (legacy parity): dim the row CONTENT to // ~60% — never the selection background, rail, or drop chrome. diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift new file mode 100644 index 000000000000..d4af78a5f0da --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift @@ -0,0 +1,310 @@ +import AppKit +import Bonsplit +import CmuxAppKitSupportUI +import CmuxFoundation +import CmuxNotifications +import CmuxSettings +import SwiftUI + +/// Workspace reorder drop: planning the drop under the pointer and committing +/// it on release. The stored state stays on the controller. +extension SidebarWorkspaceTableController { + func performReorderDrop( + point: CGPoint, + targets: [SidebarWorkspaceReorderDropOverlay.Target], + payloadWorkspaceId: UUID? + ) -> Bool { + reorderDragWindowPoint = nil + guard let actions else { + retireReorderIndicator() + return false + } + let performed: Bool + let commitSource: String + if let plan = lastAcceptedReorderDropPlan { + // Commit exactly what the indicator showed. + performed = actions.commitWorkspaceDropPlan(plan) + commitSource = "paintedPlan" + } else { + // No accepted hover plan exists: resolve from the release point. + performed = actions.performWorkspaceDrop(point, targets, payloadWorkspaceId) + commitSource = "releasePoint" + } +#if DEBUG + // Every silent "the workspace I dragged didn't move" report needs + // this line: where the drop landed, which commit source ran, and + // whether the shared planner accepted it. + cmuxDebugLog( + "sidebar.drop.perform point=(\(Int(point.x)),\(Int(point.y))) " + + "source=\(commitSource) performed=\(performed ? 1 : 0)" + ) +#endif + // A handled drop on the block's own slot moves nothing, so no + // structural apply will come to take the transforms over. The lift + // knows: no other row is displaced. Glide home now, rather than + // waiting (non-structural applies can land first after any drop). + let liftShowsMove = reorderLiftSession?.appliedTargets.contains { $0 != 0 } ?? false + if performed && !liftShowsMove { + suppressSelectedScrollAfterLocalDrop = true + endReorderLift(animated: true) + } else if performed { + suppressSelectedScrollAfterLocalDrop = true + // The rows already stand at their new positions via transforms; + // the apply that lands the committed order clears them in the + // same update, so the real frames take over in place. + clearsReorderTransformsOnNextApply = true + // Bounded fallback if the committed order never arrives, so the + // rows are not left frozen where the lift drew them. Injected + // clock with cancellation: the hand-off apply and the next drag + // begin both cancel it. + reorderDropCommitFallbackTask?.cancel() + reorderDropCommitFallbackTask = Task { [weak self, previewBailoutClock] in + try? await previewBailoutClock.sleep(for: .seconds(1)) + guard let self, !Task.isCancelled, self.clearsReorderTransformsOnNextApply else { return } + self.reorderDropCommitFallbackTask = nil + self.clearsReorderTransformsOnNextApply = false + self.endReorderLift(animated: true) + } + } else { + endReorderLift(animated: true) + } + retireReorderIndicator() + return performed + } + + func reorderDropDragExited() { +#if DEBUG + cmuxDebugLog("sidebar.reorder.exited") +#endif + // Visual-inert on purpose: destination exit fires at churn rate + // whenever the plan goes nil (the overlay's operation flips to none + // and AppKit re-resolves the destination). The poll owns the + // visuals; the session ends only at endedAt. + reorderDragPayloadWorkspaceId = nil + guard reorderDragWindowPoint != nil || reorderIndicatorPainter != nil else { return } + reorderDragWindowPoint = nil + retireReorderIndicator() + } + + /// Runs the shared reorder planner for a drag hovering at `windowPoint` + /// and paints the resulting indicator. An accepted position is remembered + /// (window space) so viewport changes can re-plan it; a rejected one + /// stops the re-plan loop until the pointer produces a new overlay update. + @discardableResult + func updateReorderDrag(windowPoint: NSPoint) -> Bool { + guard let dropView = containerView?.reorderDropView else { + reorderDragWindowPoint = nil + retireReorderIndicator() + return false + } + let targets = refreshReorderDropTargets() + return updateReorderDrag( + point: dropView.convert(windowPoint, from: nil), + targets: targets, + windowPoint: windowPoint, + payloadWorkspaceId: reorderDragPayloadWorkspaceId + ) + } + + func updateReorderDrag( + point: CGPoint, + targets: [SidebarWorkspaceReorderDropOverlay.Target], + windowPoint: NSPoint, + payloadWorkspaceId: UUID? + ) -> Bool { + guard let actions else { + reorderDragWindowPoint = nil + retireReorderIndicator() + return false + } + let reorderTickStart = ProcessInfo.processInfo.systemUptime + defer { + SidebarNavigationTimings.recordSampledTick("reorder.tick", startUptime: reorderTickStart) + } + reorderDragPayloadWorkspaceId = payloadWorkspaceId + // Plan at the lifted block's slot probe, not the raw pointer, so the + // committed gap is the one the displaced rows are showing. + var plannerPoint = point + if let session = reorderLiftSession, let containerView { + let table = containerView.tableView + let tablePoint = table.convert(windowPoint, from: nil) + let placement = reorderLiftPlacement(session, pointY: tablePoint.y, tableMaxY: table.bounds.maxY) + let probeWindowPoint = table.convert(NSPoint(x: tablePoint.x, y: placement.slotProbeY), to: nil) + plannerPoint = containerView.reorderDropView.convert(probeWindowPoint, from: nil) + } + guard !targets.isEmpty, + let update = actions.updateWorkspaceDrag( + plannerPoint, + targets, + payloadWorkspaceId + ) + else { + reorderDragWindowPoint = nil + retireReorderIndicator() +#if DEBUG + cmuxDebugLog("sidebar.reorder.plan-nil payload=\(payloadWorkspaceId?.uuidString.prefix(5) ?? "none")") +#endif + // No legal gap at this point (commonly: hovering the dragged + // row's own slot). Harmless: the poll owns the visuals, and this + // callback owns only the plan. + return false + } + // The plan is still tracked exactly as stock (the release commits + // it), but it is drawn as displacement, not indicator lines: the + // rows shifting around the lifted row are the drop preview. + lastAcceptedReorderDropPlan = update.plan + if case .reorder(_, _, let explicitGroupId)? = update.plan?.action { + updateReorderLiftIndent(isGrouped: explicitGroupId != nil) + } + reorderDragWindowPoint = windowPoint +#if DEBUG + cmuxDebugLog("sidebar.reorder.tick y=\(Int(point.y))") +#endif + return true + } + + func retireReorderIndicator() { + lastAcceptedReorderDropPlan = nil + guard reorderIndicatorPainter != nil else { return } + reorderIndicatorPainter = nil + clearReorderIndicatorPaintOnVisibleCells() + actions?.clearWorkspaceDropIndicator() + setAppKitDropIndicator(nil, scope: .raw, includeRowTargets: false) + } + + func enforceReorderIndicatorPaintOnVisibleCells() { + guard reorderIndicatorPainter != nil else { return } + sweepReorderIndicatorPaint(reorderIndicatorPainter) + } + + func clearReorderIndicatorPaintOnVisibleCells() { + sweepReorderIndicatorPaint(nil) + } + + /// A nil painter clears every visible drop line, which is only safe here + /// because reorder and bonsplit drags cannot overlap: outside a reorder + /// drag the row models carry `false` for both flags, so clearing matches + /// what the next configure would apply anyway. + func sweepReorderIndicatorPaint( + _ painter: SidebarWorkspaceTableReorderIndicatorPainter? + ) { + guard let table = containerView?.tableView else { return } + let visible = table.rows(in: table.visibleRect) + guard visible.length > 0 else { return } + for row in visible.lowerBound..<(visible.lowerBound + visible.length) + where rows.indices.contains(row) { + let paint = painter?.paint(forRowWorkspaceId: rows[row].workspaceId) + ?? (top: false, bottom: false) + switch table.view(atColumn: 0, row: row, makeIfNecessary: false) { + case let cell as SidebarWorkspaceRowTableCellView: + cell.paintControllerDropIndicator(top: paint.top, bottom: paint.bottom) + case let cell as SidebarGroupHeaderTableCellView: + cell.paintControllerDropIndicator(top: paint.top, bottom: paint.bottom) + default: + break + } + } + } + + /// Refreshes visible-row targets in the overlay's coordinate space. + @discardableResult + func refreshReorderDropTargets() -> [SidebarWorkspaceReorderDropOverlay.Target] { + guard let container = containerView else { return [] } + let table = container.tableView + let visibleRange = table.rows(in: table.visibleRect) + guard visibleRange.location != NSNotFound, visibleRange.length > 0 else { + clearReorderDropTargets() + return [] + } + let lower = max(0, visibleRange.location) + let upper = min(rows.count, visibleRange.location + visibleRange.length) + guard lower < upper else { + clearReorderDropTargets() + return [] + } + let targets = (lower.. UUID? { + let type = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) + let raw = pasteboard.string(forType: type) + ?? pasteboard.data(forType: type).flatMap { String(data: $0, encoding: .utf8) } + let parsed = SidebarTabDragPayload.workspaceId(fromPasteboardString: raw) +#if DEBUG + cmuxDebugLog( + "sidebar.drop.payload raw=\(raw.map { String($0.prefix(24)) } ?? "nil") " + + "parsed=\(parsed.map { String($0.uuidString.prefix(5)) } ?? "nil")" + ) +#endif + return parsed + } + + func workspaceDragImage( + tableView: NSTableView, + row: Int, + size: NSSize, + count: Int + ) -> NSImage? { + let rowRect = tableView.rect(ofRow: row) + guard rowRect.width > 0, + rowRect.height > 0, + size.width > 0, + size.height > 0, + let representation = tableView.bitmapImageRepForCachingDisplay(in: rowRect) else { + return nil + } + tableView.cacheDisplay(in: rowRect, to: representation) + let rowImage = NSImage(size: rowRect.size) + rowImage.addRepresentation(representation) + let badgeColor = (AppDelegate.shared?.accentColor ?? CmuxAccentColor()).nsColor(for: tableView.effectiveAppearance) + + return NSImage(size: size, flipped: false) { bounds in + rowImage.draw(in: bounds) + + let badgeDiameter: CGFloat = 18 + let badgeInset: CGFloat = 2 + let badgeRect = NSRect( + x: bounds.maxX - badgeDiameter - badgeInset, + y: bounds.maxY - badgeDiameter - badgeInset, + width: badgeDiameter, + height: badgeDiameter + ) + badgeColor.setFill() + NSBezierPath(ovalIn: badgeRect).fill() + + let countText = "\(count)" as NSString + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.systemFont(ofSize: 10, weight: .semibold), + .foregroundColor: NSColor.white, + ] + let textSize = countText.size(withAttributes: attributes) + countText.draw( + at: NSPoint( + x: badgeRect.midX - (textSize.width / 2), + y: badgeRect.midY - (textSize.height / 2) + ), + withAttributes: attributes + ) + return true + } + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift new file mode 100644 index 000000000000..2f29fde7d0f4 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift @@ -0,0 +1,457 @@ +import AppKit +import Bonsplit +import CmuxAppKitSupportUI +import CmuxFoundation +import CmuxNotifications +import CmuxSettings +import SwiftUI + +/// Freeform reorder visuals: the pointer poll, the lifted block and the +/// rows shifting around it, and the settle after a drop. The stored state +/// stays on the controller. +extension SidebarWorkspaceTableController { + func startReorderPoll(workspaceId: UUID) { + stopReorderPoll() + reorderPollWorkspaceId = workspaceId + let timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] _ in + MainActor.assumeIsolated { + self?.reorderPollTick() + } + } + // .common so the timer keeps firing inside the drag's event-tracking + // loop, which is the only time it exists. + RunLoop.main.add(timer, forMode: .common) + reorderPollTimer = timer + } + + func stopReorderPoll() { + reorderPollTimer?.invalidate() + reorderPollTimer = nil + reorderPollWorkspaceId = nil + } + + func reorderPollTick() { + guard let workspaceId = reorderPollWorkspaceId, + let containerView, + let window = containerView.tableView.window else { return } + let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) + let table = containerView.tableView + let point = table.convert(windowPoint, from: nil) + // Outside the sidebar horizontally (a cross-window or into-terminal + // excursion): freeze the shift state rather than keep reacting to a + // pointer that is no longer about this list. The session ends only + // at endedAt. + guard point.x >= -48, point.x <= table.bounds.width + 48 else { return } + updateReorderLift(windowPoint: windowPoint, workspaceId: workspaceId) + } + + /// Frozen drag geometry. Captured once when the drag first hovers, and + /// never updated until the drag ends: every visual decision during the + /// drag is made against these frames, so displacement boundaries cannot + /// move mid-drag and the interaction cannot oscillate. The model is not + /// touched until release; everything the user sees during the drag is + /// layer transforms over an unchanged table. + struct ReorderLiftSession { + let workspaceId: UUID + /// Rows travelling with the pointer: one row, or a group header plus + /// its visible members. A group's header mints its anchor's id (see + /// `pasteboardWriterForRow`), so an anchor drag is a whole-group move + /// and the block is what the hand is holding. + let sourceRange: Range + /// Every other row, grouped into the units that shift as one. A row + /// drag parts single rows (a row can slot inside a group); a group + /// drag parts whole groups (a group only reorders among top-level + /// units), so a header never separates from its members mid-drag. + let otherUnits: [Range] + let frames: [CGRect] + /// Vertical gap between adjacent rows, so re-laid units keep it. + let rowSpacing: CGFloat + /// Pointer offset from the top of the dragged block at pickup. Keep + /// this stable so a multi-row group does not jump to center itself + /// under the pointer when the lift starts. + let pointerOffsetY: CGFloat + /// The applied translation target per row index, so a row only + /// animates when its target actually flips. + var appliedTargets: [CGFloat] + + func height(of unit: Range) -> CGFloat { + frames[unit].reduce(0) { $0 + $1.height } + rowSpacing * CGFloat(max(0, unit.count - 1)) + } + } + + /// The row range a drag of `workspaceId` carries: a group header plus + /// its visible members when the id is a group anchor, else the one row. + func reorderSourceRange(for workspaceId: UUID) -> Range? { + guard let first = rows.firstIndex(where: { $0.workspaceId == workspaceId }) else { return nil } + guard rows[first].isGroupHeader, let groupId = rows[first].groupId else { + return first..<(first + 1) + } + return groupBlockRange(headerIndex: first, groupId: groupId) + } + + /// Header row plus the contiguous member rows below it. A collapsed + /// group shows no members, so its block is the header alone. + func groupBlockRange(headerIndex: Int, groupId: UUID) -> Range { + var end = headerIndex + 1 + while end < rows.count, !rows[end].isGroupHeader, rows[end].groupId == groupId { + end += 1 + } + return headerIndex.., wholeGroups: Bool) -> [Range] { + var units: [Range] = [] + var index = 0 + while index < rows.count { + if source.contains(index) { + index = source.upperBound + continue + } + if wholeGroups, rows[index].isGroupHeader, let groupId = rows[index].groupId { + let block = groupBlockRange(headerIndex: index, groupId: groupId) + units.append(block) + index = block.upperBound + } else { + units.append(index..<(index + 1)) + index += 1 + } + } + return units + } + + /// Glides a row's vertical shift to `target` with a soft spring. + /// + /// Explicit animation on purpose: view-backing layers on macOS ignore + /// implicit CATransaction animations (NSView's layer delegate returns + /// no action), so transform changes here otherwise land instantly no + /// matter what the transaction says. Starting from the presentation + /// layer's live value means a retarget mid-glide continues from where + /// the row visually is instead of jumping. + func glideRowShift(layer: CALayer, to target: CGFloat) { + let currentY = (layer.presentation() ?? layer) + .value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 + let spring = CASpringAnimation(keyPath: "transform.translation.y") + spring.fromValue = currentY + spring.toValue = target + spring.mass = 1 + spring.stiffness = 300 + spring.damping = 30 + spring.duration = spring.settlingDuration + layer.add(spring, forKey: "cmux.reorderShift") + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.transform = CATransform3DMakeTranslation(0, target, 0) + CATransaction.commit() + } + + /// Drives the freeform drag visuals for the pointer at `windowPoint`. + func updateReorderLift(windowPoint: NSPoint, workspaceId: UUID) { + guard let containerView else { return } + let table = containerView.tableView + let point = table.convert(windowPoint, from: nil) + + if reorderLiftSession?.workspaceId != workspaceId { + endReorderLift(animated: false) + // A quick re-grab can land while the last drop is still settling. + // Finish those glides first: an in-flight animation overrides the + // transforms the lift sets, so the new drag would lag or snap. + // Every row also drops back to the base layer (a settle raises it). + table.enumerateAvailableRowViews { rowView, _ in + guard let layer = rowView.layer else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + if layer.animation(forKey: "cmux.reorderShift") != nil { + layer.removeAnimation(forKey: "cmux.reorderShift") + layer.transform = CATransform3DIdentity + } + layer.zPosition = 0 + CATransaction.commit() + } + guard let sourceRange = reorderSourceRange(for: workspaceId) else { return } + let frames = (0.. 0 else { return } + let isGroupDrag = rows[sourceRange.lowerBound].isGroupHeader + reorderLiftSession = ReorderLiftSession( + workspaceId: workspaceId, + sourceRange: sourceRange, + otherUnits: reorderUnits(excluding: sourceRange, wholeGroups: isGroupDrag), + frames: frames, + rowSpacing: frames.count > 1 ? max(0, frames[1].minY - frames[0].maxY) : 0, + pointerOffsetY: reorderDragStartWorkspaceId == workspaceId + ? (reorderDragStartPointerOffsetY ?? point.y - frames[sourceRange.lowerBound].minY) + : point.y - frames[sourceRange.lowerBound].minY, + appliedTargets: Array(repeating: 0, count: rows.count) + ) + table.reorderPinnedRowsRect = sourceRange.reduce(CGRect.null) { $0.union(frames[$1]) } + installReorderLiftSnapshots(table: table, sourceRange: sourceRange) + } + guard var session = reorderLiftSession else { return } + + // Keep the same pointer-to-block offset captured at pickup. Centering + // a multi-row block under the pointer makes a group jump by roughly + // half its height as soon as the lift begins. Clamp the block top so + // it stays within the list while preserving that offset everywhere + // else. + let blockTop = session.frames[session.sourceRange.lowerBound].minY + let blockFrame = session.sourceRange.reduce(CGRect.null) { $0.union(session.frames[$1]) } + table.reorderPinnedRowsRect = blockFrame + let placement = reorderLiftPlacement(session, pointY: point.y, tableMaxY: table.bounds.maxY) + let draggedTop = placement.draggedTop + let blockShift = draggedTop - blockTop + + // Insertion slot from frozen midpoints: how many other units sit + // above the block's slot probe (see `reorderLiftPlacement`). + let insertion = session.otherUnits.filter { unit in + let top = session.frames[unit.lowerBound].minY + return top + session.height(of: unit) / 2 < placement.slotProbeY + }.count + + // Re-lay every unit in drop order and read each row's offset from + // where it would land. Exact for mixed heights (a header is not a + // row) and for blocks, where a plain one-row-height shift is wrong. + var order = session.otherUnits + order.insert(session.sourceRange, at: insertion) + var targets = Array(repeating: CGFloat(0), count: session.frames.count) + var nextTop = session.frames[0].minY + for unit in order { + if unit != session.sourceRange { + let delta = nextTop - session.frames[unit.lowerBound].minY + for row in unit { targets[row] = delta } + } + nextTop += session.height(of: unit) + session.rowSpacing + } + + table.enumerateAvailableRowViews { rowView, row in + guard session.frames.indices.contains(row) else { return } + rowView.wantsLayer = true + guard let layer = rowView.layer else { return } + if session.sourceRange.contains(row) { + // Pointer-driven, never animated: any smoothing here reads + // as the block lagging the hand. + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.zPosition = 100 + layer.transform = CATransform3DMakeTranslation(0, blockShift, 0) + CATransaction.commit() + return + } + let target = targets[row] + if session.appliedTargets[row] != target { + session.appliedTargets[row] = target + layer.zPosition = 0 + glideRowShift(layer: layer, to: target) + } else if layer.animation(forKey: "cmux.reorderShift") == nil { + // Reused row views arrive with whatever transform their + // previous tenant had; keep visible rows pinned to their + // current target every tick (but never stamp over a glide + // that is still in flight). + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.transform = CATransform3DMakeTranslation(0, target, 0) + CATransaction.commit() + } + } + reorderLiftSession = session + } + + /// Glides the just-dropped block from where it stood under the pointer + /// into its committed slot. The other rows already stand at their new + /// places (the lift showed them there), so only the block moves. + func settleReorderedBlock(ids: [SidebarWorkspaceRenderItemID], fromVisualTop visualTop: CGFloat) { + guard let table = containerView?.tableView, + let firstId = ids.first, + let firstIndex = rows.firstIndex(where: { $0.id == firstId }) else { return } + let delta = visualTop - table.rect(ofRow: firstIndex).minY + guard abs(delta) > 0.5 else { return } + let idSet = Set(ids) + for (index, row) in rows.enumerated() where idSet.contains(row.id) { + guard let layer = table.rowView(atRow: index, makeIfNecessary: false)?.layer else { continue } + // Explicit start: the presentation layer still holds the lift's + // old offset (relative to the row's old frame) until the next + // frame, so reading it, as `glideRowShift` does, would start the + // glide from the wrong place and the block would jump first. + // A plain ease-out, not a spring: the block can land up to a row + // away from its slot, and a spring's fast start and long tail + // read as a snap over that distance. Longer travel, longer glide. + let settle = CABasicAnimation(keyPath: "transform.translation.y") + settle.fromValue = delta + settle.toValue = 0 + settle.duration = min(0.3, 0.18 + abs(delta) / 700) + settle.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, 0.8, 0.25, 1) + CATransaction.begin() + CATransaction.setDisableActions(true) + // Back to the base layer once the glide ends, unless a new lift + // owns the row by then. + CATransaction.setCompletionBlock { [weak self, weak layer] in + MainActor.assumeIsolated { + guard let self, let layer, self.reorderLiftSession == nil else { return } + layer.zPosition = 0 + } + } + layer.removeAnimation(forKey: "cmux.reorderShift") + layer.transform = CATransform3DIdentity + layer.zPosition = 100 + layer.add(settle, forKey: "cmux.reorderShift") + CATransaction.commit() + } + } + + func installReorderLiftSnapshots(table: NSTableView, sourceRange: Range) { + removeReorderLiftSnapshots() + let scale = table.window?.backingScaleFactor ?? 2 + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { return } + for row in sourceRange { + guard let rowView = table.rowView(atRow: row, makeIfNecessary: false), + let rowLayer = rowView.layer else { continue } + // Optionally, a lifted group header wears its active (selected) + // look for the whole drag. Off by default, behind the + // `sidebarDragHeaderActiveLook` defaults key while it is tuned. + if UserDefaults.standard.bool(forKey: "sidebarDragHeaderActiveLook"), + let header = rowView.view(atColumn: 0) as? SidebarGroupHeaderTableCellView { + header.showOptimisticAnchorActive() + header.displayIfNeeded() + } + let size = rowLayer.bounds.size + guard size.width > 0, size.height > 0, + let context = CGContext( + data: nil, + width: Int((size.width * scale).rounded(.up)), + height: Int((size.height * scale).rounded(.up)), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) else { continue } + context.scaleBy(x: scale, y: scale) + // A single workspace row: lift its fill out as its own layer and + // snapshot the content without it (see `reorderLiftIndent`). + let workspaceCell = sourceRange.count == 1 + ? rowView.view(atColumn: 0) as? SidebarWorkspaceRowTableCellView + : nil + let fill = workspaceCell?.makeLiftFillLayer(in: rowView) + if let workspaceCell { + workspaceCell.setLiftFillHidden(true) + workspaceCell.displayIfNeeded() + } + if rowView.isFlipped { + // Row views are flipped; render upright into the bitmap. + context.translateBy(x: 0, y: size.height) + context.scaleBy(x: 1, y: -1) + } + rowLayer.render(in: context) + workspaceCell?.setLiftFillHidden(false) + guard let image = context.makeImage() else { continue } + let snapshot = CALayer() + snapshot.frame = rowLayer.bounds + snapshot.zPosition = 1000 + let content = CALayer() + content.contents = image + content.contentsScale = scale + content.contentsGravity = .resize + content.frame = snapshot.bounds + if let fill { snapshot.addSublayer(fill) } + snapshot.addSublayer(content) + if workspaceCell != nil { + let wasGrouped = rows.indices.contains(row) && rows[row].groupId != nil + reorderLiftIndent = (fill, content, fill?.frame ?? .zero, wasGrouped, wasGrouped) + } + let cell = rowView.view(atColumn: 0) as? NSView + CATransaction.begin() + CATransaction.setDisableActions(true) + rowLayer.addSublayer(snapshot) + cell?.isHidden = true + CATransaction.commit() + reorderLiftSnapshots.append((rowView, cell, snapshot)) + } + } + + /// Moves the lifted row's indent to match where it would land. + func updateReorderLiftIndent(isGrouped: Bool) { + guard var lift = reorderLiftIndent, lift.isGrouped != isGrouped else { return } + lift.isGrouped = isGrouped + reorderLiftIndent = lift + let indent = SidebarWorkspaceGroupingMetrics.memberIndent + let shift = (isGrouped ? indent : 0) - (lift.wasGrouped ? indent : 0) + CATransaction.begin() + CATransaction.setAnimationDuration(0.18) + CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.25, 0.8, 0.25, 1)) + lift.content.transform = CATransform3DMakeTranslation(shift, 0, 0) + if let fill = lift.fill { + // The right edge stays put; only the leading edge follows. + var frame = lift.fillFrame + frame.origin.x += shift + frame.size.width -= shift + fill.frame = frame + } + CATransaction.commit() + } + + func removeReorderLiftSnapshots() { + reorderLiftIndent = nil + guard !reorderLiftSnapshots.isEmpty else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + for entry in reorderLiftSnapshots { + entry.layer.removeFromSuperlayer() + entry.cell?.isHidden = false + // Drop the lift-only active look; the stored model decides again. + (entry.cell as? SidebarGroupHeaderTableCellView)?.restoreStoredModelPaint() + } + CATransaction.commit() + reorderLiftSnapshots.removeAll() + } + + /// Where the lifted block sits for a pointer at `pointY` (table space), + /// and the y that decides its slot. The probe is the block's leading edge + /// in the direction it moved, not its centre: the block is clamped to the + /// list, so a block taller than the rows below it could never carry its + /// centre past their midpoints and the last slots were unreachable. The + /// drop planner is fed the same probe, so the commit matches the preview. + func reorderLiftPlacement( + _ session: ReorderLiftSession, + pointY: CGFloat, + tableMaxY: CGFloat + ) -> (draggedTop: CGFloat, slotProbeY: CGFloat) { + let blockHeight = session.height(of: session.sourceRange) + let blockTop = session.frames[session.sourceRange.lowerBound].minY + let minTop = session.frames[0].minY + let maxTop = max(minTop, tableMaxY - blockHeight) + let draggedTop = min(max(pointY - session.pointerOffsetY, minTop), maxTop) + let slotProbeY: CGFloat + if draggedTop > blockTop + 0.5 { + slotProbeY = draggedTop + blockHeight - 1 + } else if draggedTop < blockTop - 0.5 { + slotProbeY = draggedTop + 1 + } else { + slotProbeY = draggedTop + blockHeight / 2 + } + return (draggedTop, slotProbeY) + } + + /// Ends the visual session. Animated: rows glide back to their real + /// frames (a cancelled drag). Not animated: transforms drop instantly + /// (the structural apply is about to redraw the true order). + func endReorderLift(animated: Bool) { + guard reorderLiftSession != nil else { return } + reorderLiftSession = nil + removeReorderLiftSnapshots() + guard let containerView else { return } + let table = containerView.tableView + table.reorderPinnedRowsRect = nil + table.enumerateAvailableRowViews { [self] rowView, _ in + guard let layer = rowView.layer else { return } + if animated { + glideRowShift(layer: layer, to: 0) + } else { + layer.removeAnimation(forKey: "cmux.reorderShift") + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.transform = CATransform3DIdentity + CATransaction.commit() + } + layer.zPosition = 0 + } + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index 682093b9f654..cc40770e72aa 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -20,10 +20,10 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS case invalid } - private weak var containerView: SidebarWorkspaceTableContainerView? + private(set) weak var containerView: SidebarWorkspaceTableContainerView? private let createdCellViews = NSHashTable.weakObjects() - private var rows: [SidebarWorkspaceTableRowConfiguration] = [] - private var actions: SidebarWorkspaceTableActions? + private(set) var rows: [SidebarWorkspaceTableRowConfiguration] = [] + var actions: SidebarWorkspaceTableActions? private var deferredRowClick: DeferredRowClick? /// SwiftUI-side wake-up for a parked click. A deferred click only lands /// through the next authoritative apply, and applies only happen when @@ -346,6 +346,11 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS deferredStructuralHeightRows.removeAll() previewBailoutTask?.cancel() previewBailoutTask = nil + // Put lifted cells back before the table leaves this controller. + reorderDropCommitFallbackTask?.cancel() + reorderDropCommitFallbackTask = nil + clearsReorderTransformsOnNextApply = false + endReorderLift(animated: false) widthRemeasureTask?.cancel() widthRemeasureTask = nil let postUpdateActions = preserveProvisionalWorkspaceDrag @@ -796,52 +801,6 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS pureEdit = SidebarWorkspaceTableRowEdit(from: previousIds, to: nextIds) } } - // Pure removal (closing tabs): the surviving rows keep their order, - // at most a few rows disappear. These animate out instead of - // reloading, so a closed tab leaves the way the peek panel does: - // sliding off, not blinking away. - var isSmallPureRemoval = false - var removedRowIndexes = IndexSet() - if hasStructuralChanges, - previousIds.count > nextIds.count, - previousIds.count - nextIds.count <= 3 { - var survivorCursor = 0 - var removed = IndexSet() - for (index, id) in previousIds.enumerated() { - if survivorCursor < nextIds.count, nextIds[survivorCursor] == id { - survivorCursor += 1 - } else { - removed.insert(index) - } - } - if survivorCursor == nextIds.count { - isSmallPureRemoval = true - removedRowIndexes = removed - } - } - // Pure insertion (creating workspaces): the existing rows keep their - // order, at most a few rows appear. These insert in place instead of - // reloading: reloadData tears down every visible cell just to add - // one row, which is both slower to first paint and visually abrupt. - var isSmallPureInsertion = false - var insertedRowIndexes = IndexSet() - if hasStructuralChanges, - nextIds.count > previousIds.count, - nextIds.count - previousIds.count <= 3 { - var existingCursor = 0 - var inserted = IndexSet() - for (index, id) in nextIds.enumerated() { - if existingCursor < previousIds.count, previousIds[existingCursor] == id { - existingCursor += 1 - } else { - inserted.insert(index) - } - } - if existingCursor == previousIds.count { - isSmallPureInsertion = true - insertedRowIndexes = inserted - } - } let requiresAtomicReorderReload = hasStructuralChanges && !heightChanges.isEmpty && isSmallPureReorder // A forced reload follows hidden-presentation pruning, where @@ -889,18 +848,19 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS }() if handsOffReorderTransforms { clearsReorderTransformsOnNextApply = false - } - if hasStructuralChanges && !handsOffReorderTransforms { - // Rows are about to move or be reused under a live drag: put the - // real cells back before the table can hand them to other rows. - removeReorderLiftSnapshots() + reorderDropCommitFallbackTask?.cancel() + reorderDropCommitFallbackTask = nil } if hasStructuralChanges { + // Every branch below moves or reuses rows. A drop commit hands the + // lift over here (the real frames take over in the same runloop + // turn); under a live drag the frozen frames are stale, so the + // next poll tick re-lifts from fresh ones. + endReorderLift(animated: false) if forceTableReload { let table = containerView.tableView let postUpdateActions = detachLoadedCells() performTableGeometryUpdateWithoutAnimation(heightChanges, in: table) { - if handsOffReorderTransforms { endReorderLift(animated: false) } table.reloadData() restoreViewportOrigin(forcedReloadViewportOrigin, in: table) viewportAnchor?.restore(table: table, rows: nextRows) @@ -916,7 +876,6 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS // clipping checklist or notification content. let table = containerView.tableView performTableGeometryUpdateWithoutAnimation(in: table) { - if handsOffReorderTransforms { endReorderLift(animated: false) } table.beginUpdates() var current = previousIds for targetIndex in nextIds.indices where current[targetIndex] != nextIds[targetIndex] { @@ -939,61 +898,31 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS // affected rows; the rest keep their cells. let table = containerView.tableView performTableGeometryUpdateWithoutAnimation(heightChanges, in: table) { - table.beginUpdates() + // A few rows at a time slide in or out (closing or creating + // a workspace); bigger edits (a group collapsing) and edits + // that change heights stay instant. The nested group opts + // back into animation inside the no-animation wrapper. + let editCount: Int switch pureEdit { - case .remove(let indexes): - table.removeRows(at: indexes, withAnimation: []) - case .insert(let indexes): - table.insertRows(at: indexes, withAnimation: []) + case .remove(let indexes), .insert(let indexes): editCount = indexes.count } - table.endUpdates() - // Per-index state (shortcut digits, first-row flag, group - // counts) shifts with the edit even for rows whose own - // content did not; configure skips cells whose model is equal. - reconfigureLoadedRows(in: table) - } - } else if heightChanges.isEmpty, isSmallPureInsertion { - // In-place insert: existing cells stay alive, the new row - // arrives with a short fade-slide, and first paint beats the - // reload path because nothing else is rebuilt. - let table = containerView.tableView - performTableGeometryUpdateWithoutAnimation(in: table) { + let animates = heightChanges.isEmpty && editCount <= 3 NSAnimationContext.runAnimationGroup { context in - context.duration = 0.22 + context.duration = animates ? 0.22 : 0 context.timingFunction = CAMediaTimingFunction(controlPoints: 0.3, 1.0, 0.4, 1.0) table.beginUpdates() - table.insertRows(at: insertedRowIndexes, withAnimation: [.slideDown, .effectFade]) + switch pureEdit { + case .remove(let indexes): + table.removeRows(at: indexes, withAnimation: animates ? [.slideLeft, .effectFade] : []) + case .insert(let indexes): + table.insertRows(at: indexes, withAnimation: animates ? [.slideDown, .effectFade] : []) + } table.endUpdates() } - let visible = table.rows(in: table.visibleRect) - if visible.length > 0 { - reconfigureVisibleRows( - IndexSet(integersIn: visible.lowerBound..<(visible.lowerBound + visible.length)) - ) - } - } - } else if heightChanges.isEmpty, isSmallPureRemoval { - // Animated close: the row slides out and its neighbours take - // up the space, all inside one update so the data source and - // the table never disagree. - let table = containerView.tableView - performTableGeometryUpdateWithoutAnimation(in: table) { - // Nested group: the outer wrapper zeroes the context so - // structural churn stays snap; the removal opts back in - // with its own duration for the slide-out. - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.25 - context.timingFunction = CAMediaTimingFunction(controlPoints: 0.3, 1.0, 0.4, 1.0) - table.beginUpdates() - table.removeRows(at: removedRowIndexes, withAnimation: [.slideLeft, .effectFade]) - table.endUpdates() - } - let visible = table.rows(in: table.visibleRect) - if visible.length > 0 { - reconfigureVisibleRows( - IndexSet(integersIn: visible.lowerBound..<(visible.lowerBound + visible.length)) - ) - } + // Per-index state (shortcut digits, first-row flag, group + // counts) shifts with the edit even for rows whose own + // content did not; configure skips cells whose model is equal. + reconfigureLoadedRows(in: table) } } else { let table = containerView.tableView @@ -1013,9 +942,6 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } mutationScheduler.stagePostUpdateActions(postUpdateActions) } - } - if let reorderSettleOrigin { - settleReorderedBlock(ids: reorderSettleOrigin.ids, fromVisualTop: reorderSettleOrigin.visualTop) } else if forceTableReload { let table = containerView.tableView let postUpdateActions = detachLoadedCells() @@ -1036,6 +962,9 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS ) } } + if let reorderSettleOrigin { + settleReorderedBlock(ids: reorderSettleOrigin.ids, fromVisualTop: reorderSettleOrigin.visualTop) + } if hasStructuralChanges || !contentChanges.isEmpty { SidebarNavigationTimings.lapPendingSwitch("applyReached") @@ -1438,19 +1367,6 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS clearPendingWorkspaceDragWriters() return } - // Capture the pointer's offset from the source header before any - // selection/state update can rebuild the table. The first visual poll - // may arrive after that rebuild, which otherwise makes a group header - // slip away from the point where the drag started. - reorderDragStartWorkspaceId = workspaceId - if let sourceRow = draggedRows.first, - rows.indices.contains(sourceRow), - let windowPoint = tableView.window?.convertPoint(fromScreen: screenPoint) { - let tablePoint = tableView.convert(windowPoint, from: nil) - reorderDragStartPointerOffsetY = tablePoint.y - tableView.rect(ofRow: sourceRow).minY - } else { - reorderDragStartPointerOffsetY = nil - } if isWorkspaceDragSourceActive { // A second begin callback with the same session is AppKit's // reconstruction duplicate. Any distinct session is an @@ -1474,6 +1390,26 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS guard !isWorkspaceDragSourceActive else { return } } + // A new drag starts from a clean lift: a drop whose commit is still in + // flight gives up its hand-off, and any lift left over ends. + reorderDropCommitFallbackTask?.cancel() + reorderDropCommitFallbackTask = nil + clearsReorderTransformsOnNextApply = false + endReorderLift(animated: false) + // Capture the pointer's offset from the source header before any + // selection/state update can rebuild the table; the first poll may + // arrive after that rebuild. After the duplicate check, because a + // superseded session's end clears these. + reorderDragStartWorkspaceId = workspaceId + if let sourceRow = draggedRows.first, + rows.indices.contains(sourceRow), + let windowPoint = tableView.window?.convertPoint(fromScreen: screenPoint) { + let tablePoint = tableView.convert(windowPoint, from: nil) + reorderDragStartPointerOffsetY = tablePoint.y - tableView.rect(ofRow: sourceRow).minY + } else { + reorderDragStartPointerOffsetY = nil + } + // Establish table ownership before the first action closure can publish // SwiftUI state. A publish may synchronously dismantle this // representable; the exact table/session pair must already be retained @@ -1589,55 +1525,6 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } } - private func workspaceDragImage( - tableView: NSTableView, - row: Int, - size: NSSize, - count: Int - ) -> NSImage? { - let rowRect = tableView.rect(ofRow: row) - guard rowRect.width > 0, - rowRect.height > 0, - size.width > 0, - size.height > 0, - let representation = tableView.bitmapImageRepForCachingDisplay(in: rowRect) else { - return nil - } - tableView.cacheDisplay(in: rowRect, to: representation) - let rowImage = NSImage(size: rowRect.size) - rowImage.addRepresentation(representation) - let badgeColor = (AppDelegate.shared?.accentColor ?? CmuxAccentColor()).nsColor(for: tableView.effectiveAppearance) - - return NSImage(size: size, flipped: false) { bounds in - rowImage.draw(in: bounds) - - let badgeDiameter: CGFloat = 18 - let badgeInset: CGFloat = 2 - let badgeRect = NSRect( - x: bounds.maxX - badgeDiameter - badgeInset, - y: bounds.maxY - badgeDiameter - badgeInset, - width: badgeDiameter, - height: badgeDiameter - ) - badgeColor.setFill() - NSBezierPath(ovalIn: badgeRect).fill() - - let countText = "\(count)" as NSString - let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 10, weight: .semibold), - .foregroundColor: NSColor.white, - ] - let textSize = countText.size(withAttributes: attributes) - countText.draw( - at: NSPoint( - x: badgeRect.midX - (textSize.width / 2), - y: badgeRect.midY - (textSize.height / 2) - ), - withAttributes: attributes - ) - return true - } - } func tableView( _ tableView: NSTableView, @@ -1941,207 +1828,48 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS return dragWorkspaceGroupAnchorIds?[groupId] } - // MARK: Workspace reorder drop + // MARK: Workspace reorder drop (behaviour in +ReorderDrop.swift) /// Window-space location of the live reorder drag. Present only between /// an accepted overlay update and the drop/exit/end that retires it; while /// present, every viewport change re-plans against it so the indicator /// tracks rows sliding under a stationary pointer during edge autoscroll. - private var reorderDragWindowPoint: NSPoint? + var reorderDragWindowPoint: NSPoint? /// Controller-owned indicator paint for the live reorder drag. The plan /// result deliberately never enters the SwiftUI drag state (that rebuilds /// every sidebar row per gap change and made the line lag the pointer); /// the controller paints the affected cells directly instead. - private var reorderIndicatorPainter: SidebarWorkspaceTableReorderIndicatorPainter? + var reorderIndicatorPainter: SidebarWorkspaceTableReorderIndicatorPainter? /// Workspace id parsed from the live drag pasteboard by the overlay. /// Confirms the coordinator session during reconstruction; it cannot create /// a session when the native source has already completed. - private var reorderDragPayloadWorkspaceId: UUID? + var reorderDragPayloadWorkspaceId: UUID? /// Source-header pointer offset captured at AppKit drag start. This is /// stable across the selection update that can rebuild the table before /// the first lift poll arrives. - private var reorderDragStartWorkspaceId: UUID? - private var reorderDragStartPointerOffsetY: CGFloat? + private(set) var reorderDragStartWorkspaceId: UUID? + + private(set) var reorderDragStartPointerOffsetY: CGFloat? /// The plan whose indicator is currently painted. The drop commits this /// plan verbatim so the outcome always matches the line the user saw; /// re-resolving at release time could pick a different gap (pointer /// drift after the last drag update, or an autoscroll tick landing /// before the coalesced repaint). - private var lastAcceptedReorderDropPlan: SidebarWorkspaceReorderDropPlan? + var lastAcceptedReorderDropPlan: SidebarWorkspaceReorderDropPlan? /// One-shot: the next apply comes from this window's own drop, so the /// selected-workspace scroll policy must not yank the viewport away from /// the release position. - private var suppressSelectedScrollAfterLocalDrop = false - - private func performReorderDrop( - point: CGPoint, - targets: [SidebarWorkspaceReorderDropOverlay.Target], - payloadWorkspaceId: UUID? - ) -> Bool { - reorderDragWindowPoint = nil - guard let actions else { - retireReorderIndicator() - return false - } - let performed: Bool - let commitSource: String - if let plan = lastAcceptedReorderDropPlan { - // Commit exactly what the indicator showed. - performed = actions.commitWorkspaceDropPlan(plan) - commitSource = "paintedPlan" - } else { - // No accepted hover plan exists: resolve from the release point. - performed = actions.performWorkspaceDrop(point, targets, payloadWorkspaceId) - commitSource = "releasePoint" - } -#if DEBUG - // Every silent "the workspace I dragged didn't move" report needs - // this line: where the drop landed, which commit source ran, and - // whether the shared planner accepted it. - cmuxDebugLog( - "sidebar.drop.perform point=(\(Int(point.x)),\(Int(point.y))) " + - "source=\(commitSource) performed=\(performed ? 1 : 0)" - ) -#endif - // A handled drop on the block's own slot moves nothing, so no - // structural apply will come to take the transforms over. The lift - // knows: no other row is displaced. Glide home now, rather than - // waiting (non-structural applies can land first after any drop). - let liftShowsMove = reorderLiftSession?.appliedTargets.contains { $0 != 0 } ?? false - if performed && !liftShowsMove { - suppressSelectedScrollAfterLocalDrop = true - endReorderLift(animated: true) - } else if performed { - suppressSelectedScrollAfterLocalDrop = true - // The rows already stand at their new positions via transforms; - // the apply that lands the committed order clears them in the - // same update, so the real frames take over in place. - clearsReorderTransformsOnNextApply = true - // Safety net if the committed order never arrives; don't leave - // the rows frozen where the lift drew them. - reorderDropCommitGeneration &+= 1 - let generation = reorderDropCommitGeneration - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [weak self] in - guard let self, - self.reorderDropCommitGeneration == generation, - self.clearsReorderTransformsOnNextApply else { return } - self.clearsReorderTransformsOnNextApply = false - self.endReorderLift(animated: true) - } - } else { - endReorderLift(animated: true) - } - retireReorderIndicator() - return performed - } - - func reorderDropDragExited() { -#if DEBUG - cmuxDebugLog("sidebar.reorder.exited") -#endif - // Visual-inert on purpose: destination exit fires at churn rate - // whenever the plan goes nil (the overlay's operation flips to none - // and AppKit re-resolves the destination). The poll owns the - // visuals; the session ends only at endedAt. - reorderDragPayloadWorkspaceId = nil - guard reorderDragWindowPoint != nil || reorderIndicatorPainter != nil else { return } - reorderDragWindowPoint = nil - retireReorderIndicator() - } - - /// Runs the shared reorder planner for a drag hovering at `windowPoint` - /// and paints the resulting indicator. An accepted position is remembered - /// (window space) so viewport changes can re-plan it; a rejected one - /// stops the re-plan loop until the pointer produces a new overlay update. - @discardableResult - func updateReorderDrag(windowPoint: NSPoint) -> Bool { - guard let dropView = containerView?.reorderDropView else { - reorderDragWindowPoint = nil - retireReorderIndicator() - return false - } - let targets = refreshReorderDropTargets() - return updateReorderDrag( - point: dropView.convert(windowPoint, from: nil), - targets: targets, - windowPoint: windowPoint, - payloadWorkspaceId: reorderDragPayloadWorkspaceId - ) - } - - private func updateReorderDrag( - point: CGPoint, - targets: [SidebarWorkspaceReorderDropOverlay.Target], - windowPoint: NSPoint, - payloadWorkspaceId: UUID? - ) -> Bool { - guard let actions else { - reorderDragWindowPoint = nil - retireReorderIndicator() - return false - } - let reorderTickStart = ProcessInfo.processInfo.systemUptime - defer { - SidebarNavigationTimings.recordSampledTick("reorder.tick", startUptime: reorderTickStart) - } - reorderDragPayloadWorkspaceId = payloadWorkspaceId - // Plan at the lifted block's slot probe, not the raw pointer, so the - // committed gap is the one the displaced rows are showing. - var plannerPoint = point - if let session = reorderLiftSession, let containerView { - let table = containerView.tableView - let tablePoint = table.convert(windowPoint, from: nil) - let placement = reorderLiftPlacement(session, pointY: tablePoint.y, tableMaxY: table.bounds.maxY) - let probeWindowPoint = table.convert(NSPoint(x: tablePoint.x, y: placement.slotProbeY), to: nil) - plannerPoint = containerView.reorderDropView.convert(probeWindowPoint, from: nil) - } - guard !targets.isEmpty, - let update = actions.updateWorkspaceDrag( - plannerPoint, - targets, - payloadWorkspaceId - ) - else { - reorderDragWindowPoint = nil - retireReorderIndicator() -#if DEBUG - cmuxDebugLog("sidebar.reorder.plan-nil payload=\(payloadWorkspaceId?.uuidString.prefix(5) ?? "none")") -#endif - // No legal gap at this point (commonly: hovering the dragged - // row's own slot). Harmless: the poll owns the visuals, and this - // callback owns only the plan. - return false - } - // The plan is still tracked exactly as stock (the release commits - // it), but it is drawn as displacement, not indicator lines: the - // rows shifting around the lifted row are the drop preview. - lastAcceptedReorderDropPlan = update.plan - if case .reorder(_, _, let explicitGroupId)? = update.plan?.action { - updateReorderLiftIndent(isGrouped: explicitGroupId != nil) - } - reorderDragWindowPoint = windowPoint -#if DEBUG - cmuxDebugLog("sidebar.reorder.tick y=\(Int(point.y))") -#endif - return true - } - - private func retireReorderIndicator() { - lastAcceptedReorderDropPlan = nil - guard reorderIndicatorPainter != nil else { return } - reorderIndicatorPainter = nil - clearReorderIndicatorPaintOnVisibleCells() - actions?.clearWorkspaceDropIndicator() - setAppKitDropIndicator(nil, scope: .raw, includeRowTargets: false) - } + var suppressSelectedScrollAfterLocalDrop = false // MARK: Freeform reorder visuals + // Lift state; the behaviour is in SidebarWorkspaceTableController+ReorderLift.swift. + /// Pointer poll driving the drag visuals. /// /// A 120Hz timer reading `NSEvent.mouseLocation`, not any AppKit drag @@ -2154,551 +1882,34 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// /// `nonisolated(unsafe)` so deinit can invalidate; every other access is /// main-actor. - private nonisolated(unsafe) var reorderPollTimer: Timer? - private var reorderPollWorkspaceId: UUID? + nonisolated(unsafe) var reorderPollTimer: Timer? - private func startReorderPoll(workspaceId: UUID) { - stopReorderPoll() - reorderPollWorkspaceId = workspaceId - let timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] _ in - MainActor.assumeIsolated { - self?.reorderPollTick() - } - } - // .common so the timer keeps firing inside the drag's event-tracking - // loop, which is the only time it exists. - RunLoop.main.add(timer, forMode: .common) - reorderPollTimer = timer - } - - private func stopReorderPoll() { - reorderPollTimer?.invalidate() - reorderPollTimer = nil - reorderPollWorkspaceId = nil - } + var reorderPollWorkspaceId: UUID? - private func reorderPollTick() { - guard let workspaceId = reorderPollWorkspaceId, - let containerView, - let window = containerView.tableView.window else { return } - let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) - let table = containerView.tableView - let point = table.convert(windowPoint, from: nil) - // Outside the sidebar horizontally (a cross-window or into-terminal - // excursion): freeze the shift state rather than keep reacting to a - // pointer that is no longer about this list. The session ends only - // at endedAt. - guard point.x >= -48, point.x <= table.bounds.width + 48 else { return } - updateReorderLift(windowPoint: windowPoint, workspaceId: workspaceId) - } - - /// Frozen drag geometry. Captured once when the drag first hovers, and - /// never updated until the drag ends: every visual decision during the - /// drag is made against these frames, so displacement boundaries cannot - /// move mid-drag and the interaction cannot oscillate. The model is not - /// touched until release; everything the user sees during the drag is - /// layer transforms over an unchanged table. - private struct ReorderLiftSession { - let workspaceId: UUID - /// Rows travelling with the pointer: one row, or a group header plus - /// its visible members. A group's header mints its anchor's id (see - /// `pasteboardWriterForRow`), so an anchor drag is a whole-group move - /// and the block is what the hand is holding. - let sourceRange: Range - /// Every other row, grouped into the units that shift as one. A row - /// drag parts single rows (a row can slot inside a group); a group - /// drag parts whole groups (a group only reorders among top-level - /// units), so a header never separates from its members mid-drag. - let otherUnits: [Range] - let frames: [CGRect] - /// Vertical gap between adjacent rows, so re-laid units keep it. - let rowSpacing: CGFloat - /// Pointer offset from the top of the dragged block at pickup. Keep - /// this stable so a multi-row group does not jump to center itself - /// under the pointer when the lift starts. - let pointerOffsetY: CGFloat - /// The applied translation target per row index, so a row only - /// animates when its target actually flips. - var appliedTargets: [CGFloat] - - func height(of unit: Range) -> CGFloat { - frames[unit].reduce(0) { $0 + $1.height } + rowSpacing * CGFloat(max(0, unit.count - 1)) - } - } - - /// The row range a drag of `workspaceId` carries: a group header plus - /// its visible members when the id is a group anchor, else the one row. - private func reorderSourceRange(for workspaceId: UUID) -> Range? { - guard let first = rows.firstIndex(where: { $0.workspaceId == workspaceId }) else { return nil } - guard rows[first].isGroupHeader, let groupId = rows[first].groupId else { - return first..<(first + 1) - } - return groupBlockRange(headerIndex: first, groupId: groupId) - } - - /// Header row plus the contiguous member rows below it. A collapsed - /// group shows no members, so its block is the header alone. - private func groupBlockRange(headerIndex: Int, groupId: UUID) -> Range { - var end = headerIndex + 1 - while end < rows.count, !rows[end].isGroupHeader, rows[end].groupId == groupId { - end += 1 - } - return headerIndex.., wholeGroups: Bool) -> [Range] { - var units: [Range] = [] - var index = 0 - while index < rows.count { - if source.contains(index) { - index = source.upperBound - continue - } - if wholeGroups, rows[index].isGroupHeader, let groupId = rows[index].groupId { - let block = groupBlockRange(headerIndex: index, groupId: groupId) - units.append(block) - index = block.upperBound - } else { - units.append(index..<(index + 1)) - index += 1 - } - } - return units - } + var reorderLiftSession: ReorderLiftSession? - private var reorderLiftSession: ReorderLiftSession? /// Set at drop-commit time: the structural apply that lands the new /// order clears every drag transform in the same update, so the rows' /// real frames take over exactly where the transforms left them. - private var clearsReorderTransformsOnNextApply = false - /// Identifies the latest committed drop for its no-apply fallback. - private var reorderDropCommitGeneration = 0 + var clearsReorderTransformsOnNextApply = false - /// Glides a row's vertical shift to `target` with a soft spring. - /// - /// Explicit animation on purpose: view-backing layers on macOS ignore - /// implicit CATransaction animations (NSView's layer delegate returns - /// no action), so transform changes here otherwise land instantly no - /// matter what the transaction says. Starting from the presentation - /// layer's live value means a retarget mid-glide continues from where - /// the row visually is instead of jumping. - private func glideRowShift(layer: CALayer, to target: CGFloat) { - let currentY = (layer.presentation() ?? layer) - .value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 - let spring = CASpringAnimation(keyPath: "transform.translation.y") - spring.fromValue = currentY - spring.toValue = target - spring.mass = 1 - spring.stiffness = 300 - spring.damping = 30 - spring.duration = spring.settlingDuration - layer.add(spring, forKey: "cmux.reorderShift") - CATransaction.begin() - CATransaction.setDisableActions(true) - layer.transform = CATransform3DMakeTranslation(0, target, 0) - CATransaction.commit() - } - - /// Drives the freeform drag visuals for the pointer at `windowPoint`. - func updateReorderLift(windowPoint: NSPoint, workspaceId: UUID) { - guard let containerView else { return } - let table = containerView.tableView - let point = table.convert(windowPoint, from: nil) - - if reorderLiftSession?.workspaceId != workspaceId { - endReorderLift(animated: false) - // A quick re-grab can land while the last drop is still settling. - // Finish those glides first: an in-flight animation overrides the - // transforms the lift sets, so the new drag would lag or snap. - table.enumerateAvailableRowViews { rowView, _ in - guard let layer = rowView.layer, - layer.animation(forKey: "cmux.reorderShift") != nil else { return } - CATransaction.begin() - CATransaction.setDisableActions(true) - layer.removeAnimation(forKey: "cmux.reorderShift") - layer.transform = CATransform3DIdentity - layer.zPosition = 0 - CATransaction.commit() - } - guard let sourceRange = reorderSourceRange(for: workspaceId) else { return } - let frames = (0.. 0 else { return } - let isGroupDrag = rows[sourceRange.lowerBound].isGroupHeader - reorderLiftSession = ReorderLiftSession( - workspaceId: workspaceId, - sourceRange: sourceRange, - otherUnits: reorderUnits(excluding: sourceRange, wholeGroups: isGroupDrag), - frames: frames, - rowSpacing: frames.count > 1 ? max(0, frames[1].minY - frames[0].maxY) : 0, - pointerOffsetY: reorderDragStartWorkspaceId == workspaceId - ? (reorderDragStartPointerOffsetY ?? point.y - frames[sourceRange.lowerBound].minY) - : point.y - frames[sourceRange.lowerBound].minY, - appliedTargets: Array(repeating: 0, count: rows.count) - ) - table.reorderPinnedRowsRect = sourceRange.reduce(CGRect.null) { $0.union(frames[$1]) } - installReorderLiftSnapshots(table: table, sourceRange: sourceRange) - } - guard var session = reorderLiftSession else { return } - - // Keep the same pointer-to-block offset captured at pickup. Centering - // a multi-row block under the pointer makes a group jump by roughly - // half its height as soon as the lift begins. Clamp the block top so - // it stays within the list while preserving that offset everywhere - // else. - let blockTop = session.frames[session.sourceRange.lowerBound].minY - let blockFrame = session.sourceRange.reduce(CGRect.null) { $0.union(session.frames[$1]) } - table.reorderPinnedRowsRect = blockFrame - let placement = reorderLiftPlacement(session, pointY: point.y, tableMaxY: table.bounds.maxY) - let draggedTop = placement.draggedTop - let blockShift = draggedTop - blockTop - - // Insertion slot from frozen midpoints: how many other units sit - // above the block's slot probe (see `reorderLiftPlacement`). - let insertion = session.otherUnits.filter { unit in - let top = session.frames[unit.lowerBound].minY - return top + session.height(of: unit) / 2 < placement.slotProbeY - }.count - - // Re-lay every unit in drop order and read each row's offset from - // where it would land. Exact for mixed heights (a header is not a - // row) and for blocks, where a plain one-row-height shift is wrong. - var order = session.otherUnits - order.insert(session.sourceRange, at: insertion) - var targets = Array(repeating: CGFloat(0), count: session.frames.count) - var nextTop = session.frames[0].minY - for unit in order { - if unit != session.sourceRange { - let delta = nextTop - session.frames[unit.lowerBound].minY - for row in unit { targets[row] = delta } - } - nextTop += session.height(of: unit) + session.rowSpacing - } - - table.enumerateAvailableRowViews { rowView, row in - guard session.frames.indices.contains(row) else { return } - rowView.wantsLayer = true - guard let layer = rowView.layer else { return } - if session.sourceRange.contains(row) { - // Pointer-driven, never animated: any smoothing here reads - // as the block lagging the hand. - CATransaction.begin() - CATransaction.setDisableActions(true) - layer.zPosition = 100 - layer.transform = CATransform3DMakeTranslation(0, blockShift, 0) - CATransaction.commit() - return - } - let target = targets[row] - if session.appliedTargets[row] != target { - session.appliedTargets[row] = target - layer.zPosition = 0 - glideRowShift(layer: layer, to: target) - } else if layer.animation(forKey: "cmux.reorderShift") == nil { - // Reused row views arrive with whatever transform their - // previous tenant had; keep visible rows pinned to their - // current target every tick (but never stamp over a glide - // that is still in flight). - CATransaction.begin() - CATransaction.setDisableActions(true) - layer.transform = CATransform3DMakeTranslation(0, target, 0) - CATransaction.commit() - } - } - reorderLiftSession = session - } - - /// Glides the just-dropped block from where it stood under the pointer - /// into its committed slot. The other rows already stand at their new - /// places (the lift showed them there), so only the block moves. - private func settleReorderedBlock(ids: [SidebarWorkspaceRenderItemID], fromVisualTop visualTop: CGFloat) { - guard let table = containerView?.tableView, - let firstId = ids.first, - let firstIndex = rows.firstIndex(where: { $0.id == firstId }) else { return } - let delta = visualTop - table.rect(ofRow: firstIndex).minY - guard abs(delta) > 0.5 else { return } - let idSet = Set(ids) - for (index, row) in rows.enumerated() where idSet.contains(row.id) { - guard let layer = table.rowView(atRow: index, makeIfNecessary: false)?.layer else { continue } - // Explicit start: the presentation layer still holds the lift's - // old offset (relative to the row's old frame) until the next - // frame, so reading it, as `glideRowShift` does, would start the - // glide from the wrong place and the block would jump first. - // A plain ease-out, not a spring: the block can land up to a row - // away from its slot, and a spring's fast start and long tail - // read as a snap over that distance. Longer travel, longer glide. - let settle = CABasicAnimation(keyPath: "transform.translation.y") - settle.fromValue = delta - settle.toValue = 0 - settle.duration = min(0.3, 0.18 + abs(delta) / 700) - settle.timingFunction = CAMediaTimingFunction(controlPoints: 0.25, 0.8, 0.25, 1) - CATransaction.begin() - CATransaction.setDisableActions(true) - layer.removeAnimation(forKey: "cmux.reorderShift") - layer.transform = CATransform3DIdentity - layer.zPosition = 100 - layer.add(settle, forKey: "cmux.reorderShift") - CATransaction.commit() - } - } + /// The committed drop's no-apply fallback (see `performReorderDrop`). + var reorderDropCommitFallbackTask: Task? /// Static images of the lifted rows, shown in place of the live cells for /// the whole drag. AppKit does not redraw views outside the visible rect, /// so once autoscroll moves a lifted row's real frame out of view, any /// redraw (a reconfigure, a hover sweep) leaves it blank. The images are /// taken at pickup, while the rows are drawn, and never need redrawing. - private var reorderLiftSnapshots: [(rowView: NSTableRowView, cell: NSView?, layer: CALayer)] = [] + var reorderLiftSnapshots: [(rowView: NSTableRowView, cell: NSView?, layer: CALayer)] = [] /// A lifted single workspace row, split into its fill and its content so /// the indent can follow the planned drop: full width when it would land /// outside a group, indented when inside one. - private var reorderLiftIndent: ( + var reorderLiftIndent: ( fill: CALayer?, content: CALayer, fillFrame: CGRect, wasGrouped: Bool, isGrouped: Bool )? - private func installReorderLiftSnapshots(table: NSTableView, sourceRange: Range) { - removeReorderLiftSnapshots() - let scale = table.window?.backingScaleFactor ?? 2 - guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { return } - for row in sourceRange { - guard let rowView = table.rowView(atRow: row, makeIfNecessary: false), - let rowLayer = rowView.layer else { continue } - // Optionally, a lifted group header wears its active (selected) - // look for the whole drag. Off by default; try it live with - // `defaults write sidebarDragHeaderActiveLook -bool true`. - if UserDefaults.standard.bool(forKey: "sidebarDragHeaderActiveLook"), - let header = rowView.view(atColumn: 0) as? SidebarGroupHeaderTableCellView { - header.showOptimisticAnchorActive() - header.displayIfNeeded() - } - let size = rowLayer.bounds.size - guard size.width > 0, size.height > 0, - let context = CGContext( - data: nil, - width: Int((size.width * scale).rounded(.up)), - height: Int((size.height * scale).rounded(.up)), - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { continue } - context.scaleBy(x: scale, y: scale) - // A single workspace row: lift its fill out as its own layer and - // snapshot the content without it (see `reorderLiftIndent`). - let workspaceCell = sourceRange.count == 1 - ? rowView.view(atColumn: 0) as? SidebarWorkspaceRowTableCellView - : nil - let fill = workspaceCell?.makeLiftFillLayer(in: rowView) - if let workspaceCell { - workspaceCell.setLiftFillHidden(true) - workspaceCell.displayIfNeeded() - } - if rowView.isFlipped { - // Row views are flipped; render upright into the bitmap. - context.translateBy(x: 0, y: size.height) - context.scaleBy(x: 1, y: -1) - } - rowLayer.render(in: context) - workspaceCell?.setLiftFillHidden(false) - guard let image = context.makeImage() else { continue } - let snapshot = CALayer() - snapshot.frame = rowLayer.bounds - snapshot.zPosition = 1000 - let content = CALayer() - content.contents = image - content.contentsScale = scale - content.contentsGravity = .resize - content.frame = snapshot.bounds - if let fill { snapshot.addSublayer(fill) } - snapshot.addSublayer(content) - if workspaceCell != nil { - let wasGrouped = rows.indices.contains(row) && rows[row].groupId != nil - reorderLiftIndent = (fill, content, fill?.frame ?? .zero, wasGrouped, wasGrouped) - } - let cell = rowView.view(atColumn: 0) as? NSView - CATransaction.begin() - CATransaction.setDisableActions(true) - rowLayer.addSublayer(snapshot) - cell?.isHidden = true - CATransaction.commit() - reorderLiftSnapshots.append((rowView, cell, snapshot)) - } - } - - /// Moves the lifted row's indent to match where it would land. - private func updateReorderLiftIndent(isGrouped: Bool) { - guard var lift = reorderLiftIndent, lift.isGrouped != isGrouped else { return } - lift.isGrouped = isGrouped - reorderLiftIndent = lift - let indent = SidebarWorkspaceGroupingMetrics.memberIndent - let shift = (isGrouped ? indent : 0) - (lift.wasGrouped ? indent : 0) - CATransaction.begin() - CATransaction.setAnimationDuration(0.18) - CATransaction.setAnimationTimingFunction(CAMediaTimingFunction(controlPoints: 0.25, 0.8, 0.25, 1)) - lift.content.transform = CATransform3DMakeTranslation(shift, 0, 0) - if let fill = lift.fill { - // The right edge stays put; only the leading edge follows. - var frame = lift.fillFrame - frame.origin.x += shift - frame.size.width -= shift - fill.frame = frame - } - CATransaction.commit() - } - - private func removeReorderLiftSnapshots() { - reorderLiftIndent = nil - guard !reorderLiftSnapshots.isEmpty else { return } - CATransaction.begin() - CATransaction.setDisableActions(true) - for entry in reorderLiftSnapshots { - entry.layer.removeFromSuperlayer() - entry.cell?.isHidden = false - // Drop the lift-only active look; the stored model decides again. - (entry.cell as? SidebarGroupHeaderTableCellView)?.restoreStoredModelPaint() - } - CATransaction.commit() - reorderLiftSnapshots.removeAll() - } - - /// Where the lifted block sits for a pointer at `pointY` (table space), - /// and the y that decides its slot. The probe is the block's leading edge - /// in the direction it moved, not its centre: the block is clamped to the - /// list, so a block taller than the rows below it could never carry its - /// centre past their midpoints and the last slots were unreachable. The - /// drop planner is fed the same probe, so the commit matches the preview. - private func reorderLiftPlacement( - _ session: ReorderLiftSession, - pointY: CGFloat, - tableMaxY: CGFloat - ) -> (draggedTop: CGFloat, slotProbeY: CGFloat) { - let blockHeight = session.height(of: session.sourceRange) - let blockTop = session.frames[session.sourceRange.lowerBound].minY - let minTop = session.frames[0].minY - let maxTop = max(minTop, tableMaxY - blockHeight) - let draggedTop = min(max(pointY - session.pointerOffsetY, minTop), maxTop) - let slotProbeY: CGFloat - if draggedTop > blockTop + 0.5 { - slotProbeY = draggedTop + blockHeight - 1 - } else if draggedTop < blockTop - 0.5 { - slotProbeY = draggedTop + 1 - } else { - slotProbeY = draggedTop + blockHeight / 2 - } - return (draggedTop, slotProbeY) - } - - /// Ends the visual session. Animated: rows glide back to their real - /// frames (a cancelled drag). Not animated: transforms drop instantly - /// (the structural apply is about to redraw the true order). - private func endReorderLift(animated: Bool) { - guard reorderLiftSession != nil else { return } - reorderLiftSession = nil - removeReorderLiftSnapshots() - guard let containerView else { return } - let table = containerView.tableView - table.reorderPinnedRowsRect = nil - table.enumerateAvailableRowViews { [self] rowView, _ in - guard let layer = rowView.layer else { return } - if animated { - glideRowShift(layer: layer, to: 0) - } else { - layer.removeAnimation(forKey: "cmux.reorderShift") - CATransaction.begin() - CATransaction.setDisableActions(true) - layer.transform = CATransform3DIdentity - CATransaction.commit() - } - layer.zPosition = 0 - } - } - - private func enforceReorderIndicatorPaintOnVisibleCells() { - guard reorderIndicatorPainter != nil else { return } - sweepReorderIndicatorPaint(reorderIndicatorPainter) - } - - private func clearReorderIndicatorPaintOnVisibleCells() { - sweepReorderIndicatorPaint(nil) - } - - /// A nil painter clears every visible drop line, which is only safe here - /// because reorder and bonsplit drags cannot overlap: outside a reorder - /// drag the row models carry `false` for both flags, so clearing matches - /// what the next configure would apply anyway. - private func sweepReorderIndicatorPaint( - _ painter: SidebarWorkspaceTableReorderIndicatorPainter? - ) { - guard let table = containerView?.tableView else { return } - let visible = table.rows(in: table.visibleRect) - guard visible.length > 0 else { return } - for row in visible.lowerBound..<(visible.lowerBound + visible.length) - where rows.indices.contains(row) { - let paint = painter?.paint(forRowWorkspaceId: rows[row].workspaceId) - ?? (top: false, bottom: false) - switch table.view(atColumn: 0, row: row, makeIfNecessary: false) { - case let cell as SidebarWorkspaceRowTableCellView: - cell.paintControllerDropIndicator(top: paint.top, bottom: paint.bottom) - case let cell as SidebarGroupHeaderTableCellView: - cell.paintControllerDropIndicator(top: paint.top, bottom: paint.bottom) - default: - break - } - } - } - - /// Refreshes visible-row targets in the overlay's coordinate space. - @discardableResult - private func refreshReorderDropTargets() -> [SidebarWorkspaceReorderDropOverlay.Target] { - guard let container = containerView else { return [] } - let table = container.tableView - let visibleRange = table.rows(in: table.visibleRect) - guard visibleRange.location != NSNotFound, visibleRange.length > 0 else { - clearReorderDropTargets() - return [] - } - let lower = max(0, visibleRange.location) - let upper = min(rows.count, visibleRange.location + visibleRange.length) - guard lower < upper else { - clearReorderDropTargets() - return [] - } - let targets = (lower.. UUID? { - let type = NSPasteboard.PasteboardType(SidebarTabDragPayload.typeIdentifier) - let raw = pasteboard.string(forType: type) - ?? pasteboard.data(forType: type).flatMap { String(data: $0, encoding: .utf8) } - let parsed = SidebarTabDragPayload.workspaceId(fromPasteboardString: raw) -#if DEBUG - cmuxDebugLog( - "sidebar.drop.payload raw=\(raw.map { String($0.prefix(24)) } ?? "nil") " + - "parsed=\(parsed.map { String($0.uuidString.prefix(5)) } ?? "nil")" - ) -#endif - return parsed - } /// Optimistic press highlight: paints the clicked workspace cell as /// selected immediately and, for a plain click, peels the highlight off @@ -2776,7 +1987,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS private var applyGeneration: UInt64 = 0 private var previewBailoutTask: Task? - private let previewBailoutClock = ContinuousClock() + let previewBailoutClock = ContinuousClock() /// Rows whose cells carry optimistic paint. apply()'s reconcile diff only /// reconfigures rows whose MODEL changed, and a preview on a row whose /// authoritative state ends up unchanged (modifier mismatch, replaced @@ -3739,7 +2950,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } } - private func setAppKitDropIndicator( + func setAppKitDropIndicator( _ indicator: SidebarDropIndicator?, scope: SidebarWorkspaceReorderDropIndicatorScope, includeRowTargets: Bool diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift index 482f5bca80cf..370319b9fa3f 100644 --- a/Sources/Sidebar/SidebarAppearanceSupport.swift +++ b/Sources/Sidebar/SidebarAppearanceSupport.swift @@ -356,14 +356,7 @@ func sidebarSelectedWorkspaceBackgroundNSColor( return parsed } if activeTabIndicatorStyle == .solidFill || !subtleSelection { - // The selection is always glass: a light translucent patch, never - // the blue accent. - return SidebarAppearanceColorResolver().resolvedColor( - .labelColor, - for: colorScheme, - opacity: UserDefaults.standard.object(forKey: "sidebarGlassSelectionFillOpacity") as? Double - ?? (colorScheme == .dark ? 0.13 : 0.07) - ) + return SidebarGlassSelection.fill(for: colorScheme) } let surface = NSColor(white: colorScheme == .dark ? 0.16 : 0.93, alpha: 1) let fill = CmuxSelectionFill.resolve( @@ -386,13 +379,7 @@ func sidebarSelectedWorkspaceForegroundNSColor( opacity: CGFloat ) -> NSColor { let clampedOpacity = max(0, min(opacity, 1)) - // A translucent pill (the glass accent) is a tint on the pane, not a - // surface: text keeps the pane's own label colour, which the pill's - // hue encodes (white pill on dark glass, black pill on light). - if backgroundColor.alphaComponent < 0.5 { - let brightness = backgroundColor.usingColorSpace(.deviceRGB)?.brightnessComponent ?? 1 - return (brightness > 0.5 ? NSColor.white : NSColor.black).withAlphaComponent(clampedOpacity) - } + if let glass = SidebarGlassSelection.foreground(on: backgroundColor, opacity: clampedOpacity) { return glass } let whiteContrast = cmuxContrastRatio(foreground: .white, background: backgroundColor) guard whiteContrast < 2.75 else { return NSColor.white.withAlphaComponent(clampedOpacity) @@ -482,20 +469,11 @@ func sidebarWorkspaceRowBackgroundStyle( increaseContrast: increaseContrast, accent: accent ) - // Glass selection: a light translucent fill with a hairline edge, so the - // selected row reads as a lighter patch of the glass (Aside-style). - let glassEdge: NSColor? = sidebarSelectionColorHex.flatMap({ NSColor(hex: $0) }) == nil - ? SidebarAppearanceColorResolver().resolvedColor( - .labelColor, - for: colorScheme, - opacity: UserDefaults.standard.object(forKey: "sidebarGlassSelectionEdgeOpacity") as? Double - ?? (colorScheme == .dark ? 0.14 : 0.12) - ) - : nil - // Multi-selection members: the same glass at roughly half strength, so - // the active row stays the clear anchor. - let multiSelectionBackground = selectedBackground - let multiSelectionEdge = glassEdge.map { $0.withAlphaComponent($0.alphaComponent * 0.55) } + // Multi-selection members wear the same glass at roughly half strength so + // the active row stays the anchor; Increase Contrast keeps them readable. + let multiSelectionOpacity = increaseContrast ? 0.8 : 0.55 + let glassEdge = NSColor(hex: sidebarSelectionColorHex ?? "") == nil ? SidebarGlassSelection.edge(for: colorScheme) : nil + let multiSelectionEdge = glassEdge.map { $0.withAlphaComponent($0.alphaComponent * multiSelectionOpacity) } let usesSubtleSelection = sidebarUsesSubtleSelection( activeTabIndicatorStyle: activeTabIndicatorStyle, subtleSelection: subtleSelection, @@ -523,25 +501,17 @@ func sidebarWorkspaceRowBackgroundStyle( case .leftRail: if isActive { if usesSubtleSelection { return calmFill(isSecondary: false) } - return SidebarWorkspaceRowBackgroundStyle( - color: selectedBackground, - opacity: 1, - edgeColor: glassEdge - ) + return SidebarWorkspaceRowBackgroundStyle(color: selectedBackground, opacity: 1, edgeColor: glassEdge) } if isMultiSelected { if usesSubtleSelection { return calmFill(isSecondary: true) } - return SidebarWorkspaceRowBackgroundStyle(color: multiSelectionBackground, opacity: 0.55, edgeColor: multiSelectionEdge) + return SidebarWorkspaceRowBackgroundStyle(color: selectedBackground, opacity: multiSelectionOpacity, edgeColor: multiSelectionEdge) } return .clear case .solidFill: if isActive { - return SidebarWorkspaceRowBackgroundStyle( - color: selectedBackground, - opacity: 1, - edgeColor: glassEdge - ) + return SidebarWorkspaceRowBackgroundStyle(color: selectedBackground, opacity: 1, edgeColor: glassEdge) } if let customBackground { return SidebarWorkspaceRowBackgroundStyle( @@ -550,7 +520,7 @@ func sidebarWorkspaceRowBackgroundStyle( ) } if isMultiSelected { - return SidebarWorkspaceRowBackgroundStyle(color: multiSelectionBackground, opacity: 0.55, edgeColor: multiSelectionEdge) + return SidebarWorkspaceRowBackgroundStyle(color: selectedBackground, opacity: multiSelectionOpacity, edgeColor: multiSelectionEdge) } return .clear } diff --git a/Sources/Sidebar/SidebarGlassSelection.swift b/Sources/Sidebar/SidebarGlassSelection.swift new file mode 100644 index 000000000000..25268125de0c --- /dev/null +++ b/Sources/Sidebar/SidebarGlassSelection.swift @@ -0,0 +1,36 @@ +import AppKit +import SwiftUI + +/// The sidebar's glass selection: a neutral translucent patch of the glass +/// with a hairline edge, never the accent colour (Aside-style). Fill and edge +/// strength can be tuned with the `sidebarGlassSelectionFillOpacity` and +/// `sidebarGlassSelectionEdgeOpacity` defaults. +enum SidebarGlassSelection { + static func fill(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { + SidebarAppearanceColorResolver().resolvedColor( + .labelColor, + for: colorScheme, + opacity: defaults.object(forKey: "sidebarGlassSelectionFillOpacity") as? Double + ?? (colorScheme == .dark ? 0.13 : 0.07) + ) + } + + static func edge(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { + SidebarAppearanceColorResolver().resolvedColor( + .labelColor, + for: colorScheme, + opacity: defaults.object(forKey: "sidebarGlassSelectionEdgeOpacity") as? Double + ?? (colorScheme == .dark ? 0.14 : 0.12) + ) + } + + /// Text on a translucent selection. The patch is a tint on the pane, not + /// a surface, so text keeps the pane's own label colour, which the patch's + /// hue encodes (white patch on dark glass, black patch on light). Nil for + /// an opaque selection, which picks its foreground by contrast instead. + static func foreground(on backgroundColor: NSColor, opacity: CGFloat) -> NSColor? { + guard backgroundColor.alphaComponent < 0.5 else { return nil } + let brightness = backgroundColor.usingColorSpace(.deviceRGB)?.brightnessComponent ?? 1 + return (brightness > 0.5 ? NSColor.white : NSColor.black).withAlphaComponent(opacity) + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 792c8e6a3f46..897de5454def 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3256,9 +3256,11 @@ B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */; }; A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; }; + 20FFB0FB12343E17FE2624FF /* SidebarGlassSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1525C188F2BD8ED27D43C51C /* SidebarGlassSelection.swift */; }; F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10753000000000000000002 /* SidebarGroupHeaderRowActions+NotificationState.swift */; }; B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0200000000000000020 /* SidebarGroupHeaderRowModel.swift */; }; B804A0210000000000000021 /* SidebarGroupHeaderRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0210000000000000021 /* SidebarGroupHeaderRowView.swift */; }; + 0F11723899EA5A1AEDF2F6CB /* SidebarHeaderGlyphButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9935562ACC010FC73782536 /* SidebarHeaderGlyphButton.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; B8624C010000000000000001 /* SidebarHiddenPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D010000000000000001 /* SidebarHiddenPresentationTests.swift */; }; 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */; }; @@ -3384,6 +3386,7 @@ C9A5760FC9A5760FC9A5760F /* SidebarWorkspaceReorderDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */; }; C9A5760BC9A5760BC9A5760B /* SidebarWorkspaceReorderPendingDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */; }; 6707F0076707F0076707F007 /* SidebarWorkspaceRowActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0086707F0086707F008 /* SidebarWorkspaceRowActions.swift */; }; + FFD019E8E6AF6AE67B58D00C /* SidebarWorkspaceRowCellView+ReorderLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FCB3A859A840D9593BD8BF /* SidebarWorkspaceRowCellView+ReorderLift.swift */; }; B804A0240000000000000024 /* SidebarWorkspaceRowCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0240000000000000024 /* SidebarWorkspaceRowCellView.swift */; }; B804A02B000000000000002B /* SidebarWorkspaceRowChecklistSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B02B000000000000002B /* SidebarWorkspaceRowChecklistSection.swift */; }; 970900020000000000000001 /* SidebarWorkspaceRowColorMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 970900020000000000000002 /* SidebarWorkspaceRowColorMenu.swift */; }; @@ -3420,6 +3423,8 @@ B804A0020000000000000002 /* SidebarWorkspaceTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */; }; B804A00B000000000000000B /* SidebarWorkspaceTableClipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */; }; B804A0030000000000000003 /* SidebarWorkspaceTableContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */; }; + 4F8E29036B2C8FEC8D54E33D /* SidebarWorkspaceTableController+ReorderDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80734AD866B727FCEA3F6F3 /* SidebarWorkspaceTableController+ReorderDrop.swift */; }; + 1B6F8636FAA4AF557CAFCE67 /* SidebarWorkspaceTableController+ReorderLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */; }; B804A0040000000000000004 /* SidebarWorkspaceTableController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0040000000000000004 /* SidebarWorkspaceTableController.swift */; }; B804A0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift */; }; B804A00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift */; }; @@ -7679,9 +7684,11 @@ B8624D060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFocusBoundaryLifecycleTests.swift; sourceTree = ""; }; A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = ""; }; + 1525C188F2BD8ED27D43C51C /* SidebarGlassSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarGlassSelection.swift; sourceTree = ""; }; F10753000000000000000002 /* SidebarGroupHeaderRowActions+NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/Cells/SidebarGroupHeaderRowActions+NotificationState.swift"; sourceTree = ""; }; B804B0200000000000000020 /* SidebarGroupHeaderRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift; sourceTree = ""; }; B804B0210000000000000021 /* SidebarGroupHeaderRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift; sourceTree = ""; }; + C9935562ACC010FC73782536 /* SidebarHeaderGlyphButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; B8624D010000000000000001 /* SidebarHiddenPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHiddenPresentationTests.swift; sourceTree = ""; }; 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarIdentifierFormattingTests.swift; sourceTree = ""; }; @@ -7806,6 +7813,7 @@ C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropView.swift; sourceTree = ""; }; C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderPendingDrop.swift; sourceTree = ""; }; 6707F0086707F0086707F008 /* SidebarWorkspaceRowActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowActions.swift; sourceTree = ""; }; + 84FCB3A859A840D9593BD8BF /* SidebarWorkspaceRowCellView+ReorderLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift"; sourceTree = ""; }; B804B0240000000000000024 /* SidebarWorkspaceRowCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift; sourceTree = ""; }; B804B02B000000000000002B /* SidebarWorkspaceRowChecklistSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarWorkspaceRowChecklistSection.swift; sourceTree = ""; }; 970900020000000000000002 /* SidebarWorkspaceRowColorMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarWorkspaceRowColorMenu.swift; sourceTree = ""; }; @@ -7842,6 +7850,8 @@ B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift; sourceTree = ""; }; B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift; sourceTree = ""; }; B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift; sourceTree = ""; }; + E80734AD866B727FCEA3F6F3 /* SidebarWorkspaceTableController+ReorderDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift"; sourceTree = ""; }; + 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift"; sourceTree = ""; }; B804B0040000000000000004 /* SidebarWorkspaceTableController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableController.swift; sourceTree = ""; }; B804B0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift; sourceTree = ""; }; B804B00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableEmptyDropIndicatorView.swift; sourceTree = ""; }; @@ -11982,6 +11992,11 @@ 36FABB797A3D46B70F073485 /* CmuxSettingsFileStore+SleepyModeSection.swift */, 6C858D764447A3E9A2C7EE35 /* CmuxSettingsFileStore+FontSections.swift */, 4C013CE833A2331E8EFAD330 /* SwiftUIKeyViewProxyResponderGuard.swift */, + C9935562ACC010FC73782536 /* SidebarHeaderGlyphButton.swift */, + 84FCB3A859A840D9593BD8BF /* SidebarWorkspaceRowCellView+ReorderLift.swift */, + E80734AD866B727FCEA3F6F3 /* SidebarWorkspaceTableController+ReorderDrop.swift */, + 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */, + 1525C188F2BD8ED27D43C51C /* SidebarGlassSelection.swift */, ); path = Sources; sourceTree = ""; @@ -16051,9 +16066,11 @@ 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */, 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */, A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */, + 20FFB0FB12343E17FE2624FF /* SidebarGlassSelection.swift in Sources */, F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */, B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */, B804A0210000000000000021 /* SidebarGroupHeaderRowView.swift in Sources */, + 0F11723899EA5A1AEDF2F6CB /* SidebarHeaderGlyphButton.swift in Sources */, AABBCC00000000000000020C /* SidebarInlineRenameAction.swift in Sources */, AABBCC00000000000000020E /* SidebarInlineRenameCommit.swift in Sources */, AABBCC000000000000000210 /* SidebarInlineRenameCoordinator.swift in Sources */, @@ -16146,6 +16163,7 @@ C9A5760FC9A5760FC9A5760F /* SidebarWorkspaceReorderDropView.swift in Sources */, C9A5760BC9A5760BC9A5760B /* SidebarWorkspaceReorderPendingDrop.swift in Sources */, 6707F0076707F0076707F007 /* SidebarWorkspaceRowActions.swift in Sources */, + FFD019E8E6AF6AE67B58D00C /* SidebarWorkspaceRowCellView+ReorderLift.swift in Sources */, B804A0240000000000000024 /* SidebarWorkspaceRowCellView.swift in Sources */, B804A02B000000000000002B /* SidebarWorkspaceRowChecklistSection.swift in Sources */, 970900020000000000000001 /* SidebarWorkspaceRowColorMenu.swift in Sources */, @@ -16172,6 +16190,8 @@ B804A0020000000000000002 /* SidebarWorkspaceTableCellView.swift in Sources */, B804A00B000000000000000B /* SidebarWorkspaceTableClipView.swift in Sources */, B804A0030000000000000003 /* SidebarWorkspaceTableContainerView.swift in Sources */, + 4F8E29036B2C8FEC8D54E33D /* SidebarWorkspaceTableController+ReorderDrop.swift in Sources */, + 1B6F8636FAA4AF557CAFCE67 /* SidebarWorkspaceTableController+ReorderLift.swift in Sources */, B804A0040000000000000004 /* SidebarWorkspaceTableController.swift in Sources */, B804A0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift in Sources */, B804A00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift in Sources */, From d8669e10750128a2693dc4cee375a5a140af96d3 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:17:15 +1300 Subject: [PATCH 24/69] sidebar peek and toggle review fixes, group color clear only for the group's own color - only user toggles animate; programmatic setVisible (narrow-window auto-collapse, session restore) applies at once so callers can read isVisible right after. a second hide during the sweep is ignored instead of capturing the half-collapsed width - the peek panel bridge tears its panel down on dismantle and gives the terminal its cursor rects back - Clear Color shows only when the group has its own color (a config color cannot be cleared from the menu) - peek lifecycle modifiers, the toggle hover broadcast and the terminal's cursor yield move into ContentView+SidebarPeek helpers (file length) --- Sources/AppDelegate.swift | 3 - Sources/ContentView+SidebarPeek.swift | 107 ++++++++++++++++++ Sources/ContentView.swift | 80 +------------ Sources/GhosttyTerminalView.swift | 8 +- .../Cells/SidebarGroupHeaderRowModel.swift | 21 ++++ .../Cells/SidebarGroupHeaderRowView.swift | 3 +- .../Sidebar/Peek/SidebarPeekPanelHost.swift | 15 +++ Sources/Sidebar/SidebarState.swift | 27 ++--- Sources/Sidebar/SidebarToggleAnimator.swift | 13 ++- Sources/Update/UpdateTitlebarAccessory.swift | 11 +- .../VerticalTabsSidebar+WorkspaceGroups.swift | 5 +- 11 files changed, 173 insertions(+), 120 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index c738fa42cdc6..516cf59b5125 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8707,7 +8707,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent focusInitialBrowserAddressBarOnCreate: Bool = true, createdWorkspaceHandler: ((Workspace) -> Void)? = nil ) -> Bool { - // Every create route funnels here (button, cmd-T, browser variant). SidebarNavigationTimings.begin("create") let preferredContext = preferredTabManager.flatMap { mainWindowContext(for: $0) } let livePreferredContext: MainWindowContext? = { @@ -15911,8 +15910,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } if matchConfiguredShortcut(event: event, action: .closeWorkspace) { - // Workspace-close funnel; the tab-close shortcut is a different - // interaction and must not start this interval. SidebarNavigationTimings.begin("close") tabManagerForFocusedCloseShortcut(event: event)?.closeCurrentWorkspaceWithConfirmation() return true diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index a333074c3bdd..1211147f6242 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -10,6 +10,32 @@ extension Notification.Name { static let cmuxSidebarToggleHoverChanged = Notification.Name("cmux.sidebarToggleHoverChanged") } +extension View { + /// Broadcasts hover over the titlebar's sidebar toggle, which pre-reveals + /// the peek card while the pointer rests on it (Aside behaviour). + /// ContentView owns the peek state, so the button only broadcasts. + func broadcastsSidebarToggleHover() -> some View { + onHover { hovering in + NotificationCenter.default.post( + name: .cmuxSidebarToggleHoverChanged, + object: nil, + userInfo: ["hovering": hovering] + ) + } + } +} + +extension NSView { + /// While the floating sidebar card is up, the pointer over its rows is + /// geometrically over the terminal too. Terminal surfaces call this from + /// `resetCursorRects` so they keep the card's arrow instead of an I-beam. + func cmuxYieldsCursorRectsToSidebarCard() -> Bool { + guard window?.cmuxSuppressesTerminalCursorRects == true else { return false } + addCursorRect(bounds, cursor: .arrow) + return true + } +} + extension ContentView { /// Whether the sidebar should be drawn, from any cause. /// @@ -175,4 +201,85 @@ extension ContentView { .frame(maxHeight: .infinity) .zIndex(1) } + + /// The peek machine's lifecycle around the sidebar layout: policy from + /// Settings, docking and hiding retire a peek, the titlebar toggle's + /// hover pre-reveals, plus the panel host and the edge strip. + func sidebarPeekLifecycle(_ content: Content) -> some View { + content + .onAppear { + sidebarToggleAnimator.install( + sidebarState: sidebarState, + layout: sidebarLayout, + isPeekPresenting: { sidebarPeek.presentsPanel } + ) + sidebarPeek.setPolicy(SidebarCustomizationSettings.peekPolicy()) + } + .onReceive( + NotificationCenter.default + .publisher(for: UserDefaults.didChangeNotification) + .receive(on: RunLoop.main) + ) { _ in + // Settings-window edits land here. The equality guard + // keeps the frequent defaults churn from rebuilding the + // peek machine when nothing it cares about changed. + let policy = SidebarCustomizationSettings.peekPolicy() + if policy != sidebarPeek.policy { + sidebarPeek.setPolicy(policy) + } + } + .onChange(of: sidebarState.occupiesLayout) { occupies in + if occupies { + // Docking retires any active peek: the card handed + // its place to the fixed pane. + sidebarPeek.sidebarDocked() + } + } + .onReceive(NotificationCenter.default.publisher(for: .cmuxSidebarToggleHoverChanged)) { note in + // Aside behaviour: hovering the titlebar's sidebar toggle + // pre-reveals the peek card, so the click lands on a + // sidebar that is already gliding in. Routed through the + // peek machine's edge events, so dwell, grace, and + // dismissal all behave exactly like the screen-edge peek. + guard let hovering = note.userInfo?["hovering"] as? Bool else { return } + guard observedWindow?.isKeyWindow == true else { return } + guard !sidebarState.isVisible, sidebarPeek.policy.isEnabled else { return } + if hovering { + // A hover re-fired by the hide click itself is not a + // request to show; only an arrival after the window + // pre-reveals. + if let until = sidebarToggleHoverSuppressedUntil, Date() < until { + return + } + sidebarPeek.pointerEnteredActivationControl() + } else { + sidebarPeek.pointerExitedEdge() + } + } + .onChange(of: sidebarState.isVisible) { visible in + guard !visible else { return } + // Hiding retires any peek in flight and opens the hover + // suppression window, so the card cannot flash over the + // closing pane. The show direction is untouched. + sidebarPeek.sidebarCollapsed() + sidebarToggleHoverSuppressedUntil = Date().addingTimeInterval(0.3) + } + .background( + // Zero-sized anchor that owns the floating card's child + // window. The card cannot live in this tree: the portal + // hosts every terminal surface above the window's SwiftUI + // hosting view, so an in-tree card draws underneath the + // terminal no matter its zIndex. + sidebarPeekPanelHost + ) + .overlay(alignment: .leading) { + // Same slot and layering as the resizer overlay, which is + // the proven way in this codebase to receive pointer + // events over the portal-hosted terminal. A strip placed + // inside the layout stack can end up beneath the terminal's + // AppKit view and never see the pointer at all. + sidebarPeekEdgeStrip + .zIndex(999) + } + } } diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index e76c09408938..8e8b6d47efce 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -947,7 +947,7 @@ struct ContentView: View { /// Sweeps the real layout width on toggle (a synthetic divider drag), so /// the sidebar and terminal live-resize together through the same path /// the manual divider uses. - @StateObject private var sidebarToggleAnimator = SidebarToggleAnimator() + @StateObject var sidebarToggleAnimator = SidebarToggleAnimator() // Not `private`: `ContentView+SidebarPeek` reads the width for the // floating panel window's geometry. @State var sidebarLayout = SidebarLayoutModel( @@ -2072,7 +2072,7 @@ struct ContentView: View { /// closing pane. A time window rather than a wait-for-exit latch, because /// SwiftUI's onHover can drop the exit event across that re-render and a /// latch would then never clear. - @State private var sidebarToggleHoverSuppressedUntil: Date? + @State var sidebarToggleHoverSuppressedUntil: Date? private var windowIdentifier: String { "cmux.main.\(windowId.uuidString)" } // Not `private`: the peek panel card in `ContentView+SidebarPeek` derives // its content colour scheme from the same snapshot the docked sidebar uses. @@ -2619,81 +2619,7 @@ struct ContentView: View { } return AnyView( - layout - .onAppear { - sidebarToggleAnimator.install( - sidebarState: sidebarState, - layout: sidebarLayout, - isPeekPresenting: { sidebarPeek.presentsPanel } - ) - sidebarPeek.setPolicy(SidebarCustomizationSettings.peekPolicy()) - } - .onReceive( - NotificationCenter.default - .publisher(for: UserDefaults.didChangeNotification) - .receive(on: RunLoop.main) - ) { _ in - // Settings-window edits land here. The equality guard - // keeps the frequent defaults churn from rebuilding the - // peek machine when nothing it cares about changed. - let policy = SidebarCustomizationSettings.peekPolicy() - if policy != sidebarPeek.policy { - sidebarPeek.setPolicy(policy) - } - } - .onChange(of: sidebarState.occupiesLayout) { occupies in - if occupies { - // Docking retires any active peek: the card handed - // its place to the fixed pane. - sidebarPeek.sidebarDocked() - } - } - .onReceive(NotificationCenter.default.publisher(for: .cmuxSidebarToggleHoverChanged)) { note in - // Aside behaviour: hovering the titlebar's sidebar toggle - // pre-reveals the peek card, so the click lands on a - // sidebar that is already gliding in. Routed through the - // peek machine's edge events, so dwell, grace, and - // dismissal all behave exactly like the screen-edge peek. - guard let hovering = note.userInfo?["hovering"] as? Bool else { return } - guard observedWindow?.isKeyWindow == true else { return } - guard !sidebarState.isVisible, sidebarPeek.policy.isEnabled else { return } - if hovering { - // A hover re-fired by the hide click itself is not a - // request to show; only an arrival after the window - // pre-reveals. - if let until = sidebarToggleHoverSuppressedUntil, Date() < until { - return - } - sidebarPeek.pointerEnteredActivationControl() - } else { - sidebarPeek.pointerExitedEdge() - } - } - .onChange(of: sidebarState.isVisible) { visible in - guard !visible else { return } - // Hiding retires any peek in flight and opens the hover - // suppression window, so the card cannot flash over the - // closing pane. The show direction is untouched. - sidebarPeek.sidebarCollapsed() - sidebarToggleHoverSuppressedUntil = Date().addingTimeInterval(0.3) - } - .background( - // Zero-sized anchor that owns the floating card's child - // window. The card cannot live in this tree: the portal - // hosts every terminal surface above the window's SwiftUI - // hosting view, so an in-tree card draws underneath the - // terminal no matter its zIndex. - sidebarPeekPanelHost - ) - .overlay(alignment: .leading) { - // Same slot and layering as the resizer overlay, which is - // the proven way in this codebase to receive pointer - // events over the portal-hosted terminal. A strip placed - // inside the layout stack can end up beneath the terminal's - // AppKit view and never see the pointer at all. - sidebarPeekEdgeStrip - .zIndex(999) - } + sidebarPeekLifecycle(layout) .overlay(alignment: .leading) { if sidebarState.isVisible { sidebarResizerOverlay diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0f2948c76c4d..259feae30d1e 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -5496,13 +5496,7 @@ class GhosttyNSView: NSView, NSUserInterfaceValidations { override func resetCursorRects() { super.resetCursorRects() - // While the floating sidebar card is up, the pointer over its rows is - // geometrically over this surface too. Keep the terminal from - // replacing the card's arrow cursor with an I-beam. - if window?.cmuxSuppressesTerminalCursorRects == true { - addCursorRect(bounds, cursor: .arrow) - return - } + if cmuxYieldsCursorRectsToSidebarCard() { return } let cursor = codexActionCommandHovering ? NSCursor.pointingHand : Self.ghosttyMouseCursor(for: ghosttyMouseShape) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift index b6db481acc8c..d4c8bd158efc 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift @@ -80,4 +80,25 @@ struct SidebarGroupHeaderRowActions { var notificationState: () -> NotificationState = { .unavailable } /// Sets the group's own color (nil clears it back to any config color). var onSetColor: (String?) -> Void = { _ in } + /// The group's own color, without any config color behind it. + var customColorHex: () -> String? = { nil } +} + +extension SidebarGroupHeaderRowActions { + /// Wires the actions that read or write live group state: notification + /// availability and the group's own color. + @MainActor + mutating func bindLiveState( + groupId: UUID, + tabManager: TabManager, + notificationState: @escaping () -> NotificationState + ) { + self.notificationState = notificationState + onSetColor = { [weak tabManager] hex in + tabManager?.setWorkspaceGroupColor(groupId: groupId, hex: hex) + } + customColorHex = { [weak tabManager] in + tabManager?.workspaceGroups.first(where: { $0.id == groupId })?.customColor + } + } } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 3b85abdefc7e..a4bcd2c8d8ae 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -632,7 +632,8 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { let submenu = NSMenu() submenu.autoenablesItems = false let palette = WorkspaceTabColorSettings.palette() - if model.tintHex != nil { + // Clear only what the user set; a config color cannot be cleared here. + if actions.customColorHex() != nil { let clearItem = menuItem(String(localized: "contextMenu.clearColor", defaultValue: "Clear Color")) { actions.onSetColor(nil) } diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift index 408bd6545b42..c059e78b5d5e 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift @@ -267,7 +267,17 @@ final class SidebarPeekPanelWindowController { } } + /// Drops the panel when the bridge leaves the hierarchy, so the parent's + /// terminal gets its cursor rects back. + func tearDown() { + detach() + } + private func detach() { + if let parentWindow, parentWindow.cmuxSuppressesTerminalCursorRects { + parentWindow.cmuxSuppressesTerminalCursorRects = false + parentWindow.resetCursorRects() + } for observer in parentObservers { NotificationCenter.default.removeObserver(observer) } @@ -364,6 +374,11 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { AnchorView(frame: .zero) } + static func dismantleNSView(_ view: AnchorView, coordinator: Coordinator) { + view.onWindowChange = nil + coordinator.controller.tearDown() + } + func updateNSView(_ view: AnchorView, context: Context) { let controller = context.coordinator.controller let contentWidth = contentWidth diff --git a/Sources/Sidebar/SidebarState.swift b/Sources/Sidebar/SidebarState.swift index 3a474c367903..a7b7b73b4f1f 100644 --- a/Sources/Sidebar/SidebarState.swift +++ b/Sources/Sidebar/SidebarState.swift @@ -28,10 +28,12 @@ final class SidebarState: ObservableObject { } private var visibilityWillChangeOwnerId: UUID? private var visibilityWillChange: ((Bool) -> Void)? - /// When installed, visibility changes defer to this orchestrator (the - /// toggle animator's width sweep), which applies the final value itself - /// through ``applyVisibilityBypassingOrchestrator(_:)``. Returning false - /// hands the change back to the instant default path. + /// When installed, user toggles defer to this orchestrator (the toggle + /// animator's width sweep), which applies the final value itself through + /// ``setVisible(_:)``. Returning false hands the change back to the + /// instant default path. Programmatic `setVisible` calls (narrow-window + /// auto-collapse, session restore) never animate, so their callers can + /// read `isVisible` right after. var animatedVisibilityOrchestrator: ((Bool) -> Bool)? init( @@ -46,7 +48,11 @@ final class SidebarState: ObservableObject { } func toggle() { - setVisible(!isVisible) + let nextValue = !isVisible + if let animatedVisibilityOrchestrator, animatedVisibilityOrchestrator(nextValue) { + return + } + setVisible(nextValue) } /// Switches between docked and floating. @@ -55,17 +61,6 @@ final class SidebarState: ObservableObject { } func setVisible(_ nextValue: Bool) { - guard nextValue != isVisible else { return } - if let animatedVisibilityOrchestrator, animatedVisibilityOrchestrator(nextValue) { - return - } - visibilityWillChange?(nextValue) - isVisible = nextValue - } - - /// The orchestrator's commit path: applies visibility without consulting - /// the orchestrator again. - func applyVisibilityBypassingOrchestrator(_ nextValue: Bool) { guard nextValue != isVisible else { return } visibilityWillChange?(nextValue) isVisible = nextValue diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 7b28dad805aa..da2ddf392ed6 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -23,6 +23,8 @@ final class SidebarToggleAnimator: ObservableObject { /// The width the pane rests at when open, captured at hide time and /// restored after, so the sweep never corrupts the persisted width. private var restingWidth: CGFloat = 0 + /// A hide sweep is running: `isVisible` stays true until it lands. + private var isHiding = false deinit { timer?.invalidate() @@ -49,8 +51,13 @@ final class SidebarToggleAnimator: ObservableObject { // Only a docked pane trades width with the terminal; floating // visibility is the peek panel's business. guard sidebarState.presentationMode == .docked else { return false } + // A second hide while one is sweeping (`isVisible` is still true + // until it lands) is the same request; restarting it would capture + // the half-collapsed width as the resting width. + if isHiding && !targetVisible { return true } SidebarNavigationTimings.begin(targetVisible ? "toggle.show" : "toggle.hide") cancelSweep() + isHiding = false if targetVisible { // Docking over an already-revealed peek card swaps in place: the // card is where the pane belongs, so any sweep would be a ghost. @@ -60,7 +67,7 @@ final class SidebarToggleAnimator: ObservableObject { let target = restingWidth > 1 ? restingWidth : max(layout.width, CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) - sidebarState.applyVisibilityBypassingOrchestrator(true) + sidebarState.setVisible(true) layout.width = 1 // The width snap above IS the first visual feedback for show (the // terminal steps aside in this very turn); the sweep that follows @@ -70,11 +77,13 @@ final class SidebarToggleAnimator: ObservableObject { return true } else { restingWidth = layout.width + isHiding = true sweep(from: layout.width, to: 1) { [weak self] in + self?.isHiding = false guard let self, let sidebarState = self.sidebarState, let layout = self.layout else { return } - sidebarState.applyVisibilityBypassingOrchestrator(false) + sidebarState.setVisible(false) // The pane is unmounted now; restore the resting width so the // next show (and the persisted value) see the real width. layout.width = self.restingWidth diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index e60bca618aa0..c6d281e5ba93 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -1150,16 +1150,7 @@ struct TitlebarControlsView: View { sidebarIconLabel(config: config, iconGeometryKeyPrefix: "titlebarControl_toggleSidebarIcon") } .safeHelp(KeyboardShortcutSettings.Action.toggleSidebar.tooltip(String(localized: "titlebar.sidebar.tooltip", defaultValue: "Show or hide the sidebar"))) - .onHover { hovering in - // Pre-reveals the peek card while the pointer rests on the - // toggle (Aside behaviour); ContentView owns the peek state, - // so this only broadcasts. - NotificationCenter.default.post( - name: .cmuxSidebarToggleHoverChanged, - object: nil, - userInfo: ["hovering": hovering] - ) - } + .broadcastsSidebarToggleHover() TitlebarControlButton( config: config, diff --git a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift index 4448fc75c22d..7cfdc2f4a77c 100644 --- a/Sources/VerticalTabsSidebar+WorkspaceGroups.swift +++ b/Sources/VerticalTabsSidebar+WorkspaceGroups.swift @@ -576,10 +576,7 @@ extension VerticalTabsSidebar { SidebarWorkspaceGroupConfigOpener.openWorkspaceGroupsDocs() } ) - actions.notificationState = resolveNotificationState - actions.onSetColor = { [weak tabManager] hex in - tabManager?.setWorkspaceGroupColor(groupId: groupId, hex: hex) - } + actions.bindLiveState(groupId: groupId, tabManager: tabManager, notificationState: resolveNotificationState) return actions } From ddd1fed499ec00ec37cd8a970b4fff0f6fe99fb8 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:30:34 +1300 Subject: [PATCH 25/69] second review pass: hide during a show keeps the show's width, inserts animate, drop stops the poll - a hide that interrupts a show sweep rests at the show's target width, not the half-open live width that would then be persisted - an inserted row's own height change no longer blocks the insert animation - a drop stops the 120 Hz pointer poll right away, so a tick before endedAt cannot re-lift the block the commit is placing --- ...debarWorkspaceTableController+ReorderDrop.swift | 3 +++ .../SidebarWorkspaceTableController.swift | 14 +++++++++++--- Sources/Sidebar/SidebarToggleAnimator.swift | 10 ++++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift index d4af78a5f0da..ea89db9ab30f 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift @@ -14,6 +14,9 @@ extension SidebarWorkspaceTableController { targets: [SidebarWorkspaceReorderDropOverlay.Target], payloadWorkspaceId: UUID? ) -> Bool { + // A drop ends the drag: stop the pointer poll now, so a tick landing + // before `endedAt` cannot re-lift the block the commit is placing. + stopReorderPoll() reorderDragWindowPoint = nil guard let actions else { retireReorderIndicator() diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index cc40770e72aa..0aec9254d1f5 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -902,11 +902,19 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS // a workspace); bigger edits (a group collapsing) and edits // that change heights stay instant. The nested group opts // back into animation inside the no-animation wrapper. - let editCount: Int + // An inserted row's own height arrives from the delegate on + // insert, so only other rows' height changes block it. + let editIndexes: IndexSet + let otherHeightChanges: IndexSet switch pureEdit { - case .remove(let indexes), .insert(let indexes): editCount = indexes.count + case .remove(let indexes): + editIndexes = indexes + otherHeightChanges = heightChanges + case .insert(let indexes): + editIndexes = indexes + otherHeightChanges = heightChanges.subtracting(indexes) } - let animates = heightChanges.isEmpty && editCount <= 3 + let animates = otherHeightChanges.isEmpty && editIndexes.count <= 3 NSAnimationContext.runAnimationGroup { context in context.duration = animates ? 0.22 : 0 context.timingFunction = CAMediaTimingFunction(controlPoints: 0.3, 1.0, 0.4, 1.0) diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index da2ddf392ed6..453e70891495 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -25,6 +25,9 @@ final class SidebarToggleAnimator: ObservableObject { private var restingWidth: CGFloat = 0 /// A hide sweep is running: `isVisible` stays true until it lands. private var isHiding = false + /// The width a running show sweep is heading to. A hide that interrupts + /// it rests at this, not at the half-open live width. + private var showTarget: CGFloat? deinit { timer?.invalidate() @@ -58,6 +61,8 @@ final class SidebarToggleAnimator: ObservableObject { SidebarNavigationTimings.begin(targetVisible ? "toggle.show" : "toggle.hide") cancelSweep() isHiding = false + let interruptedShowTarget = showTarget + showTarget = nil if targetVisible { // Docking over an already-revealed peek card swaps in place: the // card is where the pane belongs, so any sweep would be a ghost. @@ -73,10 +78,11 @@ final class SidebarToggleAnimator: ObservableObject { // terminal steps aside in this very turn); the sweep that follows // is the pane gliding in. SidebarNavigationTimings.end("toggle.show") - sweep(from: 1, to: target, completion: nil) + showTarget = target + sweep(from: 1, to: target) { [weak self] in self?.showTarget = nil } return true } else { - restingWidth = layout.width + restingWidth = interruptedShowTarget ?? layout.width isHiding = true sweep(from: layout.width, to: 1) { [weak self] in self?.isHiding = false From 8cdd1970344fb01fe7bb9da17867699aedb2058c Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:30:34 +1300 Subject: [PATCH 26/69] license: credit the Symbols icon theme for the bundled file explorer icons the svgs and the mapping come from miguelsolorio/vscode-symbols (MIT, Copyright (c) 2020-22 Miguel Solorio), not Material Icon Theme; the resolver comment now says so too. --- Sources/FileExplorerCellView.swift | 2 +- Sources/MaterialFileIconResolver.swift | 5 +++-- THIRD_PARTY_LICENSES.md | 14 ++++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/Sources/FileExplorerCellView.swift b/Sources/FileExplorerCellView.swift index 7ee243aa5dac..6075f34531df 100644 --- a/Sources/FileExplorerCellView.swift +++ b/Sources/FileExplorerCellView.swift @@ -91,7 +91,7 @@ final class FileExplorerCellView: NSTableCellView { if let materialIcon = MaterialFileIconResolver.shared.image( name: node.name, isDirectory: node.isDirectory, pointSize: style.iconSize ) { - // VS Code-style Material icons ship their own colors; no tint. + // The bundled Symbols icons ship their own colors; no tint. iconView.apply(CmuxResolvedIconRequest( source: .image(materialIcon), size: NSSize(width: style.iconSize, height: style.iconSize) diff --git a/Sources/MaterialFileIconResolver.swift b/Sources/MaterialFileIconResolver.swift index bd2a021747a5..e631b2a13686 100644 --- a/Sources/MaterialFileIconResolver.swift +++ b/Sources/MaterialFileIconResolver.swift @@ -1,9 +1,10 @@ import AppKit -/// Resolves VS Code-style Material file icons from the bundled icon set. +/// Resolves VS Code-style file icons from the bundled icon set. /// /// The mapping (`material-icons.json`) and the SVGs (`Resources/MaterialIcons`) -/// are the Material Icon Theme's: file names beat extensions, and compound +/// come from Miguel Solorio's Symbols icon theme (MIT, see +/// THIRD_PARTY_LICENSES.md): file names beat extensions, and compound /// extensions match longest-first ("foo.test.tsx" tries "test.tsx" before /// "tsx"). Resolution is cached per icon and point size; a name with no SVG is /// remembered so the file system is only consulted once per icon. diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md index 4af3d57f60c4..7cce3e883866 100644 --- a/THIRD_PARTY_LICENSES.md +++ b/THIRD_PARTY_LICENSES.md @@ -16,6 +16,20 @@ license text is in `Assets.xcassets/AgentIcons/LOBE-LICENSE.txt`. --- +## Symbols icon theme (file explorer icons) + +- **License:** MIT License +- **Copyright:** Copyright (c) 2020-22 Miguel Solorio +- **Source:** https://github.com/miguelsolorio/vscode-symbols + +Selected file and folder SVGs from the theme are bundled under +`Resources/MaterialIcons`, and `Resources/material-icons.json` is derived from +the theme's `symbol-icon-theme.json` (file names, extensions and folder names +mapped to icon names). The complete license text is in +`Resources/MaterialIcons/LICENSE`. + +--- + ## Primer Octicons (selected diff viewer icons) - **License:** MIT License From 5d33457b40e8c3b048d38a18a63cf0c101620647 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:37:23 +1300 Subject: [PATCH 27/69] fix package tests: settings search anchors, sidebar scheme, glass under reduce transparency - settings-only sidebar rows carry explicit search anchors and the tint rows' paths are in the anchor contract (everyCuratedSettingEntryIsReachable) - a separate sidebar's tint scheme follows its own backdrop again (#14841); the take-home port had put the terminal scheme back - compositor glass is off under Reduce Transparency, and the snapshot inits default it off so only the app's settings opt in --- .../Appearance/WindowAppearanceSnapshot.swift | 8 +++++--- .../Appearance/WindowAppearanceUserSettingsSnapshot.swift | 6 +++--- .../Sidebar/SidebarBackdropSettingsSnapshot.swift | 2 +- .../Sections/SidebarSection+Customization.swift | 6 ++++++ .../SettingsRowAnchorResolutionTests.swift | 8 ++++++++ 5 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift index a8af53a8e7e6..769bbf8b11b6 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceSnapshot.swift @@ -81,7 +81,7 @@ public struct WindowAppearanceSnapshot { tintOpacity: sidebarSettings.tintOpacity, cornerRadius: sidebarSettings.cornerRadius, blurOpacity: sidebarSettings.blurOpacity, - colorScheme: resolvedScheme, + colorScheme: sidebarScheme, compositorGlass: sidebarSettings.compositorGlass, compositorBlurRadius: sidebarSettings.compositorBlurRadius ) @@ -95,10 +95,12 @@ public struct WindowAppearanceSnapshot { /// /// The workspace card paints its own opaque terminal colour, so the ground /// can go clear under it. A translucent terminal already has Ghostty's own - /// blur path and keeps the legacy plan. This one answer drives the ground, - /// the sidebar layer, and the window plan, so they can never disagree. + /// blur path and keeps the legacy plan, and Reduce Transparency keeps the + /// opaque plan. This one answer drives the ground, the sidebar layer, and + /// the window plan, so they can never disagree. public var usesCompositorGlass: Bool { sidebarSettings.compositorGlass + && !reducesTransparency && !unifySurfaceBackdrops && terminalBackgroundOpacity >= 0.999 && !terminalBackgroundBlur.isMacOSGlassStyle diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift index 2c7610b8bff8..3be5d2ac8a8c 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Appearance/WindowAppearanceUserSettingsSnapshot.swift @@ -70,9 +70,9 @@ public struct WindowAppearanceUserSettingsSnapshot { bgGlassEnabled: Bool, bgGlassTintHex: String, bgGlassTintOpacity: Double, - sidebarCompositorGlass: Bool = true, - sidebarGlassBlurRadius: Double = SidebarBackdropSettingsSnapshot.compositorBlurRadiusRange.lowerBound, - reduceTransparency: Bool = false + sidebarCompositorGlass: Bool = false, + sidebarGlassBlurRadius: Double = SidebarBackdropSettingsSnapshot.compositorBlurRadiusRange.lowerBound, + reduceTransparency: Bool = false ) { self.sidebarCompositorGlass = sidebarCompositorGlass self.sidebarGlassBlurRadius = sidebarGlassBlurRadius diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift index 0be5b2a23f48..b4e3ca94393f 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift @@ -66,7 +66,7 @@ public struct SidebarBackdropSettingsSnapshot { cornerRadius: Double, blurOpacity: Double, colorScheme: ColorScheme, - compositorGlass: Bool = true, + compositorGlass: Bool = false, compositorBlurRadius: Double = Self.compositorBlurRadiusRange.lowerBound ) { self.materialRawValue = materialRawValue diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift index b41a32541c57..75ef4a593877 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift @@ -15,6 +15,7 @@ extension SidebarSection { .toggleStyle(.switch) .controlSize(.small) } + .settingsSearchAnchors(["setting:sidebarAppearance:sidebar-liquid-glass"]) SettingsCardDivider() SettingsCardRow( @@ -62,6 +63,7 @@ extension SidebarSection { } } .disabled(!liquidGlass.current) + .settingsSearchAnchors(["setting:sidebarAppearance:sidebar-glass-blur"]) SettingsCardDivider() SettingsCardRow( @@ -167,6 +169,7 @@ extension SidebarSection { .fixedSize() .disabled(peekDisabled.current) } + .settingsSearchAnchors(["setting:sidebarAppearance:sidebar-peek-reveal"]) SettingsCardDivider() SettingsCardRow( @@ -180,6 +183,7 @@ extension SidebarSection { .labelsHidden() .controlSize(.small) } + .settingsSearchAnchors(["setting:sidebarAppearance:sidebar-peek-disabled"]) SettingsCardDivider() SettingsCardRow( @@ -199,6 +203,7 @@ extension SidebarSection { .pickerStyle(.segmented) .fixedSize() } + .settingsSearchAnchors(["setting:sidebarAppearance:sidebar-row-density"]) SettingsCardDivider() SettingsCardRow( @@ -212,6 +217,7 @@ extension SidebarSection { .labelsHidden() .controlSize(.small) } + .settingsSearchAnchors(["setting:sidebarAppearance:sidebar-drag-switch"]) SettingsCardDivider() } diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index cac6c01c33db..9b1c2a8f6d91 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -149,6 +149,8 @@ struct SettingsRowAnchorResolutionTests { "sidebar.beta.workspaceTodos.controls.enabled", "sidebar.beta.workspaceTodos.checklistStyle", "sidebarAppearance.matchTerminalBackground", + "sidebarAppearance.tintColor", + "sidebarAppearance.tintOpacity", "shortcuts.showModifierHoldHints", "terminal.agentHibernation.enabled", "terminal.agentHibernation.idleSeconds", @@ -215,6 +217,12 @@ struct SettingsRowAnchorResolutionTests { "setting:browser:http-allowlist", "setting:browser:url-allowlist", "setting:workspaceColors:palette", + "setting:sidebarAppearance:sidebar-liquid-glass", + "setting:sidebarAppearance:sidebar-glass-blur", + "setting:sidebarAppearance:sidebar-peek-reveal", + "setting:sidebarAppearance:sidebar-peek-disabled", + "setting:sidebarAppearance:sidebar-row-density", + "setting:sidebarAppearance:sidebar-drag-switch", "setting:browser:enable-browser", "setting:browserImport:import-data", "setting:browserImport:import-hint", From d1c8c1e396d4dbb5fd4751534df8d0920892628f Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:53:33 +1300 Subject: [PATCH 28/69] workspace mount budget back to main's 1 fbbe32a4b07 raised the mount lru to 4 (and 4 while cycling) so a switch back to a recent workspace was a visibility flip instead of a cold mount. that predates #9244 (warm switch latency fix, 2026-09-06), which fixed the switch lag at the source with readiness signals and dropped the cycle pair on main. the merge brought maxMountedWorkspacesDuringCycle back as an unused constant. 4 is not worth it: - every mounted workspace keeps portal rendering on (WorkspacePortalRenderingPlan -> setPortalRenderingEnabled(true)), so its terminal and browser portal views keep their geometry follow-ups and stay in the layer tree the window walks on every frame and typing update - each kept terminal keeps its surface render targets in memory, times 4 - the switch handoff is already covered: the retiring workspace stays pinned through mountedBackgroundWorkspaceLoadIds while the new one loads - WorkspaceMountPolicyTests.testDefaultPolicyMountsOnlySelectedWorkspace asserts the budget of 1 and fails at 4 so the file goes back to main byte for byte. --- .../CmuxFoundation/Workspace/WorkspaceMountPlan.swift | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceMountPlan.swift b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceMountPlan.swift index 610608195a51..57604eea2bf7 100644 --- a/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceMountPlan.swift +++ b/Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/Workspace/WorkspaceMountPlan.swift @@ -5,14 +5,8 @@ public import Foundation /// holds no state and touches no UI. Construct it with the current mount state /// and read ``mountedWorkspaceIds``. public struct WorkspaceMountPlan: Equatable { - // An LRU of recently used workspaces stays mounted, so switching back - // to a recent tab is a visibility flip instead of a cold mount of its - // whole terminal subtree. One was the old value; every switch paid the - // full mount cost, which read as click latency in the sidebar. - public static let maxMountedWorkspaces = 4 - // During workspace cycling, keep the same budget: rapid flicking is - // exactly when cold mounts hurt most. - public static let maxMountedWorkspacesDuringCycle = 4 + // Keep only the selected workspace mounted to minimize layer-tree traversal. + public static let maxMountedWorkspaces = 1 private let current: [UUID] private let selected: UUID? From 1a02517d0a0868ecd39742567179b1033c438ce4 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Wed, 7 Oct 2026 23:57:23 +1300 Subject: [PATCH 29/69] peek panel: mount only when a card can show, editors get the keyboard, clock-driven blur reset - lazy mount: the card is a second full sidebar list in its own child window, and it was mounted for everyone, docked users included. it now mounts only while a card can appear (floating, or hidden with peek on) and unmounts on dock (SidebarPeekPanelMount, package tested). it still mounts before the edge dwell or titlebar hover asks for a reveal, and a panel mounted already revealed (switch to floating) slides in on first appear instead of popping. detach empties the hosting view so the card's table dismantles cleanly and resets the first-push state. - keyboard: the panel could never become key, so an inline rename or a checklist field in the card took first responder in a window that never got keystrokes (they went to the terminal). the panel now becomes key only while an editable text responder owns its first responder, and hands key back to the parent as soon as it doesn't (makeFirstResponder, plus an event loop pass check for an editor torn down quietly). clicking away ends the edit like a docked row. hover and row clicks never take key. the unused keyboardFocusInside peek hold now keeps the card up while typing. - the 320 ms blur removal sleeps on an injected clock with cancellation, same pattern as the drop fallback. --- .../Peek/SidebarPeekPanelMount.swift | 26 +++++ .../SidebarPeekPanelMountTests.swift | 55 +++++++++ Sources/ContentView+SidebarPeek.swift | 36 +++++- .../Sidebar/Peek/SidebarPeekController.swift | 5 + .../Sidebar/Peek/SidebarPeekPanelHost.swift | 55 ++++++--- .../Sidebar/Peek/SidebarPeekPanelWindow.swift | 96 ++++++++++++++++ .../Peek/SidebarPeekPresentation.swift | 21 +++- cmux.xcodeproj/project.pbxproj | 8 ++ cmuxTests/SidebarPeekPanelWindowTests.swift | 104 ++++++++++++++++++ 9 files changed, 383 insertions(+), 23 deletions(-) create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPanelMount.swift create mode 100644 Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekPanelMountTests.swift create mode 100644 Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift create mode 100644 cmuxTests/SidebarPeekPanelWindowTests.swift diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPanelMount.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPanelMount.swift new file mode 100644 index 000000000000..5b5eb5b4661a --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPanelMount.swift @@ -0,0 +1,26 @@ +/// When the floating sidebar card's window should exist at all. +/// +/// The card is a second full sidebar list in its own child window. A docked, +/// visible sidebar can never show it, so it is not mounted there. It is +/// mounted whenever a card can appear: floating mode, or a hidden sidebar +/// with peek on (mounted ahead of the reveal, so the edge dwell and the +/// titlebar hover find it ready). +public enum SidebarPeekPanelMount { + /// Whether the card's window is needed. + /// + /// - Parameters: + /// - sidebarVisible: Whether the sidebar is shown (docked or floating). + /// - presentationMode: The window's docked or floating mode. + /// - peekEnabled: Whether hover-reveal is on. + /// - peekPresenting: Whether a peek is currently showing the card. + public static func isNeeded( + sidebarVisible: Bool, + presentationMode: SidebarPresentationMode, + peekEnabled: Bool, + peekPresenting: Bool + ) -> Bool { + let occupiesLayout = sidebarVisible && presentationMode == .docked + guard !occupiesLayout else { return false } + return sidebarVisible || peekEnabled || peekPresenting + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekPanelMountTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekPanelMountTests.swift new file mode 100644 index 000000000000..345b2e04208e --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekPanelMountTests.swift @@ -0,0 +1,55 @@ +import Testing +@testable import CmuxSidebar + +@Suite("SidebarPeekPanelMount") +struct SidebarPeekPanelMountTests { + @Test func dockedVisibleSidebarNeverMountsTheCard() { + // The typing setup most people keep: no second list behind it. + for peekEnabled in [false, true] { + for peekPresenting in [false, true] { + #expect(!SidebarPeekPanelMount.isNeeded( + sidebarVisible: true, + presentationMode: .docked, + peekEnabled: peekEnabled, + peekPresenting: peekPresenting + )) + } + } + } + + @Test func floatingVisibleSidebarMountsTheCard() { + #expect(SidebarPeekPanelMount.isNeeded( + sidebarVisible: true, + presentationMode: .floating, + peekEnabled: false, + peekPresenting: false + )) + } + + @Test(arguments: [SidebarPresentationMode.docked, .floating]) + func hiddenSidebarMountsAheadOfAPeekOnlyWhenPeekIsOn(mode: SidebarPresentationMode) { + #expect(SidebarPeekPanelMount.isNeeded( + sidebarVisible: false, + presentationMode: mode, + peekEnabled: true, + peekPresenting: false + )) + #expect(!SidebarPeekPanelMount.isNeeded( + sidebarVisible: false, + presentationMode: mode, + peekEnabled: false, + peekPresenting: false + )) + } + + @Test func aPeekStillShowingKeepsTheCardWhilePeekTurnsOff() { + // The policy can flip off while the card is up; the card leaves + // through the machine, not by its window vanishing first. + #expect(SidebarPeekPanelMount.isNeeded( + sidebarVisible: false, + presentationMode: .docked, + peekEnabled: false, + peekPresenting: true + )) + } +} diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index 1211147f6242..7c44c60862a9 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -94,8 +94,25 @@ extension ContentView { sidebarIsRevealed && sidebarRendersAsCard } + /// Whether the floating card's child window should exist right now. + /// + /// A docked, visible sidebar never needs it, and that is the common + /// typing setup: the card is a second full sidebar list, so keeping it + /// mounted there was pure cost. It mounts as soon as a card can show + /// (floating, or hidden with peek on), which is before the edge dwell + /// or the titlebar hover can ask for a reveal, and unmounts on dock. + var sidebarNeedsPeekPanel: Bool { + SidebarPeekPanelMount.isNeeded( + sidebarVisible: sidebarState.isVisible, + presentationMode: sidebarState.presentationMode, + peekEnabled: sidebarPeek.policy.isEnabled, + peekPresenting: sidebarPeek.presentsPanel + ) + } + /// Zero-sized anchor owning the child panel window that floats the card - /// above the terminal. Mounted from `contentAndSidebarLayout`. + /// above the terminal. Mounted from `sidebarPeekLifecycle` while + /// `sidebarNeedsPeekPanel`. var sidebarPeekPanelHost: some View { let appearance = windowAppearanceSnapshot return SidebarWidthReader(layout: sidebarLayout) { width in @@ -110,6 +127,15 @@ extension ContentView { glassBlurRadius: appearance.usesCompositorGlass && !sidebarState.occupiesLayout ? appearance.sidebarSettings.effectiveCompositorBlurRadius : nil, + onKeyboardFocusChange: { hasFocus in + // Typing into a rename or checklist field in the card + // keeps the peek up even after the pointer wanders off. + if hasFocus { + sidebarPeek.acquire(.keyboardFocusInside) + } else { + sidebarPeek.release(.keyboardFocusInside) + } + }, content: AnyView(sidebarPeekCard(width: width)) ) } @@ -264,14 +290,16 @@ extension ContentView { sidebarPeek.sidebarCollapsed() sidebarToggleHoverSuppressedUntil = Date().addingTimeInterval(0.3) } - .background( + .background { // Zero-sized anchor that owns the floating card's child // window. The card cannot live in this tree: the portal // hosts every terminal surface above the window's SwiftUI // hosting view, so an in-tree card draws underneath the // terminal no matter its zIndex. - sidebarPeekPanelHost - ) + if sidebarNeedsPeekPanel { + sidebarPeekPanelHost + } + } .overlay(alignment: .leading) { // Same slot and layering as the resizer overlay, which is // the proven way in this codebase to receive pointer diff --git a/Sources/Sidebar/Peek/SidebarPeekController.swift b/Sources/Sidebar/Peek/SidebarPeekController.swift index 8bfdeaa17938..b0ee70343740 100644 --- a/Sources/Sidebar/Peek/SidebarPeekController.swift +++ b/Sources/Sidebar/Peek/SidebarPeekController.swift @@ -28,6 +28,11 @@ final class SidebarPeekController: ObservableObject { /// /// - Parameter policy: The new timings and behaviour flags. func setPolicy(_ policy: SidebarPeekPolicy) { + // The policy is not published, but whether peek is on decides if + // the card's window is mounted at all (`SidebarPeekPanelMount`). + if policy.isEnabled != machine.policy.isEnabled { + objectWillChange.send() + } machine = SidebarPeekMachine(policy: policy) guard !policy.isEnabled else { return } send(.escapePressed) diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift index c059e78b5d5e..f52c38ee5da4 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift @@ -36,14 +36,6 @@ extension NSWindow { } } -final class SidebarPeekPanelWindow: NSPanel { - // Never key or main: the parent keeps keyboard focus, so keystrokes keep - // flowing to the terminal while the card is up, and clicking a row cannot - // steal the window's key state out from under the peek gesture. - override var canBecomeKey: Bool { false } - override var canBecomeMain: Bool { false } -} - /// Owns the child panel and keeps it glued to its parent window's leading /// edge. One controller per main window, held by the bridge's coordinator. @MainActor @@ -67,6 +59,12 @@ final class SidebarPeekPanelWindowController { private var appliedBlurRadius: Int? /// Pending blur removal after the card hides; see `syncCompositorBlur`. private var blurResetTask: Task? + /// Clock for the blur-removal delay (injected-clock bounded-delay + /// policy, same as the drop fallback in the reorder controller). + private let blurResetClock: any Clock + /// Told when an editor inside the card takes or gives up the keyboard; + /// see `SidebarPeekPanelWindow`. + var onKeyboardFocusChange: ((Bool) -> Void)? /// Trailing-edge coalescing for content pushes. ContentView can evaluate /// its body many times inside one runloop turn (worst during a workspace /// switch); the first push of a turn lands synchronously so clicks @@ -94,6 +92,10 @@ final class SidebarPeekPanelWindowController { WindowChromeMetrics.appTitlebarHeight + 2 } + init(blurResetClock: any Clock = ContinuousClock()) { + self.blurResetClock = blurResetClock + } + deinit { // Thread-safe by NotificationCenter contract; the panel itself is // torn down by orderOut in detach or by the parent window closing. @@ -175,9 +177,11 @@ final class SidebarPeekPanelWindowController { blurResetTask = nil applyCompositorBlur(radius, to: panel) } else if appliedBlurRadius != nil, blurResetTask == nil { - blurResetTask = Task { @MainActor [weak self] in - guard (try? await Task.sleep(for: .milliseconds(320))) != nil else { return } - guard let self, let panel = self.panel else { return } + // Cancellable: a re-reveal, a docked switch and detach all + // cancel it before it can strip the blur off a showing card. + blurResetTask = Task { @MainActor [weak self, blurResetClock] in + try? await blurResetClock.sleep(for: .milliseconds(320)) + guard let self, !Task.isCancelled, let panel = self.panel else { return } self.blurResetTask = nil self.applyCompositorBlur(nil, to: panel) } @@ -219,6 +223,9 @@ final class SidebarPeekPanelWindowController { panel.collectionBehavior = [.fullScreenAuxiliary] panel.contentView = hosting panel.ignoresMouseEvents = true + panel.onKeyboardFocusChange = { [weak self] hasFocus in + self?.onKeyboardFocusChange?(hasFocus) + } parent.addChildWindow(panel, ordered: .above) @@ -283,12 +290,30 @@ final class SidebarPeekPanelWindowController { } parentObservers = [] if let panel { + // An edit in flight hands key back to the parent before the card + // goes away. The hold release hops a turn: detach can run inside + // a SwiftUI update (dismantle), where publishing is not allowed. + let releasesKeyboardHold = panel.hostsKeyboardEditor + panel.onKeyboardFocusChange = nil + panel.relinquishKeyboardFocus() + if releasesKeyboardHold, let onKeyboardFocusChange { + Task { @MainActor in onKeyboardFocusChange(false) } + } panel.parent?.removeChildWindow(panel) panel.orderOut(nil) } + // Empty the hosting view first so SwiftUI dismantles the card's + // list (its table controller commits drafts and drops observers) + // instead of leaving that to the hosting view's deallocation. + hostingView?.rootView = AnyView(EmptyView()) panel = nil hostingView = nil parentWindow = nil + // A re-attach starts from a hidden first push, so the next reveal + // slides in instead of popping. + hasPushedContent = false + lastRevealed = false + pendingCoalescedContent = nil appliedBlurRadius = nil blurResetTask?.cancel() blurResetTask = nil @@ -298,8 +323,8 @@ final class SidebarPeekPanelWindowController { /// Hosting view that keeps the pointer honest over the card. /// /// The terminal underneath registers an I-beam cursor rect with the key -/// window, and this panel is deliberately never key, so its own cursor rects -/// are ignored: AppKit keeps applying the terminal's I-beam straight through +/// window, and this panel is not key (except while an editor inside it has +/// the keyboard), so its own cursor rects are ignored: AppKit keeps applying the terminal's I-beam straight through /// the card. Re-asserting the arrow on entry and movement is the only lever /// a non-key window has. private final class SidebarPeekPanelHostingView: NSHostingView { @@ -350,6 +375,9 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { /// Compositor blur for the card's window; nil when the card blurs /// through its own material instead. let glassBlurRadius: Int? + /// Told when an editor in the card (rename, checklist) takes or gives + /// up the keyboard, so the owner can hold the peek open meanwhile. + let onKeyboardFocusChange: (Bool) -> Void let content: AnyView @MainActor @@ -381,6 +409,7 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { func updateNSView(_ view: AnchorView, context: Context) { let controller = context.coordinator.controller + controller.onKeyboardFocusChange = onKeyboardFocusChange let contentWidth = contentWidth let metrics = metrics let acceptsMouse = acceptsMouse diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift b/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift new file mode 100644 index 000000000000..52f44fd45885 --- /dev/null +++ b/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift @@ -0,0 +1,96 @@ +import AppKit + +/// The floating sidebar card's window. +/// +/// Not key by default: the parent keeps keyboard focus, so keystrokes keep +/// flowing to the terminal while the card is up, and hovering or clicking a +/// row cannot steal the window's key state out from under the peek gesture. +/// +/// A text editor inside the card (inline rename, a checklist field) is the +/// exception. Typing has to reach it, so the panel becomes key for exactly +/// as long as an editor owns its first responder, then hands key back to +/// the parent window, whose own first responder (the terminal) never moved. +final class SidebarPeekPanelWindow: NSPanel { + /// Called when the panel takes or gives up keyboard focus for an editor, + /// so the owner can hold the peek open while the user types. + var onKeyboardFocusChange: ((Bool) -> Void)? + + /// True while an editor inside the card owns the first responder. + private(set) var hostsKeyboardEditor = false { + didSet { + guard oldValue != hostsKeyboardEditor else { return } + onKeyboardFocusChange?(hostsKeyboardEditor) + } + } + + override var canBecomeKey: Bool { hostsKeyboardEditor } + override var canBecomeMain: Bool { false } + + /// Whether `responder` takes typed text: a field editor or an editable + /// text control. Row views, the table, and buttons do not. + static func takesKeyboardInput(_ responder: NSResponder?) -> Bool { + if let textView = responder as? NSTextView { + return textView.isEditable + } + if let field = responder as? NSTextField { + return field.isEditable + } + return false + } + + /// An editor still attached to this panel owns the first responder. A + /// field editor left behind by a removed field has no window and does + /// not count. + private var editorOwnsFirstResponder: Bool { + guard let responder = firstResponder, Self.takesKeyboardInput(responder) else { return false } + return (responder as? NSView)?.window === self + } + + override func makeFirstResponder(_ responder: NSResponder?) -> Bool { + let wantsKeyboard = Self.takesKeyboardInput(responder) + if wantsKeyboard, !hostsKeyboardEditor { + // Key first, then the responder: the field editor attaches to a + // key window, and the selection it makes on attach is what the + // user's first keystroke replaces. + hostsKeyboardEditor = true + makeKey() + } + let accepted = super.makeFirstResponder(responder) + if accepted, !wantsKeyboard { + relinquishKeyboardFocus() + } else if !accepted, wantsKeyboard, !editorOwnsFirstResponder { + relinquishKeyboardFocus() + } + return accepted + } + + /// Runs on every pass through the event loop. An editor removed from the + /// hierarchy (a committed rename tears its field down) does not always + /// go through `makeFirstResponder`, so this is where the panel notices + /// the editor is gone and gives key back. + override func update() { + super.update() + if hostsKeyboardEditor, !editorOwnsFirstResponder { + relinquishKeyboardFocus() + } + } + + override func resignKey() { + super.resignKey() + guard hostsKeyboardEditor else { return } + // The user clicked elsewhere (usually the terminal). End the edit + // the way a docked row does when focus leaves it: the editor's end + // editing commits, and the rename field tears itself down. + hostsKeyboardEditor = false + _ = super.makeFirstResponder(nil) + } + + /// Drops editor focus and returns key to the parent window. + func relinquishKeyboardFocus() { + guard hostsKeyboardEditor else { return } + hostsKeyboardEditor = false + if isKeyWindow, let parent, parent.isVisible { + parent.makeKey() + } + } +} diff --git a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift index d4597c895ddc..103b8ea3797b 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift @@ -40,6 +40,14 @@ struct SidebarPeekPresentation: ViewModifier { let panelMetrics: SidebarPeekPanelMetrics /// Acquires and releases the pointer hold as the pointer crosses the panel. let onPanelHoverChange: (Bool) -> Void + /// False until the card first appears. The panel is mounted lazily, so + /// it can arrive already revealed (switching to floating); gating on + /// this keeps that first reveal a slide instead of a pop. + @State private var cardHasAppeared = false + + private var showsCard: Bool { + isRevealed && cardHasAppeared + } /// Outer width including the card's leading inset, so floating and docked /// place the list's leading edge identically. @@ -61,19 +69,20 @@ struct SidebarPeekPresentation: ViewModifier { // Slide from just behind the window edge rather than from zero // width. Animating the frame would re-lay-out every row on every // frame of the reveal; translating a laid-out subtree does not. - .offset(x: isRevealed ? 0 : -floatingWidth) - .opacity(isRevealed ? 1 : 0) - .allowsHitTesting(isRevealed) - .accessibilityHidden(!isRevealed) + .offset(x: showsCard ? 0 : -floatingWidth) + .opacity(showsCard ? 1 : 0) + .allowsHitTesting(showsCard) + .accessibilityHidden(!showsCard) .onHover(perform: onPanelHoverChange) + .onAppear { cardHasAppeared = true } // The exit is its own curve: a reveal wants a touch of arrival // settle, but a dismissal should read as the panel leaving the // screen, fast and without ceremony. .animation( - isRevealed + showsCard ? SidebarPeekMotion.reveal : (dismissesInstantly ? nil : SidebarPeekMotion.dismiss), - value: isRevealed + value: showsCard ) } else { // The width itself is live during a toggle: the toggle animator diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 897de5454def..656062040896 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3286,6 +3286,8 @@ 22326FBAA042DDAFA15F818C /* SidebarPeekController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B954AEE8BD37A8D69AA13832 /* SidebarPeekController.swift */; }; F3E95CEBB40A6AA79A9B4CEE /* SidebarPeekEdgeTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C733A9B22B4426FB8C1CD4F /* SidebarPeekEdgeTrackingView.swift */; }; 12262B17C8E63041FCC7513B /* SidebarPeekPanelHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */; }; + BF83CF990A0241B5D77B2234 /* SidebarPeekPanelWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAA6C974800D8AF0C3A15AA2 /* SidebarPeekPanelWindow.swift */; }; + 0FD5BB9FDC316B053390ABDD /* SidebarPeekPanelWindowTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6124B3A20E384A384BF58063 /* SidebarPeekPanelWindowTests.swift */; }; 5842622F8ED35FF5B317C174 /* SidebarPeekPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A9185ED75125180313AF0 /* SidebarPeekPresentation.swift */; }; A80040010000000000000001 /* SidebarPointerFrameReportingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */; }; A80040030000000000000001 /* SidebarPointerInteractionMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */; }; @@ -7714,6 +7716,8 @@ B954AEE8BD37A8D69AA13832 /* SidebarPeekController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekController.swift; sourceTree = ""; }; 2C733A9B22B4426FB8C1CD4F /* SidebarPeekEdgeTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekEdgeTrackingView.swift; sourceTree = ""; }; 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekPanelHost.swift; sourceTree = ""; }; + FAA6C974800D8AF0C3A15AA2 /* SidebarPeekPanelWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekPanelWindow.swift; sourceTree = ""; }; + 6124B3A20E384A384BF58063 /* SidebarPeekPanelWindowTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarPeekPanelWindowTests.swift"; sourceTree = ""; }; 327A9185ED75125180313AF0 /* SidebarPeekPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/Peek/SidebarPeekPresentation.swift; sourceTree = ""; }; A80040020000000000000001 /* SidebarPointerFrameReportingModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPointerFrameReportingModifier.swift; sourceTree = ""; }; A80040040000000000000001 /* SidebarPointerInteractionMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarPointerInteractionMonitor.swift; sourceTree = ""; }; @@ -11997,6 +12001,7 @@ E80734AD866B727FCEA3F6F3 /* SidebarWorkspaceTableController+ReorderDrop.swift */, 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */, 1525C188F2BD8ED27D43C51C /* SidebarGlassSelection.swift */, + FAA6C974800D8AF0C3A15AA2 /* SidebarPeekPanelWindow.swift */, ); path = Sources; sourceTree = ""; @@ -13518,6 +13523,7 @@ A0BBED2106E124DBB2E6A88A /* CLIVersionSkewMessageTests.swift */, F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */, 03F486001BC03CEE3611842F /* GhosttyExternalDisplayScalingTests.swift */, + 6124B3A20E384A384BF58063 /* SidebarPeekPanelWindowTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -16087,6 +16093,7 @@ 22326FBAA042DDAFA15F818C /* SidebarPeekController.swift in Sources */, F3E95CEBB40A6AA79A9B4CEE /* SidebarPeekEdgeTrackingView.swift in Sources */, 12262B17C8E63041FCC7513B /* SidebarPeekPanelHost.swift in Sources */, + BF83CF990A0241B5D77B2234 /* SidebarPeekPanelWindow.swift in Sources */, 5842622F8ED35FF5B317C174 /* SidebarPeekPresentation.swift in Sources */, A80040010000000000000001 /* SidebarPointerFrameReportingModifier.swift in Sources */, A80040030000000000000001 /* SidebarPointerInteractionMonitor.swift in Sources */, @@ -18337,6 +18344,7 @@ 385C6BA7E78DB87460E5D930 /* SidebarMarkdownRendererTests.swift in Sources */, 130160000000000000000001 /* SidebarNewLocalWorkspaceTests.swift in Sources */, CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */, + 0FD5BB9FDC316B053390ABDD /* SidebarPeekPanelWindowTests.swift in Sources */, A80070010000000000000001 /* SidebarPointerInteractionMonitorTests.swift in Sources */, A80040110000000000000001 /* SidebarPointerInteractionScaleTests.swift in Sources */, C0DE48000000000000000001 /* SidebarProviderMenuRegressionTests.swift in Sources */, diff --git a/cmuxTests/SidebarPeekPanelWindowTests.swift b/cmuxTests/SidebarPeekPanelWindowTests.swift new file mode 100644 index 000000000000..d10a9b1aa3fc --- /dev/null +++ b/cmuxTests/SidebarPeekPanelWindowTests.swift @@ -0,0 +1,104 @@ +import AppKit +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite(.serialized) +struct SidebarPeekPanelWindowTests { + @MainActor + private func makePanel() -> (parent: NSWindow, panel: SidebarPeekPanelWindow) { + let parent = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 600, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false + ) + let panel = SidebarPeekPanelWindow( + contentRect: NSRect(x: 0, y: 0, width: 260, height: 400), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + panel.isReleasedWhenClosed = false + panel.contentView = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 400)) + parent.addChildWindow(panel, ordered: .above) + return (parent, panel) + } + + @Test + @MainActor + func plainClicksAndHoverNeverMakeThePanelKey() { + let (parent, panel) = makePanel() + defer { parent.removeChildWindow(panel) } + #expect(!panel.canBecomeKey) + // A row click makes the table (or a row view) first responder; that + // must not pull the keyboard away from the terminal. + let plainView = NSView(frame: NSRect(x: 0, y: 0, width: 20, height: 20)) + panel.contentView?.addSubview(plainView) + _ = panel.makeFirstResponder(plainView) + #expect(!panel.canBecomeKey) + #expect(!panel.hostsKeyboardEditor) + } + + @Test + @MainActor + func anEditorTakesTheKeyboardAndGivesItBackWhenItEnds() { + let (parent, panel) = makePanel() + defer { parent.removeChildWindow(panel) } + var focusChanges: [Bool] = [] + panel.onKeyboardFocusChange = { focusChanges.append($0) } + // Same entry as the inline rename: the field asks its window for + // first responder as soon as it is attached. + let field = SidebarInlineRenameTextField(string: "workspace") + field.frame = NSRect(x: 0, y: 0, width: 200, height: 22) + panel.contentView?.addSubview(field) + #expect(panel.canBecomeKey) + #expect(panel.hostsKeyboardEditor) + #expect(SidebarPeekPanelWindow.takesKeyboardInput(panel.firstResponder)) + + // Commit or cancel tears the field down; whichever way first + // responder moves off the editor, the panel stops taking keys. + field.removeFromSuperview() + _ = panel.makeFirstResponder(nil) + #expect(!panel.canBecomeKey) + #expect(!panel.hostsKeyboardEditor) + #expect(focusChanges == [true, false]) + } + + @Test + @MainActor + func theEventLoopPassReleasesFocusWhenTheEditorVanishedQuietly() { + let (parent, panel) = makePanel() + defer { parent.removeChildWindow(panel) } + let field = SidebarInlineRenameTextField(string: "workspace") + field.frame = NSRect(x: 0, y: 0, width: 200, height: 22) + panel.contentView?.addSubview(field) + #expect(panel.hostsKeyboardEditor) + // No makeFirstResponder call here: the next event loop pass alone + // must notice the editor left the panel. + field.removeFromSuperview() + panel.update() + #expect(!panel.hostsKeyboardEditor) + #expect(!panel.canBecomeKey) + } + + @Test + @MainActor + func onlyEditableTextTakesKeyboardInput() { + let label = NSTextField(labelWithString: "title") + let field = NSTextField(string: "draft") + let readOnlyText = NSTextView() + readOnlyText.isEditable = false + #expect(!SidebarPeekPanelWindow.takesKeyboardInput(nil)) + #expect(!SidebarPeekPanelWindow.takesKeyboardInput(NSView())) + #expect(!SidebarPeekPanelWindow.takesKeyboardInput(NSTableView())) + #expect(!SidebarPeekPanelWindow.takesKeyboardInput(label)) + #expect(!SidebarPeekPanelWindow.takesKeyboardInput(readOnlyText)) + #expect(SidebarPeekPanelWindow.takesKeyboardInput(field)) + #expect(SidebarPeekPanelWindow.takesKeyboardInput(NSTextView())) + } +} From ecb53be8c5b07703a848416ffaa41515fd8ea680 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 00:04:32 +1300 Subject: [PATCH 30/69] sidebar drag: row picture outside the list, lift rebuilt across mid-drag creates and closes - cross-window image: single-row drags set a clear native image because the lifted row is the picture, but the lift cannot leave the list, so a drag taken to another window (or into a pane) showed nothing. the row's snapshot is taken at pickup and the drag shows it whenever the pointer is off the list sideways or outside its window, and goes clear again when it comes back. the in-list lift is unchanged. multi-row drags keep the badge image. the swap rides the existing pointer poll, not movedTo. - structural update mid-drag: a workspace created or closed during a live drag ended the lift (every row dropped to its real frame) and the next poll tick re-lifted from zero, so the parted rows bounced once. the lift is now carried through the apply: snapshot layers and live indent move to the new row views, the session is rebuilt from fresh frames, source range and pinned rect at the same pointer, and every row glides on from where it stood on screen. the insert or remove row animation is skipped under a live drag so frames do not slide under the block. a drop hand-off is untouched. - tests: the lift tests move to cmuxTests/SidebarWorkspaceReorderLiftTests.swift (group grab offset, the rebuild keeps the session and starts every row where it was, the ghost only shows off the list). --- ...paceTableController+ReorderDragGhost.swift | 72 +++++++ ...WorkspaceTableController+ReorderDrop.swift | 13 +- ...WorkspaceTableController+ReorderLift.swift | 44 +++- ...ceTableController+ReorderLiftRebuild.swift | 124 +++++++++++ .../SidebarWorkspaceTableController.swift | 23 ++- cmux.xcodeproj/project.pbxproj | 12 ++ .../SidebarWorkspaceReorderLiftTests.swift | 194 ++++++++++++++++++ cmuxTests/SidebarWorkspaceTableTests.swift | 64 +----- 8 files changed, 466 insertions(+), 80 deletions(-) create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDragGhost.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift create mode 100644 cmuxTests/SidebarWorkspaceReorderLiftTests.swift diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDragGhost.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDragGhost.swift new file mode 100644 index 000000000000..c8a19c7fcc0f --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDragGhost.swift @@ -0,0 +1,72 @@ +import AppKit + +/// The native drag image for a single-row drag that leaves the list. +/// +/// Inside the list the drag has no image on purpose: the real row lifts and +/// follows the pointer (the freeform reorder session). That lift cannot +/// leave the list, so once the pointer goes to another window or into a +/// pane the drag would be invisible. There it shows a snapshot of the row, +/// taken at pickup, and it hides again when the pointer comes back. +extension SidebarWorkspaceTableController { + struct ReorderDragGhost { + let session: NSDraggingSession + let image: NSImage + var isShown = false + } + + /// Horizontal slack around the list that still counts as over it, so a + /// pointer grazing the edge mid-drag does not freeze the lift. + static let reorderListSlack: CGFloat = 48 + + static func reorderPointIsOverList(tablePointX: CGFloat, tableWidth: CGFloat) -> Bool { + tablePointX >= -reorderListSlack && tablePointX <= tableWidth + reorderListSlack + } + + /// Whether the drag should show its own image: off the list sideways, + /// or outside the list's window altogether (another window). + static func reorderDragShowsGhost( + tablePointX: CGFloat, + tableWidth: CGFloat, + windowFrame: NSRect, + screenPoint: NSPoint + ) -> Bool { + !reorderPointIsOverList(tablePointX: tablePointX, tableWidth: tableWidth) + || !windowFrame.contains(screenPoint) + } + + /// A picture of `row` as drawn right now. + func reorderRowImage(tableView: NSTableView, row: Int) -> NSImage? { + let rowRect = tableView.rect(ofRow: row) + guard rowRect.width > 0, rowRect.height > 0, + let representation = tableView.bitmapImageRepForCachingDisplay(in: rowRect) else { return nil } + tableView.cacheDisplay(in: rowRect, to: representation) + let image = NSImage(size: rowRect.size) + image.addRepresentation(representation) + return image + } + + /// Takes the row's picture at pickup, before the lift restyles it. + func prepareReorderDragGhost(session: NSDraggingSession, tableView: NSTableView, row: Int) { + guard let image = reorderRowImage(tableView: tableView, row: row) else { return } + reorderDragGhost = ReorderDragGhost(session: session, image: image) + } + + /// Shows or hides the drag's own image; only touches the session when + /// the answer changes. + func syncReorderDragGhost(shown: Bool) { + guard var ghost = reorderDragGhost, ghost.isShown != shown, + let table = containerView?.tableView else { return } + ghost.isShown = shown + reorderDragGhost = ghost + let image = ghost.image + ghost.session.enumerateDraggingItems( + options: [], for: table, classes: [NSPasteboardItem.self], searchOptions: [:] + ) { item, _, stop in + stop.pointee = true + let contents = shown + ? image + : NSImage(size: item.draggingFrame.size, flipped: false) { _ in true } + item.setDraggingFrame(item.draggingFrame, contents: contents) + } + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift index ea89db9ab30f..88a0046fb7f0 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift @@ -267,17 +267,8 @@ extension SidebarWorkspaceTableController { size: NSSize, count: Int ) -> NSImage? { - let rowRect = tableView.rect(ofRow: row) - guard rowRect.width > 0, - rowRect.height > 0, - size.width > 0, - size.height > 0, - let representation = tableView.bitmapImageRepForCachingDisplay(in: rowRect) else { - return nil - } - tableView.cacheDisplay(in: rowRect, to: representation) - let rowImage = NSImage(size: rowRect.size) - rowImage.addRepresentation(representation) + guard size.width > 0, size.height > 0, + let rowImage = reorderRowImage(tableView: tableView, row: row) else { return nil } let badgeColor = (AppDelegate.shared?.accentColor ?? CmuxAccentColor()).nsColor(for: tableView.effectiveAppearance) return NSImage(size: size, flipped: false) { bounds in diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift index 2f29fde7d0f4..8d26b046ca16 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift @@ -37,11 +37,19 @@ extension SidebarWorkspaceTableController { let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) let table = containerView.tableView let point = table.convert(windowPoint, from: nil) + // Away from the list, the drag carries its own picture of the row + // (another window, a pane); back over it, the lift is the picture. + syncReorderDragGhost(shown: Self.reorderDragShowsGhost( + tablePointX: point.x, + tableWidth: table.bounds.width, + windowFrame: window.frame, + screenPoint: NSEvent.mouseLocation + )) // Outside the sidebar horizontally (a cross-window or into-terminal // excursion): freeze the shift state rather than keep reacting to a // pointer that is no longer about this list. The session ends only // at endedAt. - guard point.x >= -48, point.x <= table.bounds.width + 48 else { return } + guard Self.reorderPointIsOverList(tablePointX: point.x, tableWidth: table.bounds.width) else { return } updateReorderLift(windowPoint: windowPoint, workspaceId: workspaceId) } @@ -73,6 +81,9 @@ extension SidebarWorkspaceTableController { /// The applied translation target per row index, so a row only /// animates when its target actually flips. var appliedTargets: [CGFloat] + /// The pointer the last update placed the block for (window space), + /// so a rebuild after a structural update re-places it exactly. + var lastWindowPoint: NSPoint func height(of unit: Range) -> CGFloat { frames[unit].reduce(0) { $0 + $1.height } + rowSpacing * CGFloat(max(0, unit.count - 1)) @@ -128,8 +139,11 @@ extension SidebarWorkspaceTableController { /// matter what the transaction says. Starting from the presentation /// layer's live value means a retarget mid-glide continues from where /// the row visually is instead of jumping. - func glideRowShift(layer: CALayer, to target: CGFloat) { - let currentY = (layer.presentation() ?? layer) + /// `from` overrides the presentation read when the row's frame just + /// changed under it (a lift rebuild), where the presentation layer still + /// holds an offset from the old frame. + func glideRowShift(layer: CALayer, to target: CGFloat, from start: CGFloat? = nil) { + let currentY = start ?? (layer.presentation() ?? layer) .value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 let spring = CASpringAnimation(keyPath: "transform.translation.y") spring.fromValue = currentY @@ -146,7 +160,9 @@ extension SidebarWorkspaceTableController { } /// Drives the freeform drag visuals for the pointer at `windowPoint`. - func updateReorderLift(windowPoint: NSPoint, workspaceId: UUID) { + /// `carry` is set once, right after a structural update rebuilt the rows + /// under a live drag (see `ReorderLiftCarryOver`). + func updateReorderLift(windowPoint: NSPoint, workspaceId: UUID, carry: ReorderLiftCarryOver? = nil) { guard let containerView else { return } let table = containerView.tableView let point = table.convert(windowPoint, from: nil) @@ -179,15 +195,19 @@ extension SidebarWorkspaceTableController { otherUnits: reorderUnits(excluding: sourceRange, wholeGroups: isGroupDrag), frames: frames, rowSpacing: frames.count > 1 ? max(0, frames[1].minY - frames[0].maxY) : 0, - pointerOffsetY: reorderDragStartWorkspaceId == workspaceId + pointerOffsetY: carry?.pointerOffsetY ?? (reorderDragStartWorkspaceId == workspaceId ? (reorderDragStartPointerOffsetY ?? point.y - frames[sourceRange.lowerBound].minY) - : point.y - frames[sourceRange.lowerBound].minY, - appliedTargets: Array(repeating: 0, count: rows.count) + : point.y - frames[sourceRange.lowerBound].minY), + appliedTargets: Array(repeating: 0, count: rows.count), + lastWindowPoint: windowPoint ) table.reorderPinnedRowsRect = sourceRange.reduce(CGRect.null) { $0.union(frames[$1]) } - installReorderLiftSnapshots(table: table, sourceRange: sourceRange) + if carry.map({ reattachReorderLiftSnapshots($0, table: table, sourceRange: sourceRange) }) != true { + installReorderLiftSnapshots(table: table, sourceRange: sourceRange) + } } guard var session = reorderLiftSession else { return } + session.lastWindowPoint = windowPoint // Keep the same pointer-to-block offset captured at pickup. Centering // a multi-row block under the pointer makes a group jump by roughly @@ -238,7 +258,13 @@ extension SidebarWorkspaceTableController { return } let target = targets[row] - if session.appliedTargets[row] != target { + if let carry { + // First pass after a rebuild: every row continues from where + // it stood on screen before the update, never from zero. + session.appliedTargets[row] = target + layer.zPosition = 0 + continueRebuiltRowShift(layer: layer, row: row, target: target, frameTop: session.frames[row].minY, carry: carry) + } else if session.appliedTargets[row] != target { session.appliedTargets[row] = target layer.zPosition = 0 glideRowShift(layer: layer, to: target) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift new file mode 100644 index 000000000000..fee33176e270 --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift @@ -0,0 +1,124 @@ +import AppKit + +/// Keeping a live drag's lift through a structural update (a workspace +/// created or closed mid-drag). The update moves rows and can swap row +/// views, so the frozen frames, the source range, the pinned rect and the +/// snapshots all go stale. Ending the lift and letting the next poll tick +/// start over dropped every row to its real frame for a frame and glided it +/// out again: one visible bounce. Instead the lift is carried over and +/// rebuilt against the new rows in the same update. +extension SidebarWorkspaceTableController { + /// What survives from the old lift into the rebuilt one. + struct ReorderLiftCarryOver { + let workspaceId: UUID + let pointerOffsetY: CGFloat + let windowPoint: NSPoint + /// Snapshot layers of the lifted rows, in row order, detached from + /// their old row views. Moved as is, so the block keeps the exact + /// picture (and live indent) it had. + let snapshotLayers: [CALayer] + let indent: ( + fill: CALayer?, content: CALayer, fillFrame: CGRect, wasGrouped: Bool, isGrouped: Bool + )? + /// Where each loaded row stood on screen (table space, transform + /// included) before the update. + let visualTops: [SidebarWorkspaceRenderItemID: CGFloat] + } + + /// Detaches the live lift before a structural update touches the table. + /// Call while `previousRows` still describes the table's rows. Returns + /// nil when no drag is live (no session, or a drop is handing its + /// transforms to this very apply). + func detachReorderLiftForRebuild( + previousRows: [SidebarWorkspaceTableRowConfiguration], + isDropHandOff: Bool + ) -> ReorderLiftCarryOver? { + guard !isDropHandOff, let session = reorderLiftSession, + let table = containerView?.tableView else { return nil } + var visualTops: [SidebarWorkspaceRenderItemID: CGFloat] = [:] + table.enumerateAvailableRowViews { rowView, row in + guard previousRows.indices.contains(row) else { return } + let layer = rowView.layer + let shift = (layer?.presentation() ?? layer)? + .value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 + visualTops[previousRows[row].id] = rowView.frame.minY + shift + } + let carry = ReorderLiftCarryOver( + workspaceId: session.workspaceId, + pointerOffsetY: session.pointerOffsetY, + windowPoint: session.lastWindowPoint, + snapshotLayers: reorderLiftSnapshots.map(\.layer), + indent: reorderLiftIndent, + visualTops: visualTops + ) + // Give the old row views their cells back; the layers travel on. + CATransaction.begin() + CATransaction.setDisableActions(true) + for entry in reorderLiftSnapshots { + entry.layer.removeFromSuperlayer() + entry.cell?.isHidden = false + (entry.cell as? SidebarGroupHeaderTableCellView)?.restoreStoredModelPaint() + } + CATransaction.commit() + reorderLiftSnapshots.removeAll() + reorderLiftIndent = nil + return carry + } + + /// Re-lifts the dragged block against the updated rows. The dragged + /// workspace may be gone (closed mid-drag); then the lift simply ends. + func rebuildReorderLift(from carry: ReorderLiftCarryOver) { + endReorderLift(animated: false) + updateReorderLift(windowPoint: carry.windowPoint, workspaceId: carry.workspaceId, carry: carry) + } + + /// Moves the carried snapshot layers onto the rows' current row views. + /// False when the block's row count changed (a member joined or left + /// the dragged group), so the caller takes fresh snapshots instead. + func reattachReorderLiftSnapshots( + _ carry: ReorderLiftCarryOver, + table: NSTableView, + sourceRange: Range + ) -> Bool { + removeReorderLiftSnapshots() + guard !carry.snapshotLayers.isEmpty, carry.snapshotLayers.count == sourceRange.count else { return false } + let rowViews = sourceRange.compactMap { table.rowView(atRow: $0, makeIfNecessary: true) } + guard rowViews.count == sourceRange.count, rowViews.allSatisfy({ $0.layer != nil }) else { return false } + CATransaction.begin() + CATransaction.setDisableActions(true) + for (rowView, snapshot) in zip(rowViews, carry.snapshotLayers) { + guard let rowLayer = rowView.layer else { continue } + let cell = rowView.view(atColumn: 0) as? NSView + snapshot.frame = rowLayer.bounds + rowLayer.addSublayer(snapshot) + cell?.isHidden = true + reorderLiftSnapshots.append((rowView, cell, snapshot)) + } + CATransaction.commit() + reorderLiftIndent = carry.indent + return true + } + + /// The rebuild pass for one displaced row: glide on from where it stood + /// before the update, or land in place when it barely moved (or did not + /// exist yet, like the row just created). + func continueRebuiltRowShift( + layer: CALayer, + row: Int, + target: CGFloat, + frameTop: CGFloat, + carry: ReorderLiftCarryOver + ) { + if rows.indices.contains(row), + let oldTop = carry.visualTops[rows[row].id], + abs(oldTop - frameTop - target) > 0.5 { + glideRowShift(layer: layer, to: target, from: oldTop - frameTop) + return + } + layer.removeAnimation(forKey: "cmux.reorderShift") + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.transform = CATransform3DMakeTranslation(0, target, 0) + CATransaction.commit() + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index 0aec9254d1f5..402e55ea8b0f 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -851,11 +851,15 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS reorderDropCommitFallbackTask?.cancel() reorderDropCommitFallbackTask = nil } + // Under a live drag the lift is carried through the update and + // rebuilt against the new rows below, so nothing bounces. + let reorderLiftCarry = hasStructuralChanges + ? detachReorderLiftForRebuild(previousRows: previousRows, isDropHandOff: handsOffReorderTransforms) + : nil if hasStructuralChanges { // Every branch below moves or reuses rows. A drop commit hands the // lift over here (the real frames take over in the same runloop - // turn); under a live drag the frozen frames are stale, so the - // next poll tick re-lifts from fresh ones. + // turn); a live drag's lift is rebuilt after the update. endReorderLift(animated: false) if forceTableReload { let table = containerView.tableView @@ -914,7 +918,10 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS editIndexes = indexes otherHeightChanges = heightChanges.subtracting(indexes) } + // Not under a live drag: the lift places every row itself, + // and frames still animating would drag the block along. let animates = otherHeightChanges.isEmpty && editIndexes.count <= 3 + && reorderLiftCarry == nil NSAnimationContext.runAnimationGroup { context in context.duration = animates ? 0.22 : 0 context.timingFunction = CAMediaTimingFunction(controlPoints: 0.3, 1.0, 0.4, 1.0) @@ -973,6 +980,9 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS if let reorderSettleOrigin { settleReorderedBlock(ids: reorderSettleOrigin.ids, fromVisualTop: reorderSettleOrigin.visualTop) } + if let reorderLiftCarry { + rebuildReorderLift(from: reorderLiftCarry) + } if hasStructuralChanges || !contentChanges.isEmpty { SidebarNavigationTimings.lapPendingSwitch("applyReached") @@ -1468,6 +1478,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS } } clearPendingWorkspaceDragWriters(preserving: sourceWriter) + reorderDragGhost = nil session.enumerateDraggingItems( options: [], for: tableView, @@ -1508,6 +1519,9 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS flipped: false ) { _ in true } draggingItem.setDraggingFrame(draggingItem.draggingFrame, contents: clearImage) + // The lift stays in the list, so a drag taken to another + // window or a pane gets the row's picture there instead. + prepareReorderDragGhost(session: session, tableView: tableView, row: row) } } // The real row settles via its own transforms; a ghost flying home @@ -1541,6 +1555,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS operation: NSDragOperation ) { stopReorderPoll() + reorderDragGhost = nil // Covers every way a session can end without a local drop (Escape, // release outside, a cross-window drop): if the transforms were not // handed to a commit apply, glide the rows home. @@ -1918,6 +1933,10 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS fill: CALayer?, content: CALayer, fillFrame: CGRect, wasGrouped: Bool, isGrouped: Bool )? + /// The native drag image a single-row drag shows once it leaves the list + /// (see SidebarWorkspaceTableController+ReorderDragGhost.swift). + var reorderDragGhost: ReorderDragGhost? + /// Optimistic press highlight: paints the clicked workspace cell as /// selected immediately and, for a plain click, peels the highlight off diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 656062040896..47874e187232 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3386,6 +3386,7 @@ C9A57607C9A57607C9A57607 /* SidebarWorkspaceReorderDropOverlayTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */; }; C9A57609C9A57609C9A57609 /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */; }; C9A5760FC9A5760FC9A5760F /* SidebarWorkspaceReorderDropView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */; }; + 9E8246E065586E79E5F3A4A7 /* SidebarWorkspaceReorderLiftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32C30979AEC1CA92678696D9 /* SidebarWorkspaceReorderLiftTests.swift */; }; C9A5760BC9A5760BC9A5760B /* SidebarWorkspaceReorderPendingDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */; }; 6707F0076707F0076707F007 /* SidebarWorkspaceRowActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6707F0086707F0086707F008 /* SidebarWorkspaceRowActions.swift */; }; FFD019E8E6AF6AE67B58D00C /* SidebarWorkspaceRowCellView+ReorderLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FCB3A859A840D9593BD8BF /* SidebarWorkspaceRowCellView+ReorderLift.swift */; }; @@ -3425,8 +3426,10 @@ B804A0020000000000000002 /* SidebarWorkspaceTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */; }; B804A00B000000000000000B /* SidebarWorkspaceTableClipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */; }; B804A0030000000000000003 /* SidebarWorkspaceTableContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */; }; + 16E6608F4FECE7B4F9FCE09A /* SidebarWorkspaceTableController+ReorderDragGhost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944B5844794AC86FE82F96AE /* SidebarWorkspaceTableController+ReorderDragGhost.swift */; }; 4F8E29036B2C8FEC8D54E33D /* SidebarWorkspaceTableController+ReorderDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80734AD866B727FCEA3F6F3 /* SidebarWorkspaceTableController+ReorderDrop.swift */; }; 1B6F8636FAA4AF557CAFCE67 /* SidebarWorkspaceTableController+ReorderLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */; }; + A037444F03F4563C6C657936 /* SidebarWorkspaceTableController+ReorderLiftRebuild.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DE45ECEA152871DE70F4D89 /* SidebarWorkspaceTableController+ReorderLiftRebuild.swift */; }; B804A0040000000000000004 /* SidebarWorkspaceTableController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0040000000000000004 /* SidebarWorkspaceTableController.swift */; }; B804A0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift */; }; B804A00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift */; }; @@ -7815,6 +7818,7 @@ C9A57608C9A57608C9A57608 /* SidebarWorkspaceReorderDropOverlayTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlayTarget.swift; sourceTree = ""; }; C9A5760AC9A5760AC9A5760A /* SidebarWorkspaceReorderDropOverlayTargetBridge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropOverlayTargetBridge.swift; sourceTree = ""; }; C9A57610C9A57610C9A57610 /* SidebarWorkspaceReorderDropView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderDropView.swift; sourceTree = ""; }; + 32C30979AEC1CA92678696D9 /* SidebarWorkspaceReorderLiftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceReorderLiftTests.swift"; sourceTree = ""; }; C9A5760CC9A5760CC9A5760C /* SidebarWorkspaceReorderPendingDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceReorderPendingDrop.swift; sourceTree = ""; }; 6707F0086707F0086707F008 /* SidebarWorkspaceRowActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceRowActions.swift; sourceTree = ""; }; 84FCB3A859A840D9593BD8BF /* SidebarWorkspaceRowCellView+ReorderLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift"; sourceTree = ""; }; @@ -7854,8 +7858,10 @@ B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift; sourceTree = ""; }; B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift; sourceTree = ""; }; B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift; sourceTree = ""; }; + 944B5844794AC86FE82F96AE /* SidebarWorkspaceTableController+ReorderDragGhost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDragGhost.swift"; sourceTree = ""; }; E80734AD866B727FCEA3F6F3 /* SidebarWorkspaceTableController+ReorderDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift"; sourceTree = ""; }; 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift"; sourceTree = ""; }; + 8DE45ECEA152871DE70F4D89 /* SidebarWorkspaceTableController+ReorderLiftRebuild.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift"; sourceTree = ""; }; B804B0040000000000000004 /* SidebarWorkspaceTableController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableController.swift; sourceTree = ""; }; B804B0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableDropTargetGeometryGate.swift; sourceTree = ""; }; B804B00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableEmptyDropIndicatorView.swift; sourceTree = ""; }; @@ -12002,6 +12008,8 @@ 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */, 1525C188F2BD8ED27D43C51C /* SidebarGlassSelection.swift */, FAA6C974800D8AF0C3A15AA2 /* SidebarPeekPanelWindow.swift */, + 8DE45ECEA152871DE70F4D89 /* SidebarWorkspaceTableController+ReorderLiftRebuild.swift */, + 944B5844794AC86FE82F96AE /* SidebarWorkspaceTableController+ReorderDragGhost.swift */, ); path = Sources; sourceTree = ""; @@ -13524,6 +13532,7 @@ F1C5794CB5271957708E2E10 /* SwiftUIKeyViewProxyResponderGuardTests.swift */, 03F486001BC03CEE3611842F /* GhosttyExternalDisplayScalingTests.swift */, 6124B3A20E384A384BF58063 /* SidebarPeekPanelWindowTests.swift */, + 32C30979AEC1CA92678696D9 /* SidebarWorkspaceReorderLiftTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -16197,8 +16206,10 @@ B804A0020000000000000002 /* SidebarWorkspaceTableCellView.swift in Sources */, B804A00B000000000000000B /* SidebarWorkspaceTableClipView.swift in Sources */, B804A0030000000000000003 /* SidebarWorkspaceTableContainerView.swift in Sources */, + 16E6608F4FECE7B4F9FCE09A /* SidebarWorkspaceTableController+ReorderDragGhost.swift in Sources */, 4F8E29036B2C8FEC8D54E33D /* SidebarWorkspaceTableController+ReorderDrop.swift in Sources */, 1B6F8636FAA4AF557CAFCE67 /* SidebarWorkspaceTableController+ReorderLift.swift in Sources */, + A037444F03F4563C6C657936 /* SidebarWorkspaceTableController+ReorderLiftRebuild.swift in Sources */, B804A0040000000000000004 /* SidebarWorkspaceTableController.swift in Sources */, B804A0110000000000000011 /* SidebarWorkspaceTableDropTargetGeometryGate.swift in Sources */, B804A00C000000000000000C /* SidebarWorkspaceTableEmptyDropIndicatorView.swift in Sources */, @@ -18367,6 +18378,7 @@ C9A57303C9A57303C9A57303 /* SidebarWorkspaceGroupHeaderMetricsTests.swift in Sources */, 6707F0236707F0236707F023 /* SidebarWorkspaceNotificationIndexTests.swift in Sources */, C9A57305C9A57305C9A57305 /* SidebarWorkspaceReorderDropOverlayHitTestingTests.swift in Sources */, + 9E8246E065586E79E5F3A4A7 /* SidebarWorkspaceReorderLiftTests.swift in Sources */, B8624C9A000000000000009A /* SidebarWorkspaceRowHeightCacheRegressionTests.swift in Sources */, B8624C990000000000000099 /* SidebarWorkspaceRowHeightInvariantTests.swift in Sources */, B8624C0294950000000000A1 /* SidebarWorkspaceRowInlineRenameTests.swift in Sources */, diff --git a/cmuxTests/SidebarWorkspaceReorderLiftTests.swift b/cmuxTests/SidebarWorkspaceReorderLiftTests.swift new file mode 100644 index 000000000000..6b3ab7b82db2 --- /dev/null +++ b/cmuxTests/SidebarWorkspaceReorderLiftTests.swift @@ -0,0 +1,194 @@ +import AppKit +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +#if DEBUG +/// Freeform reorder lift: the block under the pointer, the rows parting +/// around it, and the drag's own image once it leaves the list. +extension SidebarWorkspaceTableTests { + @Test(arguments: [0, 1, 3]) + @MainActor + func groupDragPreservesHeaderGrabOffset(memberCount: Int) async throws { + let (controller, container, window) = makeLiftHost() + defer { controller.dismantleContainerView(container) } + _ = window + let groupId = UUID() + let header = makeLiftRow(groupId: groupId, isGroupHeader: true, height: 28) + let members = (0.. (SidebarWorkspaceTableController, SidebarWorkspaceTableContainerView, NSWindow) { + let controller = SidebarWorkspaceTableController() + let container = controller.makeContainerView() + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 600), + styleMask: [.borderless], + backing: .buffered, + defer: false + ) + window.contentView = container + return (controller, container, window) + } + + @MainActor + private func applyLiftRows( + _ rows: [SidebarWorkspaceTableRowConfiguration], + controller: SidebarWorkspaceTableController, + container: SidebarWorkspaceTableContainerView + ) async -> NSTableView { + controller.apply( + rows: rows, + actions: makeTableActions(), + workspaceIds: rows.map(\.workspaceId), + selectedWorkspaceId: nil, + selectedScrollTargetWorkspaceId: nil + ) + await flushStagedTableMutations() + container.layoutSubtreeIfNeeded() + container.tableView.layoutSubtreeIfNeeded() + return container.tableView + } + + /// Each row's on-screen top (frame plus lift translation), by workspace. + @MainActor + private func liftVisualTops( + rows: [SidebarWorkspaceTableRowConfiguration], + table: NSTableView + ) -> [UUID: CGFloat] { + var tops: [UUID: CGFloat] = [:] + for (index, row) in rows.enumerated() { + let shift = table.rowView(atRow: index, makeIfNecessary: false)?.layer?.transform.m42 ?? 0 + tops[row.workspaceId] = table.rect(ofRow: index).minY + shift + } + return tops + } + + @MainActor + private func makeLiftRow( + groupId: UUID? = nil, + isGroupHeader: Bool = false, + height: CGFloat + ) -> SidebarWorkspaceTableRowConfiguration { + let workspaceId = UUID() + let environment = SidebarWorkspaceTableEnvironmentSnapshot( + colorScheme: .light, + globalFontMagnificationPercent: 100, + lazyContractProbe: SidebarLazyContractProbe() + ) + let content = TestRowContent(token: 0, fixedHeight: height) + return SidebarWorkspaceTableRowConfiguration( + id: isGroupHeader ? .group(groupId ?? workspaceId) : .workspace(workspaceId), + workspaceId: workspaceId, + groupId: groupId, + isGroupHeader: isGroupHeader, + isPinned: false, + environment: environment, + equivalenceValue: content + ) { _, _ in + AnyView(content) + } + } +} +#endif diff --git a/cmuxTests/SidebarWorkspaceTableTests.swift b/cmuxTests/SidebarWorkspaceTableTests.swift index 0f71d32e0183..0eb3aafadc67 100644 --- a/cmuxTests/SidebarWorkspaceTableTests.swift +++ b/cmuxTests/SidebarWorkspaceTableTests.swift @@ -46,56 +46,6 @@ struct SidebarWorkspaceTableTests { } #if DEBUG - @Test(arguments: [0, 1, 3]) - @MainActor - func groupDragPreservesHeaderGrabOffset(memberCount: Int) async throws { - let controller = SidebarWorkspaceTableController() - let container = controller.makeContainerView() - let window = NSWindow( - contentRect: NSRect(x: 0, y: 0, width: 320, height: 600), - styleMask: [.borderless], - backing: .buffered, - defer: false - ) - window.contentView = container - defer { controller.dismantleContainerView(container) } - let groupId = UUID() - let header = makeRowConfiguration(groupId: groupId, isGroupHeader: true, fixedHeight: 28) - let members = (0.. SidebarWorkspaceTableReorderDropUpdate? = { _, _, _ in nil }, beginWorkspaceDrag: @escaping (UUID) -> Void = { _ in }, movingWorkspaceCount: ((UUID) -> Int)? = { _ in 1 }, @@ -1950,7 +1898,7 @@ struct SidebarWorkspaceTableTests { } #endif - private struct TestRowContent: View, Equatable { + struct TestRowContent: View, Equatable { let token: Int let fixedHeight: CGFloat? From d989744f333b181f870a8080aa54d6c3e716d50e Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 00:13:35 +1300 Subject: [PATCH 31/69] file length budget: move cohesive pieces out, no behaviour change every swift file the branch grew is back at or under main's length: - ContentView: SidebarMetadataRows and SidebarMetadataEntryRow to Sources/Sidebar/SidebarMetadataRows.swift (private -> internal, else verbatim) - AppDelegate: WorkspaceGroupNewWorkspaceTarget to its own file - GhosttyTerminalView: TerminalBrowserHostNormalizer and resolveTerminalOpenURLTarget to Sources/TerminalBrowserHostNormalizer.swift - UpdateTitlebarAccessory: TitlebarControlsStyleConfig and TitlebarControlsLayoutModelSnapshot to Sources/Update/TitlebarControlsStyleConfig.swift - SidebarSection: the Dock Max Width bindings to SidebarSection+RightMaxWidth - CuratedSettingEntry+Default: the 8 sidebar customization search entries to CuratedSettingEntry+SidebarCustomization, spliced back in right after match-terminal so declaration order (the search tie-breaker) is unchanged cmuxTests/SidebarWorkspaceTableTests.swift went back to main's length in the previous commit (lift tests moved to their own file). --- .../CuratedSettingEntry+Default.swift | 12 +- ...tedSettingEntry+SidebarCustomization.swift | 29 +++ .../SidebarSection+RightMaxWidth.swift | 58 ++++++ .../Sections/SidebarSection.swift | 58 +----- Sources/AppDelegate.swift | 6 - Sources/ContentView.swift | 163 ----------------- Sources/GhosttyTerminalView.swift | 18 -- Sources/Sidebar/SidebarMetadataRows.swift | 168 ++++++++++++++++++ Sources/TerminalBrowserHostNormalizer.swift | 20 +++ .../Update/TitlebarControlsStyleConfig.swift | 20 +++ Sources/Update/UpdateTitlebarAccessory.swift | 18 -- .../WorkspaceGroupNewWorkspaceTarget.swift | 8 + cmux.xcodeproj/project.pbxproj | 16 ++ 13 files changed, 324 insertions(+), 270 deletions(-) create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+SidebarCustomization.swift create mode 100644 Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightMaxWidth.swift create mode 100644 Sources/Sidebar/SidebarMetadataRows.swift create mode 100644 Sources/TerminalBrowserHostNormalizer.swift create mode 100644 Sources/Update/TitlebarControlsStyleConfig.swift create mode 100644 Sources/WorkspaceGroupNewWorkspaceTarget.swift diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift index dfbc9ef8a9c3..856e03851f6a 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+Default.swift @@ -17,7 +17,7 @@ extension Array where Element == CuratedSettingEntry { /// a different set of entries pass their own array via /// ``SettingsSearchIndex/init(catalog:curatedEntries:)``. public static func cmuxDefault(catalog: SettingCatalog) -> [CuratedSettingEntry] { - appendingDevicesEntries(to: insertingBrowserMemorySaverEntries(between: [ + appendingDevicesEntries(to: insertingSidebarCustomizationEntries(into: insertingBrowserMemorySaverEntries(between: [ // Account / integrations .init(section: .account, id: "account", title: String(localized: "settings.section.account", defaultValue: "Account"), synonyms: "Account auth authentication login logout signin sign-in signout sign-out email user profile stack team"), .init(section: .automation, id: "claude-code", title: String(localized: "settings.automation.claudeCode", defaultValue: "Claude Code Integration"), synonyms: "Claude Code Integration automation.claudeCodeIntegration claude code hooks agent integration status notifications"), @@ -324,14 +324,6 @@ extension Array where Element == CuratedSettingEntry { // Sidebar appearance + sidebar workspace row details .init(section: .sidebarAppearance, id: "match-terminal", title: String(localized: "settings.sidebarAppearance.matchTerminalBackground", defaultValue: "Match Terminal Background"), synonyms: "Match Terminal Background sidebarAppearance.matchTerminalBackground transparent background material terminal background sync"), - .init(section: .sidebarAppearance, id: "sidebar-glass-tint", title: String(localized: "settings.sidebar.tintOpacity", defaultValue: "Tint Opacity"), synonyms: "sidebarAppearance.tintOpacity sidebarTintOpacity glass glassmorphism tint opacity transparency blur floating docked panel peek clear frosted opaque"), - .init(section: .sidebarAppearance, id: "sidebar-liquid-glass", title: String(localized: "settings.sidebar.liquidGlass", defaultValue: "Liquid Glass"), synonyms: "sidebarAppearance.compositorGlass sidebarCompositorGlass liquid glass blur compositor frosted transparency see-through backdrop"), - .init(section: .sidebarAppearance, id: "sidebar-tint-color", title: String(localized: "settings.sidebar.tintColor", defaultValue: "Tint Color"), synonyms: "sidebarAppearance.tintColor sidebarTintHex tint colour color glass hex grey gray"), - .init(section: .sidebarAppearance, id: "sidebar-glass-blur", title: String(localized: "settings.sidebar.blurOpacity", defaultValue: "Blur Opacity"), synonyms: "blur opacity sidebarAppearance.glassBlurRadius sidebarGlassBlurRadius blur radius glass frosted clear see-through transparency backdrop compositor"), - .init(section: .sidebarAppearance, id: "sidebar-peek-reveal", title: String(localized: "settings.sidebar.peekReveal", defaultValue: "Sidebar Peek Reveal Speed"), synonyms: "sidebar.peekReveal peek hover reveal edge dwell instant quick relaxed sensitivity floating sidebar"), - .init(section: .sidebarAppearance, id: "sidebar-peek-disabled", title: String(localized: "settings.sidebar.peekDisabled", defaultValue: "Disable Sidebar Peek"), synonyms: "sidebar.peekDisabled peek hover reveal disable never auto hide floating sidebar edge"), - .init(section: .sidebarAppearance, id: "sidebar-row-density", title: String(localized: "settings.sidebar.rowDensity", defaultValue: "Row Density"), synonyms: "sidebar.rowDensity density compact cozy spacious row height padding workspace list"), - .init(section: .sidebarAppearance, id: "sidebar-drag-switch", title: String(localized: "settings.sidebar.dragSwitchDisabled", defaultValue: "Disable Switch on Drag"), synonyms: "sidebar.dragSwitchDisabled drag reorder switch selection workspace pick up"), .init(section: .sidebarAppearance, id: "hide-sidebar-details", title: String(localized: "settings.app.hideAllSidebarDetails", defaultValue: "Hide All Sidebar Details"), synonyms: "Hide All Sidebar Details sidebar.hideAllDetails compact sidebar hide details only title minimal left rail"), .init(section: .sidebarAppearance, id: "wrap-workspace-titles", title: String(localized: "settings.app.wrapWorkspaceTitles", defaultValue: "Wrap Workspace Titles in Sidebar"), synonyms: "Wrap Workspace Titles in Sidebar sidebar.wrapWorkspaceTitles workspace title wrap multiline pr pull request"), .init(section: .sidebarAppearance, id: "show-workspace-description", title: String(localized: "settings.app.showWorkspaceDescription", defaultValue: "Show Workspace Description in Sidebar"), synonyms: "Show Workspace Description in Sidebar sidebar.showWorkspaceDescription workspace description notes markdown sidebar"), @@ -609,7 +601,7 @@ extension Array where Element == CuratedSettingEntry { // Reset .init(section: .reset, id: "reset-all", title: String(localized: "settings.reset.resetAll", defaultValue: "Reset All Settings"), synonyms: "Reset All Settings factory reset restore defaults clear preferences"), - ])) + terminalGhosttyOptionEntries + sidebarAgentUsageEntries + ]))) + terminalGhosttyOptionEntries + sidebarAgentUsageEntries } private static var keyboardShortcutActionSynonyms: String { diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+SidebarCustomization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+SidebarCustomization.swift new file mode 100644 index 000000000000..4d646ccdd10b --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+SidebarCustomization.swift @@ -0,0 +1,29 @@ +import Foundation + +extension Array where Element == CuratedSettingEntry { + /// `entries` with ``sidebarCustomizationEntries`` placed right after the + /// Match Terminal Background entry, where the rows sit in Settings > + /// Sidebar. A call rather than splitting ``cmuxDefault(catalog:)``'s + /// large literal keeps its contextual type concrete. + static func insertingSidebarCustomizationEntries(into entries: [CuratedSettingEntry]) -> [CuratedSettingEntry] { + var result = entries + let anchor = result.firstIndex { $0.section == .sidebarAppearance && $0.id == "match-terminal" } + result.insert(contentsOf: sidebarCustomizationEntries, at: anchor.map { $0 + 1 } ?? result.endIndex) + return result + } + + /// Search entries for the sidebar glass, peek, density and drag rows + /// (`SidebarSection+Customization`), in row order. + static var sidebarCustomizationEntries: [CuratedSettingEntry] { + [ + .init(section: .sidebarAppearance, id: "sidebar-glass-tint", title: String(localized: "settings.sidebar.tintOpacity", defaultValue: "Tint Opacity"), synonyms: "sidebarAppearance.tintOpacity sidebarTintOpacity glass glassmorphism tint opacity transparency blur floating docked panel peek clear frosted opaque"), + .init(section: .sidebarAppearance, id: "sidebar-liquid-glass", title: String(localized: "settings.sidebar.liquidGlass", defaultValue: "Liquid Glass"), synonyms: "sidebarAppearance.compositorGlass sidebarCompositorGlass liquid glass blur compositor frosted transparency see-through backdrop"), + .init(section: .sidebarAppearance, id: "sidebar-tint-color", title: String(localized: "settings.sidebar.tintColor", defaultValue: "Tint Color"), synonyms: "sidebarAppearance.tintColor sidebarTintHex tint colour color glass hex grey gray"), + .init(section: .sidebarAppearance, id: "sidebar-glass-blur", title: String(localized: "settings.sidebar.blurOpacity", defaultValue: "Blur Opacity"), synonyms: "blur opacity sidebarAppearance.glassBlurRadius sidebarGlassBlurRadius blur radius glass frosted clear see-through transparency backdrop compositor"), + .init(section: .sidebarAppearance, id: "sidebar-peek-reveal", title: String(localized: "settings.sidebar.peekReveal", defaultValue: "Sidebar Peek Reveal Speed"), synonyms: "sidebar.peekReveal peek hover reveal edge dwell instant quick relaxed sensitivity floating sidebar"), + .init(section: .sidebarAppearance, id: "sidebar-peek-disabled", title: String(localized: "settings.sidebar.peekDisabled", defaultValue: "Disable Sidebar Peek"), synonyms: "sidebar.peekDisabled peek hover reveal disable never auto hide floating sidebar edge"), + .init(section: .sidebarAppearance, id: "sidebar-row-density", title: String(localized: "settings.sidebar.rowDensity", defaultValue: "Row Density"), synonyms: "sidebar.rowDensity density compact cozy spacious row height padding workspace list"), + .init(section: .sidebarAppearance, id: "sidebar-drag-switch", title: String(localized: "settings.sidebar.dragSwitchDisabled", defaultValue: "Disable Switch on Drag"), synonyms: "sidebar.dragSwitchDisabled drag reorder switch selection workspace pick up") + ] + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightMaxWidth.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightMaxWidth.swift new file mode 100644 index 000000000000..875ad98b948c --- /dev/null +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+RightMaxWidth.swift @@ -0,0 +1,58 @@ +import CmuxSettings +import SwiftUI + +/// The Dock Max Width row's bindings: the override toggle remembers the +/// last custom width so turning it back on restores it. +extension SidebarSection { + var rightMaxWidthOverrideEnabled: Bool { + rightMaxWidth.current.isFinite && rightMaxWidth.current > 0 + } + var rightMaxWidthOverrideBinding: Binding { + Binding( + get: { rightMaxWidthOverrideEnabled }, + set: { enabled in + if enabled { + let restored = rightSidebarWidthSettings.storedMaximumWidthWhenEnabling( + rememberedStoredValue: rememberedRightMaxWidth.current + ) + rememberedRightMaxWidth.set(restored) + rightMaxWidth.set(restored) + } else { + rememberedRightMaxWidth.set( + rightSidebarWidthSettings.storedRememberedMaximumWidth( + activeStoredValue: rightMaxWidth.current, + rememberedStoredValue: rememberedRightMaxWidth.current + ) + ) + rightMaxWidth.set(RightSidebarWidthSettings.noOverrideValue) + } + } + ) + } + + var rightMaxWidthEditorBinding: Binding { + Binding( + get: { + rightSidebarWidthSettings.editorMaximumWidth( + activeStoredValue: rightMaxWidth.current, + rememberedStoredValue: rememberedRightMaxWidth.current + ) + }, + set: { + let clamped = clampedRightMaxWidth($0) + rememberedRightMaxWidth.set(clamped) + if rightMaxWidthOverrideEnabled { + rightMaxWidth.set(clamped) + } + } + ) + } + + var rightMaxWidthSubtitle: String { + String(localized: "settings.sidebar.rightMaxWidth.subtitle", defaultValue: "Lets the Dock in the right sidebar grow up to this width while leaving room for terminals.") + } + + private func clampedRightMaxWidth(_ value: Double) -> Double { + rightSidebarWidthSettings.clampedSettingsEditorMaximumWidth(value) + } +} diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift index c5779096a9ef..3c92604c5b0e 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection.swift @@ -6,7 +6,7 @@ public struct SidebarSection: View { private let catalog: SettingCatalog let hostActions: SettingsHostActions @State var rightSidebarTabs: [RightSidebarTabSettingsItem] - private let rightSidebarWidthSettings = RightSidebarWidthSettings() + let rightSidebarWidthSettings = RightSidebarWidthSettings() @State private var sidebarFont: SettingsFontSize @State private var fontSaveFailed = false // Not `private`: the customization rows debounce their slider writes @@ -38,8 +38,8 @@ public struct SidebarSection: View { @State var notificationBadgePosition: DefaultsValueModel @State var showMetadata: DefaultsValueModel @State private var compactAgentStatus: DefaultsValueModel - @State private var rightMaxWidth: DefaultsValueModel - @State private var rememberedRightMaxWidth: DefaultsValueModel + @State var rightMaxWidth: DefaultsValueModel + @State var rememberedRightMaxWidth: DefaultsValueModel @State var glassTint: DefaultsValueModel @State var glassBlur: DefaultsValueModel @State var liquidGlass: DefaultsValueModel @@ -152,58 +152,6 @@ public struct SidebarSection: View { if !Task.isCancelled { fontSaveFailed = !saved } } } - private var rightMaxWidthOverrideEnabled: Bool { - rightMaxWidth.current.isFinite && rightMaxWidth.current > 0 - } - private var rightMaxWidthOverrideBinding: Binding { - Binding( - get: { rightMaxWidthOverrideEnabled }, - set: { enabled in - if enabled { - let restored = rightSidebarWidthSettings.storedMaximumWidthWhenEnabling( - rememberedStoredValue: rememberedRightMaxWidth.current - ) - rememberedRightMaxWidth.set(restored) - rightMaxWidth.set(restored) - } else { - rememberedRightMaxWidth.set( - rightSidebarWidthSettings.storedRememberedMaximumWidth( - activeStoredValue: rightMaxWidth.current, - rememberedStoredValue: rememberedRightMaxWidth.current - ) - ) - rightMaxWidth.set(RightSidebarWidthSettings.noOverrideValue) - } - } - ) - } - - private var rightMaxWidthEditorBinding: Binding { - Binding( - get: { - rightSidebarWidthSettings.editorMaximumWidth( - activeStoredValue: rightMaxWidth.current, - rememberedStoredValue: rememberedRightMaxWidth.current - ) - }, - set: { - let clamped = clampedRightMaxWidth($0) - rememberedRightMaxWidth.set(clamped) - if rightMaxWidthOverrideEnabled { - rightMaxWidth.set(clamped) - } - } - ) - } - - private var rightMaxWidthSubtitle: String { - String(localized: "settings.sidebar.rightMaxWidth.subtitle", defaultValue: "Lets the Dock in the right sidebar grow up to this width while leaving room for terminals.") - } - - private func clampedRightMaxWidth(_ value: Double) -> Double { - rightSidebarWidthSettings.clampedSettingsEditorMaximumWidth(value) - } - @ViewBuilder private var mainCard: some View { SettingsCard { diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 516cf59b5125..6b68a263c6f0 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -49,12 +49,6 @@ private enum CmuxThemeNotifications { static let reloadConfig = Notification.Name("com.cmuxterm.themes.reload-config") } -struct WorkspaceGroupNewWorkspaceTarget { - let groupId: UUID - let referenceWorkspaceId: UUID - let placement: WorkspaceGroupNewPlacement -} - /// Owns debug-window coordinators at the application composition root. @MainActor final class CmuxDebugWindowsCoordinator { diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8e8b6d47efce..b428c6b96527 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -17199,169 +17199,6 @@ extension String { } } -private struct SidebarMetadataRows: View { - let entries: [SidebarStatusEntry] - let isActive: Bool - let activeForegroundColor: Color - let activeSecondaryForegroundColor: Color - let fontScale: CGFloat - let onFocus: () -> Void - - @State private var isExpanded: Bool = false - private let collapsedEntryLimit = 3 - - var body: some View { - VStack(alignment: .leading, spacing: 2) { - ForEach(visibleEntries, id: \.key) { entry in - SidebarMetadataEntryRow( - entry: entry, - isActive: isActive, - activeForegroundColor: activeForegroundColor, - fontScale: fontScale, - onFocus: onFocus - ) - } - - if shouldShowToggle { - Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { - onFocus() - withAnimation(.easeInOut(duration: 0.15)) { - isExpanded.toggle() - } - } - .buttonStyle(.plain) - .cmuxFont(size: 10 * fontScale, weight: .semibold) - .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .safeHelp(helpText) - } - - private var visibleEntries: [SidebarStatusEntry] { - guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } - return Array(entries.prefix(collapsedEntryLimit)) - } - - private var helpText: String { - entries.map(\.sidebarHelpText) - .joined(separator: "\n") - } - - private var shouldShowToggle: Bool { - entries.count > collapsedEntryLimit - } -} - -private struct SidebarMetadataEntryRow: View { - let entry: SidebarStatusEntry - let isActive: Bool - let activeForegroundColor: Color - let fontScale: CGFloat - let onFocus: () -> Void - @Environment(\.colorScheme) private var colorScheme - @Environment(\.cmuxAccentColor) private var cmuxAccent - - var body: some View { - Group { - if let url = entry.url { - Button { - onFocus() - NSWorkspace.shared.open(url) - } label: { - rowContent(underlined: true) - } - .buttonStyle(.plain) - .safeHelp(entry.sidebarToolTip(linkURL: url) ?? url.absoluteString) - } else { - rowContent(underlined: false) - .contentShape(Rectangle()) - .onTapGesture { onFocus() } - } - } - } - - @ViewBuilder - private func rowContent(underlined: Bool) -> some View { - HStack(alignment: .center, spacing: 4) { - if let icon = iconView { - // `emoji:` / `text:` icons are SwiftUI text and take the row - // color from here; the SF Symbol branch bakes it as `tint`. - icon - .foregroundColor(foregroundColor.opacity(0.95)) - } - metadataText(underlined: underlined) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) - } - .cmuxFont(size: 10 * fontScale) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private var foregroundColor: Color { - let explicit = cmuxAccent.statusEntryColor(hex: entry.color, isDark: colorScheme == .dark) - if isActive, explicit != nil { - return activeForegroundColor - } - if let explicit { - return Color(nsColor: explicit) - } - return isActive ? activeForegroundColor.opacity(0.84) : .secondary - } - - private var iconView: AnyView? { - guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), - !iconRaw.isEmpty else { - return nil - } - if iconRaw.hasPrefix("emoji:") { - let value = String(iconRaw.dropFirst("emoji:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).cmuxFont(size: 9 * fontScale)) - } - if iconRaw.hasPrefix("text:") { - let value = String(iconRaw.dropFirst("text:".count)) - guard !value.isEmpty else { return nil } - return AnyView(Text(value).cmuxFont(size: 8 * fontScale, weight: .semibold)) - } - let symbolName: String - if iconRaw.hasPrefix("sf:") { - symbolName = String(iconRaw.dropFirst("sf:".count)) - } else { - symbolName = iconRaw - } - guard !symbolName.isEmpty else { return nil } - return AnyView(CmuxSystemSymbolImage( - magnified: symbolName, - pointSize: 8 * fontScale, - weight: .medium, - tint: foregroundColor.opacity(0.95) - )) - } - - @ViewBuilder - private func metadataText(underlined: Bool) -> some View { - let display = entry.sidebarDisplayText - if entry.format == .markdown, - let parsed = try? AttributedString( - markdown: display, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) - ) { - let attributed = parsed.applyingSidebarRowLinkPolicy( - activeForegroundColor: isActive ? foregroundColor : nil - ) - Text(attributed) - .underline(underlined) - .foregroundColor(foregroundColor) - } else { - Text(display) - .underline(underlined) - .foregroundColor(foregroundColor) - } - } -} - private struct SidebarMetadataMarkdownBlocks: View { let blocks: [SidebarMetadataBlock] let isActive: Bool diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 259feae30d1e..5d50b2c000a7 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -70,24 +70,6 @@ private func cmuxRuntimeReadClipboardCallback( // CmuxTerminalCore). The process-wide instance is the transitional // GhosttyApp.terminalPasteboard composition static below. -/// The app-side conformance injected into ``TerminalLinkRouter``: terminal -/// links validate hosts and resolve bare domains through the same browser -/// rules the embedded browser uses. -struct TerminalBrowserHostNormalizer: BrowserHostNormalizing { - func normalizedHost(_ rawHost: String) -> String? { - BrowserInsecureHTTPSettings.normalizeHost(rawHost) - } - - func navigableWebURL(_ input: String) -> URL? { - resolveBrowserNavigableURL(input) - } -} - -func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { - TerminalLinkRouter(hostNormalizer: TerminalBrowserHostNormalizer()) - .resolveOpenURLTarget(rawValue) -} - private var terminalKeyboardCopyModeIndicatorText: String { String(localized: "ghostty.copy-mode.indicator", defaultValue: "vim") } diff --git a/Sources/Sidebar/SidebarMetadataRows.swift b/Sources/Sidebar/SidebarMetadataRows.swift new file mode 100644 index 000000000000..75b69136f3a3 --- /dev/null +++ b/Sources/Sidebar/SidebarMetadataRows.swift @@ -0,0 +1,168 @@ +import AppKit +import CmuxFoundation +import CmuxSidebar +import SwiftUI + +/// Status metadata entries under a workspace row in the SwiftUI sidebar. +struct SidebarMetadataRows: View { + let entries: [SidebarStatusEntry] + let isActive: Bool + let activeForegroundColor: Color + let activeSecondaryForegroundColor: Color + let fontScale: CGFloat + let onFocus: () -> Void + + @State private var isExpanded: Bool = false + private let collapsedEntryLimit = 3 + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + ForEach(visibleEntries, id: \.key) { entry in + SidebarMetadataEntryRow( + entry: entry, + isActive: isActive, + activeForegroundColor: activeForegroundColor, + fontScale: fontScale, + onFocus: onFocus + ) + } + + if shouldShowToggle { + Button(isExpanded ? String(localized: "sidebar.metadata.showLess", defaultValue: "Show less") : String(localized: "sidebar.metadata.showMore", defaultValue: "Show more")) { + onFocus() + withAnimation(.easeInOut(duration: 0.15)) { + isExpanded.toggle() + } + } + .buttonStyle(.plain) + .cmuxFont(size: 10 * fontScale, weight: .semibold) + .foregroundColor(isActive ? activeSecondaryForegroundColor : .secondary.opacity(0.9)) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .safeHelp(helpText) + } + + private var visibleEntries: [SidebarStatusEntry] { + guard !isExpanded, entries.count > collapsedEntryLimit else { return entries } + return Array(entries.prefix(collapsedEntryLimit)) + } + + private var helpText: String { + entries.map(\.sidebarHelpText) + .joined(separator: "\n") + } + + private var shouldShowToggle: Bool { + entries.count > collapsedEntryLimit + } +} + +struct SidebarMetadataEntryRow: View { + let entry: SidebarStatusEntry + let isActive: Bool + let activeForegroundColor: Color + let fontScale: CGFloat + let onFocus: () -> Void + @Environment(\.colorScheme) private var colorScheme + @Environment(\.cmuxAccentColor) private var cmuxAccent + + var body: some View { + Group { + if let url = entry.url { + Button { + onFocus() + NSWorkspace.shared.open(url) + } label: { + rowContent(underlined: true) + } + .buttonStyle(.plain) + .safeHelp(entry.sidebarToolTip(linkURL: url) ?? url.absoluteString) + } else { + rowContent(underlined: false) + .contentShape(Rectangle()) + .onTapGesture { onFocus() } + } + } + } + + @ViewBuilder + private func rowContent(underlined: Bool) -> some View { + HStack(alignment: .center, spacing: 4) { + if let icon = iconView { + // `emoji:` / `text:` icons are SwiftUI text and take the row + // color from here; the SF Symbol branch bakes it as `tint`. + icon + .foregroundColor(foregroundColor.opacity(0.95)) + } + metadataText(underlined: underlined) + .lineLimit(1) + .truncationMode(.tail) + Spacer(minLength: 0) + } + .cmuxFont(size: 10 * fontScale) + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var foregroundColor: Color { + let explicit = cmuxAccent.statusEntryColor(hex: entry.color, isDark: colorScheme == .dark) + if isActive, explicit != nil { + return activeForegroundColor + } + if let explicit { + return Color(nsColor: explicit) + } + return isActive ? activeForegroundColor.opacity(0.84) : .secondary + } + + private var iconView: AnyView? { + guard let iconRaw = entry.icon?.trimmingCharacters(in: .whitespacesAndNewlines), + !iconRaw.isEmpty else { + return nil + } + if iconRaw.hasPrefix("emoji:") { + let value = String(iconRaw.dropFirst("emoji:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).cmuxFont(size: 9 * fontScale)) + } + if iconRaw.hasPrefix("text:") { + let value = String(iconRaw.dropFirst("text:".count)) + guard !value.isEmpty else { return nil } + return AnyView(Text(value).cmuxFont(size: 8 * fontScale, weight: .semibold)) + } + let symbolName: String + if iconRaw.hasPrefix("sf:") { + symbolName = String(iconRaw.dropFirst("sf:".count)) + } else { + symbolName = iconRaw + } + guard !symbolName.isEmpty else { return nil } + return AnyView(CmuxSystemSymbolImage( + magnified: symbolName, + pointSize: 8 * fontScale, + weight: .medium, + tint: foregroundColor.opacity(0.95) + )) + } + + @ViewBuilder + private func metadataText(underlined: Bool) -> some View { + let display = entry.sidebarDisplayText + if entry.format == .markdown, + let parsed = try? AttributedString( + markdown: display, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace) + ) { + let attributed = parsed.applyingSidebarRowLinkPolicy( + activeForegroundColor: isActive ? foregroundColor : nil + ) + Text(attributed) + .underline(underlined) + .foregroundColor(foregroundColor) + } else { + Text(display) + .underline(underlined) + .foregroundColor(foregroundColor) + } + } +} diff --git a/Sources/TerminalBrowserHostNormalizer.swift b/Sources/TerminalBrowserHostNormalizer.swift new file mode 100644 index 000000000000..4e966e613b9c --- /dev/null +++ b/Sources/TerminalBrowserHostNormalizer.swift @@ -0,0 +1,20 @@ +import CmuxTerminalCore +import Foundation + +/// The app-side conformance injected into ``TerminalLinkRouter``: terminal +/// links validate hosts and resolve bare domains through the same browser +/// rules the embedded browser uses. +struct TerminalBrowserHostNormalizer: BrowserHostNormalizing { + func normalizedHost(_ rawHost: String) -> String? { + BrowserInsecureHTTPSettings.normalizeHost(rawHost) + } + + func navigableWebURL(_ input: String) -> URL? { + resolveBrowserNavigableURL(input) + } +} + +func resolveTerminalOpenURLTarget(_ rawValue: String) -> TerminalOpenURLTarget? { + TerminalLinkRouter(hostNormalizer: TerminalBrowserHostNormalizer()) + .resolveOpenURLTarget(rawValue) +} diff --git a/Sources/Update/TitlebarControlsStyleConfig.swift b/Sources/Update/TitlebarControlsStyleConfig.swift new file mode 100644 index 000000000000..2e262947b407 --- /dev/null +++ b/Sources/Update/TitlebarControlsStyleConfig.swift @@ -0,0 +1,20 @@ +import AppKit +import SwiftUI + +struct TitlebarControlsStyleConfig { + let spacing: CGFloat + let iconSize: CGFloat + let buttonSize: CGFloat + let badgeSize: CGFloat + let badgeOffset: CGSize + let groupBackground: Bool + let groupPadding: EdgeInsets + let buttonBackground: Bool + let buttonCornerRadius: CGFloat + let hoverBackground: Bool +} + +struct TitlebarControlsLayoutModelSnapshot: Equatable { + let style: TitlebarControlsStyle + let contentSize: NSSize +} diff --git a/Sources/Update/UpdateTitlebarAccessory.swift b/Sources/Update/UpdateTitlebarAccessory.swift index c6d281e5ba93..68c652ee4ae3 100644 --- a/Sources/Update/UpdateTitlebarAccessory.swift +++ b/Sources/Update/UpdateTitlebarAccessory.swift @@ -128,24 +128,6 @@ enum TitlebarControlsStyle: Int, CaseIterable, Identifiable { } } -struct TitlebarControlsStyleConfig { - let spacing: CGFloat - let iconSize: CGFloat - let buttonSize: CGFloat - let badgeSize: CGFloat - let badgeOffset: CGSize - let groupBackground: Bool - let groupPadding: EdgeInsets - let buttonBackground: Bool - let buttonCornerRadius: CGFloat - let hoverBackground: Bool -} - -struct TitlebarControlsLayoutModelSnapshot: Equatable { - let style: TitlebarControlsStyle - let contentSize: NSSize -} - /// Owns the expensive shortcut/font-derived titlebar size once for every /// titlebar surface. Unrelated defaults and notification activity must not /// invalidate titlebar geometry. diff --git a/Sources/WorkspaceGroupNewWorkspaceTarget.swift b/Sources/WorkspaceGroupNewWorkspaceTarget.swift new file mode 100644 index 000000000000..b7bb07678321 --- /dev/null +++ b/Sources/WorkspaceGroupNewWorkspaceTarget.swift @@ -0,0 +1,8 @@ +import CmuxSettings +import Foundation + +struct WorkspaceGroupNewWorkspaceTarget { + let groupId: UUID + let referenceWorkspaceId: UUID + let placement: WorkspaceGroupNewPlacement +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 47874e187232..0a809235e7a3 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3279,6 +3279,7 @@ 385C6BA7E78DB87460E5D930 /* SidebarMarkdownRendererTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */; }; A6AC73F20000000000000001 /* SidebarMediaActivityIndicators.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73F20000000000000002 /* SidebarMediaActivityIndicators.swift */; }; C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */; }; + C35940171171F069B78CD8C5 /* SidebarMetadataRows.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9A19AD78B9125704236916C /* SidebarMetadataRows.swift */; }; 72AC501F0A669B401708CCC4 /* SidebarNavigationTimings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AAA0330CB6FD8B7279F8398 /* SidebarNavigationTimings.swift */; }; 130160000000000000000001 /* SidebarNewLocalWorkspaceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 130160000000000000000002 /* SidebarNewLocalWorkspaceTests.swift */; }; CB23911D7E131E8FBC9B82B6 /* SidebarOrderingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */; }; @@ -3694,6 +3695,7 @@ A10199010000000000000001 /* TerminalAdaptiveDefaultThemeSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10199010000000000000002 /* TerminalAdaptiveDefaultThemeSettings.swift */; }; C10190010000000000000002 /* TerminalAgentPanelInitialTitleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C10190010000000000000001 /* TerminalAgentPanelInitialTitleTests.swift */; }; 46F6AC15863EC84DCD3770A2 /* TerminalAndGhosttyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */; }; + 8EFCC158465F186D84D2BD9F /* TerminalBrowserHostNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 832F4DCA73AF2BCDC0DA6C9D /* TerminalBrowserHostNormalizer.swift */; }; 8362A0030000000000000003 /* TerminalCallerTTYBinding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8362A0040000000000000004 /* TerminalCallerTTYBinding.swift */; }; 8362A0010000000000000001 /* TerminalCallerTTYResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8362A0020000000000000002 /* TerminalCallerTTYResolver.swift */; }; 8362B0010000000000000001 /* TerminalCallerTTYResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8362B0020000000000000002 /* TerminalCallerTTYResolverTests.swift */; }; @@ -4099,6 +4101,7 @@ F50030050000000000000001 /* TitlebarAccessoryContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F50030050000000000000002 /* TitlebarAccessoryContainerView.swift */; }; F50030060000000000000001 /* TitlebarAccessoryHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F50030060000000000000002 /* TitlebarAccessoryHostingView.swift */; }; C4160A060000000000000001 /* TitlebarChromeGeometryReporting.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A060000000000000002 /* TitlebarChromeGeometryReporting.swift */; }; + 26122C8C4C2FD6A2A97800DD /* TitlebarControlsStyleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = D83581C030F97F43F72D421C /* TitlebarControlsStyleConfig.swift */; }; F50030020000000000000001 /* TitlebarInteractiveControlModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = F50030020000000000000002 /* TitlebarInteractiveControlModifier.swift */; }; F50030040000000000000001 /* TitlebarInteractiveControlTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F50030040000000000000002 /* TitlebarInteractiveControlTests.swift */; }; C4160A070000000000000001 /* TitlebarLayoutDebugWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4160A070000000000000002 /* TitlebarLayoutDebugWindow.swift */; }; @@ -4378,6 +4381,7 @@ C9A57203C9A57203C9A57203 /* WorkspaceGroupMenuSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */; }; C9A57206C9A57206C9A57206 /* WorkspaceGroupMoveToMenuState.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57205C9A57205C9A57205 /* WorkspaceGroupMoveToMenuState.swift */; }; C9A57401C9A57401C9A57401 /* WorkspaceGroupMoveToMenuStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */; }; + F9191A2BEA8DD03D4B9382C1 /* WorkspaceGroupNewWorkspaceTarget.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16A1380FA28417F95C922EA /* WorkspaceGroupNewWorkspaceTarget.swift */; }; 9164A0019164A0019164A001 /* WorkspaceGroupNumberedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9164A0029164A0029164A002 /* WorkspaceGroupNumberedSelectionTests.swift */; }; C9A57001C9A57001C9A57001 /* WorkspaceGroupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */; }; D92310030000000000000001 /* WorkspaceHandoffRetirementGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D92310030000000000000002 /* WorkspaceHandoffRetirementGate.swift */; }; @@ -7712,6 +7716,7 @@ F1C3F1DBF6BF5D7223C4A30C /* SidebarMarkdownRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMarkdownRendererTests.swift; sourceTree = ""; }; A6AC73F20000000000000002 /* SidebarMediaActivityIndicators.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarMediaActivityIndicators.swift; sourceTree = ""; }; C0DE5F210000000000000002 /* SidebarMetadataMarkdownRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarMetadataMarkdownRenderer.swift; sourceTree = ""; }; + D9A19AD78B9125704236916C /* SidebarMetadataRows.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarMetadataRows.swift; sourceTree = ""; }; 0AAA0330CB6FD8B7279F8398 /* SidebarNavigationTimings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarNavigationTimings.swift; sourceTree = ""; }; 130160000000000000000002 /* SidebarNewLocalWorkspaceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarNewLocalWorkspaceTests.swift; sourceTree = ""; }; BC39DE4B96D1931C52AF7D68 /* SidebarOrderingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarOrderingTests.swift; sourceTree = ""; }; @@ -8118,6 +8123,7 @@ A10199010000000000000002 /* TerminalAdaptiveDefaultThemeSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAdaptiveDefaultThemeSettings.swift; sourceTree = ""; }; C10190010000000000000001 /* TerminalAgentPanelInitialTitleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAgentPanelInitialTitleTests.swift; sourceTree = ""; }; 02FC74F2C27127CC565B3E8C /* TerminalAndGhosttyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalAndGhosttyTests.swift; sourceTree = ""; }; + 832F4DCA73AF2BCDC0DA6C9D /* TerminalBrowserHostNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalBrowserHostNormalizer.swift; sourceTree = ""; }; 8362A0040000000000000004 /* TerminalCallerTTYBinding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCallerTTYBinding.swift; sourceTree = ""; }; 8362A0020000000000000002 /* TerminalCallerTTYResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCallerTTYResolver.swift; sourceTree = ""; }; 8362B0020000000000000002 /* TerminalCallerTTYResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalCallerTTYResolverTests.swift; sourceTree = ""; }; @@ -8523,6 +8529,7 @@ F50030050000000000000002 /* TitlebarAccessoryContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarAccessoryContainerView.swift; sourceTree = ""; }; F50030060000000000000002 /* TitlebarAccessoryHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarAccessoryHostingView.swift; sourceTree = ""; }; C4160A060000000000000002 /* TitlebarChromeGeometryReporting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/TitlebarChromeGeometryReporting.swift; sourceTree = ""; }; + D83581C030F97F43F72D421C /* TitlebarControlsStyleConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Update/TitlebarControlsStyleConfig.swift; sourceTree = ""; }; F50030020000000000000002 /* TitlebarInteractiveControlModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarInteractiveControlModifier.swift; sourceTree = ""; }; F50030040000000000000002 /* TitlebarInteractiveControlTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarInteractiveControlTests.swift; sourceTree = ""; }; C4160A070000000000000002 /* TitlebarLayoutDebugWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitlebarLayoutDebugWindow.swift; sourceTree = ""; }; @@ -8798,6 +8805,7 @@ C9A57204C9A57204C9A57204 /* WorkspaceGroupMenuSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMenuSnapshot.swift; sourceTree = ""; }; C9A57205C9A57205C9A57205 /* WorkspaceGroupMoveToMenuState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMoveToMenuState.swift; sourceTree = ""; }; C9A57402C9A57402C9A57402 /* WorkspaceGroupMoveToMenuStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupMoveToMenuStateTests.swift; sourceTree = ""; }; + A16A1380FA28417F95C922EA /* WorkspaceGroupNewWorkspaceTarget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupNewWorkspaceTarget.swift; sourceTree = ""; }; 9164A0029164A0029164A002 /* WorkspaceGroupNumberedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupNumberedSelectionTests.swift; sourceTree = ""; }; C9A57002C9A57002C9A57002 /* WorkspaceGroupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceGroupTests.swift; sourceTree = ""; }; D92310030000000000000002 /* WorkspaceHandoffRetirementGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceHandoffRetirementGate.swift; sourceTree = ""; }; @@ -12010,6 +12018,10 @@ FAA6C974800D8AF0C3A15AA2 /* SidebarPeekPanelWindow.swift */, 8DE45ECEA152871DE70F4D89 /* SidebarWorkspaceTableController+ReorderLiftRebuild.swift */, 944B5844794AC86FE82F96AE /* SidebarWorkspaceTableController+ReorderDragGhost.swift */, + D9A19AD78B9125704236916C /* SidebarMetadataRows.swift */, + 832F4DCA73AF2BCDC0DA6C9D /* TerminalBrowserHostNormalizer.swift */, + D83581C030F97F43F72D421C /* TitlebarControlsStyleConfig.swift */, + A16A1380FA28417F95C922EA /* WorkspaceGroupNewWorkspaceTarget.swift */, ); path = Sources; sourceTree = ""; @@ -16097,6 +16109,7 @@ 5732A0125732A0125732A012 /* SidebarLazyContractProbeKey.swift in Sources */, A6AC73F20000000000000001 /* SidebarMediaActivityIndicators.swift in Sources */, C0DE5F210000000000000001 /* SidebarMetadataMarkdownRenderer.swift in Sources */, + C35940171171F069B78CD8C5 /* SidebarMetadataRows.swift in Sources */, 72AC501F0A669B401708CCC4 /* SidebarNavigationTimings.swift in Sources */, EA1F00000000000000000001 /* SidebarPathFormatter.swift in Sources */, 22326FBAA042DDAFA15F818C /* SidebarPeekController.swift in Sources */, @@ -16383,6 +16396,7 @@ 0044CBDD567D9CD29E6A6F12 /* TelemetrySettings.swift in Sources */, D63161E354D8403AB167AD2A /* TerminalAccessibilityText.swift in Sources */, A10199010000000000000001 /* TerminalAdaptiveDefaultThemeSettings.swift in Sources */, + 8EFCC158465F186D84D2BD9F /* TerminalBrowserHostNormalizer.swift in Sources */, 8362A0030000000000000003 /* TerminalCallerTTYBinding.swift in Sources */, 8362A0010000000000000001 /* TerminalCallerTTYResolver.swift in Sources */, B8835A000000000000000001 /* TerminalClipboardInputSequencer+State.swift in Sources */, @@ -16715,6 +16729,7 @@ F50030050000000000000001 /* TitlebarAccessoryContainerView.swift in Sources */, F50030060000000000000001 /* TitlebarAccessoryHostingView.swift in Sources */, C4160A060000000000000001 /* TitlebarChromeGeometryReporting.swift in Sources */, + 26122C8C4C2FD6A2A97800DD /* TitlebarControlsStyleConfig.swift in Sources */, F50030020000000000000001 /* TitlebarInteractiveControlModifier.swift in Sources */, C4160A070000000000000001 /* TitlebarLayoutDebugWindow.swift in Sources */, C10D00110000000000000011 /* TitlebarNewWorkspaceSplitButton.swift in Sources */, @@ -16915,6 +16930,7 @@ F0ACC0DE000000000000000B /* WorkspaceForkAgentConversationAvailability.swift in Sources */, C9A57203C9A57203C9A57203 /* WorkspaceGroupMenuSnapshot.swift in Sources */, C9A57206C9A57206C9A57206 /* WorkspaceGroupMoveToMenuState.swift in Sources */, + F9191A2BEA8DD03D4B9382C1 /* WorkspaceGroupNewWorkspaceTarget.swift in Sources */, D92310030000000000000001 /* WorkspaceHandoffRetirementGate.swift in Sources */, E3B7A4000000000000000007 /* WorkspaceIndicatorStyle+Display.swift in Sources */, A17C0A110000000000000001 /* WorkspaceInitialCommandLoginShell.swift in Sources */, From 02816ae413d00b98c08c941f8bdfe4b8147ea0c4 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 00:17:46 +1300 Subject: [PATCH 32/69] peek panel mount rule as a SidebarPresentationMode method, not a namespace enum (package conventions lint) --- .../Peek/SidebarPeekPanelMount.swift | 26 ------------------ .../SidebarPresentationMode+PeekPanel.swift | 24 +++++++++++++++++ ...debarPresentationModePeekPanelTests.swift} | 27 +++++-------------- Sources/ContentView+SidebarPeek.swift | 3 +-- .../Sidebar/Peek/SidebarPeekController.swift | 2 +- 5 files changed, 33 insertions(+), 49 deletions(-) delete mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPanelMount.swift create mode 100644 Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode+PeekPanel.swift rename Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/{SidebarPeekPanelMountTests.swift => SidebarPresentationModePeekPanelTests.swift} (60%) diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPanelMount.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPanelMount.swift deleted file mode 100644 index 5b5eb5b4661a..000000000000 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPeekPanelMount.swift +++ /dev/null @@ -1,26 +0,0 @@ -/// When the floating sidebar card's window should exist at all. -/// -/// The card is a second full sidebar list in its own child window. A docked, -/// visible sidebar can never show it, so it is not mounted there. It is -/// mounted whenever a card can appear: floating mode, or a hidden sidebar -/// with peek on (mounted ahead of the reveal, so the edge dwell and the -/// titlebar hover find it ready). -public enum SidebarPeekPanelMount { - /// Whether the card's window is needed. - /// - /// - Parameters: - /// - sidebarVisible: Whether the sidebar is shown (docked or floating). - /// - presentationMode: The window's docked or floating mode. - /// - peekEnabled: Whether hover-reveal is on. - /// - peekPresenting: Whether a peek is currently showing the card. - public static func isNeeded( - sidebarVisible: Bool, - presentationMode: SidebarPresentationMode, - peekEnabled: Bool, - peekPresenting: Bool - ) -> Bool { - let occupiesLayout = sidebarVisible && presentationMode == .docked - guard !occupiesLayout else { return false } - return sidebarVisible || peekEnabled || peekPresenting - } -} diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode+PeekPanel.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode+PeekPanel.swift new file mode 100644 index 000000000000..8872624e0be7 --- /dev/null +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode+PeekPanel.swift @@ -0,0 +1,24 @@ +extension SidebarPresentationMode { + /// Whether the floating sidebar card's window should exist in this mode. + /// + /// The card is a second full sidebar list in its own child window. A + /// docked, visible sidebar can never show it, so it is not mounted there. + /// It is mounted whenever a card can appear: floating mode, or a hidden + /// sidebar with peek on (mounted ahead of the reveal, so the edge dwell + /// and the titlebar hover find it ready). + /// + /// - Parameters: + /// - sidebarVisible: Whether the sidebar is shown (docked or floating). + /// - peekEnabled: Whether hover-reveal is on. + /// - peekPresenting: Whether a peek is currently showing the card. + /// - Returns: True while a card can appear. + public func needsPeekPanel( + sidebarVisible: Bool, + peekEnabled: Bool, + peekPresenting: Bool + ) -> Bool { + let occupiesLayout = sidebarVisible && self == .docked + guard !occupiesLayout else { return false } + return sidebarVisible || peekEnabled || peekPresenting + } +} diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekPanelMountTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPresentationModePeekPanelTests.swift similarity index 60% rename from Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekPanelMountTests.swift rename to Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPresentationModePeekPanelTests.swift index 345b2e04208e..f57e7a88ee44 100644 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPeekPanelMountTests.swift +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPresentationModePeekPanelTests.swift @@ -1,15 +1,14 @@ import Testing @testable import CmuxSidebar -@Suite("SidebarPeekPanelMount") -struct SidebarPeekPanelMountTests { +@Suite("SidebarPresentationMode peek panel mount") +struct SidebarPresentationModePeekPanelTests { @Test func dockedVisibleSidebarNeverMountsTheCard() { // The typing setup most people keep: no second list behind it. for peekEnabled in [false, true] { for peekPresenting in [false, true] { - #expect(!SidebarPeekPanelMount.isNeeded( + #expect(!SidebarPresentationMode.docked.needsPeekPanel( sidebarVisible: true, - presentationMode: .docked, peekEnabled: peekEnabled, peekPresenting: peekPresenting )) @@ -18,9 +17,8 @@ struct SidebarPeekPanelMountTests { } @Test func floatingVisibleSidebarMountsTheCard() { - #expect(SidebarPeekPanelMount.isNeeded( + #expect(SidebarPresentationMode.floating.needsPeekPanel( sidebarVisible: true, - presentationMode: .floating, peekEnabled: false, peekPresenting: false )) @@ -28,26 +26,15 @@ struct SidebarPeekPanelMountTests { @Test(arguments: [SidebarPresentationMode.docked, .floating]) func hiddenSidebarMountsAheadOfAPeekOnlyWhenPeekIsOn(mode: SidebarPresentationMode) { - #expect(SidebarPeekPanelMount.isNeeded( - sidebarVisible: false, - presentationMode: mode, - peekEnabled: true, - peekPresenting: false - )) - #expect(!SidebarPeekPanelMount.isNeeded( - sidebarVisible: false, - presentationMode: mode, - peekEnabled: false, - peekPresenting: false - )) + #expect(mode.needsPeekPanel(sidebarVisible: false, peekEnabled: true, peekPresenting: false)) + #expect(!mode.needsPeekPanel(sidebarVisible: false, peekEnabled: false, peekPresenting: false)) } @Test func aPeekStillShowingKeepsTheCardWhilePeekTurnsOff() { // The policy can flip off while the card is up; the card leaves // through the machine, not by its window vanishing first. - #expect(SidebarPeekPanelMount.isNeeded( + #expect(SidebarPresentationMode.docked.needsPeekPanel( sidebarVisible: false, - presentationMode: .docked, peekEnabled: false, peekPresenting: true )) diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index 7c44c60862a9..9e6af0dc448e 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -102,9 +102,8 @@ extension ContentView { /// (floating, or hidden with peek on), which is before the edge dwell /// or the titlebar hover can ask for a reveal, and unmounts on dock. var sidebarNeedsPeekPanel: Bool { - SidebarPeekPanelMount.isNeeded( + sidebarState.presentationMode.needsPeekPanel( sidebarVisible: sidebarState.isVisible, - presentationMode: sidebarState.presentationMode, peekEnabled: sidebarPeek.policy.isEnabled, peekPresenting: sidebarPeek.presentsPanel ) diff --git a/Sources/Sidebar/Peek/SidebarPeekController.swift b/Sources/Sidebar/Peek/SidebarPeekController.swift index b0ee70343740..9ee7725c16cf 100644 --- a/Sources/Sidebar/Peek/SidebarPeekController.swift +++ b/Sources/Sidebar/Peek/SidebarPeekController.swift @@ -29,7 +29,7 @@ final class SidebarPeekController: ObservableObject { /// - Parameter policy: The new timings and behaviour flags. func setPolicy(_ policy: SidebarPeekPolicy) { // The policy is not published, but whether peek is on decides if - // the card's window is mounted at all (`SidebarPeekPanelMount`). + // the card's window is mounted at all (`needsPeekPanel`). if policy.isEnabled != machine.policy.isEnabled { objectWillChange.send() } From ae154a8e8c0efbf058c75c9d1d7c48914b89bb9d Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 00:17:47 +1300 Subject: [PATCH 33/69] peek panel: a hidden card never takes the keyboard the hidden card's list stays live (isPresented true), so a checklist add request can arm a field in it on its own. with the panel now able to become key, that would have pulled typing into an invisible window. editors only get key while the card is showing, and hiding the card mid-edit ends the edit and hands key back. --- .../Sidebar/Peek/SidebarPeekPanelHost.swift | 1 + .../Sidebar/Peek/SidebarPeekPanelWindow.swift | 20 ++++++++++- cmuxTests/SidebarPeekPanelWindowTests.swift | 34 +++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift index f52c38ee5da4..3fdcc39437c3 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift @@ -148,6 +148,7 @@ final class SidebarPeekPanelWindowController { // pointer, or it would swallow the very edge hovers that arm the // reveal in the parent window below it. panel.ignoresMouseEvents = !acceptsMouse + panel.allowsKeyboardEditors = acceptsMouse panel.appearance = parent.appearance // Terminal cursor rects yield to the card while it can be pointed // at, and come back the moment it cannot. diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift b/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift index 52f44fd45885..478c368de91a 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift @@ -15,6 +15,17 @@ final class SidebarPeekPanelWindow: NSPanel { /// so the owner can hold the peek open while the user types. var onKeyboardFocusChange: ((Bool) -> Void)? + /// Whether an editor may take the keyboard at all: only while the card + /// is showing. The hidden card's list stays live and can arm a field on + /// its own (a checklist add request), which must not pull typing into + /// an invisible window. + var allowsKeyboardEditors = false { + didSet { + guard !allowsKeyboardEditors, hostsKeyboardEditor else { return } + endKeyboardEditing() + } + } + /// True while an editor inside the card owns the first responder. private(set) var hostsKeyboardEditor = false { didSet { @@ -48,7 +59,7 @@ final class SidebarPeekPanelWindow: NSPanel { override func makeFirstResponder(_ responder: NSResponder?) -> Bool { let wantsKeyboard = Self.takesKeyboardInput(responder) - if wantsKeyboard, !hostsKeyboardEditor { + if wantsKeyboard, !hostsKeyboardEditor, allowsKeyboardEditors { // Key first, then the responder: the field editor attaches to a // key window, and the selection it makes on attach is what the // user's first keystroke replaces. @@ -85,6 +96,13 @@ final class SidebarPeekPanelWindow: NSPanel { _ = super.makeFirstResponder(nil) } + /// Ends the edit (the editor's end editing commits, like clicking away) + /// and returns key to the parent window. + func endKeyboardEditing() { + relinquishKeyboardFocus() + _ = super.makeFirstResponder(nil) + } + /// Drops editor focus and returns key to the parent window. func relinquishKeyboardFocus() { guard hostsKeyboardEditor else { return } diff --git a/cmuxTests/SidebarPeekPanelWindowTests.swift b/cmuxTests/SidebarPeekPanelWindowTests.swift index d10a9b1aa3fc..9f14d0257db0 100644 --- a/cmuxTests/SidebarPeekPanelWindowTests.swift +++ b/cmuxTests/SidebarPeekPanelWindowTests.swift @@ -24,6 +24,8 @@ struct SidebarPeekPanelWindowTests { defer: false ) panel.isReleasedWhenClosed = false + // The card is showing; see `hiddenCardNeverTakesTheKeyboard`. + panel.allowsKeyboardEditors = true panel.contentView = NSView(frame: NSRect(x: 0, y: 0, width: 260, height: 400)) parent.addChildWindow(panel, ordered: .above) return (parent, panel) @@ -86,6 +88,38 @@ struct SidebarPeekPanelWindowTests { #expect(!panel.canBecomeKey) } + @Test + @MainActor + func hiddenCardNeverTakesTheKeyboard() { + let (parent, panel) = makePanel() + defer { parent.removeChildWindow(panel) } + // A hidden card's live list can arm a field by itself (a checklist + // add request); typing must stay with the terminal. + panel.allowsKeyboardEditors = false + let field = SidebarInlineRenameTextField(string: "workspace") + field.frame = NSRect(x: 0, y: 0, width: 200, height: 22) + panel.contentView?.addSubview(field) + #expect(!panel.hostsKeyboardEditor) + #expect(!panel.canBecomeKey) + } + + @Test + @MainActor + func hidingTheCardEndsTheEdit() { + let (parent, panel) = makePanel() + defer { parent.removeChildWindow(panel) } + var focusChanges: [Bool] = [] + panel.onKeyboardFocusChange = { focusChanges.append($0) } + let field = SidebarInlineRenameTextField(string: "workspace") + field.frame = NSRect(x: 0, y: 0, width: 200, height: 22) + panel.contentView?.addSubview(field) + #expect(panel.hostsKeyboardEditor) + panel.allowsKeyboardEditors = false + #expect(!panel.hostsKeyboardEditor) + #expect(!SidebarPeekPanelWindow.takesKeyboardInput(panel.firstResponder)) + #expect(focusChanges == [true, false]) + } + @Test @MainActor func onlyEditableTextTakesKeyboardInput() { From f0e8a9c85f75cd2639def48340bacbdc1ec2d4b3 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 00:30:09 +1300 Subject: [PATCH 34/69] review fixes: panel keyboard and teardown off the swiftui update, floating keeps the card mounted from the correctness review of the risk fixes: - ending an edit because the card hid now happens on the next turn (the flag is set from updateNSView and the rename commit publishes) - losing key only ends the edit when another cmux window took it; cmd-tab to another app keeps the edit, like a docked row - detach hands key back to the parent and empties the hosting view on the next turn, so dismantle never fires key-window observers or a draft commit mid-update - floating mode keeps the card mounted while hidden, so hiding still slides it out with peek off (docked hidden still mounts only with peek on) - the lift carry is skipped on a forced reload, where previousRows no longer maps the table's rows - tests: the rebuild test requires the carried snapshot, panel tests order the panel out so a key panel cannot leak into later tests --- .../SidebarPresentationMode+PeekPanel.swift | 5 ++-- ...idebarPresentationModePeekPanelTests.swift | 19 +++++++++++--- Sources/ContentView+SidebarPeek.swift | 4 +-- .../SidebarWorkspaceTableController.swift | 5 ++-- .../Sidebar/Peek/SidebarPeekPanelHost.swift | 22 ++++++++++------ .../Sidebar/Peek/SidebarPeekPanelWindow.swift | 24 +++++++++++++++--- cmuxTests/SidebarPeekPanelWindowTests.swift | 25 ++++++++++++++----- .../SidebarWorkspaceReorderLiftTests.swift | 10 ++++---- 8 files changed, 82 insertions(+), 32 deletions(-) diff --git a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode+PeekPanel.swift b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode+PeekPanel.swift index 8872624e0be7..c4a8e51029f8 100644 --- a/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode+PeekPanel.swift +++ b/Packages/macOS/CmuxSidebar/Sources/CmuxSidebar/Peek/SidebarPresentationMode+PeekPanel.swift @@ -3,7 +3,8 @@ extension SidebarPresentationMode { /// /// The card is a second full sidebar list in its own child window. A /// docked, visible sidebar can never show it, so it is not mounted there. - /// It is mounted whenever a card can appear: floating mode, or a hidden + /// It is mounted whenever a card can appear: floating mode (shown or + /// hidden, so hiding still slides the card out), or a hidden docked /// sidebar with peek on (mounted ahead of the reveal, so the edge dwell /// and the titlebar hover find it ready). /// @@ -19,6 +20,6 @@ extension SidebarPresentationMode { ) -> Bool { let occupiesLayout = sidebarVisible && self == .docked guard !occupiesLayout else { return false } - return sidebarVisible || peekEnabled || peekPresenting + return self == .floating || peekEnabled || peekPresenting } } diff --git a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPresentationModePeekPanelTests.swift b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPresentationModePeekPanelTests.swift index f57e7a88ee44..f52ed22baf41 100644 --- a/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPresentationModePeekPanelTests.swift +++ b/Packages/macOS/CmuxSidebar/Tests/CmuxSidebarTests/SidebarPresentationModePeekPanelTests.swift @@ -24,10 +24,21 @@ struct SidebarPresentationModePeekPanelTests { )) } - @Test(arguments: [SidebarPresentationMode.docked, .floating]) - func hiddenSidebarMountsAheadOfAPeekOnlyWhenPeekIsOn(mode: SidebarPresentationMode) { - #expect(mode.needsPeekPanel(sidebarVisible: false, peekEnabled: true, peekPresenting: false)) - #expect(!mode.needsPeekPanel(sidebarVisible: false, peekEnabled: false, peekPresenting: false)) + @Test func hiddenDockedSidebarMountsAheadOfAPeekOnlyWhenPeekIsOn() { + #expect(SidebarPresentationMode.docked.needsPeekPanel(sidebarVisible: false, peekEnabled: true, peekPresenting: false)) + #expect(!SidebarPresentationMode.docked.needsPeekPanel(sidebarVisible: false, peekEnabled: false, peekPresenting: false)) + } + + @Test func hiddenFloatingSidebarKeepsTheCardSoHidingSlidesItOut() { + // Unmounting in the same update as the hide would drop the exit + // slide, peek or no peek. + for peekEnabled in [false, true] { + #expect(SidebarPresentationMode.floating.needsPeekPanel( + sidebarVisible: false, + peekEnabled: peekEnabled, + peekPresenting: false + )) + } } @Test func aPeekStillShowingKeepsTheCardWhilePeekTurnsOff() { diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index 9e6af0dc448e..011dd2255cab 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -99,8 +99,8 @@ extension ContentView { /// A docked, visible sidebar never needs it, and that is the common /// typing setup: the card is a second full sidebar list, so keeping it /// mounted there was pure cost. It mounts as soon as a card can show - /// (floating, or hidden with peek on), which is before the edge dwell - /// or the titlebar hover can ask for a reveal, and unmounts on dock. + /// (floating, or hidden docked with peek on), which is before the edge + /// dwell or the titlebar hover can ask for a reveal, and unmounts on dock. var sidebarNeedsPeekPanel: Bool { sidebarState.presentationMode.needsPeekPanel( sidebarVisible: sidebarState.isVisible, diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index 402e55ea8b0f..a505451da03d 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -852,8 +852,9 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS reorderDropCommitFallbackTask = nil } // Under a live drag the lift is carried through the update and - // rebuilt against the new rows below, so nothing bounces. - let reorderLiftCarry = hasStructuralChanges + // rebuilt against the new rows below, so nothing bounces. Not on a + // forced reload: `previousRows` no longer maps the table's rows. + let reorderLiftCarry = hasStructuralChanges && !forceTableReload ? detachReorderLiftForRebuild(previousRows: previousRows, isDropHandOff: handsOffReorderTransforms) : nil if hasStructuralChanges { diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift index 3fdcc39437c3..a00286315b93 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift @@ -294,19 +294,27 @@ final class SidebarPeekPanelWindowController { // An edit in flight hands key back to the parent before the card // goes away. The hold release hops a turn: detach can run inside // a SwiftUI update (dismantle), where publishing is not allowed. + // The key handoff and the hold release hop a turn too: they + // fire key-window observers and publish. let releasesKeyboardHold = panel.hostsKeyboardEditor + let parentToRefocus = panel.isKeyWindow ? panel.parent : nil panel.onKeyboardFocusChange = nil - panel.relinquishKeyboardFocus() - if releasesKeyboardHold, let onKeyboardFocusChange { - Task { @MainActor in onKeyboardFocusChange(false) } + panel.allowsKeyboardEditors = false + let onKeyboardFocusChange = onKeyboardFocusChange + DispatchQueue.main.async { + if let parentToRefocus, parentToRefocus.isVisible { parentToRefocus.makeKey() } + if releasesKeyboardHold { onKeyboardFocusChange?(false) } } panel.parent?.removeChildWindow(panel) panel.orderOut(nil) } - // Empty the hosting view first so SwiftUI dismantles the card's - // list (its table controller commits drafts and drops observers) - // instead of leaving that to the hosting view's deallocation. - hostingView?.rootView = AnyView(EmptyView()) + // Empty the hosting view so SwiftUI dismantles the card's list (its + // table controller commits drafts and drops observers) instead of + // leaving that to deallocation. Next turn: a draft commit publishes, + // and detach can run inside a SwiftUI update (dismantle). + if let hostingView { + DispatchQueue.main.async { hostingView.rootView = AnyView(EmptyView()) } + } panel = nil hostingView = nil parentWindow = nil diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift b/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift index 478c368de91a..9ae1c43245b7 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift @@ -22,7 +22,12 @@ final class SidebarPeekPanelWindow: NSPanel { var allowsKeyboardEditors = false { didSet { guard !allowsKeyboardEditors, hostsKeyboardEditor else { return } - endKeyboardEditing() + // Set from a SwiftUI update; the edit's commit publishes, so it + // ends on the next turn. + DispatchQueue.main.async { [weak self] in + guard let self, !self.allowsKeyboardEditors else { return } + self.endKeyboardEditing() + } } } @@ -89,9 +94,20 @@ final class SidebarPeekPanelWindow: NSPanel { override func resignKey() { super.resignKey() guard hostsKeyboardEditor else { return } - // The user clicked elsewhere (usually the terminal). End the edit - // the way a docked row does when focus leaves it: the editor's end - // editing commits, and the rename field tears itself down. + // Decided a turn later, once the new key window is known. + DispatchQueue.main.async { [weak self] in + self?.endEditIfKeyMovedElsewhereInApp() + } + } + + /// The user clicked elsewhere in cmux (usually the terminal): end the + /// edit the way a docked row does when focus leaves it, so the editor's + /// end editing commits and the rename field tears itself down. Switching + /// to another app keeps the edit, like a docked row, and AppKit gives + /// the panel key back on return. + private func endEditIfKeyMovedElsewhereInApp() { + guard hostsKeyboardEditor, !isKeyWindow, NSApp.isActive else { return } + if let keyWindow = NSApp.keyWindow, keyWindow.parent === self { return } hostsKeyboardEditor = false _ = super.makeFirstResponder(nil) } diff --git a/cmuxTests/SidebarPeekPanelWindowTests.swift b/cmuxTests/SidebarPeekPanelWindowTests.swift index 9f14d0257db0..442fb857f104 100644 --- a/cmuxTests/SidebarPeekPanelWindowTests.swift +++ b/cmuxTests/SidebarPeekPanelWindowTests.swift @@ -31,11 +31,19 @@ struct SidebarPeekPanelWindowTests { return (parent, panel) } + /// Orders the panel out too, so a panel that took key in a test cannot + /// stay the app's key window for the next one. + @MainActor + private func tearDown(parent: NSWindow, panel: SidebarPeekPanelWindow) { + parent.removeChildWindow(panel) + panel.orderOut(nil) + } + @Test @MainActor func plainClicksAndHoverNeverMakeThePanelKey() { let (parent, panel) = makePanel() - defer { parent.removeChildWindow(panel) } + defer { tearDown(parent: parent, panel: panel) } #expect(!panel.canBecomeKey) // A row click makes the table (or a row view) first responder; that // must not pull the keyboard away from the terminal. @@ -50,7 +58,7 @@ struct SidebarPeekPanelWindowTests { @MainActor func anEditorTakesTheKeyboardAndGivesItBackWhenItEnds() { let (parent, panel) = makePanel() - defer { parent.removeChildWindow(panel) } + defer { tearDown(parent: parent, panel: panel) } var focusChanges: [Bool] = [] panel.onKeyboardFocusChange = { focusChanges.append($0) } // Same entry as the inline rename: the field asks its window for @@ -75,7 +83,7 @@ struct SidebarPeekPanelWindowTests { @MainActor func theEventLoopPassReleasesFocusWhenTheEditorVanishedQuietly() { let (parent, panel) = makePanel() - defer { parent.removeChildWindow(panel) } + defer { tearDown(parent: parent, panel: panel) } let field = SidebarInlineRenameTextField(string: "workspace") field.frame = NSRect(x: 0, y: 0, width: 200, height: 22) panel.contentView?.addSubview(field) @@ -92,7 +100,7 @@ struct SidebarPeekPanelWindowTests { @MainActor func hiddenCardNeverTakesTheKeyboard() { let (parent, panel) = makePanel() - defer { parent.removeChildWindow(panel) } + defer { tearDown(parent: parent, panel: panel) } // A hidden card's live list can arm a field by itself (a checklist // add request); typing must stay with the terminal. panel.allowsKeyboardEditors = false @@ -105,9 +113,9 @@ struct SidebarPeekPanelWindowTests { @Test @MainActor - func hidingTheCardEndsTheEdit() { + func hidingTheCardEndsTheEdit() async { let (parent, panel) = makePanel() - defer { parent.removeChildWindow(panel) } + defer { tearDown(parent: parent, panel: panel) } var focusChanges: [Bool] = [] panel.onKeyboardFocusChange = { focusChanges.append($0) } let field = SidebarInlineRenameTextField(string: "workspace") @@ -115,6 +123,11 @@ struct SidebarPeekPanelWindowTests { panel.contentView?.addSubview(field) #expect(panel.hostsKeyboardEditor) panel.allowsKeyboardEditors = false + // The edit ends on the next turn (the flag is set from a SwiftUI + // update); a main-queue hop queued after it runs once it has. + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } #expect(!panel.hostsKeyboardEditor) #expect(!SidebarPeekPanelWindow.takesKeyboardInput(panel.firstResponder)) #expect(focusChanges == [true, false]) diff --git a/cmuxTests/SidebarWorkspaceReorderLiftTests.swift b/cmuxTests/SidebarWorkspaceReorderLiftTests.swift index 6b3ab7b82db2..14d2f6b25307 100644 --- a/cmuxTests/SidebarWorkspaceReorderLiftTests.swift +++ b/cmuxTests/SidebarWorkspaceReorderLiftTests.swift @@ -52,7 +52,9 @@ extension SidebarWorkspaceTableTests { _ = window let closed = makeLiftRow(height: 40) let dragged = makeLiftRow(height: 40) - let others = (0..<3).map { _ in makeLiftRow(height: 40) } + // A tall last row keeps the block clear of the list's bottom clamp + // both before and after the close. + let others = (0..<3).map { _ in makeLiftRow(height: 40) } + [makeLiftRow(height: 160)] let rows = [closed, dragged] + others let table = await applyLiftRows(rows, controller: controller, container: container) for row in rows.indices { @@ -70,7 +72,7 @@ extension SidebarWorkspaceTableTests { table.enumerateAvailableRowViews { rowView, _ in rowView.layer?.removeAllAnimations() } await flushStagedTableMutations() let visualTopsBefore = liftVisualTops(rows: rows, table: table) - let snapshotBefore = controller.reorderLiftSnapshots.first?.layer + let snapshotBefore = try #require(controller.reorderLiftSnapshots.first?.layer) // A workspace above the drag closes mid-drag. let remaining = Array(rows.dropFirst()) @@ -80,9 +82,7 @@ extension SidebarWorkspaceTableTests { let session = try #require(controller.reorderLiftSession) #expect(session.workspaceId == dragged.workspaceId) #expect(session.sourceRange == 0..<1) - if let snapshotBefore { - #expect(controller.reorderLiftSnapshots.first?.layer === snapshotBefore) - } + #expect(controller.reorderLiftSnapshots.first?.layer === snapshotBefore) // Every surviving row starts its motion exactly where it stood // before the update: no drop to its real frame, no bounce. for (index, row) in remaining.enumerated() { From 5194180f8a9e6529c546854c79a6b1b9cbcb121c Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 00:37:06 +1300 Subject: [PATCH 35/69] peek panel: refuse editors while hidden, recheck the edit after an app switch second review pass on the keyboard fixes: - a field arming itself in the hidden card was still made first responder (only key was skipped), so once the card showed, clicking it never came back through makeFirstResponder and typing could not reach it. the panel now refuses an editor outright while hidden. - an edit kept across a switch to another app is checked again when cmux becomes active, so coming back by clicking the terminal ends it instead of leaving the keyboard hold pinning the card open. - detach keeps the panel alive until the deferred edit end and list teardown have run, in that order. --- .../Sidebar/Peek/SidebarPeekPanelHost.swift | 7 ++++- .../Sidebar/Peek/SidebarPeekPanelWindow.swift | 28 +++++++++++++++++-- cmuxTests/SidebarPeekPanelWindowTests.swift | 3 ++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift index a00286315b93..49c561936e3b 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift @@ -312,8 +312,13 @@ final class SidebarPeekPanelWindowController { // table controller commits drafts and drops observers) instead of // leaving that to deallocation. Next turn: a draft commit publishes, // and detach can run inside a SwiftUI update (dismantle). + // The panel rides along so the deferred edit end above still finds + // it, and runs before the list is dismantled. if let hostingView { - DispatchQueue.main.async { hostingView.rootView = AnyView(EmptyView()) } + DispatchQueue.main.async { [panel] in + hostingView.rootView = AnyView(EmptyView()) + _ = panel + } } panel = nil hostingView = nil diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift b/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift index 9ae1c43245b7..6cbdc26d5732 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelWindow.swift @@ -39,6 +39,10 @@ final class SidebarPeekPanelWindow: NSPanel { } } + /// Waits for cmux to become active again after an edit lost key to + /// another app; see `endEditIfKeyMovedElsewhereInApp`. + private var reactivationObserver: NSObjectProtocol? + override var canBecomeKey: Bool { hostsKeyboardEditor } override var canBecomeMain: Bool { false } @@ -64,7 +68,11 @@ final class SidebarPeekPanelWindow: NSPanel { override func makeFirstResponder(_ responder: NSResponder?) -> Bool { let wantsKeyboard = Self.takesKeyboardInput(responder) - if wantsKeyboard, !hostsKeyboardEditor, allowsKeyboardEditors { + // Refused outright while hidden: an editor armed here would hold + // first responder without the keyboard, and a later click on it + // would never come back through this method to take key. + if wantsKeyboard, !allowsKeyboardEditors { return false } + if wantsKeyboard, !hostsKeyboardEditor { // Key first, then the responder: the field editor attaches to a // key window, and the selection it makes on attach is what the // user's first keystroke replaces. @@ -106,7 +114,23 @@ final class SidebarPeekPanelWindow: NSPanel { /// to another app keeps the edit, like a docked row, and AppKit gives /// the panel key back on return. private func endEditIfKeyMovedElsewhereInApp() { - guard hostsKeyboardEditor, !isKeyWindow, NSApp.isActive else { return } + guard hostsKeyboardEditor, !isKeyWindow else { return } + guard NSApp.isActive else { + // Coming back by clicking the terminal never makes the panel + // key again, so look again once cmux is active. + guard reactivationObserver == nil else { return } + reactivationObserver = NotificationCenter.default.addObserver( + forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { + guard let self, let observer = self.reactivationObserver else { return } + NotificationCenter.default.removeObserver(observer) + self.reactivationObserver = nil + DispatchQueue.main.async { [weak self] in self?.endEditIfKeyMovedElsewhereInApp() } + } + } + return + } if let keyWindow = NSApp.keyWindow, keyWindow.parent === self { return } hostsKeyboardEditor = false _ = super.makeFirstResponder(nil) diff --git a/cmuxTests/SidebarPeekPanelWindowTests.swift b/cmuxTests/SidebarPeekPanelWindowTests.swift index 442fb857f104..d64670c2a5e6 100644 --- a/cmuxTests/SidebarPeekPanelWindowTests.swift +++ b/cmuxTests/SidebarPeekPanelWindowTests.swift @@ -109,6 +109,9 @@ struct SidebarPeekPanelWindowTests { panel.contentView?.addSubview(field) #expect(!panel.hostsKeyboardEditor) #expect(!panel.canBecomeKey) + // Not armed either: a click once the card shows must start the edit + // through the normal path. + #expect(!SidebarPeekPanelWindow.takesKeyboardInput(panel.firstResponder)) } @Test From f853384adaa1aa728255a057d26575723639f970 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 01:10:14 +1300 Subject: [PATCH 36/69] tests: pass onTogglePresentationMode, match terminal background is opt-in now two sidebar tests built VerticalTabsSidebar without the new onTogglePresentationMode argument (compile failure in the app-host test product). the resolver test pinned main's default (sidebar matches the terminal until the user opts out); this branch makes the glass sidebar the default and matching opt-in, pending austin's call on that default. --- .../Appearance/WindowAppearanceResolverTests.swift | 12 ++++++------ cmuxTests/SidebarLazyLayoutScaleTests.swift | 1 + cmuxTests/SidebarSnapshotOwnerTests.swift | 1 + 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift index f81469f0f5ca..1269080fc0c9 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift @@ -182,9 +182,9 @@ import Testing #expect(snapshot.resolvedColorScheme == terminalScheme) } - /// A user who never chose keeps the sidebar and titlebar on the terminal - /// background, so a light terminal theme never sits beside dark chrome. - @Test func sidebarMatchesTerminalBackgroundUntilTheUserOptsOut() throws { + /// A user who never chose gets the glass sidebar; matching the terminal + /// background is opt-in (Settings > Sidebar > Match Terminal Background). + @Test func sidebarUsesGlassUntilTheUserOptsIntoMatchingTheTerminal() throws { let suiteName = "cmux.tests.match-terminal-default" let defaults = try #require(UserDefaults(suiteName: suiteName)) defaults.removePersistentDomain(forName: suiteName) @@ -198,10 +198,10 @@ import Testing ) ) - #expect(resolver.currentFromUserDefaults(defaults: defaults).unifySurfaceBackdrops) - - defaults.set(false, forKey: "sidebarMatchTerminalBackground") #expect(!resolver.currentFromUserDefaults(defaults: defaults).unifySurfaceBackdrops) + + defaults.set(true, forKey: "sidebarMatchTerminalBackground") + #expect(resolver.currentFromUserDefaults(defaults: defaults).unifySurfaceBackdrops) } @Test func ghosttyMacOSGlassStyleForcesClearRootAndTerminalTintedGlass() { diff --git a/cmuxTests/SidebarLazyLayoutScaleTests.swift b/cmuxTests/SidebarLazyLayoutScaleTests.swift index 17216722285e..b40a279f8099 100644 --- a/cmuxTests/SidebarLazyLayoutScaleTests.swift +++ b/cmuxTests/SidebarLazyLayoutScaleTests.swift @@ -172,6 +172,7 @@ final class SidebarLazyLayoutScaleTests { onSendFeedback: {}, onToggleSidebar: {}, onNewTab: {}, + onTogglePresentationMode: {}, observedWindowReference: WeakWindowReference(), chromeBackgroundColor: .black, selection: .constant(.tabs), diff --git a/cmuxTests/SidebarSnapshotOwnerTests.swift b/cmuxTests/SidebarSnapshotOwnerTests.swift index 0567dc574d0a..58a8a0826310 100644 --- a/cmuxTests/SidebarSnapshotOwnerTests.swift +++ b/cmuxTests/SidebarSnapshotOwnerTests.swift @@ -78,6 +78,7 @@ struct SidebarSnapshotOwnerTests { titlebarControlsLayoutModel: TitlebarControlsLayoutModel(), windowId: UUID(), onSendFeedback: {}, onToggleSidebar: {}, onNewTab: {}, + onTogglePresentationMode: {}, observedWindowReference: WeakWindowReference(), chromeBackgroundColor: .black, selection: .constant(.tabs), From 7533734b3b6ca67e5f82cc0c17640bbda7682bf2 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 02:10:01 +1300 Subject: [PATCH 37/69] test: a sidebar presented while the docked one is hidden must refresh snapshots on rename the floating peek card is that instance; a rename committed in it never repainted. also brings the lazy layout test file back under its line budget. --- cmux.xcodeproj/project.pbxproj | 4 ++ cmuxTests/SidebarLazyLayoutScaleTests.swift | 9 +-- ...barPresentedWhileHiddenSnapshotTests.swift | 58 +++++++++++++++++++ 3 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 cmuxTests/SidebarPresentedWhileHiddenSnapshotTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 0a809235e7a3..2ed1f5d49a49 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3295,6 +3295,7 @@ A80070010000000000000001 /* SidebarPointerInteractionMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80070020000000000000001 /* SidebarPointerInteractionMonitorTests.swift */; }; A80040110000000000000001 /* SidebarPointerInteractionScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A80040120000000000000001 /* SidebarPointerInteractionScaleTests.swift */; }; C0DEF0A30000000000000001 /* SidebarPortDisplayText.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */; }; + 2E9404437EB47D116E0EE48D /* SidebarPresentedWhileHiddenSnapshotTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E400D59987274763C0558AD7 /* SidebarPresentedWhileHiddenSnapshotTests.swift */; }; C0DEA7710000000000000005 /* SidebarProBadge.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEA7710000000000000006 /* SidebarProBadge.swift */; }; D75280020000000000000001 /* SidebarProfilingSignposts.swift in Sources */ = {isa = PBXBuildFile; fileRef = D75280020000000000000002 /* SidebarProfilingSignposts.swift */; }; C0DE48000000000000000001 /* SidebarProviderMenuRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE48000000000000000002 /* SidebarProviderMenuRegressionTests.swift */; }; @@ -7732,6 +7733,7 @@ A80070020000000000000001 /* SidebarPointerInteractionMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPointerInteractionMonitorTests.swift; sourceTree = ""; }; A80040120000000000000001 /* SidebarPointerInteractionScaleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPointerInteractionScaleTests.swift; sourceTree = ""; }; C0DEF0A30000000000000002 /* SidebarPortDisplayText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarPortDisplayText.swift; sourceTree = ""; }; + E400D59987274763C0558AD7 /* SidebarPresentedWhileHiddenSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarPresentedWhileHiddenSnapshotTests.swift"; sourceTree = ""; }; C0DEA7710000000000000006 /* SidebarProBadge.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarProBadge.swift; sourceTree = ""; }; D75280020000000000000002 /* SidebarProfilingSignposts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarProfilingSignposts.swift; sourceTree = ""; }; C0DE48000000000000000002 /* SidebarProviderMenuRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarProviderMenuRegressionTests.swift; sourceTree = ""; }; @@ -13545,6 +13547,7 @@ 03F486001BC03CEE3611842F /* GhosttyExternalDisplayScalingTests.swift */, 6124B3A20E384A384BF58063 /* SidebarPeekPanelWindowTests.swift */, 32C30979AEC1CA92678696D9 /* SidebarWorkspaceReorderLiftTests.swift */, + E400D59987274763C0558AD7 /* SidebarPresentedWhileHiddenSnapshotTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -18374,6 +18377,7 @@ 0FD5BB9FDC316B053390ABDD /* SidebarPeekPanelWindowTests.swift in Sources */, A80070010000000000000001 /* SidebarPointerInteractionMonitorTests.swift in Sources */, A80040110000000000000001 /* SidebarPointerInteractionScaleTests.swift in Sources */, + 2E9404437EB47D116E0EE48D /* SidebarPresentedWhileHiddenSnapshotTests.swift in Sources */, C0DE48000000000000000001 /* SidebarProviderMenuRegressionTests.swift in Sources */, 5B0C00010000000000000003 /* SidebarResizerOcclusionResolverTests.swift in Sources */, 3D0EDA5CB6EA473DB4DB9464 /* SidebarRowPaletteTests.swift in Sources */, diff --git a/cmuxTests/SidebarLazyLayoutScaleTests.swift b/cmuxTests/SidebarLazyLayoutScaleTests.swift index b40a279f8099..45cb10d3aa7e 100644 --- a/cmuxTests/SidebarLazyLayoutScaleTests.swift +++ b/cmuxTests/SidebarLazyLayoutScaleTests.swift @@ -96,7 +96,7 @@ final class SidebarLazyLayoutScaleTests { } @MainActor - static func mountSidebar(workspaceCount: Int, includeGroups: Bool = true) async throws -> Harness { + static func mountSidebar(workspaceCount: Int, includeGroups: Bool = true, sidebarState: SidebarState = SidebarState()) async throws -> Harness { _ = NSApplication.shared // Hermetic defaults: VerticalTabsSidebar picks between the workspace @@ -108,10 +108,7 @@ final class SidebarLazyLayoutScaleTests { let defaultsSuiteName = "SidebarLazyLayoutScaleTests.\(UUID().uuidString)" let defaults = try #require(UserDefaults(suiteName: defaultsSuiteName)) defaults.removePersistentDomain(forName: defaultsSuiteName) - defaults.set( - CmuxExtensionSidebarSelection.defaultProviderId, - forKey: CmuxExtensionSidebarSelection.defaultsKey - ) + defaults.set(CmuxExtensionSidebarSelection.defaultProviderId, forKey: CmuxExtensionSidebarSelection.defaultsKey) // This suite measures SwiftUI lazy row bodies and pointer ownership. // Keep that implementation explicit now that AppKit is the default. let featureFlags = CmuxFeatureFlags( @@ -184,7 +181,7 @@ final class SidebarLazyLayoutScaleTests { .environmentObject(tabManager) .environmentObject(CmuxConfigStore()) .environmentObject(TerminalNotificationStore.shared) - .environmentObject(SidebarState()) + .environmentObject(sidebarState) .environmentObject(SidebarSelectionState()) .environment( \.sidebarLazyContractProbe, diff --git a/cmuxTests/SidebarPresentedWhileHiddenSnapshotTests.swift b/cmuxTests/SidebarPresentedWhileHiddenSnapshotTests.swift new file mode 100644 index 000000000000..b596d6db8ee0 --- /dev/null +++ b/cmuxTests/SidebarPresentedWhileHiddenSnapshotTests.swift @@ -0,0 +1,58 @@ +import AppKit +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// The floating peek card is a second `VerticalTabsSidebar` that is presented +/// while the window's docked sidebar is hidden (`sidebarState.isVisible == +/// false`). Its rows must keep tracking live workspace state there: a rename +/// committed inline in the card used to persist without ever repainting the +/// card, because the snapshot refresh was gated on the window's docked +/// visibility instead of the instance's own presentation. +@Suite(.serialized) +final class SidebarPresentedWhileHiddenSnapshotTests { + @Test + @MainActor + func renameRefreshesSnapshotsOfAnInstancePresentedWhileTheSidebarIsHidden() async throws { + let harness = try await SidebarLazyLayoutScaleTests.mountSidebar( + workspaceCount: 4, + includeGroups: false, + sidebarState: SidebarState(isVisible: false) + ) + defer { harness.tearDown() } + + await SidebarLazyLayoutScaleTests.drainUntilRowWorkQuiesces( + for: harness.window, + counter: harness.counter + ) + #expect( + harness.counter.workspaceSnapshotBuilds > 0, + "The presented instance never built its initial snapshots; the probe wiring is broken." + ) + await SidebarLazyLayoutScaleTests.drainMainRunLoop(for: harness.window) + + harness.counter.reset() + let workspace = try #require(harness.tabManager.tabs.last) + #expect(harness.tabManager.setCustomTitle(tabId: workspace.id, title: "Renamed in card")) + + let deadline = ProcessInfo.processInfo.systemUptime + 3 + while harness.counter.workspaceSnapshotBuilds == 0, + ProcessInfo.processInfo.systemUptime < deadline { + SidebarLazyLayoutScaleTests.turnMainRunLoopOnce(layingOut: harness.window) + await Task.yield() + } + #expect( + harness.counter.workspaceSnapshotBuilds > 0, + """ + A rename did not rebuild the workspace snapshot of a sidebar instance that is \ + presented while the docked sidebar is hidden, so the floating card keeps \ + painting the old title until the sidebar is docked again. + """ + ) + } +} From 23a275dcf7c2eadfe2320f58e3919cf7ed4fe804 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 02:10:08 +1300 Subject: [PATCH 38/69] peek card: snapshot refresh follows the instance's own presentation, not docked visibility the card is presented while the docked sidebar is hidden, but the coalesced refresh bailed on sidebarState.isVisible, so titles, branch, status etc. froze in the card until the sidebar docked again. gate on isPresented at schedule time instead; the hidden docked instance has isPresented false and cancels its coalescer, so it still does no work. --- Sources/ContentView.swift | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b428c6b96527..aa67461042df 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -13206,9 +13206,15 @@ struct VerticalTabsSidebar: View, Equatable { extensionSidebarUpdateToken &+= 1 } + /// Gated on this instance's own `isPresented`, never on the window's + /// `sidebarState.isVisible`: the floating card is a second instance that + /// is presented while the docked sidebar is hidden, so a window-level gate + /// froze the card's rows (an inline rename never repainted). The docked + /// instance gets `occupiesLayout` here, and leaving presentation cancels + /// the coalescer, so a hidden docked list still does no work. private func scheduleWorkspaceSnapshotRefresh(workspaceId: UUID) { - workspaceSnapshotRefreshCoalescer.schedule(workspaceId: workspaceId) { [sidebarState] workspaceIds in - guard sidebarState.isVisible else { return } + guard isPresented else { return } + workspaceSnapshotRefreshCoalescer.schedule(workspaceId: workspaceId) { workspaceIds in refreshWorkspaceSnapshots(workspaceIds: workspaceIds) } } From b39bd8872db23737053d9aad397665212f57465d Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 07:36:15 +1300 Subject: [PATCH 39/69] sidebar peek: two-parameter onChange, drops the two deprecation warnings the swift warning budget check failed on +2 'onChange(of:perform:)' was deprecated warnings in ContentView+SidebarPeek.swift. --- Sources/ContentView+SidebarPeek.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index 011dd2255cab..be5b42f0a7be 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -253,7 +253,7 @@ extension ContentView { sidebarPeek.setPolicy(policy) } } - .onChange(of: sidebarState.occupiesLayout) { occupies in + .onChange(of: sidebarState.occupiesLayout) { _, occupies in if occupies { // Docking retires any active peek: the card handed // its place to the fixed pane. @@ -281,7 +281,7 @@ extension ContentView { sidebarPeek.pointerExitedEdge() } } - .onChange(of: sidebarState.isVisible) { visible in + .onChange(of: sidebarState.isVisible) { _, visible in guard !visible else { return } // Hiding retires any peek in flight and opens the hover // suppression window, so the card cannot flash over the From 076ae7fd519d5a6837aa9c3a71038e29a4048738 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 09:16:40 +1300 Subject: [PATCH 40/69] sidebar toggle: a hide mid-sweep reads as hidden, a second toggle reverses it toggle() and sidebarVisibility() follow the requested visibility, so the animated hide no longer drops a second toggle and routing checks see the flip right away --- Sources/AppDelegate.swift | 5 +++- Sources/Sidebar/SidebarState.swift | 14 +++++++++- Sources/Sidebar/SidebarToggleAnimator.swift | 17 +++++++---- .../SidebarFocusBoundaryLifecycleTests.swift | 28 +++++++++++++++++++ 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index 6b68a263c6f0..e9987aa6d672 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -8294,8 +8294,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent return result } + /// The visibility the window's sidebar is heading to. An animated hide + /// keeps `isVisible` true until its sweep lands; this reports it hidden + /// from the moment the toggle is accepted. func sidebarVisibility(windowId: UUID) -> Bool? { - mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.isVisible + mainWindowContexts.values.first(where: { $0.windowId == windowId })?.sidebarState.requestedVisibility } func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { diff --git a/Sources/Sidebar/SidebarState.swift b/Sources/Sidebar/SidebarState.swift index a7b7b73b4f1f..2308495c66ea 100644 --- a/Sources/Sidebar/SidebarState.swift +++ b/Sources/Sidebar/SidebarState.swift @@ -35,6 +35,17 @@ final class SidebarState: ObservableObject { /// auto-collapse, session restore) never animate, so their callers can /// read `isVisible` right after. var animatedVisibilityOrchestrator: ((Bool) -> Bool)? + /// A hide sweep is running. `isVisible` stays true until it lands so the + /// pane stays mounted, but the hide was already requested. Set by the + /// toggle animator; any committed ``setVisible(_:)`` clears it. + var isHidePending = false + + /// The visibility the sidebar is heading to: false as soon as a hide is + /// requested, even while its sweep runs. Toggles read this, so a second + /// toggle mid-sweep reverses the hide instead of being dropped. + var requestedVisibility: Bool { + isVisible && !isHidePending + } init( isVisible: Bool = true, @@ -48,7 +59,7 @@ final class SidebarState: ObservableObject { } func toggle() { - let nextValue = !isVisible + let nextValue = !requestedVisibility if let animatedVisibilityOrchestrator, animatedVisibilityOrchestrator(nextValue) { return } @@ -61,6 +72,7 @@ final class SidebarState: ObservableObject { } func setVisible(_ nextValue: Bool) { + isHidePending = false guard nextValue != isVisible else { return } visibilityWillChange?(nextValue) isVisible = nextValue diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 453e70891495..1aeddc490e1d 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -23,8 +23,11 @@ final class SidebarToggleAnimator: ObservableObject { /// The width the pane rests at when open, captured at hide time and /// restored after, so the sweep never corrupts the persisted width. private var restingWidth: CGFloat = 0 - /// A hide sweep is running: `isVisible` stays true until it lands. - private var isHiding = false + /// A hide sweep is running: `isVisible` stays true until it lands. Mirrored + /// onto the state so a toggle mid-sweep sees the hide as requested. + private var isHiding = false { + didSet { sidebarState?.isHidePending = isHiding } + } /// The width a running show sweep is heading to. A hide that interrupts /// it rests at this, not at the half-open live width. private var showTarget: CGFloat? @@ -58,6 +61,7 @@ final class SidebarToggleAnimator: ObservableObject { // until it lands) is the same request; restarting it would capture // the half-collapsed width as the resting width. if isHiding && !targetVisible { return true } + let reversesHide = isHiding && targetVisible SidebarNavigationTimings.begin(targetVisible ? "toggle.show" : "toggle.hide") cancelSweep() isHiding = false @@ -66,20 +70,23 @@ final class SidebarToggleAnimator: ObservableObject { if targetVisible { // Docking over an already-revealed peek card swaps in place: the // card is where the pane belongs, so any sweep would be a ghost. - if isPeekPresenting() { + if !reversesHide, isPeekPresenting() { return false } let target = restingWidth > 1 ? restingWidth : max(layout.width, CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) + // A show that reverses a hide mid-sweep glides back from where the + // pane is now (it is still mounted) instead of snapping shut first. + let start = reversesHide ? layout.width : 1 sidebarState.setVisible(true) - layout.width = 1 + layout.width = start // The width snap above IS the first visual feedback for show (the // terminal steps aside in this very turn); the sweep that follows // is the pane gliding in. SidebarNavigationTimings.end("toggle.show") showTarget = target - sweep(from: 1, to: target) { [weak self] in self?.showTarget = nil } + sweep(from: start, to: target) { [weak self] in self?.showTarget = nil } return true } else { restingWidth = interruptedShowTarget ?? layout.width diff --git a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift index 18a4b04702b0..e19e40479dcd 100644 --- a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift +++ b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift @@ -60,3 +60,31 @@ struct SidebarFocusBoundaryLifecycleTests { return host } } + +@Suite +@MainActor +struct SidebarStateAnimatedToggleTests { + /// An animated hide keeps `isVisible` true until its sweep lands. The + /// request is hidden at once, and a second toggle mid-sweep asks to show + /// again instead of repeating the hide. + @Test + func toggleDuringAHideSweepReversesIt() { + let state = SidebarState(isVisible: true) + var requests: [Bool] = [] + state.animatedVisibilityOrchestrator = { targetVisible in + requests.append(targetVisible) + if !targetVisible { state.isHidePending = true } + return true + } + + state.toggle() + #expect(state.isVisible) + #expect(!state.requestedVisibility) + + state.toggle() + #expect(requests == [false, true]) + + state.setVisible(true) + #expect(state.requestedVisibility) + } +} From 3c783f96444775ee1a12d933fb1c2898cc406c5e Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 09:16:40 +1300 Subject: [PATCH 41/69] tests: glass selection, solid tools sidebar, glass-off pin for browser under-page tests selection tests assert the neutral glass patch (custom selection color still wins), the right sidebar sits on a solid terminal backdrop, and the under-page refresh tests pin window glass off now that it's the default --- cmux.xcodeproj/project.pbxproj | 8 ++ cmuxTests/BrowserConfigTests.swift | 6 +- .../BrowserGlassWindowBackgroundTests.swift | 49 ++++++++++ cmuxTests/SidebarAppKitRowCellTests.swift | 4 +- cmuxTests/SidebarGlassSelectionTests.swift | 98 +++++++++++++++++++ cmuxTests/WindowAppearanceSnapshotTests.swift | 14 +-- cmuxTests/WorkspaceUnitTests.swift | 80 --------------- 7 files changed, 165 insertions(+), 94 deletions(-) create mode 100644 cmuxTests/BrowserGlassWindowBackgroundTests.swift create mode 100644 cmuxTests/SidebarGlassSelectionTests.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 2ed1f5d49a49..6c7fd7e30e67 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -452,6 +452,7 @@ A5008373 /* BrowserFindWebViewEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5008372 /* BrowserFindWebViewEvaluator.swift */; }; 7B5F1A2E9C0D4B6A8E217302 /* BrowserFixtureInteractionUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */; }; 8478B0000000000000000002 /* BrowserFixtureSocketTestCase+PendingRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8478B0000000000000000001 /* BrowserFixtureSocketTestCase+PendingRequest.swift */; }; + 3368BD0CC7C8EF18C407D005 /* BrowserGlassWindowBackgroundTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37CB5F859904B727E303E4C7 /* BrowserGlassWindowBackgroundTests.swift */; }; 173381D0BD5068A03B70F8C5 /* BrowserHiddenWebViewDiscardBlockerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D5B362083A3A609ADB63D80 /* BrowserHiddenWebViewDiscardBlockerTests.swift */; }; 5B8C549C031604DAE01ADBA6 /* BrowserHiddenWebViewDiscardSettingsFileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD210E0F9E610E4124ADA8DD /* BrowserHiddenWebViewDiscardSettingsFileTests.swift */; }; AD3D4D3E24D4142DA5C6099D /* BrowserHiddenWebViewDiscardTimerPolicyTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C048F37095B42BFAB9CB65C /* BrowserHiddenWebViewDiscardTimerPolicyTestSupport.swift */; }; @@ -3257,6 +3258,7 @@ A8624F0C0000000000000001 /* SidebarFocusBoundaryReference.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */; }; 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */; }; 20FFB0FB12343E17FE2624FF /* SidebarGlassSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1525C188F2BD8ED27D43C51C /* SidebarGlassSelection.swift */; }; + 550C0F088E89B0C35370402C /* SidebarGlassSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D1FFF6470F3C414CBF16A79 /* SidebarGlassSelectionTests.swift */; }; F10753000000000000000001 /* SidebarGroupHeaderRowActions+NotificationState.swift in Sources */ = {isa = PBXBuildFile; fileRef = F10753000000000000000002 /* SidebarGroupHeaderRowActions+NotificationState.swift */; }; B804A0200000000000000020 /* SidebarGroupHeaderRowModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0200000000000000020 /* SidebarGroupHeaderRowModel.swift */; }; B804A0210000000000000021 /* SidebarGroupHeaderRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0210000000000000021 /* SidebarGroupHeaderRowView.swift */; }; @@ -5052,6 +5054,7 @@ A5008372 /* BrowserFindWebViewEvaluator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Find/BrowserFindWebViewEvaluator.swift; sourceTree = ""; }; 7B5F1A2E9C0D4B6A8E217301 /* BrowserFixtureInteractionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserFixtureInteractionUITests.swift; sourceTree = ""; }; 8478B0000000000000000001 /* BrowserFixtureSocketTestCase+PendingRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserFixtureSocketTestCase+PendingRequest.swift"; sourceTree = ""; }; + 37CB5F859904B727E303E4C7 /* BrowserGlassWindowBackgroundTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserGlassWindowBackgroundTests.swift"; sourceTree = ""; }; 3D5B362083A3A609ADB63D80 /* BrowserHiddenWebViewDiscardBlockerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserHiddenWebViewDiscardBlockerTests.swift"; sourceTree = ""; }; AD210E0F9E610E4124ADA8DD /* BrowserHiddenWebViewDiscardSettingsFileTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserHiddenWebViewDiscardSettingsFileTests.swift"; sourceTree = ""; }; 8C048F37095B42BFAB9CB65C /* BrowserHiddenWebViewDiscardTimerPolicyTestSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "BrowserHiddenWebViewDiscardTimerPolicyTestSupport.swift"; sourceTree = ""; }; @@ -7695,6 +7698,7 @@ A8624F0C0000000000000002 /* SidebarFocusBoundaryReference.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarFocusBoundaryReference.swift; sourceTree = ""; }; 8175A0010000000000000002 /* SidebarGitProcessCompositionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarGitProcessCompositionTests.swift; sourceTree = ""; }; 1525C188F2BD8ED27D43C51C /* SidebarGlassSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarGlassSelection.swift; sourceTree = ""; }; + 3D1FFF6470F3C414CBF16A79 /* SidebarGlassSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarGlassSelectionTests.swift"; sourceTree = ""; }; F10753000000000000000002 /* SidebarGroupHeaderRowActions+NotificationState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/Cells/SidebarGroupHeaderRowActions+NotificationState.swift"; sourceTree = ""; }; B804B0200000000000000020 /* SidebarGroupHeaderRowModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarGroupHeaderRowModel.swift; sourceTree = ""; }; B804B0210000000000000021 /* SidebarGroupHeaderRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift; sourceTree = ""; }; @@ -13548,6 +13552,8 @@ 6124B3A20E384A384BF58063 /* SidebarPeekPanelWindowTests.swift */, 32C30979AEC1CA92678696D9 /* SidebarWorkspaceReorderLiftTests.swift */, E400D59987274763C0558AD7 /* SidebarPresentedWhileHiddenSnapshotTests.swift */, + 37CB5F859904B727E303E4C7 /* BrowserGlassWindowBackgroundTests.swift */, + 3D1FFF6470F3C414CBF16A79 /* SidebarGlassSelectionTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -17504,6 +17510,7 @@ B5A5E55B0000000000000001 /* BrowserEphemeralRestorationTests.swift in Sources */, 129980000000000000000001 /* BrowserFailedNavigationReloadTests.swift in Sources */, A5008381 /* BrowserFindJavaScriptTests.swift in Sources */, + 3368BD0CC7C8EF18C407D005 /* BrowserGlassWindowBackgroundTests.swift in Sources */, 173381D0BD5068A03B70F8C5 /* BrowserHiddenWebViewDiscardBlockerTests.swift in Sources */, 5B8C549C031604DAE01ADBA6 /* BrowserHiddenWebViewDiscardSettingsFileTests.swift in Sources */, AD3D4D3E24D4142DA5C6099D /* BrowserHiddenWebViewDiscardTimerPolicyTestSupport.swift in Sources */, @@ -18366,6 +18373,7 @@ A7FD1002 /* SidebarFileDropFindRoutingTests.swift in Sources */, B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */, 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */, + 550C0F088E89B0C35370402C /* SidebarGlassSelectionTests.swift in Sources */, B8624C010000000000000001 /* SidebarHiddenPresentationTests.swift in Sources */, 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */, AABBCC000000000000000206 /* SidebarInlineRenameKeyResolverTests.swift in Sources */, diff --git a/cmuxTests/BrowserConfigTests.swift b/cmuxTests/BrowserConfigTests.swift index 09795d78c628..570148c68740 100644 --- a/cmuxTests/BrowserConfigTests.swift +++ b/cmuxTests/BrowserConfigTests.swift @@ -2002,6 +2002,7 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { } func testBrowserPanelRefreshesUnderPageBackgroundColorWhenGhosttyBackgroundChanges() throws { + pinWindowGlass(enabled: false) let panel = BrowserPanel(workspaceId: UUID()) let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) let updatedOpacity = 0.57 @@ -2082,9 +2083,9 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { XCTAssertNil(panel.webView.appearance) } - /// Same refresh, but with the opacity boxed as `NSNumber` the way the - /// Ghostty notification carries it. + /// Same refresh, with the opacity boxed as `NSNumber` like the notification. func testBrowserPanelRefreshesUnderPageBackgroundColorWithGhosttyOpacity() throws { + pinWindowGlass(enabled: false) let panel = BrowserPanel(workspaceId: UUID()) let updatedColor = NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0) @@ -2106,7 +2107,6 @@ final class BrowserDeveloperToolsConfigurationTests: XCTestCase { } } - @MainActor final class BrowserInsecureHTTPAlertPresentationTests: XCTestCase { private final class BrowserInsecureHTTPAlertSpy: NSAlert { diff --git a/cmuxTests/BrowserGlassWindowBackgroundTests.swift b/cmuxTests/BrowserGlassWindowBackgroundTests.swift new file mode 100644 index 000000000000..2ad7760b644d --- /dev/null +++ b/cmuxTests/BrowserGlassWindowBackgroundTests.swift @@ -0,0 +1,49 @@ +import AppKit +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +extension XCTestCase { + /// Pins the window-glass keys for one test and restores them after. + /// Window glass is on by default, and a glass window clears a blank + /// browser page so the glass shows through, so opaque-path tests opt out. + func pinWindowGlass(enabled: Bool) { + let defaults = UserDefaults.standard + let savedGlass = defaults.object(forKey: "bgGlassEnabled") as? Bool + let savedBlendMode = defaults.string(forKey: "sidebarBlendMode") + defaults.set(enabled, forKey: "bgGlassEnabled") + defaults.set("behindWindow", forKey: "sidebarBlendMode") + addTeardownBlock { + if let savedGlass { defaults.set(savedGlass, forKey: "bgGlassEnabled") } else { defaults.removeObject(forKey: "bgGlassEnabled") } + if let savedBlendMode { defaults.set(savedBlendMode, forKey: "sidebarBlendMode") } else { defaults.removeObject(forKey: "sidebarBlendMode") } + } + } +} + +@MainActor +final class BrowserGlassWindowBackgroundTests: XCTestCase { + /// With window glass on (the default), a blank page leaves the glass + /// showing: the panel clears its under-page fill instead of painting the + /// terminal colour over it. + func testBlankPageClearsUnderPageBackgroundOnGlassWindow() throws { + pinWindowGlass(enabled: true) + let panel = BrowserPanel(workspaceId: UUID()) + XCTAssertTrue(panel.isShowingNewTabPage) + + NotificationCenter.default.post( + name: .ghosttyDefaultBackgroundDidChange, + object: nil, + userInfo: [ + GhosttyNotificationKey.backgroundColor: NSColor(srgbRed: 0.18, green: 0.29, blue: 0.44, alpha: 1.0), + GhosttyNotificationKey.backgroundOpacity: 1.0, + ] + ) + + let actual = try XCTUnwrap(panel.webView.underPageBackgroundColor?.usingColorSpace(.sRGB)) + XCTAssertEqual(actual.alphaComponent, 0, accuracy: 0.005) + } +} diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index adc6cba2bc85..688e56eb3d66 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -2417,9 +2417,9 @@ struct SidebarGroupHeaderSelectionEdgeTests { } @Test - func legacySelectionGroupHeadersDrawNoHairline() { + func glassSelectionGroupHeadersDrawTheHairlineOnlyWhenMultiSelected() { #expect(Self.edgeWidth(subtleSelection: false, isAnchorActive: true, isMultiSelected: false) == 0) - #expect(Self.edgeWidth(subtleSelection: false, isAnchorActive: false, isMultiSelected: true) == 0) + #expect(Self.edgeWidth(subtleSelection: false, isAnchorActive: false, isMultiSelected: true) == 1) } } diff --git a/cmuxTests/SidebarGlassSelectionTests.swift b/cmuxTests/SidebarGlassSelectionTests.swift new file mode 100644 index 000000000000..c92a172da7ea --- /dev/null +++ b/cmuxTests/SidebarGlassSelectionTests.swift @@ -0,0 +1,98 @@ +import AppKit +import CmuxFoundation +import CmuxSettings +import SwiftUI +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// The selected workspace row is always a neutral glass patch with a hairline +/// edge, never the accent colour; multi-selection is the same glass, weaker. +/// A configured `sidebarSelectionColorHex` still wins as a solid fill. +@Suite +struct SidebarGlassSelectionTests { + @Test(arguments: [(ColorScheme.light, CGFloat(0)), (.dark, CGFloat(1))]) + func solidFillSelectionIsNeutralTranslucentGlass(scheme: ColorScheme, expectedWhite: CGFloat) throws { + let color = try #require(sidebarSelectedWorkspaceBackgroundNSColor( + for: scheme, + sidebarSelectionColorHex: nil, + activeTabIndicatorStyle: .solidFill + ).usingColorSpace(.sRGB)) + let glass = try #require(SidebarGlassSelection.fill(for: scheme).usingColorSpace(.sRGB)) + + #expect(color.hexString(includeAlpha: true) == glass.hexString(includeAlpha: true)) + #expect(abs(color.redComponent - expectedWhite) < 0.001) + #expect(abs(color.greenComponent - expectedWhite) < 0.001) + #expect(abs(color.blueComponent - expectedWhite) < 0.001) + #expect(color.alphaComponent > 0) + #expect(color.alphaComponent < 0.5, "Glass selection is translucent") + } + + @Test(arguments: [ColorScheme.light, .dark]) + func selectionIsGlassUnlessSubtleSelectionOrAConfiguredColor(scheme: ColorScheme) { + #expect(!SettingCatalog().workspaceColors.subtleSelection.defaultValue) + let glass = sidebarWorkspaceRowBackgroundStyle( + activeTabIndicatorStyle: .leftRail, + isActive: true, + isMultiSelected: false, + customColorHex: nil, + colorScheme: scheme, + sidebarSelectionColorHex: nil + ) + #expect(glass.color?.hexString(includeAlpha: true) == SidebarGlassSelection.fill(for: scheme).hexString(includeAlpha: true)) + #expect(glass.color?.hexString() != CmuxAccentColor().nsColor(for: scheme).hexString()) + #expect(abs(glass.opacity - 1) < 0.001) + #expect(glass.edgeColor?.hexString(includeAlpha: true) == SidebarGlassSelection.edge(for: scheme).hexString(includeAlpha: true)) + + // Multi-selection members wear the same glass, weaker, so the active + // row stays the anchor. + let multi = sidebarWorkspaceRowBackgroundStyle( + activeTabIndicatorStyle: .leftRail, + isActive: false, + isMultiSelected: true, + customColorHex: nil, + colorScheme: scheme, + sidebarSelectionColorHex: nil + ) + #expect(multi.color?.hexString(includeAlpha: true) == glass.color?.hexString(includeAlpha: true)) + #expect(multi.opacity < glass.opacity) + #expect((multi.edgeColor?.alphaComponent ?? 1) < (glass.edgeColor?.alphaComponent ?? 0)) + + // A configured selection colour still wins as a solid fill with no + // glass edge, even with subtle selection on. + let configured = sidebarWorkspaceRowBackgroundStyle( + activeTabIndicatorStyle: .leftRail, + isActive: true, + isMultiSelected: false, + customColorHex: nil, + colorScheme: scheme, + sidebarSelectionColorHex: "#123456", + subtleSelection: true + ) + #expect(configured.color?.hexString() == "#123456") + #expect(configured.edgeColor == nil) + } + + /// Text on the glass patch keeps the pane's own label colour: the patch is + /// a tint, not a surface, so there is no accent to contrast against. + @Test(arguments: [(ColorScheme.light, CGFloat(0)), (.dark, CGFloat(1))]) + func glassSelectionForegroundKeepsThePaneLabelColor(scheme: ColorScheme, expectedWhite: CGFloat) throws { + let color = try #require(sidebarSelectedWorkspaceForegroundNSColor( + on: sidebarSelectedWorkspaceBackgroundNSColor( + for: scheme, + sidebarSelectionColorHex: nil, + activeTabIndicatorStyle: .solidFill + ), + opacity: 0.65 + ).usingColorSpace(.sRGB)) + + #expect(abs(color.redComponent - expectedWhite) < 0.001) + #expect(abs(color.greenComponent - expectedWhite) < 0.001) + #expect(abs(color.blueComponent - expectedWhite) < 0.001) + #expect(abs(color.alphaComponent - 0.65) < 0.001) + } +} diff --git a/cmuxTests/WindowAppearanceSnapshotTests.swift b/cmuxTests/WindowAppearanceSnapshotTests.swift index d0ac3fc58c23..4cd89a12ea56 100644 --- a/cmuxTests/WindowAppearanceSnapshotTests.swift +++ b/cmuxTests/WindowAppearanceSnapshotTests.swift @@ -24,7 +24,7 @@ final class WindowAppearanceSnapshotTests: XCTestCase { assertClearBackdrop(snapshot.policy(for: .rightSidebar)) } - func testSeparateSurfaceBackdropsKeepRootBackdropAndSidebarMaterialsSeparate() { + func testSeparateSurfaceBackdropsKeepSidebarMaterialAndSolidToolsSidebar() { let snapshot = makeSnapshot(unifySurfaceBackdrops: false) assertTerminalBackdrop(snapshot.policy(for: .windowRoot)) @@ -40,12 +40,8 @@ final class WindowAppearanceSnapshotTests: XCTestCase { XCTAssertEqual(leftPolicy.material, .sidebar) XCTAssertEqual(leftPolicy.blendingMode, .withinWindow) - guard case let .sidebarMaterial(rightPolicy) = snapshot.policy(for: .rightSidebar) else { - XCTFail("right sidebar should keep its own material policy") - return - } - XCTAssertEqual(rightPolicy.material, .sidebar) - XCTAssertEqual(rightPolicy.blendingMode, .withinWindow) + // The tools sidebar is a work surface on a solid terminal ground. + assertTerminalBackdrop(snapshot.policy(for: .rightSidebar), expectedOpacity: 1.0) } func testMacOSGlassClearForcesTransparentHostingAndClearGlassStyle() { @@ -234,8 +230,8 @@ final class WindowAppearanceSnapshotTests: XCTestCase { sidebarColorScheme: .dark ) - guard case let .sidebarMaterial(policy) = snapshot.policy(for: .rightSidebar) else { - XCTFail("right sidebar should keep its own material policy") + guard case let .sidebarMaterial(policy) = snapshot.policy(for: .leftSidebar) else { + XCTFail("left sidebar should keep its own material policy") return } XCTAssertEqual(policy.tintColor.hexString(includeAlpha: true), "#00000066") diff --git a/cmuxTests/WorkspaceUnitTests.swift b/cmuxTests/WorkspaceUnitTests.swift index 9ac53b899fd4..1ab978857bf7 100644 --- a/cmuxTests/WorkspaceUnitTests.swift +++ b/cmuxTests/WorkspaceUnitTests.swift @@ -45,38 +45,6 @@ func makeTemporaryBrowserProfile(named prefix: String) throws -> BrowserProfileD } final class SidebarSelectedWorkspaceColorTests: XCTestCase { - func testLightModeSolidFillUsesSaturatedSelectedWorkspaceBackgroundColor() { - guard let color = sidebarSelectedWorkspaceBackgroundNSColor( - for: .light, - sidebarSelectionColorHex: nil, - activeTabIndicatorStyle: .solidFill - ).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 136.0 / 255.0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) - } - - func testDarkModeSolidFillUsesSaturatedSelectedWorkspaceBackgroundColor() { - guard let color = sidebarSelectedWorkspaceBackgroundNSColor( - for: .dark, - sidebarSelectionColorHex: nil, - activeTabIndicatorStyle: .solidFill - ).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(color.redComponent, 0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 145.0 / 255.0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 1.0, accuracy: 0.001) - } - func testSelectedWorkspaceForegroundUsesBlackOnLightSelectionBackground() { guard let color = sidebarSelectedWorkspaceForegroundNSColor( on: NSColor(hex: "#FFFFFF")!, @@ -147,35 +115,6 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { } } - func testSelectionStaysSolidUnlessSubtleSelectionIsEnabled() { - XCTAssertFalse(SettingCatalog().workspaceColors.subtleSelection.defaultValue) - for scheme in [ColorScheme.light, .dark] { - let solid = sidebarWorkspaceRowBackgroundStyle( - activeTabIndicatorStyle: .leftRail, - isActive: true, - isMultiSelected: false, - customColorHex: nil, - colorScheme: scheme, - sidebarSelectionColorHex: nil - ) - XCTAssertEqual(solid.color?.hexString(), CmuxAccentColor().nsColor(for: scheme).hexString()) - XCTAssertEqual(solid.opacity, 1.0, accuracy: 0.001) - XCTAssertNil(solid.edgeColor) - - let configured = sidebarWorkspaceRowBackgroundStyle( - activeTabIndicatorStyle: .leftRail, - isActive: true, - isMultiSelected: false, - customColorHex: nil, - colorScheme: scheme, - sidebarSelectionColorHex: "#123456", - subtleSelection: true - ) - XCTAssertEqual(configured.color?.hexString(), "#123456") - XCTAssertNil(configured.edgeColor) - } - } - func testSolidFillDarkModeRowUsesChosenColorWhenBrighteningDisabled() { let customHex = "#0E151B" let brightened = sidebarWorkspaceRowBackgroundStyle( @@ -225,25 +164,6 @@ final class SidebarSelectedWorkspaceColorTests: XCTestCase { } } - func testSolidFillSelectedWorkspaceForegroundUsesNativeSelectionTextOnAccentBackground() { - guard let color = sidebarSelectedWorkspaceForegroundNSColor( - on: sidebarSelectedWorkspaceBackgroundNSColor( - for: .light, - sidebarSelectionColorHex: nil, - activeTabIndicatorStyle: .solidFill - ), - opacity: 0.65 - ).usingColorSpace(.sRGB) else { - XCTFail("Expected sRGB-convertible color") - return - } - - XCTAssertEqual(color.redComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.greenComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.blueComponent, 1.0, accuracy: 0.001) - XCTAssertEqual(color.alphaComponent, 0.65, accuracy: 0.001) - } - @MainActor func testSolidFillKeepsSelectedBackgroundForActiveCustomColoredWorkspaceRow() { let manager = TabManager() From 77eab498e04d5112fb3b67541ba47e1e93b85936 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 10:18:38 +1300 Subject: [PATCH 42/69] sidebar table: retire live drop updates, retire rows as they slide out, read resting lift tops from the model the lift never sets an indicator painter, so exit and hide skipped the drop indicator clear (and its autoscroll stop). an animated close kept the removed row interactive until the slide ended. the lift rebuild read a presentation copy that can lag a just-removed glide. --- ...arWorkspaceTableController+ReorderDrop.swift | 5 ++++- ...paceTableController+ReorderLiftRebuild.swift | 6 +++++- .../SidebarWorkspaceTableController.swift | 17 +++++++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift index 88a0046fb7f0..8ce631c4853c 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift @@ -156,6 +156,7 @@ extension SidebarWorkspaceTableController { // it), but it is drawn as displacement, not indicator lines: the // rows shifting around the lifted row are the drop preview. lastAcceptedReorderDropPlan = update.plan + hasLiveReorderDropUpdate = true if case .reorder(_, _, let explicitGroupId)? = update.plan?.action { updateReorderLiftIndent(isGrouped: explicitGroupId != nil) } @@ -168,7 +169,9 @@ extension SidebarWorkspaceTableController { func retireReorderIndicator() { lastAcceptedReorderDropPlan = nil - guard reorderIndicatorPainter != nil else { return } + let hadLiveUpdate = hasLiveReorderDropUpdate + hasLiveReorderDropUpdate = false + guard reorderIndicatorPainter != nil || hadLiveUpdate else { return } reorderIndicatorPainter = nil clearReorderIndicatorPaintOnVisibleCells() actions?.clearWorkspaceDropIndicator() diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift index fee33176e270..f8b0c8780d4e 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift @@ -39,7 +39,11 @@ extension SidebarWorkspaceTableController { table.enumerateAvailableRowViews { rowView, row in guard previousRows.indices.contains(row) else { return } let layer = rowView.layer - let shift = (layer?.presentation() ?? layer)? + // Mid-glide the presentation is what is on screen; at rest the + // model is, and a presentation copy can lag it until the next + // commit (a just-removed glide still reads mid-flight). + let isGliding = !(layer?.animationKeys() ?? []).isEmpty + let shift = ((isGliding ? layer?.presentation() : nil) ?? layer)? .value(forKeyPath: "transform.translation.y") as? CGFloat ?? 0 visualTops[previousRows[row].id] = rowView.frame.minY + shift } diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index a505451da03d..73d0262c4140 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -929,6 +929,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS table.beginUpdates() switch pureEdit { case .remove(let indexes): + if animates { retireRowsSlidingOut(indexes, in: table) } table.removeRows(at: indexes, withAnimation: animates ? [.slideLeft, .effectFade] : []) case .insert(let indexes): table.insertRows(at: indexes, withAnimation: animates ? [.slideDown, .effectFade] : []) @@ -1241,6 +1242,17 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS return cell } + /// An animated removal keeps the row views on screen until the slide + /// ends, and `didRemove` only fires then. The workspace is already gone, + /// so its row stops being interactive now: edits commit, popovers close, + /// actions and link proxies are released. `didRemove` repeats it, a no-op. + private func retireRowsSlidingOut(_ indexes: IndexSet, in table: NSTableView) { + for row in indexes { + guard let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) else { continue } + mutationScheduler.stagePostUpdateActions(retirePresentation(from: cell, commitEdits: true)) + } + } + func tableView(_ tableView: NSTableView, didRemove rowView: NSTableRowView, forRow row: Int) { guard let cell = rowView.view(atColumn: 0) as? NSView else { return } if let workspaceCell = cell as? SidebarWorkspaceRowTableCellView { @@ -1885,6 +1897,11 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// before the coalesced repaint). var lastAcceptedReorderDropPlan: SidebarWorkspaceReorderDropPlan? + /// An accepted drop update is live. The lift draws the preview, so no + /// painter is set; this is what tells retirement to clear the drag + /// model's indicator and stop its autoscroll. + var hasLiveReorderDropUpdate = false + /// One-shot: the next apply comes from this window's own drop, so the /// selected-workspace scroll policy must not yank the viewport away from /// the release position. From 218d22a675fb8782b38c083016405428e1e6b0f8 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 10:18:38 +1300 Subject: [PATCH 43/69] tests: raster glass links over the dark pane, keep native glass root under test the link raster tests composite the translucent glass selection over the sidebar surface, and the overlay chrome test turns compositor glass off so the native glass root path stays the one it checks --- cmuxTests/SidebarAppKitRowCellTests.swift | 20 ++++++++------------ cmuxTests/WindowOverlayChromeTests.swift | 7 ++++++- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/cmuxTests/SidebarAppKitRowCellTests.swift b/cmuxTests/SidebarAppKitRowCellTests.swift index 688e56eb3d66..a82d7b8eb75a 100644 --- a/cmuxTests/SidebarAppKitRowCellTests.swift +++ b/cmuxTests/SidebarAppKitRowCellTests.swift @@ -669,10 +669,8 @@ struct SidebarAppKitRowCellTests { Self.layoutCell(cell, model: model) let textView = try #require(Self.descriptionTextView(in: cell, showing: url.absoluteString)) - let selectionBackground = sidebarSelectedWorkspaceBackgroundNSColor( - for: .dark, - sidebarSelectionColorHex: settings.selectionColorHex - ) + let selectionBackground = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark, sidebarSelectionColorHex: settings.selectionColorHex) + let surface = cmuxCompositedNSColor(selectionBackground, over: NSColor(white: 0.16, alpha: 1)) // glass tints the dark pane let expected = try #require( sidebarSelectedWorkspaceForegroundNSColor(on: selectionBackground, opacity: 1.0) .usingColorSpace(.sRGB) @@ -703,12 +701,12 @@ struct SidebarAppKitRowCellTests { #expect(accessibilityLink.accessibilityURL() == url) #expect(!accessibilityLink.accessibilityFrameInParentSpace().isEmpty) - let raster = try Self.raster(of: textView, background: selectionBackground) + let raster = try Self.raster(of: textView, background: surface) let systemLink = try #require( NSColor.linkColor.usingColorSpace(.sRGB), "linkColor must resolve in sRGB" ) - let glyphColor = try Self.mostVisibleGlyphColor(in: raster, excluding: selectionBackground) + let glyphColor = try Self.mostVisibleGlyphColor(in: raster, excluding: surface) #expect(Self.distance(glyphColor, expected) < 0.05) #expect(Self.distance(glyphColor, systemLink) > 0.15) } @@ -1456,19 +1454,17 @@ struct SidebarAppKitRowCellTests { attributed.attribute(.foregroundColor, at: linkLocation, effectiveRange: nil) as? NSColor ) if isActive { - let selectionBackground = sidebarSelectedWorkspaceBackgroundNSColor( - for: .dark, - sidebarSelectionColorHex: model.settings.selectionColorHex - ) + let selectionBackground = sidebarSelectedWorkspaceBackgroundNSColor(for: .dark, sidebarSelectionColorHex: model.settings.selectionColorHex) + let surface = cmuxCompositedNSColor(selectionBackground, over: NSColor(white: 0.16, alpha: 1)) // glass tints the dark pane let expected = try #require( sidebarSelectedWorkspaceForegroundNSColor(on: selectionBackground, opacity: 1.0) .usingColorSpace(.sRGB) ) let renderedSRGB = try #require(rendered.usingColorSpace(.sRGB)) #expect(renderedSRGB == expected) - let raster = try Self.raster(of: textView, background: selectionBackground) + let raster = try Self.raster(of: textView, background: surface) let systemLink = try #require(NSColor.linkColor.usingColorSpace(.sRGB)) - let glyphColor = try Self.mostVisibleGlyphColor(in: raster, excluding: selectionBackground) + let glyphColor = try Self.mostVisibleGlyphColor(in: raster, excluding: surface) #expect(Self.distance(glyphColor, expected) < 0.05) #expect(Self.distance(glyphColor, systemLink) > 0.15) } else { diff --git a/cmuxTests/WindowOverlayChromeTests.swift b/cmuxTests/WindowOverlayChromeTests.swift index 82ae310eb3a5..403c08b2fde5 100644 --- a/cmuxTests/WindowOverlayChromeTests.swift +++ b/cmuxTests/WindowOverlayChromeTests.swift @@ -103,16 +103,21 @@ struct WindowOverlayChromeTests { // mount removes the native glass root on macOS 26, where // `NSGlassEffectView` exists. Configure glass in settings so the // surface keeps the glass root the test installed. That leaves the - // portals as the only thing that could replace it. + // portals as the only thing that could replace it. Compositor glass + // (on by default) clears the window instead of keeping a native glass + // root, so it is turned off to keep the native root under test. let defaults = UserDefaults.standard let savedBlendMode = defaults.object(forKey: "sidebarBlendMode") let savedGlassEnabled = defaults.object(forKey: "bgGlassEnabled") + let savedCompositorGlass = defaults.object(forKey: "sidebarCompositorGlass") defer { defaults.set(savedBlendMode, forKey: "sidebarBlendMode") defaults.set(savedGlassEnabled, forKey: "bgGlassEnabled") + defaults.set(savedCompositorGlass, forKey: "sidebarCompositorGlass") } defaults.set(useGlass ? "behindWindow" : "withinWindow", forKey: "sidebarBlendMode") defaults.set(useGlass, forKey: "bgGlassEnabled") + defaults.set(false, forKey: "sidebarCompositorGlass") let window = makeWindow(withBrowserHost: true) defer { window.orderOut(nil) } From fe96d1b6e6583dc44fef2e75e67c42c60c905f53 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 17:05:34 +1300 Subject: [PATCH 44/69] sidebar toggle: slide the content on the render server, one layout commit per landed toggle the width sweep relaid out swiftui, the splits and every terminal grid on each frame (50-75 ms main thread cpu per frame in debug). now one critically damped core animation spring moves the content root and the portal hosts together, so nothing runs on the main thread while it moves. - hide drops the docked layout at the keypress (the terminal takes its full width under a content root still offset by the sidebar width), show commits it when it lands, always at the wide end so no gap opens - a new docksSidebar flag on SidebarLayoutModel drives the keypress frame; isVisible (and contentview's body, peek panel mount, focus) follows after the motion - commits use the divider drag's atomic recipe so terminal frames land in the same frame - one target in a pure slide machine: a press mid-slide retargets from the presented offset carrying velocity, never overshooting, and commits nothing; programmatic setVisible drops the slide - the hidden pane stays full width, parked off the leading edge, and the retained docked list stays live, so a show slides in painted rows - right sidebar open, reduce motion or a non-default sidebar toggle instantly - debug-only probe and notify trigger for measuring --- Sources/ContentView+SidebarPeek.swift | 8 +- Sources/ContentView.swift | 21 +- .../Peek/SidebarPeekPresentation.swift | 11 +- Sources/Sidebar/SidebarLayoutModel.swift | 14 +- .../Sidebar/SidebarNavigationTimings.swift | 6 + Sources/Sidebar/SidebarState.swift | 36 +- Sources/Sidebar/SidebarToggleAnimator.swift | 536 ++++++++++++++---- .../Sidebar/SidebarToggleSlideMachine.swift | 170 ++++++ cmux.xcodeproj/project.pbxproj | 4 + 9 files changed, 671 insertions(+), 135 deletions(-) create mode 100644 Sources/Sidebar/SidebarToggleSlideMachine.swift diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index be5b42f0a7be..2ac474a6ea43 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -81,7 +81,9 @@ extension ContentView { /// underneath the portal-hosted terminal. func sidebarPeekPresentationModifier(width: CGFloat) -> SidebarPeekPresentation { SidebarPeekPresentation( - isRevealed: sidebarState.occupiesLayout, + // The layout flag, not `isVisible`: a toggle moves the pane + // before ContentView hears about it. + isRevealed: sidebarState.presentationMode == .docked && sidebarLayout.docksSidebar, rendersAsCard: false, width: width, panelMetrics: sidebarPeekPanelMetrics, @@ -236,6 +238,10 @@ extension ContentView { sidebarToggleAnimator.install( sidebarState: sidebarState, layout: sidebarLayout, + window: { observedWindow }, + // The right sidebar would ride along with the content + // root and snap back on landing; toggle instantly there. + canSlide: { retainsDefaultAppKitSidebarWhenHidden && !rightSidebarVisible }, isPeekPresenting: { sidebarPeek.presentsPanel } ) sidebarPeek.setPolicy(SidebarCustomizationSettings.peekPolicy()) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index aa67461042df..9de9be48e032 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1897,7 +1897,7 @@ struct ContentView: View { } } - private var rightSidebarVisible: Bool { + var rightSidebarVisible: Bool { fileExplorerState.isVisible } @@ -1957,7 +1957,10 @@ struct ContentView: View { // the tint inside the sidebar column. hidesBackdrop: true ) { - sidebarView(isPresented: sidebarState.occupiesLayout) + // The retained docked list stays live while hidden, so a show + // slides in painted rows from its first frame. + sidebarView(isPresented: sidebarState.occupiesLayout + || (retainsDefaultAppKitSidebarWhenHidden && sidebarState.presentationMode == .docked)) } } } @@ -2529,7 +2532,7 @@ struct ContentView: View { ) } - private var retainsDefaultAppKitSidebarWhenHidden: Bool { + var retainsDefaultAppKitSidebarWhenHidden: Bool { Self.retainsDefaultAppKitSidebar( appKitListEnabled: featureFlags.isAppKitSidebarListEnabled, effectiveProviderId: effectiveLeftSidebarProviderId @@ -2554,16 +2557,16 @@ struct ContentView: View { && !sidebarMatchTerminalBackground if retainsDefaultAppKitSidebarWhenHidden { // Native sidebar identity is independent of presentation. Keep its - // full-width subtree mounted behind a zero-width clipping shell so + // full-width subtree mounted, parked off the leading edge, so // hide/show and backdrop changes cannot cold-start the table. layout = AnyView( ZStack(alignment: .leading) { terminalContentWithRightSidebarPanel(appearance: appearance) - // `occupiesLayout`, not `isVisible`: a floating sidebar - // is visible but does not push the terminal aside. + // Docked mode only (a floating sidebar does not push + // the terminal aside), gated by the layout flag. .modifier(SidebarWidthLeadingPaddingModifier( layout: sidebarLayout, - enabled: sidebarState.occupiesLayout + enabled: sidebarState.presentationMode == .docked )) SidebarWidthReader(layout: sidebarLayout) { width in sidebarPanelWithBackdrop(appearance: appearance) @@ -2582,7 +2585,7 @@ struct ContentView: View { terminalContentWithSidebarDropOverlay(appearance: appearance) .modifier(SidebarWidthLeadingPaddingModifier( layout: sidebarLayout, - enabled: sidebarState.occupiesLayout + enabled: sidebarState.presentationMode == .docked )) .frame(maxWidth: .infinity, maxHeight: .infinity) .layoutPriority(1) @@ -2674,6 +2677,7 @@ struct ContentView: View { var view = AnyView( ZStack(alignment: .topLeading) { WindowBackdropLayer(role: .windowRoot, snapshot: appearance) + .padding(.leading, -SidebarLayoutModel.groundBleed) .ignoresSafeArea() .allowsHitTesting(false) @@ -2685,6 +2689,7 @@ struct ContentView: View { // column, the corner reveals, and the pane gaps, which is // exactly where the glass should show. WindowBackdropLayer(role: .leftSidebar, snapshot: appearance) + .padding(.leading, -SidebarLayoutModel.groundBleed) .ignoresSafeArea() .allowsHitTesting(false) diff --git a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift index 103b8ea3797b..199d59d136ad 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift @@ -85,13 +85,14 @@ struct SidebarPeekPresentation: ViewModifier { value: showsCard ) } else { - // The width itself is live during a toggle: the toggle animator - // sweeps the real layout width (a synthetic divider drag), so the - // pane collapses and the terminal expands through the same - // proven live-resize path. No SwiftUI animation here at all. + // Hidden, the pane keeps its full width, parked just past the + // window's leading edge: its rows never re-lay out, and a toggle + // slide (which translates the content root on the render server, + // see SidebarToggleAnimator) carries it in and out as it is. content - .frame(width: isRevealed ? width : 0, alignment: .leading) + .frame(width: width, alignment: .leading) .clipped() + .offset(x: isRevealed ? 0 : -width) .allowsHitTesting(isRevealed) .accessibilityHidden(!isRevealed) } diff --git a/Sources/Sidebar/SidebarLayoutModel.swift b/Sources/Sidebar/SidebarLayoutModel.swift index a95bf72a3c52..c1d5269d0af9 100644 --- a/Sources/Sidebar/SidebarLayoutModel.swift +++ b/Sources/Sidebar/SidebarLayoutModel.swift @@ -14,6 +14,18 @@ import SwiftUI @MainActor final class SidebarLayoutModel: ObservableObject { @Published var width: CGFloat + /// Whether the docked layout gives the sidebar its width (terminal + /// inset, pane in its slot). Mirrors `SidebarState.isVisible`, except + /// that the toggle animator flips it first: only the small wrappers + /// below read it, so the keypress frame of a toggle never re-evaluates + /// ContentView's body. While false, the pane stays laid out at full + /// width, parked just past the window's leading edge. + @Published var docksSidebar = true + + /// How far the window ground reaches past the leading edge, so a toggle + /// slide (which translates the content root by up to the sidebar width) + /// never uncovers the window edge. Static: nothing re-lays out per slide. + static let groundBleed = CGFloat(SessionPersistencePolicy.maximumSidebarWidth) init(width: CGFloat) { self.width = width @@ -56,6 +68,6 @@ struct SidebarWidthLeadingPaddingModifier: ViewModifier { let enabled: Bool func body(content: Content) -> some View { - content.padding(.leading, enabled ? layout.width : 0) + content.padding(.leading, enabled && layout.docksSidebar ? layout.width : 0) } } diff --git a/Sources/Sidebar/SidebarNavigationTimings.swift b/Sources/Sidebar/SidebarNavigationTimings.swift index 285c776251be..853712af6c5f 100644 --- a/Sources/Sidebar/SidebarNavigationTimings.swift +++ b/Sources/Sidebar/SidebarNavigationTimings.swift @@ -105,6 +105,12 @@ enum SidebarNavigationTimings { appendLine("nav.timing interaction=\(interaction) ms=\(String(format: "%.2f", ms))") } + /// Appends a preformatted line (frame statistics for a sweep). + static func record(_ line: String) { + guard isEnabled else { return } + appendLine(line) + } + private static func appendLine(_ line: String) { guard let logHandle, let data = (line + "\n").data(using: .utf8) else { return } logHandle.write(data) diff --git a/Sources/Sidebar/SidebarState.swift b/Sources/Sidebar/SidebarState.swift index 2308495c66ea..67152edcdb5a 100644 --- a/Sources/Sidebar/SidebarState.swift +++ b/Sources/Sidebar/SidebarState.swift @@ -29,22 +29,24 @@ final class SidebarState: ObservableObject { private var visibilityWillChangeOwnerId: UUID? private var visibilityWillChange: ((Bool) -> Void)? /// When installed, user toggles defer to this orchestrator (the toggle - /// animator's width sweep), which applies the final value itself through - /// ``setVisible(_:)``. Returning false hands the change back to the - /// instant default path. Programmatic `setVisible` calls (narrow-window + /// animator's slide), which applies the final value itself once the + /// slide lands. Returning false hands the change back to the instant + /// default path. Programmatic `setVisible` calls (narrow-window /// auto-collapse, session restore) never animate, so their callers can /// read `isVisible` right after. var animatedVisibilityOrchestrator: ((Bool) -> Bool)? - /// A hide sweep is running. `isVisible` stays true until it lands so the - /// pane stays mounted, but the hide was already requested. Set by the - /// toggle animator; any committed ``setVisible(_:)`` clears it. - var isHidePending = false - - /// The visibility the sidebar is heading to: false as soon as a hide is - /// requested, even while its sweep runs. Toggles read this, so a second - /// toggle mid-sweep reverses the hide instead of being dropped. + /// Told after every committed ``setVisible(_:)``, so the toggle animator + /// can drop a running slide and keep its layout flag in step. + var visibilityDidCommit: ((Bool) -> Void)? + /// Where a running toggle animation is heading while `isVisible` still + /// shows the other value. Owned by the toggle animator; nil otherwise. + var pendingVisibility: Bool? + + /// The visibility the sidebar is heading to, even while a toggle + /// animation runs. Toggles read this, so a second toggle mid-animation + /// reverses it instead of being dropped. var requestedVisibility: Bool { - isVisible && !isHidePending + pendingVisibility ?? isVisible } init( @@ -72,10 +74,12 @@ final class SidebarState: ObservableObject { } func setVisible(_ nextValue: Bool) { - isHidePending = false - guard nextValue != isVisible else { return } - visibilityWillChange?(nextValue) - isVisible = nextValue + pendingVisibility = nil + if nextValue != isVisible { + visibilityWillChange?(nextValue) + isVisible = nextValue + } + visibilityDidCommit?(nextValue) } func installVisibilityWillChangeHandler( diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 1aeddc490e1d..edde521c49db 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -1,139 +1,467 @@ import AppKit import Foundation +import QuartzCore -/// The sidebar toggle as a synthetic divider drag. +/// The docked sidebar toggle as a render-server slide. /// -/// cmux already has one place where the terminal live-resizes beautifully: -/// dragging the sidebar's width divider, which feeds real widths through the -/// layout on every pointer tick. The toggle borrows exactly that mechanism: -/// a timer sweeps the REAL `SidebarLayoutModel.width` along an ease curve, so -/// the sidebar collapse, the terminal's live expansion, and every dependent -/// piece of chrome move together through the same proven path. No SwiftUI -/// animation is involved anywhere (the earlier attempts layered clip sweeps -/// and offsets over a static layout, and every seam showed); this is the -/// same drive-the-truth approach that fixed the drag reorder. +/// Sweeping the real layout width cost a SwiftUI relayout, a split relayout +/// and a terminal grid resize on every frame. The slide instead moves the +/// window content root and the portal hosts above it (terminals, browsers) +/// as one rigid body, with one Core Animation spring on +/// `transform.translation.x` per layer, so the sidebar edge, the terminal's +/// leading edge and the pane chrome cannot drift apart, and nothing runs on +/// the main thread while it moves. +/// +/// `SidebarToggleSlideMachine` decides what happens; this type carries it +/// out. Layout changes at most once per landed toggle, always at the wide +/// end, through `SidebarLayoutModel.docksSidebar` (read only by small +/// wrappers), committed with the divider drag's atomic recipe. The heavier +/// `SidebarState.isVisible` follows once the motion is over. @MainActor final class SidebarToggleAnimator: ObservableObject { + private static let animationKey = "cmux.sidebarToggleSlide" + private weak var sidebarState: SidebarState? private weak var layout: SidebarLayoutModel? + private var window: () -> NSWindow? = { nil } + private var canSlide: () -> Bool = { false } private var isPeekPresenting: () -> Bool = { false } - // `nonisolated(unsafe)` so deinit can invalidate; every other access is - // main-actor. - private nonisolated(unsafe) var timer: Timer? - /// The width the pane rests at when open, captured at hide time and - /// restored after, so the sweep never corrupts the persisted width. - private var restingWidth: CGFloat = 0 - /// A hide sweep is running: `isVisible` stays true until it lands. Mirrored - /// onto the state so a toggle mid-sweep sees the hide as requested. - private var isHiding = false { - didSet { sidebarState?.isHidePending = isHiding } - } - /// The width a running show sweep is heading to. A hide that interrupts - /// it rests at this, not at the half-open live width. - private var showTarget: CGFloat? - - deinit { - timer?.invalidate() - } + private var machine = SidebarToggleSlideMachine(docked: true) + /// Layers carrying the running slide, captured when it starts and kept + /// for every retarget until it lands. + private var slideLayers: [CALayer] = [] + /// Effects are being carried out; a press arriving meanwhile (the atomic + /// commit runs the run loop once) waits its turn. + private var isExecuting = false + private var queuedRequests: [Bool] = [] + /// The animator itself is committing `isVisible`. + private var isCommittingVisibility = false func install( sidebarState: SidebarState, layout: SidebarLayoutModel, + window: @escaping () -> NSWindow?, + canSlide: @escaping () -> Bool, isPeekPresenting: @escaping () -> Bool ) { self.sidebarState = sidebarState self.layout = layout + self.window = window + self.canSlide = canSlide self.isPeekPresenting = isPeekPresenting + machine = SidebarToggleSlideMachine(docked: sidebarState.isVisible) + layout.docksSidebar = sidebarState.isVisible sidebarState.animatedVisibilityOrchestrator = { [weak self] targetVisible in - guard let self else { return false } - return self.animateVisibility(to: targetVisible) + self?.request(visible: targetVisible) ?? false } + sidebarState.visibilityDidCommit = { [weak self] visible in + self?.visibilityDidCommit(visible) + } +#if DEBUG + SidebarToggleSlideProbe.installTrigger(for: self) +#endif } - /// Returns true when the change was consumed and will be applied by the - /// sweep; false hands the change back to the instant default path. - private func animateVisibility(to targetVisible: Bool) -> Bool { + /// Returns true when the toggle was consumed by a slide; false hands it + /// back to the instant path. + private func request(visible: Bool) -> Bool { guard let sidebarState, let layout else { return false } - // Only a docked pane trades width with the terminal; floating - // visibility is the peek panel's business. - guard sidebarState.presentationMode == .docked else { return false } - // A second hide while one is sweeping (`isVisible` is still true - // until it lands) is the same request; restarting it would capture - // the half-collapsed width as the resting width. - if isHiding && !targetVisible { return true } - let reversesHide = isHiding && targetVisible - SidebarNavigationTimings.begin(targetVisible ? "toggle.show" : "toggle.hide") - cancelSweep() - isHiding = false - let interruptedShowTarget = showTarget - showTarget = nil - if targetVisible { - // Docking over an already-revealed peek card swaps in place: the - // card is where the pane belongs, so any sweep would be a ghost. - if !reversesHide, isPeekPresenting() { - return false - } - let target = restingWidth > 1 - ? restingWidth - : max(layout.width, CGFloat(SessionPersistencePolicy.defaultSidebarWidth)) - // A show that reverses a hide mid-sweep glides back from where the - // pane is now (it is still mounted) instead of snapping shut first. - let start = reversesHide ? layout.width : 1 - sidebarState.setVisible(true) - layout.width = start - // The width snap above IS the first visual feedback for show (the - // terminal steps aside in this very turn); the sweep that follows - // is the pane gliding in. - SidebarNavigationTimings.end("toggle.show") - showTarget = target - sweep(from: start, to: target) { [weak self] in self?.showTarget = nil } - return true - } else { - restingWidth = interruptedShowTarget ?? layout.width - isHiding = true - sweep(from: layout.width, to: 1) { [weak self] in - self?.isHiding = false - guard let self, - let sidebarState = self.sidebarState, - let layout = self.layout else { return } - sidebarState.setVisible(false) - // The pane is unmounted now; restore the resting width so the - // next show (and the persisted value) see the real width. - layout.width = self.restingWidth - } + let isSliding = machine.slide != nil + guard sidebarState.presentationMode == .docked, + !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, + canSlide(), + let window = window(), + isSliding || Self.slidingLayers(in: window) != nil else { + return false + } + // Docking over an already-revealed peek card swaps in place: the + // card is where the pane belongs, so any slide would be a ghost. + if !isSliding, visible, isPeekPresenting() { return false } + if isExecuting { + queuedRequests.append(visible) + sidebarState.pendingVisibility = visible return true } + SidebarNavigationTimings.begin(visible ? "toggle.show" : "toggle.hide") +#if DEBUG + let probe = SidebarToggleSlideProbe.begin(visible: visible, window: window, animator: self) +#endif + let width = Double(layout.width) + execute(machine.request(visible: visible, width: width, now: CACurrentMediaTime()), in: window) + while !queuedRequests.isEmpty { + let next = queuedRequests.removeFirst() + execute(machine.request(visible: next, width: width, now: CACurrentMediaTime()), in: window) + } + syncPendingVisibility() +#if DEBUG + probe?.keypressDidFinish() +#endif + SidebarNavigationTimings.end(visible ? "toggle.show" : "toggle.hide") + return true } - private func sweep(from: CGFloat, to: CGFloat, completion: (() -> Void)?) { - let duration: TimeInterval = 0.17 - let start = ProcessInfo.processInfo.systemUptime - let timer = Timer(timeInterval: 1.0 / 120.0, repeats: true) { [weak self] tick in - MainActor.assumeIsolated { - guard let self, let layout = self.layout else { - tick.invalidate() - return + private func execute(_ effects: [SidebarToggleSlideMachine.Effect], in window: NSWindow) { + isExecuting = true + defer { isExecuting = false } + var index = effects.startIndex + while index < effects.endIndex { + switch effects[index] { + case .commitHiddenLayout: + // The hidden layout and the slide's first pose land in one + // frame: the terminal takes its full width under a content + // root still offset by the sidebar width. + let next = effects.index(after: index) + let slide: SidebarToggleSlideMachine.Slide? + if next < effects.endIndex, case let .animate(nextSlide) = effects[next] { + slide = nextSlide + index = next + } else { + slide = nil + } + commitAtomically(in: window) { + layout?.docksSidebar = false + } layers: { + if let slide { addSlideAnimation(slide, in: window) } + } + if let slide { machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) } + case let .animate(slide): + CATransaction.begin() + addSlideAnimation(slide, in: window) + CATransaction.commit() + CATransaction.flush() + machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) + case .commitShownLayout: + commitAtomically(in: window) { + layout?.docksSidebar = true + commitVisibility(true) + } layers: { + removeSlideAnimations() } - let progress = min(1, (ProcessInfo.processInfo.systemUptime - start) / duration) - // Ease in-out: the pane leaves and arrives, it does not snap. - let eased = progress < 0.5 - ? 2 * progress * progress - : 1 - pow(-2 * progress + 2, 2) / 2 - layout.width = from + (to - from) * CGFloat(eased) - SidebarNavigationTimings.end("toggle.hide") - if progress >= 1 { - tick.invalidate() - self.timer = nil - completion?() + case .finishHide: + CATransaction.begin() + CATransaction.setDisableActions(true) + removeSlideAnimations() + CATransaction.commit() + // ContentView hears about the hide once the motion is over; + // its body pass, the peek panel mount and the focus handoff + // run on a frame where nothing moves. + DispatchQueue.main.async { [weak self] in + guard let self, self.machine.slide == nil, !self.machine.target else { return } + self.commitVisibility(false) + self.syncPendingVisibility() + } + } + index = effects.index(after: index) + } + } + + /// The divider drag's recipe (`SidebarDividerTrackingView`): with the + /// interactive geometry resize held, mutate, let SwiftUI and Core + /// Animation commit inside this event, then lay out, display and flush. + /// The layout change, the portal's terminal frames and the layer change + /// reach the screen in one frame, and the terminal resizes once. + private func commitAtomically(in window: NSWindow, _ mutate: () -> Void, layers: () -> Void) { + TerminalWindowPortalRegistry.beginInteractiveGeometryResize(owner: self, in: window) + mutate() + layers() + RunLoop.current.run(mode: .eventTracking, before: Date(timeIntervalSinceNow: 0.001)) + window.contentView?.layoutSubtreeIfNeeded() + window.displayIfNeeded() + CATransaction.flush() + TerminalWindowPortalRegistry.endInteractiveGeometryResize(owner: self) + } + + private func addSlideAnimation(_ slide: SidebarToggleSlideMachine.Slide, in window: NSWindow) { + if slideLayers.isEmpty { + slideLayers = Self.slidingLayers(in: window) ?? [] + } + let spring = machine.spring + let distance = slide.to - slide.from + for (index, layer) in slideLayers.enumerated() { + let animation = CASpringAnimation(keyPath: "transform.translation.x") + animation.fromValue = slide.from + animation.toValue = slide.to + animation.mass = 1 + animation.stiffness = spring.stiffness + animation.damping = spring.damping + animation.initialVelocity = distance == 0 ? 0 : slide.velocity / distance + animation.duration = slide.duration + // No begin time: the spring starts when its transaction commits, + // so a slow keypress commit delays the motion, never skips it. + animation.fillMode = .both + animation.isRemovedOnCompletion = false + if index == 0 { + animation.delegate = SlideLandingDelegate { [weak self] in + self?.land(generation: slide.generation) } } + layer.add(animation, forKey: Self.animationKey) + } + // A missed delegate callback must not strand the layout: land anyway. + DispatchQueue.main.asyncAfter(deadline: .now() + slide.duration + 0.1) { [weak self] in + self?.land(generation: slide.generation) + } + } + + private func removeSlideAnimations() { + for layer in slideLayers { + layer.removeAnimation(forKey: Self.animationKey) + } + slideLayers = [] + } + + /// Idempotent: a stale or repeated landing does nothing. + private func land(generation: Int) { + if isExecuting { + DispatchQueue.main.async { [weak self] in self?.land(generation: generation) } + return + } + guard let window = window() else { return } + let effects = machine.land(generation: generation) + guard !effects.isEmpty else { return } +#if DEBUG + let landingCPU = SidebarToggleSlideProbe.threadCPU() +#endif + execute(effects, in: window) + syncPendingVisibility() +#if DEBUG + SidebarToggleSlideProbe.current?.didLand(cpu: SidebarToggleSlideProbe.threadCPU() - landingCPU) + assert(slideLayers.allSatisfy { $0.animation(forKey: Self.animationKey) == nil }) + assert(layout?.docksSidebar == machine.docked) +#endif + } + + private func commitVisibility(_ visible: Bool) { + isCommittingVisibility = true + sidebarState?.setVisible(visible) + isCommittingVisibility = false + } + + /// Someone else set the visibility (session restore, narrow-window + /// collapse, the instant path): drop any slide and follow it. + private func visibilityDidCommit(_ visible: Bool) { + guard !isCommittingVisibility else { return } + if machine.slide != nil { + CATransaction.begin() + CATransaction.setDisableActions(true) + removeSlideAnimations() + CATransaction.commit() + } + machine.reset(visible: visible) + if layout?.docksSidebar != visible { + layout?.docksSidebar = visible + } + } + + private func syncPendingVisibility() { + guard let sidebarState else { return } + let pending: Bool? = machine.target == sidebarState.isVisible ? nil : machine.target + if sidebarState.pendingVisibility != pending { + sidebarState.pendingVisibility = pending + } + } + + /// The layers that move: the window content root (the sidebar column and + /// the workspace card live in it) and the terminal and browser portal + /// hosts stacked above it, which draw the terminals. + private static func slidingLayers(in window: NSWindow) -> [CALayer]? { + guard let portal = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)], + let reference = portal.installedReferenceView, + let container = reference.superview else { return nil } + var views: [NSView] = [reference] + if portal.hostView.superview === container { + views.append(portal.hostView) } - RunLoop.main.add(timer, forMode: .common) - self.timer = timer + views.append(contentsOf: container.subviews.filter { $0 is WindowBrowserHostView }) + let layers = views.compactMap(\.layer) + return layers.count == views.count ? layers : nil } - private func cancelSweep() { - timer?.invalidate() - timer = nil +#if DEBUG + var debugWindow: NSWindow? { window() } + var debugSidebarState: SidebarState? { sidebarState } +#endif +} + +/// Lands the slide when its spring finishes. Core Animation retains its +/// delegate; the closure holds the animator weakly. +private final class SlideLandingDelegate: NSObject, CAAnimationDelegate { + private let onLand: @MainActor @Sendable () -> Void + + init(onLand: @escaping @MainActor @Sendable () -> Void) { + self.onLand = onLand + } + + func animationDidStop(_ animation: CAAnimation, finished: Bool) { + guard finished else { return } + let onLand = onLand + // Out of Core Animation's callout before touching layout. + DispatchQueue.main.async { + MainActor.assumeIsolated { onLand() } + } + } +} + +#if DEBUG +/// DEBUG-only numbers per press into `CMUX_NAV_TIMINGS_LOG`: main-thread CPU +/// of the keypress turn and of the landing, keypress to first frame, frame +/// intervals and main-thread CPU per frame while moving, and how many +/// visible sidebar rows had no title on the first frame of a show. +/// `notifyutil -p com.cmuxterm.debug.sidebar-toggle` presses the toggle in +/// the frontmost window, so this runs without driving the pointer. +@MainActor +final class SidebarToggleSlideProbe: NSObject { + static weak var current: SidebarToggleSlideProbe? + private static var live: SidebarToggleSlideProbe? + + private let name: String + private let visible: Bool + private weak var window: NSWindow? + private let startWall = CACurrentMediaTime() + private let startCPU = SidebarToggleSlideProbe.threadCPU() + private var keypressCPU = 0.0 + private var landingCPU: Double? + private var link: CADisplayLink? + private var last: (wall: CFTimeInterval, cpu: Double)? + private var firstFrameMs: Double? + private var blankRows: Int? + private var intervals: [Double] = [] + private var cpu: [Double] = [] + private var landedWall: CFTimeInterval? + private var finished = false + + static func threadCPU() -> Double { + Double(clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID)) / 1_000_000 + } + + static func begin(visible: Bool, window: NSWindow, animator: SidebarToggleAnimator) -> SidebarToggleSlideProbe? { + guard SidebarNavigationTimings.isEnabled, let view = window.contentView else { return nil } + live?.finish() + let probe = SidebarToggleSlideProbe(visible: visible, window: window) + let link = view.displayLink(target: probe, selector: #selector(tick(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) + link.add(to: .main, forMode: .common) + probe.link = link + live = probe + current = probe + return probe + } + + private init(visible: Bool, window: NSWindow) { + self.visible = visible + self.name = visible ? "toggle.show" : "toggle.hide" + self.window = window + super.init() + } + + func keypressDidFinish() { + keypressCPU = Self.threadCPU() - startCPU + } + + func didLand(cpu: Double) { + landingCPU = cpu + landedWall = CACurrentMediaTime() + } + + @objc private func tick(_ link: CADisplayLink) { + let now = (wall: CACurrentMediaTime(), cpu: Self.threadCPU()) + if firstFrameMs == nil { + firstFrameMs = (now.wall - startWall) * 1000 + if visible { blankRows = countBlankSidebarRows() } + } else if let last { + intervals.append((now.wall - last.wall) * 1000) + cpu.append(now.cpu - last.cpu) + } + last = now + if let landedWall, now.wall - landedWall > 0.15 { finish() } + if now.wall - startWall > 2 { finish() } + } + + private func countBlankSidebarRows() -> Int { + guard let root = window?.contentView, + let table = Self.sidebarTable(in: root) else { return -1 } + let range = table.rows(in: table.visibleRect) + var blank = 0 + for row in range.location..<(range.location + range.length) { + guard let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false), + NSStringFromClass(type(of: cell)).contains("WorkspaceRow") else { continue } + if !Self.hasText(cell) { blank += 1 } + } + return blank + } + + private static func sidebarTable(in view: NSView) -> NSTableView? { + if let table = view as? NSTableView, + NSStringFromClass(type(of: table)).contains("Sidebar") + || NSStringFromClass(type(of: table.enclosingScrollView?.superview ?? table)).contains("SidebarWorkspaceTable") { + return table + } + for subview in view.subviews { + if let table = sidebarTable(in: subview) { return table } + } + return nil + } + + private static func hasText(_ view: NSView) -> Bool { + if let field = view as? NSTextField, !field.isHidden, !field.stringValue.isEmpty { return true } + return view.subviews.contains { hasText($0) } + } + + func finish() { + guard !finished else { return } + finished = true + link?.invalidate() + link = nil + if Self.live === self { Self.live = nil } + let period = 1000.0 / Double(max(1, window?.screen?.maximumFramesPerSecond ?? 60)) + let dropped = intervals.reduce(0) { $0 + max(0, Int(($1 / period).rounded()) - 1) } + let f = { (value: Double) in String(format: "%.2f", value) } + let average = intervals.isEmpty ? 0 : intervals.reduce(0, +) / Double(intervals.count) + let moving = cpu.dropLast(landingCPU == nil ? 0 : 1) + let cpuAverage = moving.isEmpty ? 0 : moving.reduce(0, +) / Double(moving.count) + SidebarNavigationTimings.record( + "nav.frames interaction=\(name) frames=\(intervals.count + 1) " + + "keypressCpu=\(f(keypressCPU)) firstFrameMs=\(f(firstFrameMs ?? -1)) " + + "landingCpu=\(f(landingCPU ?? -1)) landed=\(landingCPU == nil ? 0 : 1) " + + "intervalAvg=\(f(average)) intervalMax=\(f(intervals.max() ?? 0)) dropped=\(dropped) period=\(f(period)) " + + "movingCpuAvg=\(f(cpuAverage)) movingCpuMax=\(f(moving.max() ?? 0)) " + + "blankRows=\(blankRows ?? -1)" + ) + SidebarNavigationTimings.record( + "nav.frames.detail interaction=\(name) intervals=" + + intervals.map { String(format: "%.1f", $0) }.joined(separator: ",") + + " cpu=" + cpu.map { String(format: "%.1f", $0) }.joined(separator: ",") + ) + } + + private static func toggleIfFrontmost(_ animator: SidebarToggleAnimator) { + let frontmost = NSApp.mainWindow ?? NSApp.orderedWindows.first { $0.isVisible && $0.contentView != nil } + guard let window = animator.debugWindow, window === frontmost else { return } + animator.debugSidebarState?.toggle() + } + + private static var triggerTargets: [ObjectIdentifier: () -> SidebarToggleAnimator?] = [:] + + static func installTrigger(for animator: SidebarToggleAnimator) { + guard SidebarNavigationTimings.isEnabled else { return } + triggerTargets[ObjectIdentifier(animator)] = { [weak animator] in animator } + guard triggerTargets.count == 1 else { return } + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + nil, + { _, _, _, _, _ in + DispatchQueue.main.async { + MainActor.assumeIsolated { + for target in SidebarToggleSlideProbe.triggerTargets.values { + if let animator = target() { SidebarToggleSlideProbe.toggleIfFrontmost(animator) } + } + } + } + }, + "com.cmuxterm.debug.sidebar-toggle" as CFString, + nil, + .deliverImmediately + ) } } +#endif diff --git a/Sources/Sidebar/SidebarToggleSlideMachine.swift b/Sources/Sidebar/SidebarToggleSlideMachine.swift new file mode 100644 index 000000000000..d560f2b1894b --- /dev/null +++ b/Sources/Sidebar/SidebarToggleSlideMachine.swift @@ -0,0 +1,170 @@ +import Foundation + +/// A critically damped spring, evaluated in closed form so a reversal can +/// start the next spring from the exact presented position and velocity. +/// +/// Position relative to the target follows `(a + b·t)·e^(−ω·t)` with +/// `a = from − to` and `b = v₀ + ω·a`. Core Animation's `CASpringAnimation` +/// with mass 1, stiffness ω² and damping 2ω draws the same curve. +struct SidebarSlideSpring: Equatable { + /// Natural frequency in rad/s. 43 lands a 240 pt slide in about 0.2 s. + var omega: Double = 43 + /// The slide lands once it is this close to its target, in points. + var landingTolerance: Double = 0.5 + + var stiffness: Double { omega * omega } + var damping: Double { 2 * omega } + + func position(from: Double, to: Double, velocity: Double, at time: Double) -> Double { + let a = from - to + let b = velocity + omega * a + return to + (a + b * time) * exp(-omega * time) + } + + func velocity(from: Double, to: Double, velocity: Double, at time: Double) -> Double { + let a = from - to + let b = velocity + omega * a + return (b - omega * (a + b * time)) * exp(-omega * time) + } + + /// When the slide is visually done: the remaining distance stays under + /// the tolerance from here on. + func landingTime(from: Double, to: Double, velocity: Double) -> Double { + let a = from - to + let b = velocity + omega * a + guard abs(a) > landingTolerance || abs(b) > 0 else { return 0 } + // The envelope |a| + |b|·t is an upper bound on the remaining + // distance before the exponential, so stepping it is monotone. + var time = 0.0 + while time < 1 { + if (abs(a) + abs(b) * time) * exp(-omega * time) < landingTolerance { return time } + time += 0.001 + } + return 1 + } + + /// Caps a velocity heading toward `to` so the spring cannot overshoot + /// it: a hide that overshoots 0 would pull the terminal's trailing edge + /// in from the window edge. + func nonOvershootingVelocity(from: Double, to: Double, velocity: Double) -> Double { + let distance = to - from + guard distance != 0, velocity * distance > 0 else { return velocity } + let limit = omega * abs(distance) + return min(abs(velocity), limit) * (distance > 0 ? 1 : -1) + } +} + +/// The toggle's state, kept pure so press sequences can be tested with a +/// fake clock. Positions are the content offset in points: 0 is the hidden +/// pose, the sidebar width is the shown pose. +/// +/// Layout changes only at the wide end, so the terminal is at full width +/// whenever it moves: +/// - a hide from the docked layout commits the hidden layout at the +/// keypress, then slides from the width to 0; +/// - a show slides from 0 to the width on the hidden layout and commits the +/// docked layout when it lands; +/// - a press mid-slide only retargets the motion from where it is, carrying +/// its velocity, and commits nothing. +struct SidebarToggleSlideMachine: Equatable { + struct Slide: Equatable { + var from: Double + var to: Double + var velocity: Double + var begin: Double + var duration: Double + var landsVisible: Bool + var generation: Int + } + + enum Effect: Equatable { + /// Drop the docked layout now (the one terminal resize of a hide). + case commitHiddenLayout + /// Start or replace the motion. + case animate(Slide) + /// A show landed: commit the docked layout under the landed pose. + case commitShownLayout + /// A hide landed: drop the motion; the layout is already hidden. + case finishHide + } + + let spring: SidebarSlideSpring + /// Whether the committed layout gives the sidebar its width. + private(set) var docked: Bool + /// Where the latest press asked the sidebar to be. + private(set) var target: Bool + private(set) var slide: Slide? + private var generation = 0 + + init(docked: Bool, spring: SidebarSlideSpring = SidebarSlideSpring()) { + self.docked = docked + self.target = docked + self.spring = spring + } + + /// The offset presented at `now`, or nil when nothing moves. + func offset(at now: Double) -> Double? { + guard let slide else { return nil } + return spring.position(from: slide.from, to: slide.to, velocity: slide.velocity, at: now - slide.begin) + } + + mutating func request(visible: Bool, width: Double, now: Double) -> [Effect] { + target = visible + let end = visible ? width : 0 + if let current = slide { + guard current.landsVisible != visible else { return [] } + let elapsed = now - current.begin + let from = spring.position(from: current.from, to: current.to, velocity: current.velocity, at: elapsed) + let velocity = spring.velocity(from: current.from, to: current.to, velocity: current.velocity, at: elapsed) + return [.animate(startSlide(from: from, to: end, velocity: velocity, visible: visible, now: now))] + } + guard visible != docked else { return [] } + if visible { + return [.animate(startSlide(from: 0, to: end, velocity: 0, visible: true, now: now))] + } + docked = false + return [.commitHiddenLayout, .animate(startSlide(from: width, to: 0, velocity: 0, visible: false, now: now))] + } + + /// The motion with `generation` finished. Stale generations (a slide + /// that was retargeted, or a duplicate fallback) do nothing. + mutating func land(generation landed: Int) -> [Effect] { + guard let current = slide, current.generation == landed else { return [] } + slide = nil + guard current.landsVisible else { return [.finishHide] } + docked = true + return [.commitShownLayout] + } + + /// The slide actually started at `time` (its layout commit can take a + /// while); later retargets measure from there. + mutating func slideDidStart(generation started: Int, at time: Double) { + guard slide?.generation == started else { return } + slide?.begin = time + } + + /// Visibility was set from outside the toggle (session restore, narrow + /// window collapse, the instant path): any motion is abandoned. + mutating func reset(visible: Bool) { + docked = visible + target = visible + slide = nil + generation &+= 1 + } + + private mutating func startSlide(from: Double, to: Double, velocity: Double, visible: Bool, now: Double) -> Slide { + generation &+= 1 + let velocity = spring.nonOvershootingVelocity(from: from, to: to, velocity: velocity) + let next = Slide( + from: from, + to: to, + velocity: velocity, + begin: now, + duration: spring.landingTime(from: from, to: to, velocity: velocity), + landsVisible: visible, + generation: generation + ) + slide = next + return next + } +} diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 6c7fd7e30e67..1f4f5ce0ef60 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3351,6 +3351,7 @@ 8430A0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430B0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift */; }; A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73F10000000000000002 /* SidebarTitleFirstLineCenterAlignment.swift */; }; 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */; }; + 5B1D66585F4ECB32768ACE69 /* SidebarToggleSlideMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430B0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift */; }; EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */; }; @@ -7790,6 +7791,7 @@ 8430B0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarTabItemSettingsSnapshot.swift; sourceTree = ""; }; A6AC73F10000000000000002 /* SidebarTitleFirstLineCenterAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarTitleFirstLineCenterAlignment.swift; sourceTree = ""; }; 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleAnimator.swift; sourceTree = ""; }; + 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideMachine.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; 8430B0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceBranchDirectorySettings.swift; sourceTree = ""; }; BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceChecklistPopover.swift"; sourceTree = ""; }; @@ -10201,6 +10203,7 @@ D1614EAD3CCF70A177A51BD1 /* SidebarState.swift */, 0AAA0330CB6FD8B7279F8398 /* SidebarNavigationTimings.swift */, 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */, + 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */, 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */, 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */, A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */, @@ -16169,6 +16172,7 @@ 8430A0030000000000000003 /* SidebarTabItemSettingsSnapshot.swift in Sources */, A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */, 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */, + 5B1D66585F4ECB32768ACE69 /* SidebarToggleSlideMachine.swift in Sources */, 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */, EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */, B290FAD92C810ACB2D9CD9C6 /* SidebarWorkspaceChecklistView.swift in Sources */, From 5fec384577fba825fc8e29d3c6f414db8e6805ee Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 17:05:35 +1300 Subject: [PATCH 45/69] tests: slide machine press sequences, reversal from the presented offset, show-hide-show mid-slide --- .../SidebarFocusBoundaryLifecycleTests.swift | 139 +++++++++++++++++- 1 file changed, 135 insertions(+), 4 deletions(-) diff --git a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift index e19e40479dcd..b542d870d726 100644 --- a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift +++ b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift @@ -64,16 +64,16 @@ struct SidebarFocusBoundaryLifecycleTests { @Suite @MainActor struct SidebarStateAnimatedToggleTests { - /// An animated hide keeps `isVisible` true until its sweep lands. The - /// request is hidden at once, and a second toggle mid-sweep asks to show + /// An animated hide keeps `isVisible` true until its slide lands. The + /// request is hidden at once, and a second toggle mid-slide asks to show /// again instead of repeating the hide. @Test - func toggleDuringAHideSweepReversesIt() { + func toggleDuringAHideSlideReversesIt() { let state = SidebarState(isVisible: true) var requests: [Bool] = [] state.animatedVisibilityOrchestrator = { targetVisible in requests.append(targetVisible) - if !targetVisible { state.isHidePending = true } + state.pendingVisibility = targetVisible return true } @@ -85,6 +85,137 @@ struct SidebarStateAnimatedToggleTests { #expect(requests == [false, true]) state.setVisible(true) + #expect(state.pendingVisibility == nil) #expect(state.requestedVisibility) } + + /// A show slide keeps `isVisible` false until it lands; a hide that + /// interrupts it, and the press after that, must both register. + @Test + func showHideShowMidSlideIsNotSwallowed() { + let state = SidebarState(isVisible: false) + var requests: [Bool] = [] + state.animatedVisibilityOrchestrator = { targetVisible in + requests.append(targetVisible) + state.pendingVisibility = targetVisible == state.isVisible ? nil : targetVisible + return true + } + + state.toggle() + state.toggle() + state.toggle() + #expect(requests == [true, false, true]) + #expect(state.requestedVisibility) + #expect(!state.isVisible) + } +} + +/// The toggle's slide machine, driven with a fake clock: the layout commits +/// only at the wide end, a press mid-slide retargets from where the motion +/// is (velocity carried, never overshooting), and any press sequence ends +/// with the layout matching the last request. +@Suite +struct SidebarToggleSlideMachineTests { + private let width = 240.0 + + @Test + func hideCommitsAtTheKeypressAndShowCommitsOnLanding() { + var machine = SidebarToggleSlideMachine(docked: true) + let hide = machine.request(visible: false, width: width, now: 0) + #expect(hide.first == .commitHiddenLayout) + #expect(!machine.docked) + guard case let .animate(slide)? = hide.last else { + Issue.record("hide did not animate") + return + } + #expect(slide.from == width && slide.to == 0) + #expect(slide.duration > 0.1 && slide.duration < 0.3, "landing at \(slide.duration)s") + #expect(machine.land(generation: slide.generation) == [.finishHide]) + + let show = machine.request(visible: true, width: width, now: 1) + #expect(!show.contains(.commitHiddenLayout)) + guard case let .animate(showSlide)? = show.first else { + Issue.record("show did not animate") + return + } + #expect(!machine.docked) + #expect(machine.land(generation: showSlide.generation) == [.commitShownLayout]) + #expect(machine.docked) + } + + @Test + func reversalRetargetsFromThePresentedOffsetWithoutCommitting() throws { + var machine = SidebarToggleSlideMachine(docked: true) + _ = machine.request(visible: false, width: width, now: 0) + let offset = try #require(machine.offset(at: 0.05)) + let reverse = machine.request(visible: true, width: width, now: 0.05) + #expect(reverse.count == 1) + guard case let .animate(slide)? = reverse.first else { + Issue.record("reversal did not animate") + return + } + #expect(abs(slide.from - offset) < 0.001) + #expect(slide.to == width) + // Moving away from the new target: no overshoot past the width. + for step in 0...400 { + let position = try #require(machine.offset(at: 0.05 + Double(step) / 1000)) + #expect(position <= width + 0.001 && position >= -0.001) + } + } + + @Test + func staleLandingsAreIgnored() { + var machine = SidebarToggleSlideMachine(docked: true) + guard case let .animate(first)? = machine.request(visible: false, width: width, now: 0).last else { + Issue.record("hide did not animate") + return + } + _ = machine.request(visible: true, width: width, now: 0.02) + #expect(machine.land(generation: first.generation).isEmpty) + #expect(machine.slide != nil) + } + + @Test + func spamEndsMatchingTheLastPressWithAtMostTwoCommits() { + for presses in 1...21 { + var machine = SidebarToggleSlideMachine(docked: true) + var visible = true + var commits = 0 + var now = 0.0 + for _ in 0.. Date: Thu, 8 Oct 2026 19:12:09 +1300 Subject: [PATCH 46/69] sidebar toggle: titles stay drawn while hidden, right sidebar holds still, atomic landing, panes meet the sidebar - the hidden docked pane lives in its own host that never leaves its slot as far as appkit knows. parking it past the window edge made appkit clear the titles, so the pane slid in blank. parking and the slide now move only what is drawn (a sublayerTransform), so titles stay current while hidden - the hide keypress and the show landing commit layout and the layer change in one transaction, with the portal synced inside it. before, the terminal moved a frame after the layer change, which read as a bounce - an open right sidebar stays put: the moving layers are masked at its edge (the mask runs the same spring backwards) and a snapshot of the column sits on top while sliding. dock mode still toggles instantly - panes have no leading inset, so they touch the sidebar edge - debug probe moved to its own file; trigger registers once --- Sources/ContentView+SidebarPeek.swift | 70 +++- Sources/ContentView.swift | 18 +- .../Peek/SidebarPeekPresentation.swift | 16 +- Sources/Sidebar/SidebarDockedPaneHost.swift | 69 ++++ Sources/Sidebar/SidebarToggleAnimator.swift | 336 ++++++++---------- Sources/Sidebar/SidebarToggleSlideProbe.swift | 215 +++++++++++ cmux.xcodeproj/project.pbxproj | 8 + 7 files changed, 508 insertions(+), 224 deletions(-) create mode 100644 Sources/Sidebar/SidebarDockedPaneHost.swift create mode 100644 Sources/Sidebar/SidebarToggleSlideProbe.swift diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index 2ac474a6ea43..08b4bbd0bba3 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -2,6 +2,8 @@ import AppKit import CmuxAppKitSupportUI import CmuxFoundation import CmuxSidebar +import Bonsplit +import CmuxSettingsUI import SwiftUI extension Notification.Name { @@ -193,16 +195,14 @@ extension ContentView { } )) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .environmentObject(tabManager) - .environmentObject(notificationStore) - .environmentObject(sidebarState) - .environmentObject(sidebarSelectionState) - .environmentObject(fileExplorerState) - .environmentObject(cmuxConfigStore) - .environment(\.sessionDragRegistry, sessionDragRegistryEnv) - .environment(\.tabDragTransferRegistry, tabDragTransferRegistryEnv) - .environment(\.settingsRuntime, settingsRuntimeEnv) - .cmuxFontMagnificationEnvironment() + .modifier(SidebarHostedEnvironment(contentView: self)) + } + + /// The environment ContentView's own hosting view gives the sidebar, for + /// a sidebar hosted in its own NSHostingView (the docked pane's host, the + /// peek card's window), which inherits none of it. + func sidebarEnvironment(_ content: Content) -> some View { + content.modifier(SidebarHostedEnvironment(contentView: self)) } /// The invisible leading-edge strip that arms the hover-reveal. @@ -239,9 +239,13 @@ extension ContentView { sidebarState: sidebarState, layout: sidebarLayout, window: { observedWindow }, - // The right sidebar would ride along with the content - // root and snap back on landing; toggle instantly there. - canSlide: { retainsDefaultAppKitSidebarWhenHidden && !rightSidebarVisible }, + // The Dock's terminals live in the moving portal and + // cannot be held still; toggle instantly there. + canSlide: { + retainsDefaultAppKitSidebarWhenHidden + && !(rightSidebarVisible && fileExplorerState.mode == .dock) + }, + trailingStillWidth: { rightSidebarWidth }, isPeekPresenting: { sidebarPeek.presentsPanel } ) sidebarPeek.setPolicy(SidebarCustomizationSettings.peekPolicy()) @@ -308,6 +312,46 @@ extension ContentView { .overlay(alignment: .leading) { // Same slot and layering as the resizer overlay, which is // the proven way in this codebase to receive pointer + +/// Mirrors what AppDelegate injects into ContentView's hosting view. +struct SidebarHostedEnvironment: ViewModifier { + let tabManager: TabManager + let notificationStore: TerminalNotificationStore + let sidebarState: SidebarState + let sidebarSelectionState: SidebarSelectionState + let fileExplorerState: FileExplorerState + let cmuxConfigStore: CmuxConfigStore + let sessionDragRegistry: SessionDragRegistry? + let tabDragTransferRegistry: TabDragTransferRegistry? + let settingsRuntime: SettingsRuntime? + + @MainActor + init(contentView: ContentView) { + tabManager = contentView.tabManager + notificationStore = contentView.notificationStore + sidebarState = contentView.sidebarState + sidebarSelectionState = contentView.sidebarSelectionState + fileExplorerState = contentView.fileExplorerState + cmuxConfigStore = contentView.cmuxConfigStore + sessionDragRegistry = contentView.sessionDragRegistryEnv + tabDragTransferRegistry = contentView.tabDragTransferRegistryEnv + settingsRuntime = contentView.settingsRuntimeEnv + } + + func body(content: Content) -> some View { + content + .environmentObject(tabManager) + .environmentObject(notificationStore) + .environmentObject(sidebarState) + .environmentObject(sidebarSelectionState) + .environmentObject(fileExplorerState) + .environmentObject(cmuxConfigStore) + .environment(\.sessionDragRegistry, sessionDragRegistry) + .environment(\.tabDragTransferRegistry, tabDragTransferRegistry) + .environment(\.settingsRuntime, settingsRuntime) + .cmuxFontMagnificationEnvironment() + } +} // events over the portal-hosted terminal. A strip placed // inside the layout stack can end up beneath the terminal's // AppKit view and never see the pointer at all. diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 9de9be48e032..77baf4083dab 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1868,7 +1868,7 @@ struct ContentView: View { // Inner insets: panes sit inside the workspace card, clear of its // rounded border. .padding(.top, WorkspaceCardMetrics.bandGap) - .padding(.leading, WorkspaceCardMetrics.paneInset) + // No leading inset: the panes meet the sidebar edge. .padding(.trailing, WorkspaceCardMetrics.paneInset) .padding(.bottom, WorkspaceCardMetrics.paneInset) // Reserves the titlebar band's height inside the card, so the band @@ -1901,7 +1901,7 @@ struct ContentView: View { fileExplorerState.isVisible } - private var rightSidebarWidth: CGFloat { + var rightSidebarWidth: CGFloat { rightSidebarVisible ? fileExplorerWidth : 0 } @@ -1957,10 +1957,16 @@ struct ContentView: View { // the tint inside the sidebar column. hidesBackdrop: true ) { - // The retained docked list stays live while hidden, so a show - // slides in painted rows from its first frame. - sidebarView(isPresented: sidebarState.occupiesLayout - || (retainsDefaultAppKitSidebarWhenHidden && sidebarState.presentationMode == .docked)) + // The retained docked list stays live while hidden, in its own + // host that parks it by drawing only, so a show slides in + // painted rows from its first frame. + SidebarDockedPaneHost( + parkedOffset: sidebarState.presentationMode == .docked && sidebarLayout.docksSidebar ? 0 : width, + content: AnyView(sidebarEnvironment(sidebarView(isPresented: sidebarState.occupiesLayout + || (retainsDefaultAppKitSidebarWhenHidden && sidebarState.presentationMode == .docked)) + .environment(\.colorScheme, appearance.sidebarContentColorScheme) + .environment(\.sidebarReadabilityBackdrop, appearance.sidebarReadabilityBackdrop))) + ) } } } diff --git a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift index 199d59d136ad..98d2546125c6 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift @@ -6,9 +6,9 @@ import SwiftUI /// as a floating card, without rebuilding it. /// /// The whole point is that the two modes share one mounted subtree. cmux keeps -/// the AppKit workspace table alive behind a zero-width shell while the sidebar -/// is hidden (see `retainsDefaultAppKitSidebarWhenHidden` in `ContentView`), so -/// revealing it is a frame and opacity change, never a cold start. Peek rides +/// the AppKit workspace table alive, parked off the leading edge, while the +/// sidebar is hidden (see `retainsDefaultAppKitSidebarWhenHidden` in +/// `ContentView`), so revealing it is a move, never a cold start. Peek rides /// on that: the reveal has no table to build, which is what lets it be /// instant instead of merely fast. struct SidebarPeekPresentation: ViewModifier { @@ -85,14 +85,12 @@ struct SidebarPeekPresentation: ViewModifier { value: showsCard ) } else { - // Hidden, the pane keeps its full width, parked just past the - // window's leading edge: its rows never re-lay out, and a toggle - // slide (which translates the content root on the render server, - // see SidebarToggleAnimator) carries it in and out as it is. + // The pane keeps its full width and its slot whether shown or + // hidden: its host parks it by drawing only (see + // SidebarDockedPaneHost), and the toggle's slide carries it in + // and out as it is. content .frame(width: width, alignment: .leading) - .clipped() - .offset(x: isRevealed ? 0 : -width) .allowsHitTesting(isRevealed) .accessibilityHidden(!isRevealed) } diff --git a/Sources/Sidebar/SidebarDockedPaneHost.swift b/Sources/Sidebar/SidebarDockedPaneHost.swift new file mode 100644 index 000000000000..b6f3120b498f --- /dev/null +++ b/Sources/Sidebar/SidebarDockedPaneHost.swift @@ -0,0 +1,69 @@ +import AppKit +import SwiftUI + +/// Hosts the docked sidebar pane in its own AppKit view, always in its slot. +/// +/// A hidden docked pane stays mounted (its table never cold-starts) and +/// must slide in already drawn. Parking it past the window's leading edge +/// with a layout offset does not work: AppKit clears the text of views it +/// considers outside the window and draws nothing for them until they come +/// back. So the pane never leaves its slot as far as AppKit knows; parking +/// and the toggle's slide move only what is drawn, with a render-only +/// `sublayerTransform` on this container. Hidden, the container passes +/// clicks through. +struct SidebarDockedPaneHost: NSViewRepresentable { + /// How far left the pane's drawing is moved: 0 docked, its width parked. + let parkedOffset: CGFloat + let content: AnyView + + final class ContainerView: NSView { + let hostingView: NSHostingView + fileprivate var parkedOffset: CGFloat = 0 + + init(content: AnyView) { + hostingView = NSHostingView(rootView: content) + super.init(frame: .zero) + wantsLayer = true + hostingView.sizingOptions = [] + hostingView.autoresizingMask = [.width, .height] + hostingView.frame = bounds + addSubview(hostingView) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported") + } + + override func hitTest(_ point: NSPoint) -> NSView? { + parkedOffset != 0 ? nil : super.hitTest(point) + } + } + + func makeNSView(context: Context) -> ContainerView { + let view = ContainerView(content: content) + apply(parkedOffset, to: view) + return view + } + + func updateNSView(_ view: ContainerView, context: Context) { + // A toggle's park or unpark changes nothing in the pane: move the + // drawing and skip re-diffing the whole sidebar in the keypress and + // landing frames. The next update pushes content as usual. + if view.parkedOffset != parkedOffset { + apply(parkedOffset, to: view) + return + } + view.hostingView.rootView = content + } + + private func apply(_ offset: CGFloat, to view: ContainerView) { + view.parkedOffset = offset + let transform = CATransform3DMakeTranslation(-offset, 0, 0) + guard let layer = view.layer, !CATransform3DEqualToTransform(layer.sublayerTransform, transform) else { return } + CATransaction.begin() + CATransaction.setDisableActions(true) + layer.sublayerTransform = transform + CATransaction.commit() + } +} diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index edde521c49db..00fe2e9d9dc5 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -25,11 +25,12 @@ final class SidebarToggleAnimator: ObservableObject { private weak var layout: SidebarLayoutModel? private var window: () -> NSWindow? = { nil } private var canSlide: () -> Bool = { false } + private var trailingStillWidth: () -> CGFloat = { 0 } private var isPeekPresenting: () -> Bool = { false } private var machine = SidebarToggleSlideMachine(docked: true) - /// Layers carrying the running slide, captured when it starts and kept - /// for every retarget until it lands. - private var slideLayers: [CALayer] = [] + /// What carries the running slide, captured when it starts and kept for + /// every retarget until it lands. + private var session: SlideSession? /// Effects are being carried out; a press arriving meanwhile (the atomic /// commit runs the run loop once) waits its turn. private var isExecuting = false @@ -42,12 +43,14 @@ final class SidebarToggleAnimator: ObservableObject { layout: SidebarLayoutModel, window: @escaping () -> NSWindow?, canSlide: @escaping () -> Bool, + trailingStillWidth: @escaping () -> CGFloat, isPeekPresenting: @escaping () -> Bool ) { self.sidebarState = sidebarState self.layout = layout self.window = window self.canSlide = canSlide + self.trailingStillWidth = trailingStillWidth self.isPeekPresenting = isPeekPresenting machine = SidebarToggleSlideMachine(docked: sidebarState.isVisible) layout.docksSidebar = sidebarState.isVisible @@ -71,7 +74,7 @@ final class SidebarToggleAnimator: ObservableObject { !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, canSlide(), let window = window(), - isSliding || Self.slidingLayers(in: window) != nil else { + isSliding || Self.slidingViews(in: window) != nil else { return false } // Docking over an already-revealed peek card swaps in place: the @@ -162,34 +165,44 @@ final class SidebarToggleAnimator: ObservableObject { /// reach the screen in one frame, and the terminal resizes once. private func commitAtomically(in window: NSWindow, _ mutate: () -> Void, layers: () -> Void) { TerminalWindowPortalRegistry.beginInteractiveGeometryResize(owner: self, in: window) +#if DEBUG + let before = SidebarToggleSlideProbe.terminalFrameX(in: window) +#endif + // One explicit transaction around everything: SwiftUI applies the + // new layout (and the portal moves the terminals) inside the run loop + // pass, but nothing reaches the screen until the layer change is in + // too. Otherwise the content shows one frame at the wrong offset. + CATransaction.begin() + CATransaction.setDisableActions(true) mutate() - layers() RunLoop.current.run(mode: .eventTracking, before: Date(timeIntervalSinceNow: 0.001)) window.contentView?.layoutSubtreeIfNeeded() +#if DEBUG + let after = SidebarToggleSlideProbe.terminalFrameX(in: window) + let container = SidebarToggleSlideProbe.paneContainerX(in: window) +#endif + // The portal follows SwiftUI's anchors on its own queue; move the + // terminals now so they land with the layout. + TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]? + .synchronizeAllEntriesFromExternalGeometryChange() + layers() window.displayIfNeeded() + CATransaction.commit() CATransaction.flush() TerminalWindowPortalRegistry.endInteractiveGeometryResize(owner: self) +#if DEBUG + SidebarNavigationTimings.record("toggle.commit terminalX before=\(before) afterLayout=\(after) paneContainerX=\(container) afterFlush=\(SidebarToggleSlideProbe.terminalFrameX(in: window))") +#endif } private func addSlideAnimation(_ slide: SidebarToggleSlideMachine.Slide, in window: NSWindow) { - if slideLayers.isEmpty { - slideLayers = Self.slidingLayers(in: window) ?? [] + if session == nil, let views = Self.slidingViews(in: window) { + session = SlideSession(views: views, trailingStillWidth: trailingStillWidth()) } - let spring = machine.spring + guard let session else { return } let distance = slide.to - slide.from - for (index, layer) in slideLayers.enumerated() { - let animation = CASpringAnimation(keyPath: "transform.translation.x") - animation.fromValue = slide.from - animation.toValue = slide.to - animation.mass = 1 - animation.stiffness = spring.stiffness - animation.damping = spring.damping - animation.initialVelocity = distance == 0 ? 0 : slide.velocity / distance - animation.duration = slide.duration - // No begin time: the spring starts when its transaction commits, - // so a slow keypress commit delays the motion, never skips it. - animation.fillMode = .both - animation.isRemovedOnCompletion = false + for (index, layer) in session.movingLayers.enumerated() { + let animation = slideSpring(from: slide.from, to: slide.to, velocity: distance == 0 ? 0 : slide.velocity / distance, duration: slide.duration) if index == 0 { animation.delegate = SlideLandingDelegate { [weak self] in self?.land(generation: slide.generation) @@ -197,17 +210,39 @@ final class SidebarToggleAnimator: ObservableObject { } layer.add(animation, forKey: Self.animationKey) } + // The masks run the same spring backwards, so their edge stays put on + // screen while the content under them moves. + for mask in session.masks { + let animation = slideSpring(from: -slide.from, to: -slide.to, velocity: distance == 0 ? 0 : slide.velocity / distance, duration: slide.duration) + mask.add(animation, forKey: Self.animationKey) + } // A missed delegate callback must not strand the layout: land anyway. - DispatchQueue.main.asyncAfter(deadline: .now() + slide.duration + 0.1) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + slide.duration / Double(Self.speed) + 0.1) { [weak self] in self?.land(generation: slide.generation) } } + private func slideSpring(from: Double, to: Double, velocity: Double, duration: Double) -> CASpringAnimation { + let spring = machine.spring + let animation = CASpringAnimation(keyPath: "transform.translation.x") + animation.fromValue = from + animation.toValue = to + animation.mass = 1 + animation.stiffness = spring.stiffness + animation.damping = spring.damping + animation.initialVelocity = velocity + animation.duration = duration + // No begin time: the spring starts when its transaction commits, + // so a slow keypress commit delays the motion, never skips it. + animation.fillMode = .both + animation.isRemovedOnCompletion = false + animation.speed = Self.speed + return animation + } + private func removeSlideAnimations() { - for layer in slideLayers { - layer.removeAnimation(forKey: Self.animationKey) - } - slideLayers = [] + session?.tearDown(animationKey: Self.animationKey) + session = nil } /// Idempotent: a stale or repeated landing does nothing. @@ -226,7 +261,7 @@ final class SidebarToggleAnimator: ObservableObject { syncPendingVisibility() #if DEBUG SidebarToggleSlideProbe.current?.didLand(cpu: SidebarToggleSlideProbe.threadCPU() - landingCPU) - assert(slideLayers.allSatisfy { $0.animation(forKey: Self.animationKey) == nil }) + assert(session == nil) assert(layout?.docksSidebar == machine.docked) #endif } @@ -241,7 +276,7 @@ final class SidebarToggleAnimator: ObservableObject { /// collapse, the instant path): drop any slide and follow it. private func visibilityDidCommit(_ visible: Bool) { guard !isCommittingVisibility else { return } - if machine.slide != nil { + if session != nil { CATransaction.begin() CATransaction.setDisableActions(true) removeSlideAnimations() @@ -261,10 +296,10 @@ final class SidebarToggleAnimator: ObservableObject { } } - /// The layers that move: the window content root (the sidebar column and + /// The views that move: the window content root (the sidebar column and /// the workspace card live in it) and the terminal and browser portal /// hosts stacked above it, which draw the terminals. - private static func slidingLayers(in window: NSWindow) -> [CALayer]? { + private static func slidingViews(in window: NSWindow) -> [NSView]? { guard let portal = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)], let reference = portal.installedReferenceView, let container = reference.superview else { return nil } @@ -273,16 +308,89 @@ final class SidebarToggleAnimator: ObservableObject { views.append(portal.hostView) } views.append(contentsOf: container.subviews.filter { $0 is WindowBrowserHostView }) - let layers = views.compactMap(\.layer) - return layers.count == views.count ? layers : nil + return views.allSatisfy({ $0.layer != nil }) ? views : nil } + /// DEBUG-only slow motion (`CMUX_SIDEBAR_SLIDE_SLOWDOWN=20`) for checking + /// captured frames by eye. + private static let speed: Float = { +#if DEBUG + if let raw = ProcessInfo.processInfo.environment["CMUX_SIDEBAR_SLIDE_SLOWDOWN"], + let slowdown = Float(raw), slowdown > 0 { + return 1 / slowdown + } +#endif + return 1 + }() + #if DEBUG var debugWindow: NSWindow? { window() } var debugSidebarState: SidebarState? { sidebarState } #endif } +/// The layers a slide moves, plus what keeps an open right sidebar still. +/// +/// The right sidebar lives in the moving content root. While a slide runs, +/// every moving layer is masked off at the right sidebar's leading edge (the +/// mask runs the same spring backwards, so the edge stays put on screen) and +/// a snapshot of the column is shown in its place, above everything. The +/// terminal reads as sliding under the right sidebar, which never moves. +@MainActor +private final class SlideSession { + let movingLayers: [CALayer] + let masks: [CALayer] + private let stillOverlay: NSView? + + init(views: [NSView], trailingStillWidth: CGFloat) { + movingLayers = views.compactMap(\.layer) + guard trailingStillWidth > 0, + let reference = views.first, + let container = reference.superview else { + masks = [] + stillOverlay = nil + return + } + let stillRect = NSRect( + x: reference.bounds.maxX - trailingStillWidth, + y: reference.bounds.minY, + width: trailingStillWidth, + height: reference.bounds.height + ) + if let rep = reference.bitmapImageRepForCachingDisplay(in: stillRect) { + reference.cacheDisplay(in: stillRect, to: rep) + let overlay = NSView(frame: container.convert(stillRect, from: reference)) + overlay.wantsLayer = true + overlay.layer?.contents = rep.cgImage + overlay.layer?.contentsGravity = .resize + container.addSubview(overlay, positioned: .above, relativeTo: nil) + stillOverlay = overlay + } else { + stillOverlay = nil + } + let bleed: CGFloat = 10_000 + var masks: [CALayer] = [] + for view in views { + guard let layer = view.layer else { continue } + let edge = view.convert(NSPoint(x: stillRect.minX, y: 0), from: reference).x + let mask = CALayer() + mask.backgroundColor = NSColor.black.cgColor + mask.frame = CGRect(x: -bleed, y: -bleed, width: edge + bleed, height: layer.bounds.height + 2 * bleed) + layer.mask = mask + masks.append(mask) + } + self.masks = masks + } + + func tearDown(animationKey: String) { + for layer in movingLayers { + layer.removeAnimation(forKey: animationKey) + layer.mask = nil + } + stillOverlay?.removeFromSuperview() + } +} + /// Lands the slide when its spring finishes. Core Animation retains its /// delegate; the closure holds the animator weakly. private final class SlideLandingDelegate: NSObject, CAAnimationDelegate { @@ -301,167 +409,3 @@ private final class SlideLandingDelegate: NSObject, CAAnimationDelegate { } } } - -#if DEBUG -/// DEBUG-only numbers per press into `CMUX_NAV_TIMINGS_LOG`: main-thread CPU -/// of the keypress turn and of the landing, keypress to first frame, frame -/// intervals and main-thread CPU per frame while moving, and how many -/// visible sidebar rows had no title on the first frame of a show. -/// `notifyutil -p com.cmuxterm.debug.sidebar-toggle` presses the toggle in -/// the frontmost window, so this runs without driving the pointer. -@MainActor -final class SidebarToggleSlideProbe: NSObject { - static weak var current: SidebarToggleSlideProbe? - private static var live: SidebarToggleSlideProbe? - - private let name: String - private let visible: Bool - private weak var window: NSWindow? - private let startWall = CACurrentMediaTime() - private let startCPU = SidebarToggleSlideProbe.threadCPU() - private var keypressCPU = 0.0 - private var landingCPU: Double? - private var link: CADisplayLink? - private var last: (wall: CFTimeInterval, cpu: Double)? - private var firstFrameMs: Double? - private var blankRows: Int? - private var intervals: [Double] = [] - private var cpu: [Double] = [] - private var landedWall: CFTimeInterval? - private var finished = false - - static func threadCPU() -> Double { - Double(clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID)) / 1_000_000 - } - - static func begin(visible: Bool, window: NSWindow, animator: SidebarToggleAnimator) -> SidebarToggleSlideProbe? { - guard SidebarNavigationTimings.isEnabled, let view = window.contentView else { return nil } - live?.finish() - let probe = SidebarToggleSlideProbe(visible: visible, window: window) - let link = view.displayLink(target: probe, selector: #selector(tick(_:))) - link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) - link.add(to: .main, forMode: .common) - probe.link = link - live = probe - current = probe - return probe - } - - private init(visible: Bool, window: NSWindow) { - self.visible = visible - self.name = visible ? "toggle.show" : "toggle.hide" - self.window = window - super.init() - } - - func keypressDidFinish() { - keypressCPU = Self.threadCPU() - startCPU - } - - func didLand(cpu: Double) { - landingCPU = cpu - landedWall = CACurrentMediaTime() - } - - @objc private func tick(_ link: CADisplayLink) { - let now = (wall: CACurrentMediaTime(), cpu: Self.threadCPU()) - if firstFrameMs == nil { - firstFrameMs = (now.wall - startWall) * 1000 - if visible { blankRows = countBlankSidebarRows() } - } else if let last { - intervals.append((now.wall - last.wall) * 1000) - cpu.append(now.cpu - last.cpu) - } - last = now - if let landedWall, now.wall - landedWall > 0.15 { finish() } - if now.wall - startWall > 2 { finish() } - } - - private func countBlankSidebarRows() -> Int { - guard let root = window?.contentView, - let table = Self.sidebarTable(in: root) else { return -1 } - let range = table.rows(in: table.visibleRect) - var blank = 0 - for row in range.location..<(range.location + range.length) { - guard let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false), - NSStringFromClass(type(of: cell)).contains("WorkspaceRow") else { continue } - if !Self.hasText(cell) { blank += 1 } - } - return blank - } - - private static func sidebarTable(in view: NSView) -> NSTableView? { - if let table = view as? NSTableView, - NSStringFromClass(type(of: table)).contains("Sidebar") - || NSStringFromClass(type(of: table.enclosingScrollView?.superview ?? table)).contains("SidebarWorkspaceTable") { - return table - } - for subview in view.subviews { - if let table = sidebarTable(in: subview) { return table } - } - return nil - } - - private static func hasText(_ view: NSView) -> Bool { - if let field = view as? NSTextField, !field.isHidden, !field.stringValue.isEmpty { return true } - return view.subviews.contains { hasText($0) } - } - - func finish() { - guard !finished else { return } - finished = true - link?.invalidate() - link = nil - if Self.live === self { Self.live = nil } - let period = 1000.0 / Double(max(1, window?.screen?.maximumFramesPerSecond ?? 60)) - let dropped = intervals.reduce(0) { $0 + max(0, Int(($1 / period).rounded()) - 1) } - let f = { (value: Double) in String(format: "%.2f", value) } - let average = intervals.isEmpty ? 0 : intervals.reduce(0, +) / Double(intervals.count) - let moving = cpu.dropLast(landingCPU == nil ? 0 : 1) - let cpuAverage = moving.isEmpty ? 0 : moving.reduce(0, +) / Double(moving.count) - SidebarNavigationTimings.record( - "nav.frames interaction=\(name) frames=\(intervals.count + 1) " + - "keypressCpu=\(f(keypressCPU)) firstFrameMs=\(f(firstFrameMs ?? -1)) " + - "landingCpu=\(f(landingCPU ?? -1)) landed=\(landingCPU == nil ? 0 : 1) " + - "intervalAvg=\(f(average)) intervalMax=\(f(intervals.max() ?? 0)) dropped=\(dropped) period=\(f(period)) " + - "movingCpuAvg=\(f(cpuAverage)) movingCpuMax=\(f(moving.max() ?? 0)) " + - "blankRows=\(blankRows ?? -1)" - ) - SidebarNavigationTimings.record( - "nav.frames.detail interaction=\(name) intervals=" + - intervals.map { String(format: "%.1f", $0) }.joined(separator: ",") + - " cpu=" + cpu.map { String(format: "%.1f", $0) }.joined(separator: ",") - ) - } - - private static func toggleIfFrontmost(_ animator: SidebarToggleAnimator) { - let frontmost = NSApp.mainWindow ?? NSApp.orderedWindows.first { $0.isVisible && $0.contentView != nil } - guard let window = animator.debugWindow, window === frontmost else { return } - animator.debugSidebarState?.toggle() - } - - private static var triggerTargets: [ObjectIdentifier: () -> SidebarToggleAnimator?] = [:] - - static func installTrigger(for animator: SidebarToggleAnimator) { - guard SidebarNavigationTimings.isEnabled else { return } - triggerTargets[ObjectIdentifier(animator)] = { [weak animator] in animator } - guard triggerTargets.count == 1 else { return } - CFNotificationCenterAddObserver( - CFNotificationCenterGetDarwinNotifyCenter(), - nil, - { _, _, _, _, _ in - DispatchQueue.main.async { - MainActor.assumeIsolated { - for target in SidebarToggleSlideProbe.triggerTargets.values { - if let animator = target() { SidebarToggleSlideProbe.toggleIfFrontmost(animator) } - } - } - } - }, - "com.cmuxterm.debug.sidebar-toggle" as CFString, - nil, - .deliverImmediately - ) - } -} -#endif diff --git a/Sources/Sidebar/SidebarToggleSlideProbe.swift b/Sources/Sidebar/SidebarToggleSlideProbe.swift new file mode 100644 index 000000000000..4d0afbc6b9d3 --- /dev/null +++ b/Sources/Sidebar/SidebarToggleSlideProbe.swift @@ -0,0 +1,215 @@ +import AppKit +import Foundation +import QuartzCore + +#if DEBUG +/// DEBUG-only numbers per press into `CMUX_NAV_TIMINGS_LOG`: main-thread CPU +/// of the keypress turn and of the landing, keypress to first frame, frame +/// intervals and main-thread CPU per frame while moving, and how many +/// visible sidebar rows had no title on the first frame of a show. +/// `notifyutil -p com.cmuxterm.debug.sidebar-toggle` presses the toggle in +/// the frontmost window, so this runs without driving the pointer. +@MainActor +final class SidebarToggleSlideProbe: NSObject { + static weak var current: SidebarToggleSlideProbe? + private static var live: SidebarToggleSlideProbe? + + private let name: String + private let visible: Bool + private weak var window: NSWindow? + private let startWall: CFTimeInterval + private let startCPU = SidebarToggleSlideProbe.threadCPU() + private var keypressCPU = 0.0 + private var landingCPU: Double? + private var link: CADisplayLink? + private var last: (wall: CFTimeInterval, cpu: Double)? + private var firstFrameMs: Double? + private var blankRows: Int? + private var intervals: [Double] = [] + private var cpu: [Double] = [] + private var landedWall: CFTimeInterval? + private var finished = false + + /// Leading edge, in window points, of the leftmost portal-hosted + /// terminal: where AppKit has the terminal right now. + static func terminalFrameX(in window: NSWindow) -> Double { + guard let portal = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)] else { return -1 } + let xs = portal.hostView.subviews + .filter { !$0.isHidden && $0.alphaValue > 0 && $0.bounds.width > 1 } + .map { $0.convert($0.bounds, to: nil).minX } + .filter { $0 > 1 } + return Double(xs.min() ?? 0) + } + + /// Leading edge, in window points, of the leftmost Bonsplit pane + /// container in the content root: where SwiftUI has laid the panes out. + static func paneContainerX(in window: NSWindow) -> Double { + var xs: [CGFloat] = [] + func walk(_ view: NSView) { + if NSStringFromClass(type(of: view)).contains("SplitArrangedContainerView"), !view.isHiddenOrHasHiddenAncestor { + xs.append(view.convert(view.bounds, to: nil).minX) + } + view.subviews.forEach(walk) + } + if let root = TerminalWindowPortalRegistry.portalsByWindowId[ObjectIdentifier(window)]?.installedReferenceView { + walk(root) + } + return Double(xs.min() ?? -1) + } + + static func threadCPU() -> Double { + Double(clock_gettime_nsec_np(CLOCK_THREAD_CPUTIME_ID)) / 1_000_000 + } + + static func begin(visible: Bool, window: NSWindow, animator: SidebarToggleAnimator) -> SidebarToggleSlideProbe? { + guard SidebarNavigationTimings.isEnabled, let view = window.contentView else { return nil } + live?.finish() + let probe = SidebarToggleSlideProbe(visible: visible, window: window) + let link = view.displayLink(target: probe, selector: #selector(tick(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) + link.add(to: .main, forMode: .common) + probe.link = link + live = probe + current = probe + return probe + } + + private init(visible: Bool, window: NSWindow, name: String? = nil, since start: CFTimeInterval? = nil) { + self.visible = visible + self.name = name ?? (visible ? "toggle.show" : "toggle.hide") + self.window = window + self.startWall = start ?? CACurrentMediaTime() + super.init() + } + + /// The peek card's reveal, timed from the hover that asked for it. + static func beginPeek(window: NSWindow, since hover: CFTimeInterval?) -> SidebarToggleSlideProbe? { + guard SidebarNavigationTimings.isEnabled, let view = window.contentView else { return nil } + live?.finish() + let probe = SidebarToggleSlideProbe(visible: false, window: window, name: "peek.reveal", since: hover) + let link = view.displayLink(target: probe, selector: #selector(tick(_:))) + link.preferredFrameRateRange = CAFrameRateRange(minimum: 60, maximum: 120, preferred: 120) + link.add(to: .main, forMode: .common) + probe.link = link + live = probe + return probe + } + + func keypressDidFinish() { + keypressCPU = Self.threadCPU() - startCPU + } + + func didLand(cpu: Double) { + landingCPU = cpu + landedWall = CACurrentMediaTime() + } + + @objc private func tick(_ link: CADisplayLink) { + let now = (wall: CACurrentMediaTime(), cpu: Self.threadCPU()) + if firstFrameMs == nil { + firstFrameMs = (now.wall - startWall) * 1000 + if visible { blankRows = countBlankSidebarRows() } + } else if let last { + intervals.append((now.wall - last.wall) * 1000) + cpu.append(now.cpu - last.cpu) + } + last = now + if let landedWall, now.wall - landedWall > 0.15 { finish() } + if now.wall - startWall > 2 { finish() } + } + + private func countBlankSidebarRows() -> Int { + guard let root = window?.contentView, + let table = Self.sidebarTable(in: root) else { return -1 } + // The rows the clip view shows, whether or not AppKit loaded them: a + // row with no view counts as blank too. + let shown = table.enclosingScrollView?.contentView.documentVisibleRect ?? table.bounds + let range = table.rows(in: shown) + var blank = 0 + for row in range.location..<(range.location + range.length) { + guard let cell = table.view(atColumn: 0, row: row, makeIfNecessary: false) else { + blank += 1 + continue + } + if NSStringFromClass(type(of: cell)).contains("WorkspaceRow"), !Self.hasText(cell) { blank += 1 } + } + return blank + } + + private static func sidebarTable(in view: NSView) -> NSTableView? { + if let table = view as? NSTableView, + NSStringFromClass(type(of: table)).contains("Sidebar") + || NSStringFromClass(type(of: table.enclosingScrollView?.superview ?? table)).contains("SidebarWorkspaceTable") { + return table + } + for subview in view.subviews { + if let table = sidebarTable(in: subview) { return table } + } + return nil + } + + private static func hasText(_ view: NSView) -> Bool { + if let field = view as? NSTextField, !field.isHidden, !field.stringValue.isEmpty { return true } + return view.subviews.contains { hasText($0) } + } + + func finish() { + guard !finished else { return } + finished = true + link?.invalidate() + link = nil + if Self.live === self { Self.live = nil } + let period = 1000.0 / Double(max(1, window?.screen?.maximumFramesPerSecond ?? 60)) + let dropped = intervals.reduce(0) { $0 + max(0, Int(($1 / period).rounded()) - 1) } + let f = { (value: Double) in String(format: "%.2f", value) } + let average = intervals.isEmpty ? 0 : intervals.reduce(0, +) / Double(intervals.count) + let moving = cpu.dropLast(landingCPU == nil ? 0 : 1) + let cpuAverage = moving.isEmpty ? 0 : moving.reduce(0, +) / Double(moving.count) + SidebarNavigationTimings.record( + "nav.frames interaction=\(name) frames=\(intervals.count + 1) " + + "keypressCpu=\(f(keypressCPU)) firstFrameMs=\(f(firstFrameMs ?? -1)) " + + "landingCpu=\(f(landingCPU ?? -1)) landed=\(landingCPU == nil ? 0 : 1) " + + "intervalAvg=\(f(average)) intervalMax=\(f(intervals.max() ?? 0)) dropped=\(dropped) period=\(f(period)) " + + "movingCpuAvg=\(f(cpuAverage)) movingCpuMax=\(f(moving.max() ?? 0)) " + + "blankRows=\(blankRows ?? -1)" + ) + SidebarNavigationTimings.record( + "nav.frames.detail interaction=\(name) intervals=" + + intervals.map { String(format: "%.1f", $0) }.joined(separator: ",") + + " cpu=" + cpu.map { String(format: "%.1f", $0) }.joined(separator: ",") + ) + } + + private static func toggleIfFrontmost(_ animator: SidebarToggleAnimator) { + let frontmost = NSApp.mainWindow ?? NSApp.orderedWindows.first { $0.isVisible && $0.contentView != nil } + guard let window = animator.debugWindow, window === frontmost else { return } + animator.debugSidebarState?.toggle() + } + + private static var triggerTargets: [ObjectIdentifier: () -> SidebarToggleAnimator?] = [:] + private static var isTriggerRegistered = false + + static func installTrigger(for animator: SidebarToggleAnimator) { + guard SidebarNavigationTimings.isEnabled else { return } + triggerTargets[ObjectIdentifier(animator)] = { [weak animator] in animator } + guard !isTriggerRegistered else { return } + isTriggerRegistered = true + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + nil, + { _, _, _, _, _ in + DispatchQueue.main.async { + MainActor.assumeIsolated { + for target in SidebarToggleSlideProbe.triggerTargets.values { + if let animator = target() { SidebarToggleSlideProbe.toggleIfFrontmost(animator) } + } + } + } + }, + "com.cmuxterm.debug.sidebar-toggle" as CFString, + nil, + .deliverImmediately + ) + } +} +#endif diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 1f4f5ce0ef60..91add9a2bee9 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3247,6 +3247,7 @@ A12481700000000000000001 /* SidebarDerivedStateScaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A12481700000000000000002 /* SidebarDerivedStateScaleTests.swift */; }; EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA1F00000000000000000004 /* SidebarDirectoryText.swift */; }; B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0270000000000000027 /* SidebarDividerTrackingView.swift */; }; + 4DBCD1AE4E1B80C0503A8EC0 /* SidebarDockedPaneHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FAC0DA415898D27426B2292 /* SidebarDockedPaneHost.swift */; }; A1B2C3D410248001A1B2C3D4E5F60718 /* SidebarEmptyAreaDoubleClickUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D410248002A1B2C3D4E5F60718 /* SidebarEmptyAreaDoubleClickUITests.swift */; }; B804C0010000000000009043 /* SidebarEmptyAreaNewWorkspaceActionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804D0010000000000009043 /* SidebarEmptyAreaNewWorkspaceActionTests.swift */; }; 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */; }; @@ -3352,6 +3353,7 @@ A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */ = {isa = PBXBuildFile; fileRef = A6AC73F10000000000000002 /* SidebarTitleFirstLineCenterAlignment.swift */; }; 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */; }; 5B1D66585F4ECB32768ACE69 /* SidebarToggleSlideMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */; }; + E50824578CDCF63DB210D64F /* SidebarToggleSlideProbe.swift in Sources */ = {isa = PBXBuildFile; fileRef = 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */; }; CA39C0304FE351A21C372429 /* SidebarWidthPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */; }; 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8430B0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift */; }; EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */; }; @@ -7688,6 +7690,7 @@ A12481700000000000000002 /* SidebarDerivedStateScaleTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SidebarDerivedStateScaleTests.swift; sourceTree = ""; }; EA1F00000000000000000004 /* SidebarDirectoryText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDirectoryText.swift; sourceTree = ""; }; B804B0270000000000000027 /* SidebarDividerTrackingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDividerTrackingView.swift; sourceTree = ""; }; + 3FAC0DA415898D27426B2292 /* SidebarDockedPaneHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarDockedPaneHost.swift; sourceTree = ""; }; A1B2C3D410248002A1B2C3D4E5F60718 /* SidebarEmptyAreaDoubleClickUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarEmptyAreaDoubleClickUITests.swift; sourceTree = ""; }; B804D0010000000000009043 /* SidebarEmptyAreaNewWorkspaceActionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarEmptyAreaNewWorkspaceActionTests.swift; sourceTree = ""; }; 9212B0019212B0019212B001 /* SidebarEmptyAreaWindowDragController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarEmptyAreaWindowDragController.swift; sourceTree = ""; }; @@ -7792,6 +7795,7 @@ A6AC73F10000000000000002 /* SidebarTitleFirstLineCenterAlignment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarTitleFirstLineCenterAlignment.swift; sourceTree = ""; }; 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleAnimator.swift; sourceTree = ""; }; 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideMachine.swift; sourceTree = ""; }; + 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarToggleSlideProbe.swift; sourceTree = ""; }; EE0171AF1F49F7547191CEE5 /* SidebarWidthPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWidthPolicyTests.swift; sourceTree = ""; }; 8430B0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarWorkspaceBranchDirectorySettings.swift; sourceTree = ""; }; BAC8DE2AF74EC2C272681A75 /* SidebarWorkspaceChecklistPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarWorkspaceChecklistPopover.swift"; sourceTree = ""; }; @@ -10204,6 +10208,8 @@ 0AAA0330CB6FD8B7279F8398 /* SidebarNavigationTimings.swift */, 599E6D613B9CD389A75B0B06 /* SidebarToggleAnimator.swift */, 2790AEE13376A9F6117A90FF /* SidebarToggleSlideMachine.swift */, + 3FAC0DA415898D27426B2292 /* SidebarDockedPaneHost.swift */, + 325684B58553FAF634D156AD /* SidebarToggleSlideProbe.swift */, 599E6D613B9CD389A75BCU01 /* SidebarCustomizationSettings.swift */, 1102C452474E10C5962941D3 /* SidebarPeekPanelHost.swift */, A46624E7B573873D41B28E5C /* VerticalTabsSidebar+PresentationToggle.swift */, @@ -16101,6 +16107,7 @@ 5C01156CFF64659AE0158CU1 /* SidebarCustomizationSettings.swift in Sources */, EA1F00000000000000000003 /* SidebarDirectoryText.swift in Sources */, B804A0270000000000000027 /* SidebarDividerTrackingView.swift in Sources */, + 4DBCD1AE4E1B80C0503A8EC0 /* SidebarDockedPaneHost.swift in Sources */, 9212A0019212A0019212A001 /* SidebarEmptyAreaWindowDragController.swift in Sources */, 9212A0049212A0049212A004 /* SidebarEmptyAreaWindowDragOutcome.swift in Sources */, 9212A0059212A0059212A005 /* SidebarEmptyAreaWindowDragTrackingEvent.swift in Sources */, @@ -16173,6 +16180,7 @@ A6AC73F10000000000000001 /* SidebarTitleFirstLineCenterAlignment.swift in Sources */, 5C01156CFF64659AE01583A0 /* SidebarToggleAnimator.swift in Sources */, 5B1D66585F4ECB32768ACE69 /* SidebarToggleSlideMachine.swift in Sources */, + E50824578CDCF63DB210D64F /* SidebarToggleSlideProbe.swift in Sources */, 8430A0010000000000000001 /* SidebarWorkspaceBranchDirectorySettings.swift in Sources */, EDA24082C65B4E2A2A03F276 /* SidebarWorkspaceChecklistPopover.swift in Sources */, B290FAD92C810ACB2D9CD9C6 /* SidebarWorkspaceChecklistView.swift in Sources */, From d4196a4a1cbe5d187f304c3b2073f8ccf3b0e247 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 19:12:26 +1300 Subject: [PATCH 47/69] peek: slide the card on the render server with its own glass the peek card moved with a main-thread swiftui offset inside a fixed child window whose blur was a window-level compositor blur, so the glass could not follow the card. now the card is laid out where it rests and the panel host drives one core animation spring on its hosting layer (reveal 0.21 s with a light settle, dismiss 0.2 s critically damped, reversing from the presented offset). the card carries its own behind-window material in compositor-glass mode, so blur, tint and rows move as one layer subtree. the window blur and its 320 ms reset timer are gone. debug-only trigger and probe for the reveal. --- Sources/ContentView+SidebarPeek.swift | 36 ++--- .../Sidebar/Peek/SidebarPeekController.swift | 43 ++++++ .../Sidebar/Peek/SidebarPeekPanelHost.swift | 125 +++++++++--------- .../Peek/SidebarPeekPresentation.swift | 56 ++------ 4 files changed, 129 insertions(+), 131 deletions(-) diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index 08b4bbd0bba3..e74f3f204a3d 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -117,19 +117,11 @@ extension ContentView { /// above the terminal. Mounted from `sidebarPeekLifecycle` while /// `sidebarNeedsPeekPanel`. var sidebarPeekPanelHost: some View { - let appearance = windowAppearanceSnapshot - return SidebarWidthReader(layout: sidebarLayout) { width in + SidebarWidthReader(layout: sidebarLayout) { width in SidebarPeekPanelBridge( contentWidth: width, metrics: sidebarPeekPanelMetrics, acceptsMouse: sidebarPanelCardIsRevealed, - // The card's own window gets the same compositor blur as the - // docked ground, so floating and docked glass match exactly. - // Never while docked: the panel then sits over the docked - // pane's rows and would blur them as they slide in. - glassBlurRadius: appearance.usesCompositorGlass && !sidebarState.occupiesLayout - ? appearance.sidebarSettings.effectiveCompositorBlurRadius - : nil, onKeyboardFocusChange: { hasFocus in // Typing into a rename or checklist field in the card // keeps the peek up even after the pointer wanders off. @@ -162,9 +154,11 @@ extension ContentView { var panelGlassOpacity = 1.0 if case let .sidebarMaterial(materialPolicy) = appearance.policy(for: .leftSidebar) { panelTint = Color(nsColor: materialPolicy.tintColor) - // A nil material is the compositor-glass path: the card's window - // is blurred by the compositor, so no effect view is drawn. - panelGlassMaterial = materialPolicy.material + // The compositor-glass path has no material: the docked ground + // is blurred by the window. The card carries its own glass so the + // blur moves with it (a window-level blur on the fixed panel + // could not follow the slide). + panelGlassMaterial = materialPolicy.material ?? .hudWindow panelGlassOpacity = materialPolicy.opacity } // `isPresented: true` keeps the panel's list live even while hidden: @@ -180,8 +174,6 @@ extension ContentView { isRevealed: revealed, rendersAsCard: true, width: width, - // Docking supersedes the card in place: no exit slide. - dismissesInstantly: sidebarState.occupiesLayout, panelTint: panelTint, panelGlassMaterial: panelGlassMaterial, panelGlassOpacity: panelGlassOpacity, @@ -312,6 +304,14 @@ extension ContentView { .overlay(alignment: .leading) { // Same slot and layering as the resizer overlay, which is // the proven way in this codebase to receive pointer + // events over the portal-hosted terminal. A strip placed + // inside the layout stack can end up beneath the terminal's + // AppKit view and never see the pointer at all. + sidebarPeekEdgeStrip + .zIndex(999) + } + } +} /// Mirrors what AppDelegate injects into ContentView's hosting view. struct SidebarHostedEnvironment: ViewModifier { @@ -351,12 +351,4 @@ struct SidebarHostedEnvironment: ViewModifier { .environment(\.settingsRuntime, settingsRuntime) .cmuxFontMagnificationEnvironment() } -} - // events over the portal-hosted terminal. A strip placed - // inside the layout stack can end up beneath the terminal's - // AppKit view and never see the pointer at all. - sidebarPeekEdgeStrip - .zIndex(999) - } - } } diff --git a/Sources/Sidebar/Peek/SidebarPeekController.swift b/Sources/Sidebar/Peek/SidebarPeekController.swift index 9ee7725c16cf..767b09b2289d 100644 --- a/Sources/Sidebar/Peek/SidebarPeekController.swift +++ b/Sources/Sidebar/Peek/SidebarPeekController.swift @@ -34,10 +34,50 @@ final class SidebarPeekController: ObservableObject { objectWillChange.send() } machine = SidebarPeekMachine(policy: policy) +#if DEBUG + Self.installDebugTrigger(for: self) +#endif guard !policy.isEnabled else { return } send(.escapePressed) } +#if DEBUG + /// When the latest activation-control hover arrived, for the probe. + static var debugLastHoverUptime: CFTimeInterval? + private static var debugTargets: [ObjectIdentifier: () -> SidebarPeekController?] = [:] + private static var isDebugTriggerRegistered = false + + /// `notifyutil -p com.cmuxterm.debug.sidebar-peek` alternates a titlebar + /// toggle hover enter and exit on every window, for measuring the reveal. + private static func installDebugTrigger(for controller: SidebarPeekController) { + guard SidebarNavigationTimings.isEnabled else { return } + debugTargets[ObjectIdentifier(controller)] = { [weak controller] in controller } + guard !isDebugTriggerRegistered else { return } + isDebugTriggerRegistered = true + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + nil, + { _, _, _, _, _ in + DispatchQueue.main.async { + MainActor.assumeIsolated { + for target in SidebarPeekController.debugTargets.values { + guard let controller = target() else { continue } + if controller.presentsPanel { + controller.pointerExitedEdge() + } else { + controller.pointerEnteredActivationControl() + } + } + } + } + }, + "com.cmuxterm.debug.sidebar-peek" as CFString, + nil, + .deliverImmediately + ) + } +#endif + // MARK: - Events func pointerEnteredEdge() { @@ -48,6 +88,9 @@ final class SidebarPeekController: ObservableObject { /// titlebar's sidebar toggle) is already a deliberate act, so the reveal /// starts the moment the pointer lands instead of after the edge dwell. func pointerEnteredActivationControl() { +#if DEBUG + Self.debugLastHoverUptime = CACurrentMediaTime() +#endif SidebarNavigationTimings.begin("peek") send(.pointerEnteredEdge) send(.dwellElapsed) diff --git a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift index 49c561936e3b..b6644f6eda01 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPanelHost.swift @@ -50,18 +50,12 @@ final class SidebarPeekPanelWindowController { private var lastMetrics: SidebarPeekPanelMetrics = .default /// Whether the previous update had the card revealed. Content pushes are /// skipped while hidden (repainting an invisible panel on every - /// ContentView update is pure cost); the one push after reveal flips to - /// false still lands so the exit animation runs. + /// ContentView update is pure cost). private var lastRevealed = false private var hasPushedContent = false - /// Compositor blur radius last pushed to the panel window, so the CGS - /// call only fires when the value (or the panel) changes. - private var appliedBlurRadius: Int? - /// Pending blur removal after the card hides; see `syncCompositorBlur`. - private var blurResetTask: Task? - /// Clock for the blur-removal delay (injected-clock bounded-delay - /// policy, same as the drop fallback in the reorder controller). - private let blurResetClock: any Clock + /// Bumped per slide, so a replaced slide's completion does nothing. + private var slideGeneration = 0 + private static let slideKey = "cmux.peekCardSlide" /// Told when an editor inside the card takes or gives up the keyboard; /// see `SidebarPeekPanelWindow`. var onKeyboardFocusChange: ((Bool) -> Void)? @@ -92,10 +86,6 @@ final class SidebarPeekPanelWindowController { WindowChromeMetrics.appTitlebarHeight + 2 } - init(blurResetClock: any Clock = ContinuousClock()) { - self.blurResetClock = blurResetClock - } - deinit { // Thread-safe by NotificationCenter contract; the panel itself is // torn down by orderOut in detach or by the parent window closing. @@ -109,7 +99,6 @@ final class SidebarPeekPanelWindowController { contentWidth: CGFloat, metrics: SidebarPeekPanelMetrics, acceptsMouse: Bool, - glassBlurRadius: Int?, content: AnyView ) { guard let parent, parent.isVisible else { @@ -120,9 +109,8 @@ final class SidebarPeekPanelWindowController { lastMetrics = metrics ensurePanel(parent: parent) guard let panel, let hostingView else { return } - syncCompositorBlur(glassBlurRadius, revealed: acceptsMouse, panel: panel) - // Push content only while the card shows (or on the hide transition, - // which the slide-out animation needs). Pushing on every ContentView + // Push content only while the card shows (or on the hide transition). + // Pushing on every ContentView // update while hidden re-diffed the whole sidebar subtree during // workspace-switch churn, which is where the click-to-switch lag in // floating mode came from. @@ -143,6 +131,9 @@ final class SidebarPeekPanelWindowController { } hasPushedContent = true } + if acceptsMouse != lastRevealed { + slideCard(revealed: acceptsMouse) + } lastRevealed = acceptsMouse // When the card is not revealed the panel must be transparent to the // pointer, or it would swallow the very edge hovers that arm the @@ -159,44 +150,51 @@ final class SidebarPeekPanelWindowController { layoutPanel() } - /// Mirrors the docked ground's compositor blur onto the card's own window, - /// but only while the card is showing. The panel stays attached over the - /// leading column even when hidden, and a transparent window with a blur - /// radius blurs everything beneath it: with the card away that would be - /// the docked sidebar's own rows. Removal waits for the exit slide so the - /// card does not go clear mid-flight. - private func syncCompositorBlur(_ radius: Int?, revealed: Bool, panel: NSWindow) { - if radius == nil { - // No blur wanted at all (docked, or glass off): drop it now rather - // than after the exit delay, or the docked rows underneath start - // out blurred. - blurResetTask?.cancel() - blurResetTask = nil - applyCompositorBlur(nil, to: panel) - } else if revealed { - blurResetTask?.cancel() - blurResetTask = nil - applyCompositorBlur(radius, to: panel) - } else if appliedBlurRadius != nil, blurResetTask == nil { - // Cancellable: a re-reveal, a docked switch and detach all - // cancel it before it can strip the blur off a showing card. - blurResetTask = Task { @MainActor [weak self, blurResetClock] in - try? await blurResetClock.sleep(for: .milliseconds(320)) - guard let self, !Task.isCancelled, let panel = self.panel else { return } - self.blurResetTask = nil - self.applyCompositorBlur(nil, to: panel) + /// Slides the whole card, its glass included, on the render server: one + /// spring on the hosting view's layer, model at identity so hit testing + /// and hover tracking stay where the card rests. A blur owned by the + /// moving layer cannot trail it, and a busy main thread cannot stall it. + /// A slide that interrupts another starts from the presented offset. + private func slideCard(revealed: Bool) { + guard let panel, let hostingView, let layer = hostingView.layer else { return } + let travel = panel.frame.width + var from: CGFloat = revealed ? -travel : 0 + if layer.animation(forKey: Self.slideKey) != nil, + let presented = layer.presentation()?.value(forKeyPath: "transform.translation.x") as? NSNumber { + from = CGFloat(presented.doubleValue) + } else if !revealed, hostingView.isHidden { + return + } + slideGeneration &+= 1 + let generation = slideGeneration + hostingView.isHidden = false + let animation = revealed + ? CASpringAnimation(perceptualDuration: 0.21, bounce: 0.1) + : CASpringAnimation(perceptualDuration: 0.2, bounce: 0) + animation.keyPath = "transform.translation.x" + animation.fromValue = from + animation.toValue = revealed ? 0 : -travel + animation.duration = animation.settlingDuration + animation.fillMode = .both + animation.isRemovedOnCompletion = false +#if DEBUG + let probe = revealed + ? SidebarToggleSlideProbe.beginPeek(window: panel, since: SidebarPeekController.debugLastHoverUptime) + : nil +#endif + CATransaction.begin() + CATransaction.setCompletionBlock { [weak self, weak layer] in + MainActor.assumeIsolated { +#if DEBUG + probe?.didLand(cpu: 0) +#endif + guard let self, self.slideGeneration == generation else { return } + layer?.removeAnimation(forKey: Self.slideKey) + if !revealed { self.hostingView?.isHidden = true } } } - } - - private func applyCompositorBlur(_ radius: Int?, to panel: NSWindow) { - guard radius != appliedBlurRadius else { return } - guard panel.windowNumber > 0 else { return } - WindowBackgroundComposition.blurController.setBackgroundBlur( - windowNumber: panel.windowNumber, - radius: radius ?? 0 - ) - appliedBlurRadius = radius + layer.add(animation, forKey: Self.slideKey) + CATransaction.commit() } private func ensurePanel(parent: NSWindow) { @@ -207,6 +205,14 @@ final class SidebarPeekPanelWindowController { let hosting = SidebarPeekPanelHostingView(rootView: AnyView(EmptyView())) hosting.autoresizingMask = [.width, .height] + hosting.wantsLayer = true + // Starts hidden: the first reveal slides in instead of popping. + hosting.isHidden = true + // The card slides inside a still container (see `slideCard`). + let container = NSView(frame: NSRect(x: 0, y: 0, width: 100, height: 100)) + container.wantsLayer = true + hosting.frame = container.bounds + container.addSubview(hosting) let panel = SidebarPeekPanelWindow( contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), @@ -222,7 +228,7 @@ final class SidebarPeekPanelWindowController { panel.isReleasedWhenClosed = false panel.animationBehavior = .none panel.collectionBehavior = [.fullScreenAuxiliary] - panel.contentView = hosting + panel.contentView = container panel.ignoresMouseEvents = true panel.onKeyboardFocusChange = { [weak self] hasFocus in self?.onKeyboardFocusChange?(hasFocus) @@ -328,9 +334,7 @@ final class SidebarPeekPanelWindowController { hasPushedContent = false lastRevealed = false pendingCoalescedContent = nil - appliedBlurRadius = nil - blurResetTask?.cancel() - blurResetTask = nil + slideGeneration &+= 1 } } @@ -386,9 +390,6 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { let contentWidth: CGFloat let metrics: SidebarPeekPanelMetrics let acceptsMouse: Bool - /// Compositor blur for the card's window; nil when the card blurs - /// through its own material instead. - let glassBlurRadius: Int? /// Told when an editor in the card (rename, checklist) takes or gives /// up the keyboard, so the owner can hold the peek open meanwhile. let onKeyboardFocusChange: (Bool) -> Void @@ -427,7 +428,6 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { let contentWidth = contentWidth let metrics = metrics let acceptsMouse = acceptsMouse - let glassBlurRadius = glassBlurRadius let content = content let push: @MainActor () -> Void = { [weak view] in controller.update( @@ -435,7 +435,6 @@ struct SidebarPeekPanelBridge: NSViewRepresentable { contentWidth: contentWidth, metrics: metrics, acceptsMouse: acceptsMouse, - glassBlurRadius: glassBlurRadius, content: content ) } diff --git a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift index 98d2546125c6..6cf6c42be1da 100644 --- a/Sources/Sidebar/Peek/SidebarPeekPresentation.swift +++ b/Sources/Sidebar/Peek/SidebarPeekPresentation.swift @@ -6,9 +6,9 @@ import SwiftUI /// as a floating card, without rebuilding it. /// /// The whole point is that the two modes share one mounted subtree. cmux keeps -/// the AppKit workspace table alive, parked off the leading edge, while the -/// sidebar is hidden (see `retainsDefaultAppKitSidebarWhenHidden` in -/// `ContentView`), so revealing it is a move, never a cold start. Peek rides +/// the AppKit workspace table alive and drawn while the sidebar is hidden +/// (see `retainsDefaultAppKitSidebarWhenHidden` in `ContentView` and +/// `SidebarDockedPaneHost`), so revealing it is a move, never a cold start. Peek rides /// on that: the reveal has no table to build, which is what lets it be /// instant instead of merely fast. struct SidebarPeekPresentation: ViewModifier { @@ -23,15 +23,11 @@ struct SidebarPeekPresentation: ViewModifier { let rendersAsCard: Bool /// The sidebar's resolved width. let width: CGFloat - /// Whether a dismissal should skip the exit slide. True when the card - /// is being superseded by the docked pane: the fixed sidebar replaces it - /// in place, and a card gliding off underneath reads as a ghost. - var dismissesInstantly: Bool = false /// The card's legibility tint (alpha included), resolved from the same /// appearance policy that paints the docked ground. var panelTint: Color = Color(nsColor: .windowBackgroundColor).opacity(0.52) - /// The card's glass material, matching the docked ground's. Nil when the - /// card's window is blurred by the compositor instead. + /// The card's glass material, matching the docked ground's. Nil draws + /// tint only. var panelGlassMaterial: NSVisualEffectView.Material? = .popover /// The material's alpha, matching the docked ground's frost thickness so /// the floating card is exactly as see-through as the docked pane. @@ -40,15 +36,6 @@ struct SidebarPeekPresentation: ViewModifier { let panelMetrics: SidebarPeekPanelMetrics /// Acquires and releases the pointer hold as the pointer crosses the panel. let onPanelHoverChange: (Bool) -> Void - /// False until the card first appears. The panel is mounted lazily, so - /// it can arrive already revealed (switching to floating); gating on - /// this keeps that first reveal a slide instead of a pop. - @State private var cardHasAppeared = false - - private var showsCard: Bool { - isRevealed && cardHasAppeared - } - /// Outer width including the card's leading inset, so floating and docked /// place the list's leading edge identically. private var floatingWidth: CGFloat { @@ -66,24 +53,12 @@ struct SidebarPeekPresentation: ViewModifier { content.frame(width: width, alignment: .leading) } .frame(width: floatingWidth, alignment: .leading) - // Slide from just behind the window edge rather than from zero - // width. Animating the frame would re-lay-out every row on every - // frame of the reveal; translating a laid-out subtree does not. - .offset(x: showsCard ? 0 : -floatingWidth) - .opacity(showsCard ? 1 : 0) - .allowsHitTesting(showsCard) - .accessibilityHidden(!showsCard) + // Laid out where it rests. The panel host slides the whole card, + // glass included, on the render server (see + // SidebarPeekPanelWindowController), so the blur cannot trail it. + .allowsHitTesting(isRevealed) + .accessibilityHidden(!isRevealed) .onHover(perform: onPanelHoverChange) - .onAppear { cardHasAppeared = true } - // The exit is its own curve: a reveal wants a touch of arrival - // settle, but a dismissal should read as the panel leaving the - // screen, fast and without ceremony. - .animation( - showsCard - ? SidebarPeekMotion.reveal - : (dismissesInstantly ? nil : SidebarPeekMotion.dismiss), - value: showsCard - ) } else { // The pane keeps its full width and its slot whether shown or // hidden: its host parks it by drawing only (see @@ -99,17 +74,6 @@ struct SidebarPeekPresentation: ViewModifier { /// The motion curves the peek panel uses. enum SidebarPeekMotion { - /// Reveal and dismissal. - /// - /// A spring rather than an ease so an interrupted reveal (pointer leaves - /// mid-animation) retargets from wherever the panel currently is instead of - /// snapping. `bounce` is zero: this is a panel arriving, not a toy. - static let reveal = Animation.spring(response: 0.21, dampingFraction: 0.9) - - /// Dismissal: faster than the reveal and fully damped, so the panel - /// slides off the edge rather than lingering or bouncing on its way out. - static let dismiss = Animation.spring(response: 0.2, dampingFraction: 1.0) - /// Switching between docked and floating. /// /// Slower than the reveal because the terminal reflows with it, and a fast From 004010ca623a3138131b0a70a812534108c82b5e Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 19:52:59 +1300 Subject: [PATCH 48/69] sidebar toggle: review fixes, no stuck slides, no asyncAfter landing, docked list follows dock/float - the docked host skipped the content push on any park change, so a dock/float switch (park and presentation change together) left the docked list stale. it now skips only when nothing but the park changed - padding, park and reveal also require occupiesLayout, so a window restored with a hidden sidebar is right from the first frame (docksSidebar starts true until the animator installs). during a toggle both agree, nothing moves differently - the landing fallback was an asyncAfter. the animation delegate now lands on every stop, finished or not; stale generations still do nothing - a slide with nothing to move lands at once, and a slide dropped by a visibility commit during the atomic commit's run loop pass no longer gets a session (masks and the right sidebar snapshot could stay up) - presses queued during a landing run right after it; an outside visibility commit clears the queue - tests: setVisible always reports the commit, reversal carries the presented velocity, repeated landings commit once --- Sources/ContentView+SidebarPeek.swift | 2 +- Sources/ContentView.swift | 15 +++-- Sources/Sidebar/SidebarDockedPaneHost.swift | 16 +++++- Sources/Sidebar/SidebarToggleAnimator.swift | 41 +++++++++----- .../Sidebar/SidebarToggleSlideMachine.swift | 2 +- .../SidebarFocusBoundaryLifecycleTests.swift | 56 +++++++++++++++++++ 6 files changed, 110 insertions(+), 22 deletions(-) diff --git a/Sources/ContentView+SidebarPeek.swift b/Sources/ContentView+SidebarPeek.swift index e74f3f204a3d..ed0a1411d51f 100644 --- a/Sources/ContentView+SidebarPeek.swift +++ b/Sources/ContentView+SidebarPeek.swift @@ -85,7 +85,7 @@ extension ContentView { SidebarPeekPresentation( // The layout flag, not `isVisible`: a toggle moves the pane // before ContentView hears about it. - isRevealed: sidebarState.presentationMode == .docked && sidebarLayout.docksSidebar, + isRevealed: sidebarState.occupiesLayout && sidebarLayout.docksSidebar, rendersAsCard: false, width: width, panelMetrics: sidebarPeekPanelMetrics, diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 77baf4083dab..8bbe39272885 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1960,10 +1960,13 @@ struct ContentView: View { // The retained docked list stays live while hidden, in its own // host that parks it by drawing only, so a show slides in // painted rows from its first frame. + let listIsPresented = sidebarState.occupiesLayout + || (retainsDefaultAppKitSidebarWhenHidden && sidebarState.presentationMode == .docked) SidebarDockedPaneHost( - parkedOffset: sidebarState.presentationMode == .docked && sidebarLayout.docksSidebar ? 0 : width, - content: AnyView(sidebarEnvironment(sidebarView(isPresented: sidebarState.occupiesLayout - || (retainsDefaultAppKitSidebarWhenHidden && sidebarState.presentationMode == .docked)) + parkedOffset: sidebarState.occupiesLayout && sidebarLayout.docksSidebar ? 0 : width, + isPresented: listIsPresented, + presentationMode: sidebarState.presentationMode, + content: AnyView(sidebarEnvironment(sidebarView(isPresented: listIsPresented) .environment(\.colorScheme, appearance.sidebarContentColorScheme) .environment(\.sidebarReadabilityBackdrop, appearance.sidebarReadabilityBackdrop))) ) @@ -2568,11 +2571,11 @@ struct ContentView: View { layout = AnyView( ZStack(alignment: .leading) { terminalContentWithRightSidebarPanel(appearance: appearance) - // Docked mode only (a floating sidebar does not push + // `occupiesLayout` (a floating sidebar does not push // the terminal aside), gated by the layout flag. .modifier(SidebarWidthLeadingPaddingModifier( layout: sidebarLayout, - enabled: sidebarState.presentationMode == .docked + enabled: sidebarState.occupiesLayout )) SidebarWidthReader(layout: sidebarLayout) { width in sidebarPanelWithBackdrop(appearance: appearance) @@ -2591,7 +2594,7 @@ struct ContentView: View { terminalContentWithSidebarDropOverlay(appearance: appearance) .modifier(SidebarWidthLeadingPaddingModifier( layout: sidebarLayout, - enabled: sidebarState.presentationMode == .docked + enabled: sidebarState.occupiesLayout )) .frame(maxWidth: .infinity, maxHeight: .infinity) .layoutPriority(1) diff --git a/Sources/Sidebar/SidebarDockedPaneHost.swift b/Sources/Sidebar/SidebarDockedPaneHost.swift index b6f3120b498f..1fc5d9de4a07 100644 --- a/Sources/Sidebar/SidebarDockedPaneHost.swift +++ b/Sources/Sidebar/SidebarDockedPaneHost.swift @@ -1,4 +1,5 @@ import AppKit +import CmuxSidebar import SwiftUI /// Hosts the docked sidebar pane in its own AppKit view, always in its slot. @@ -14,11 +15,18 @@ import SwiftUI struct SidebarDockedPaneHost: NSViewRepresentable { /// How far left the pane's drawing is moved: 0 docked, its width parked. let parkedOffset: CGFloat + /// What the content was built for. A park change alone skips the content + /// push; a change here never does (a dock/float switch moves the park + /// and the list's presentation in the same update). + let isPresented: Bool + let presentationMode: SidebarPresentationMode let content: AnyView final class ContainerView: NSView { let hostingView: NSHostingView fileprivate var parkedOffset: CGFloat = 0 + fileprivate var isPresented = false + fileprivate var presentationMode: SidebarPresentationMode = .docked init(content: AnyView) { hostingView = NSHostingView(rootView: content) @@ -42,6 +50,8 @@ struct SidebarDockedPaneHost: NSViewRepresentable { func makeNSView(context: Context) -> ContainerView { let view = ContainerView(content: content) + view.isPresented = isPresented + view.presentationMode = presentationMode apply(parkedOffset, to: view) return view } @@ -50,10 +60,14 @@ struct SidebarDockedPaneHost: NSViewRepresentable { // A toggle's park or unpark changes nothing in the pane: move the // drawing and skip re-diffing the whole sidebar in the keypress and // landing frames. The next update pushes content as usual. + let contentInputsChanged = view.isPresented != isPresented + || view.presentationMode != presentationMode if view.parkedOffset != parkedOffset { apply(parkedOffset, to: view) - return + guard contentInputsChanged else { return } } + view.isPresented = isPresented + view.presentationMode = presentationMode view.hostingView.rootView = content } diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 00fe2e9d9dc5..2cde77271f02 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -89,12 +89,8 @@ final class SidebarToggleAnimator: ObservableObject { #if DEBUG let probe = SidebarToggleSlideProbe.begin(visible: visible, window: window, animator: self) #endif - let width = Double(layout.width) - execute(machine.request(visible: visible, width: width, now: CACurrentMediaTime()), in: window) - while !queuedRequests.isEmpty { - let next = queuedRequests.removeFirst() - execute(machine.request(visible: next, width: width, now: CACurrentMediaTime()), in: window) - } + execute(machine.request(visible: visible, width: Double(layout.width), now: CACurrentMediaTime()), in: window) + drainQueuedRequests(in: window) syncPendingVisibility() #if DEBUG probe?.keypressDidFinish() @@ -103,6 +99,16 @@ final class SidebarToggleAnimator: ObservableObject { return true } + /// Presses that arrived while effects ran (the atomic commit spins the + /// run loop once), in order. + private func drainQueuedRequests(in window: NSWindow) { + while !queuedRequests.isEmpty { + let next = queuedRequests.removeFirst() + guard let layout else { continue } + execute(machine.request(visible: next, width: Double(layout.width), now: CACurrentMediaTime()), in: window) + } + } + private func execute(_ effects: [SidebarToggleSlideMachine.Effect], in window: NSWindow) { isExecuting = true defer { isExecuting = false } @@ -196,10 +202,18 @@ final class SidebarToggleAnimator: ObservableObject { } private func addSlideAnimation(_ slide: SidebarToggleSlideMachine.Slide, in window: NSWindow) { + // A visibility commit during the atomic commit's run loop pass can + // drop the slide this was for; a session built for it would never land. + guard machine.slide?.generation == slide.generation else { return } if session == nil, let views = Self.slidingViews(in: window) { session = SlideSession(views: views, trailingStillWidth: trailingStillWidth()) } - guard let session else { return } + guard let session, !session.movingLayers.isEmpty else { + // Nothing to move: land now, so the press still takes effect. + // Called mid-execute, so the landing runs on the next turn. + land(generation: slide.generation) + return + } let distance = slide.to - slide.from for (index, layer) in session.movingLayers.enumerated() { let animation = slideSpring(from: slide.from, to: slide.to, velocity: distance == 0 ? 0 : slide.velocity / distance, duration: slide.duration) @@ -216,10 +230,6 @@ final class SidebarToggleAnimator: ObservableObject { let animation = slideSpring(from: -slide.from, to: -slide.to, velocity: distance == 0 ? 0 : slide.velocity / distance, duration: slide.duration) mask.add(animation, forKey: Self.animationKey) } - // A missed delegate callback must not strand the layout: land anyway. - DispatchQueue.main.asyncAfter(deadline: .now() + slide.duration / Double(Self.speed) + 0.1) { [weak self] in - self?.land(generation: slide.generation) - } } private func slideSpring(from: Double, to: Double, velocity: Double, duration: Double) -> CASpringAnimation { @@ -258,6 +268,7 @@ final class SidebarToggleAnimator: ObservableObject { let landingCPU = SidebarToggleSlideProbe.threadCPU() #endif execute(effects, in: window) + drainQueuedRequests(in: window) syncPendingVisibility() #if DEBUG SidebarToggleSlideProbe.current?.didLand(cpu: SidebarToggleSlideProbe.threadCPU() - landingCPU) @@ -276,6 +287,7 @@ final class SidebarToggleAnimator: ObservableObject { /// collapse, the instant path): drop any slide and follow it. private func visibilityDidCommit(_ visible: Bool) { guard !isCommittingVisibility else { return } + queuedRequests.removeAll() if session != nil { CATransaction.begin() CATransaction.setDisableActions(true) @@ -391,8 +403,12 @@ private final class SlideSession { } } -/// Lands the slide when its spring finishes. Core Animation retains its +/// Lands the slide when its spring stops. Core Animation retains its /// delegate; the closure holds the animator weakly. +/// +/// Every stop lands, finished or not: a spring removed early (its layer +/// left the tree) must not strand the layout. A retargeted or torn-down +/// slide's generation is stale by then, so its landing does nothing. private final class SlideLandingDelegate: NSObject, CAAnimationDelegate { private let onLand: @MainActor @Sendable () -> Void @@ -401,7 +417,6 @@ private final class SlideLandingDelegate: NSObject, CAAnimationDelegate { } func animationDidStop(_ animation: CAAnimation, finished: Bool) { - guard finished else { return } let onLand = onLand // Out of Core Animation's callout before touching layout. DispatchQueue.main.async { diff --git a/Sources/Sidebar/SidebarToggleSlideMachine.swift b/Sources/Sidebar/SidebarToggleSlideMachine.swift index d560f2b1894b..1c17b8ff905b 100644 --- a/Sources/Sidebar/SidebarToggleSlideMachine.swift +++ b/Sources/Sidebar/SidebarToggleSlideMachine.swift @@ -127,7 +127,7 @@ struct SidebarToggleSlideMachine: Equatable { } /// The motion with `generation` finished. Stale generations (a slide - /// that was retargeted, or a duplicate fallback) do nothing. + /// that was retargeted, or a repeated landing) do nothing. mutating func land(generation landed: Int) -> [Effect] { guard let current = slide, current.generation == landed else { return [] } slide = nil diff --git a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift index b542d870d726..1c76b583951f 100644 --- a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift +++ b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift @@ -108,6 +108,27 @@ struct SidebarStateAnimatedToggleTests { #expect(state.requestedVisibility) #expect(!state.isVisible) } + + /// The toggle animator drops a running slide when anyone else commits + /// visibility, so every `setVisible` must report the commit, even one + /// that repeats the current value, and clear a pending request. + @Test + func programmaticSetVisibleAlwaysReportsTheCommit() { + let state = SidebarState(isVisible: true) + var commits: [Bool] = [] + state.visibilityDidCommit = { commits.append($0) } + state.pendingVisibility = false + + state.setVisible(true) + #expect(commits == [true]) + #expect(state.pendingVisibility == nil) + #expect(state.requestedVisibility) + + state.setVisible(false) + state.setVisible(false) + #expect(commits == [true, false, false]) + #expect(!state.isVisible) + } } /// The toggle's slide machine, driven with a fake clock: the layout commits @@ -163,6 +184,41 @@ struct SidebarToggleSlideMachineTests { } } + /// A reversal hands its velocity to the next spring, so the motion + /// turns around without a kink. + @Test + func reversalCarriesThePresentedVelocity() { + var machine = SidebarToggleSlideMachine(docked: true) + guard case let .animate(first)? = machine.request(visible: false, width: width, now: 0).last else { + Issue.record("hide did not animate") + return + } + let elapsed = 0.04 + let presented = machine.spring.velocity(from: first.from, to: first.to, velocity: first.velocity, at: elapsed) + #expect(presented < 0, "the hide is moving toward 0") + guard case let .animate(reverse)? = machine.request(visible: true, width: width, now: elapsed).first else { + Issue.record("reversal did not animate") + return + } + #expect(abs(reverse.velocity - presented) < 1e-9) + let start = machine.spring.velocity(from: reverse.from, to: reverse.to, velocity: reverse.velocity, at: 0) + #expect(abs(start - presented) < 1e-9) + } + + /// Every Core Animation stop lands, so a landing can arrive twice for + /// the same slide; only the first may commit. + @Test + func repeatedLandingsCommitOnce() { + var machine = SidebarToggleSlideMachine(docked: false) + guard case let .animate(slide)? = machine.request(visible: true, width: width, now: 0).first else { + Issue.record("show did not animate") + return + } + #expect(machine.land(generation: slide.generation) == [.commitShownLayout]) + #expect(machine.land(generation: slide.generation).isEmpty) + #expect(machine.docked && machine.slide == nil) + } + @Test func staleLandingsAreIgnored() { var machine = SidebarToggleSlideMachine(docked: true) From df98f3e7d81cc78d9a45211fce41c4c1cbe13b01 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 19:52:59 +1300 Subject: [PATCH 49/69] workspace card: panes meet the band line and an open right sidebar, bottom-left corner fits the pane - no gap under the band, so split dividers and the selected tab's top line meet the band line, and the tab bar hairline lines up with the right sidebar's header divider - no trailing inset beside an open right sidebar, so the tab bar hairline runs into its divider. the 8pt inset stays where the card meets the window edge - with panes touching the left sidebar, the pane's square bottom-left corner stuck out past the card's 16pt corner. that corner's curve now ends where the pane starts --- Sources/ContentView.swift | 9 ++++----- Sources/WindowChromeMetrics.swift | 12 +++++++----- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 8bbe39272885..a5a29b360221 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1865,11 +1865,10 @@ struct ContentView: View { .allowsHitTesting(sidebarSelectionState.selection == .tabs) .accessibilityHidden(sidebarSelectionState.selection != .tabs) } - // Inner insets: panes sit inside the workspace card, clear of its - // rounded border. - .padding(.top, WorkspaceCardMetrics.bandGap) - // No leading inset: the panes meet the sidebar edge. - .padding(.trailing, WorkspaceCardMetrics.paneInset) + // Panes meet the band and both sidebars, so the band line, the split + // dividers and the tab bar hairline join the sidebars' own lines. + // They keep an inset only where the card meets the window edge. + .padding(.trailing, rightSidebarVisible ? 0 : WorkspaceCardMetrics.paneInset) .padding(.bottom, WorkspaceCardMetrics.paneInset) // Reserves the titlebar band's height inside the card, so the band // (drawn by the window-level overlay at the same fixed position) diff --git a/Sources/WindowChromeMetrics.swift b/Sources/WindowChromeMetrics.swift index 78065447705a..621c99302555 100644 --- a/Sources/WindowChromeMetrics.swift +++ b/Sources/WindowChromeMetrics.swift @@ -25,11 +25,13 @@ enum WindowChromeMetrics { enum WorkspaceCardMetrics { static let cornerRadius: CGFloat = 16 static let borderWidth: CGFloat = 1 - /// Pane edge to card edge. Larger than the corner sagitta so square pane - /// corners never intrude on the card's rounded border. + /// Pane edge to card edge where the card meets the window edge. static let paneInset: CGFloat = 8 - /// Gap between the band's bottom and the first pane row. - static let bandGap: CGFloat = 2 + /// The bottom leading corner sits under a pane that meets the sidebar, + /// so its curve has to end where the pane's square corner starts: a + /// continuous corner's curve runs about 1.53 radii along each edge, and + /// it must fit in the bottom inset. + static let bottomLeadingCornerRadius: CGFloat = paneInset / 1.53 } /// The card surface, filled with the terminal's own background colour. @@ -50,7 +52,7 @@ struct WorkspaceCardBackground: View { // alone. UnevenRoundedRectangle( topLeadingRadius: WorkspaceCardMetrics.cornerRadius, - bottomLeadingRadius: WorkspaceCardMetrics.cornerRadius, + bottomLeadingRadius: WorkspaceCardMetrics.bottomLeadingCornerRadius, bottomTrailingRadius: 0, topTrailingRadius: 0, style: .continuous From ee1ca41cd2d28f4fa509380430b3707516ba8fd1 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 20:06:03 +1300 Subject: [PATCH 50/69] sidebar toggle: repair review follow-ups, determinism lint - land() runs its debug asserts before draining queued presses, since a queued press can start the next slide - a slide whose spring is long over but never reported its stop lands before the next press, so a lost callback can't leave the toggle retarget-only - a re-install drops a running slide and keeps the generation counting - the slide machine test checks the duration equals the spring's landing time instead of comparing it to literals (assert-on-duration lint) --- Sources/Sidebar/SidebarToggleAnimator.swift | 23 ++++++++++++++++--- .../SidebarFocusBoundaryLifecycleTests.swift | 6 ++--- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 2cde77271f02..51d9d464d265 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -52,7 +52,16 @@ final class SidebarToggleAnimator: ObservableObject { self.canSlide = canSlide self.trailingStillWidth = trailingStillWidth self.isPeekPresenting = isPeekPresenting - machine = SidebarToggleSlideMachine(docked: sidebarState.isVisible) + // A re-install drops any running slide; `reset` keeps the generation + // counting, so a late stop from the old slide stays stale. + if session != nil { + CATransaction.begin() + CATransaction.setDisableActions(true) + removeSlideAnimations() + CATransaction.commit() + } + queuedRequests.removeAll() + machine.reset(visible: sidebarState.isVisible) layout.docksSidebar = sidebarState.isVisible sidebarState.animatedVisibilityOrchestrator = { [weak self] targetVisible in self?.request(visible: targetVisible) ?? false @@ -69,6 +78,13 @@ final class SidebarToggleAnimator: ObservableObject { /// back to the instant path. private func request(visible: Bool) -> Bool { guard let sidebarState, let layout else { return false } + // A slide whose spring is long over but never reported its stop (its + // layer left the tree) lands before this press, so a lost callback + // cannot strand the toggle in retarget-only mode. + if !isExecuting, let slide = machine.slide, + CACurrentMediaTime() > slide.begin + slide.duration / Double(Self.speed) + 0.1 { + land(generation: slide.generation) + } let isSliding = machine.slide != nil guard sidebarState.presentationMode == .docked, !NSWorkspace.shared.accessibilityDisplayShouldReduceMotion, @@ -268,13 +284,14 @@ final class SidebarToggleAnimator: ObservableObject { let landingCPU = SidebarToggleSlideProbe.threadCPU() #endif execute(effects, in: window) - drainQueuedRequests(in: window) - syncPendingVisibility() #if DEBUG SidebarToggleSlideProbe.current?.didLand(cpu: SidebarToggleSlideProbe.threadCPU() - landingCPU) assert(session == nil) assert(layout?.docksSidebar == machine.docked) #endif + // After the asserts: a queued press may start the next slide. + drainQueuedRequests(in: window) + syncPendingVisibility() } private func commitVisibility(_ visible: Bool) { diff --git a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift index 1c76b583951f..0aee2523676d 100644 --- a/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift +++ b/cmuxTests/SidebarFocusBoundaryLifecycleTests.swift @@ -150,7 +150,9 @@ struct SidebarToggleSlideMachineTests { return } #expect(slide.from == width && slide.to == 0) - #expect(slide.duration > 0.1 && slide.duration < 0.3, "landing at \(slide.duration)s") + // The animation runs exactly until the spring is within its landing + // tolerance; `springLandsWithinHalfAPoint` bounds that time. + #expect(slide.duration == machine.spring.landingTime(from: width, to: 0, velocity: 0)) #expect(machine.land(generation: slide.generation) == [.finishHide]) let show = machine.request(visible: true, width: width, now: 1) @@ -201,8 +203,6 @@ struct SidebarToggleSlideMachineTests { return } #expect(abs(reverse.velocity - presented) < 1e-9) - let start = machine.spring.velocity(from: reverse.from, to: reverse.to, velocity: reverse.velocity, at: 0) - #expect(abs(start - presented) < 1e-9) } /// Every Core Animation stop lands, so a landing can arrive twice for From 5b2c4c90ae09eb8d168186a61ca309c2812440b5 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 21:20:04 +1300 Subject: [PATCH 51/69] sidebar: hidden docked list stays current, its row animations pause, a reveal applies once - the parked docked list keeps applying content (titles, order, cloud identity), so a show slides in current rows. spinners, status pulses and hover pause while it's off screen (#14832's concern) through a table-level rows-on-screen gate, and resume on reveal - VerticalTabsSidebar no longer subscribes to SidebarState it never reads. in its own hosting view every visibility publish cost a full body pass, which is where the triple row projection on reveal came from - isRevealed on the sidebar also unmounts the titlebar double-click/drag strip and minimal mode controls over a hidden sidebar - the reveal's push waits a main-queue turn instead of running in the landing frame - SidebarHiddenPresentationTests waits for the toggle to land on isVisible's own commit (the slide's completion), asserts rows and cloud identity apply while hidden with row animations off, resume on reveal, and at most one projection per row. the provider-scope test moved to its own file unchanged --- Sources/ContentView.swift | 15 ++- .../Cells/SidebarWorkspaceRowCellView.swift | 2 +- .../SidebarWorkspaceTableController.swift | 21 +++- .../SidebarWorkspaceTableView.swift | 3 + Sources/Sidebar/SidebarDockedPaneHost.swift | 27 ++++- cmux.xcodeproj/project.pbxproj | 4 + .../SidebarHiddenPresentationTests.swift | 103 ++++++++---------- .../SidebarRetainedProviderScopeTests.swift | 53 +++++++++ 8 files changed, 163 insertions(+), 65 deletions(-) create mode 100644 cmuxTests/SidebarRetainedProviderScopeTests.swift diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 69947bf96b6d..a01385e59089 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1694,6 +1694,7 @@ struct ContentView: View { // must not shadow the in-layout instance's claim. func sidebarView( isPresented: Bool, + isRevealed: Bool = true, attachesFocusBoundary: Bool = true, usesCompactTopInset: Bool = false ) -> some View { @@ -1703,6 +1704,7 @@ struct ContentView: View { sessionIndexStore: sessionIndexStore, featureFlags: featureFlags, isPresented: isPresented, + isRevealed: isRevealed, sidebarUnread: sidebarUnread, titlebarControlsLayoutModel: titlebarControlsLayoutModel, windowId: windowId, @@ -1964,8 +1966,9 @@ struct ContentView: View { SidebarDockedPaneHost( parkedOffset: sidebarState.occupiesLayout && sidebarLayout.docksSidebar ? 0 : width, isPresented: listIsPresented, + isRevealed: sidebarState.occupiesLayout, presentationMode: sidebarState.presentationMode, - content: AnyView(sidebarEnvironment(sidebarView(isPresented: listIsPresented) + content: AnyView(sidebarEnvironment(sidebarView(isPresented: listIsPresented, isRevealed: sidebarState.occupiesLayout) .environment(\.colorScheme, appearance.sidebarContentColorScheme) .environment(\.sidebarReadabilityBackdrop, appearance.sidebarReadabilityBackdrop))) ) @@ -11418,6 +11421,7 @@ struct VerticalTabsSidebar: View, Equatable { && lhs.sidebarUnread === rhs.sidebarUnread && lhs.titlebarControlsLayoutModel === rhs.titlebarControlsLayoutModel && lhs.isPresented == rhs.isPresented + && lhs.isRevealed == rhs.isRevealed && lhs.presentationMode == rhs.presentationMode && lhs.chromeBackgroundColor.isEqual(rhs.chromeBackgroundColor) } @@ -11427,6 +11431,9 @@ struct VerticalTabsSidebar: View, Equatable { let sessionIndexStore: SessionIndexStore var featureFlags: CmuxFeatureFlags = .shared var isPresented: Bool = true + /// False while a presented docked list is parked off screen: content + /// stays current, row animations, hover and the titlebar strip pause. + var isRevealed: Bool = true let sidebarUnread: SidebarUnreadModel let titlebarControlsLayoutModel: TitlebarControlsLayoutModel let windowId: UUID @@ -11445,7 +11452,6 @@ struct VerticalTabsSidebar: View, Equatable { let chromeBackgroundColor: NSColor var observedWindow: NSWindow? { observedWindowReference.window } @EnvironmentObject var tabManager: TabManager - @EnvironmentObject var sidebarState: SidebarState // Plain reference by design. Native row and titlebar subscribers own the // unread invalidation boundary, so this O(workspaces) root stays inert. var notificationStore: TerminalNotificationStore { .shared } @@ -12448,6 +12454,7 @@ struct VerticalTabsSidebar: View, Equatable { selectedWorkspaceId: selectedWorkspaceId, selectedScrollTargetWorkspaceId: selectedScrollTargetWorkspaceId, isPresented: isPresented, + rowsOnScreen: isRevealed, usesCompactTopInset: usesCompactTopInset, unreadSource: sidebarUnread, onDeferredClickAwaitingApply: { appKitTableApplyRequestToken &+= 1 } @@ -12460,7 +12467,7 @@ struct VerticalTabsSidebar: View, Equatable { ) ) .overlay(alignment: .top) { - if isPresented { + if isPresented && isRevealed { // The sidebar top strip remains draggable and handles // double-clicks with the standard titlebar action. WindowDragHandleView() @@ -12469,7 +12476,7 @@ struct VerticalTabsSidebar: View, Equatable { } } .overlay(alignment: .topLeading) { - if isPresented { minimalModeSidebarTitlebarControlsOverlay() } + if isPresented && isRevealed { minimalModeSidebarTitlebarControlsOverlay() } } .background(Color.clear) .onChange(of: selectedWorkspaceId) { _, _ in diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index b464f448fbb7..fee9742bf17e 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -76,7 +76,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { private var pumpCancellables: [AnyCancellable] = [] private weak var pumpWorkspace: Workspace? private var pumpRebuild: (@MainActor () -> Void)? - private var isPresentationActive = true + private(set) var isPresentationActive = true #if DEBUG /// Test seam: observes every full model application (configure, pump, diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index 73d0262c4140..efd1ea215b54 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -38,6 +38,8 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS private var workspaceIds: [UUID] = [] private var selectedScrollTargetWorkspaceId: UUID? private var isPresentationActive = true + /// See `setRowsOnScreen`. + private var rowsAreOnScreen = true private var structuralUpdateDepth = 0 private var deferredPumpHeightRowIds: Set = [] private var deferredStructuralHeightRows = IndexSet() @@ -2126,7 +2128,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// `windowPoint` is the location an event just delivered; recomputes /// without one read the live pointer. func recomputeHoveredRow(windowPoint: NSPoint? = nil) { - guard contextMenuRowId == nil, + guard contextMenuRowId == nil, rowsAreOnScreen, let table = containerView?.tableView else { return } @@ -2335,6 +2337,19 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS return containerView.clipView.bounds.width } + /// A hidden docked pane keeps applying content, so its reveal shows + /// current rows, but its spinners, status pulses and hover pause. + func setRowsOnScreen(_ onScreen: Bool) { + guard rowsAreOnScreen != onScreen else { return } + rowsAreOnScreen = onScreen + if !onScreen { setHoveredRowId(nil) } + let animates = isPresentationActive && onScreen + containerView?.tableView.enumerateAvailableRowViews { rowView, _ in + (rowView.view(atColumn: 0) as? SidebarWorkspaceRowTableCellView)?.setPresentationActive(animates) + (rowView.view(atColumn: 0) as? SidebarGroupHeaderTableCellView)?.setPresentationActive(animates) + } + } + private func setHoveredRowId(_ next: SidebarWorkspaceRenderItemID?) { guard hoveredRowId != next else { return } let previous = hoveredRowId @@ -2521,7 +2536,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS if cell.currentModelForMeasurement != model { releasePumpHeightOverride(for: rowId, ownedBy: cell) } - cell.setPresentationActive(isPresentationActive) + cell.setPresentationActive(isPresentationActive && rowsAreOnScreen) cell.configure( model: model, actions: actions, @@ -2768,7 +2783,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS return } let rowId = configuration.id - cell.setPresentationActive(isPresentationActive) + cell.setPresentationActive(isPresentationActive && rowsAreOnScreen) cell.configure( model: model, actions: actions, diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift index 8402e73c2059..e69e61dbf88f 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableView.swift @@ -23,6 +23,8 @@ struct SidebarWorkspaceTableView: NSViewRepresentable { let selectedWorkspaceId: UUID? let selectedScrollTargetWorkspaceId: UUID? let isPresented: Bool + /// False while a live list is parked off screen (see `setRowsOnScreen`). + var rowsOnScreen = true let usesCompactTopInset: Bool let unreadSource: SidebarUnreadModel /// Invoked when a completed row click parks awaiting live actions; the @@ -62,6 +64,7 @@ struct SidebarWorkspaceTableView: NSViewRepresentable { workspaceIds: workspaceIds, rowIds: liveRowIds ) + context.coordinator.setRowsOnScreen(rowsOnScreen) guard isPresented else { return } guard case let .apply(rows, actions) = contentUpdate else { return } context.coordinator.apply( diff --git a/Sources/Sidebar/SidebarDockedPaneHost.swift b/Sources/Sidebar/SidebarDockedPaneHost.swift index 1fc5d9de4a07..566f94dbd63d 100644 --- a/Sources/Sidebar/SidebarDockedPaneHost.swift +++ b/Sources/Sidebar/SidebarDockedPaneHost.swift @@ -19,6 +19,9 @@ struct SidebarDockedPaneHost: NSViewRepresentable { /// push; a change here never does (a dock/float switch moves the park /// and the list's presentation in the same update). let isPresented: Bool + /// On screen (not parked). The reveal's push waits a turn, so the + /// landing frame stays as light as the keypress. + let isRevealed: Bool let presentationMode: SidebarPresentationMode let content: AnyView @@ -26,7 +29,9 @@ struct SidebarDockedPaneHost: NSViewRepresentable { let hostingView: NSHostingView fileprivate var parkedOffset: CGFloat = 0 fileprivate var isPresented = false + fileprivate var isRevealed = false fileprivate var presentationMode: SidebarPresentationMode = .docked + fileprivate var deferredContent: AnyView? init(content: AnyView) { hostingView = NSHostingView(rootView: content) @@ -51,6 +56,7 @@ struct SidebarDockedPaneHost: NSViewRepresentable { func makeNSView(context: Context) -> ContainerView { let view = ContainerView(content: content) view.isPresented = isPresented + view.isRevealed = isRevealed view.presentationMode = presentationMode apply(parkedOffset, to: view) return view @@ -62,11 +68,28 @@ struct SidebarDockedPaneHost: NSViewRepresentable { // landing frames. The next update pushes content as usual. let contentInputsChanged = view.isPresented != isPresented || view.presentationMode != presentationMode - if view.parkedOffset != parkedOffset { + let parks = view.parkedOffset != parkedOffset + if parks { apply(parkedOffset, to: view) - guard contentInputsChanged else { return } + if !contentInputsChanged { + guard view.isRevealed != isRevealed else { return } + // A show landing: one push, on the next turn. + view.isRevealed = isRevealed + let pending = view.deferredContent == nil + view.deferredContent = content + if pending { + DispatchQueue.main.async { [weak view] in + guard let view, let content = view.deferredContent else { return } + view.deferredContent = nil + view.hostingView.rootView = content + } + } + return + } } + view.deferredContent = nil view.isPresented = isPresented + view.isRevealed = isRevealed view.presentationMode = presentationMode view.hostingView.rootView = content } diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index c031566d1fe3..8e4a002be52c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3331,6 +3331,7 @@ 5B0C00010000000000000001 /* SidebarResizerOcclusionResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0C00010000000000000002 /* SidebarResizerOcclusionResolver.swift */; }; 5B0C00010000000000000003 /* SidebarResizerOcclusionResolverTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B0C00010000000000000004 /* SidebarResizerOcclusionResolverTests.swift */; }; B8F266236A1A3D9A45BD840F /* SidebarResizeUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */; }; + 732C24510A2C03B924A3A3C5 /* SidebarRetainedProviderScopeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519C34B311CB68481382511A /* SidebarRetainedProviderScopeTests.swift */; }; AABBCC000000000000000208 /* SidebarRowAccessibilityModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC000000000000000207 /* SidebarRowAccessibilityModifier.swift */; }; B804A02E000000000000002E /* SidebarRowChecklistAddRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B02E000000000000002E /* SidebarRowChecklistAddRow.swift */; }; B804A02F000000000000002F /* SidebarRowChecklistAttachmentButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B02F000000000000002F /* SidebarRowChecklistAttachmentButton.swift */; }; @@ -7798,6 +7799,7 @@ 5B0C00010000000000000002 /* SidebarResizerOcclusionResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarResizerOcclusionResolver.swift; sourceTree = ""; }; 5B0C00010000000000000004 /* SidebarResizerOcclusionResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizerOcclusionResolverTests.swift; sourceTree = ""; }; 818DBCD4AB69EB72573E8138 /* SidebarResizeUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarResizeUITests.swift; sourceTree = ""; }; + 519C34B311CB68481382511A /* SidebarRetainedProviderScopeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarRetainedProviderScopeTests.swift"; sourceTree = ""; }; AABBCC000000000000000207 /* SidebarRowAccessibilityModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarRowAccessibilityModifier.swift; sourceTree = ""; }; B804B02E000000000000002E /* SidebarRowChecklistAddRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowChecklistAddRow.swift; sourceTree = ""; }; B804B02F000000000000002F /* SidebarRowChecklistAttachmentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowChecklistAttachmentButton.swift; sourceTree = ""; }; @@ -13635,6 +13637,7 @@ 0FBCBAF21AECA805D71D54BD /* FilePreviewQuickLookRetirementTests.swift */, 67015335076C61C9D20E1D6A /* CmuxMainStartupTests.swift */, 07DB2FB4809697C47BBD0341 /* TextViewUndoRegistrationLifetimeTests.swift */, + 519C34B311CB68481382511A /* SidebarRetainedProviderScopeTests.swift */, ); path = cmuxTests; sourceTree = ""; @@ -18495,6 +18498,7 @@ 2E9404437EB47D116E0EE48D /* SidebarPresentedWhileHiddenSnapshotTests.swift in Sources */, C0DE48000000000000000001 /* SidebarProviderMenuRegressionTests.swift in Sources */, 5B0C00010000000000000003 /* SidebarResizerOcclusionResolverTests.swift in Sources */, + 732C24510A2C03B924A3A3C5 /* SidebarRetainedProviderScopeTests.swift in Sources */, 3D0EDA5CB6EA473DB4DB9464 /* SidebarRowPaletteTests.swift in Sources */, C12791000000000000000005 /* SidebarRowSnapshotCacheTests.swift in Sources */, D70D754898C65B4C6FA39754 /* SidebarRowSwiftUIPopoverPresenterTests.swift in Sources */, diff --git a/cmuxTests/SidebarHiddenPresentationTests.swift b/cmuxTests/SidebarHiddenPresentationTests.swift index b10b155c30ba..c97c4368b0ab 100644 --- a/cmuxTests/SidebarHiddenPresentationTests.swift +++ b/cmuxTests/SidebarHiddenPresentationTests.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import CmuxUpdater import QuartzCore import SwiftUI @@ -219,7 +220,9 @@ struct SidebarHiddenPresentationTests { #expect(arc.preferredFrameRateRange.maximum <= 60) } - /// Ensures a hidden sidebar rebuilds retained rows from current Cloud state. + /// Ensures a hidden docked sidebar keeps its table and stays current + /// (rows and Cloud identity apply while hidden) with its row animations + /// paused, and that a reveal applies at most once. @Test func visibilityToggleKeepsAppKitTableContainerMounted() async throws { _ = NSApplication.shared @@ -333,7 +336,7 @@ struct SidebarHiddenPresentationTests { #expect(window.firstResponder === initialContainer.tableView) sidebarState.toggle() - await drainMainRunLoop(for: window) + await awaitToggleLanding(of: sidebarState, in: window) let hiddenContainers = descendants( of: SidebarWorkspaceTableContainerView.self, in: window.contentView @@ -352,13 +355,29 @@ struct SidebarHiddenPresentationTests { tabManager.addWorkspace(initialSurface: .cloudVMLoading, select: false, autoWelcomeIfNeeded: false) await drainMainRunLoop(for: window) #expect( - initialContainer.tableView.numberOfRows == initialRowCount, - "The retained native table must not apply workspace updates while hidden." + initialContainer.tableView.numberOfRows == initialRowCount + 1, + "A hidden docked list stays current: workspace updates still apply while hidden." + ) + let hiddenRowCells = descendants(of: SidebarWorkspaceRowTableCellView.self, in: initialContainer) + #expect(!hiddenRowCells.isEmpty) + #expect( + hiddenRowCells.allSatisfy { !$0.isPresentationActive }, + "While hidden, row spinners and status pulses must not run." ) var cloudChangeIterator = focusedWorkspace.cloudBindingState.changes().makeAsyncIterator() _ = await cloudChangeIterator.next() focusedWorkspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "vivid-newt", isBase: true) _ = await cloudChangeIterator.next() + // The parked row picks up the Cloud identity while hidden, so the + // reveal below has nothing left to rebuild. + var hiddenCloudRow: SidebarWorkspaceRowTableCellView? + let hiddenCloudDeadline = Date(timeIntervalSinceNow: 1) + while hiddenCloudRow == nil, Date() < hiddenCloudDeadline { + hiddenCloudRow = descendants(of: SidebarWorkspaceRowTableCellView.self, in: initialContainer) + .first { $0.accessibilityLabel()?.contains("Cloud workspace on vivid-newt") == true } + if hiddenCloudRow == nil { await drainMainRunLoop(for: window, iterations: 1) } + } + #expect(hiddenCloudRow != nil, "A hidden docked list must apply Cloud identity changes while hidden.") // A doubled projection count means a SECOND sidebar body pass followed // the reveal. Record what landed inside the reveal window (the async // inputs the hidden phase queued: the workspace's directory channel, @@ -389,7 +408,7 @@ struct SidebarHiddenPresentationTests { isMeasuringRevealInvalidations = true defer { isMeasuringRevealInvalidations = false } sidebarState.toggle() - await drainMainRunLoop(for: window, iterations: 1) + await awaitToggleLanding(of: sidebarState, in: window, drains: 1) let projectionsAfterFirstRevealTurn = revealRowInputProjections await drainMainRunLoop(for: window) isMeasuringRevealInvalidations = false @@ -407,9 +426,13 @@ struct SidebarHiddenPresentationTests { "Reopening must reconcile the retained table from the current workspace model." ) #expect( - revealRowInputProjections == tabManager.tabs.count, + descendants(of: SidebarWorkspaceRowTableCellView.self, in: initialContainer).allSatisfy(\.isPresentationActive), + "Revealing resumes row spinners and status pulses." + ) + #expect( + revealRowInputProjections <= tabManager.tabs.count, """ - Reopening must project each current workspace row exactly once. \ + Reopening may project each current workspace row at most once. \ firstTurn=\(projectionsAfterFirstRevealTurn) \ signals=[\(revealSignals.summary)] """ @@ -457,16 +480,19 @@ struct SidebarHiddenPresentationTests { let sidebarEditor = try #require(sidebarField.currentEditor()) let sidebarBoundary = SidebarFocusBoundaryReference() sidebarBoundary.attach(sidebarFocusHost) - #expect(sidebarBoundary.contains(sidebarEditor, in: window), "The fixture must belong to sidebar \(sidebarFrame).") + #expect( + sidebarBoundary.contains(sidebarEditor, in: window), + "The fixture must belong to sidebar \(sidebarFrame); field visible \(sidebarField.visibleRect) at \(sidebarField.convert(sidebarField.bounds, to: nil))." + ) sidebarState.toggle() - await drainMainRunLoop(for: window) + await awaitToggleLanding(of: sidebarState, in: window) let responderAfterSidebarFieldHide = try #require(window.firstResponder) #expect( focusedPanel.ownedFocusIntent(for: responderAfterSidebarFieldHide, in: window) != nil, "Hiding must restore main-panel focus from controls in sidebar \(sidebarFrame)." ) sidebarState.toggle() - await drainMainRunLoop(for: window) + await awaitToggleLanding(of: sidebarState, in: window) sidebarField.removeFromSuperview() let foreignField = NSTextField(frame: NSRect(x: 500, y: 400, width: 120, height: 24)) @@ -476,57 +502,13 @@ struct SidebarHiddenPresentationTests { let foreignEditor = try #require(foreignField.currentEditor()) #expect(window.firstResponder === foreignEditor) sidebarState.toggle() - await drainMainRunLoop(for: window) + await awaitToggleLanding(of: sidebarState, in: window) #expect( window.firstResponder === foreignEditor && foreignField.currentEditor() === foreignEditor, "Hiding the sidebar must preserve focus owned by non-sidebar main content." ) } - @Test - func persistenceIsScopedToDefaultProvider() throws { - #expect( - ContentView.retainsDefaultAppKitSidebar( - appKitListEnabled: true, - effectiveProviderId: CmuxExtensionSidebarSelection.defaultProviderId - ) - ) - #expect( - !ContentView.retainsDefaultAppKitSidebar( - appKitListEnabled: true, - effectiveProviderId: CmuxExtensionSidebarSelection.hostedExtensionsProviderId - ) - ) - let bundledProviderId = try #require(CmuxExtensionSidebarSelection.providers.first?.descriptor.id) - #expect( - !ContentView.retainsDefaultAppKitSidebar( - appKitListEnabled: true, - effectiveProviderId: bundledProviderId - ) - ) - - let customSidebarsDirectory = FileManager.default.temporaryDirectory - .appendingPathComponent("cmux-sidebar-visibility-\(UUID().uuidString)", isDirectory: true) - try FileManager.default.createDirectory(at: customSidebarsDirectory, withIntermediateDirectories: true) - defer { try? FileManager.default.removeItem(at: customSidebarsDirectory) } - let customProviderId = CmuxExtensionSidebarSelection.customSidebarProviderPrefix + "lifecycle-test" - try Data().write(to: customSidebarsDirectory.appendingPathComponent("lifecycle-test.swift")) - CmuxExtensionSidebarSelection.withCustomSidebarsDirectoryForTesting(customSidebarsDirectory) { - #expect( - !ContentView.retainsDefaultAppKitSidebar( - appKitListEnabled: true, - effectiveProviderId: customProviderId - ) - ) - } - #expect( - !ContentView.retainsDefaultAppKitSidebar( - appKitListEnabled: false, - effectiveProviderId: CmuxExtensionSidebarSelection.defaultProviderId - ) - ) - } - private func descendants(of type: View.Type, in root: NSView?) -> [View] { guard let root else { return [] } var matches: [View] = [] @@ -539,6 +521,17 @@ struct SidebarHiddenPresentationTests { return matches } + /// Waits for a toggle to land. The slide commits `isVisible` when its + /// Core Animation spring reports it stopped; the instant path already + /// has. Then drains, so the landing's follow-up updates apply. + private func awaitToggleLanding(of state: SidebarState, in window: NSWindow, drains: Int = 20) async { + let target = state.requestedVisibility + if state.isVisible != target { + for await visible in state.$isVisible.values where visible == target { break } + } + await drainMainRunLoop(for: window, iterations: drains) + } + private func drainMainRunLoop(for window: NSWindow, iterations: Int = 20) async { for _ in 0.. Date: Thu, 8 Oct 2026 22:05:01 +1300 Subject: [PATCH 52/69] sidebar: rows animate from a show's first frame, reveal push stays out of the landing review follow-ups on 5b2c4c90ae0: - the animator turns the docked table's rows on screen when a show starts and off when a hide lands, so spinners and status pulses slide in moving, as before, instead of waiting for SwiftUI after the landing - the deferred reveal push runs in the default run loop mode, so the landing's event tracking spin can't pick it up, and the rest of the landing update rides the pending push - the hidden presentation test has a one minute limit and checks that rows were added while hidden rather than an exact count --- Sources/ContentView.swift | 1 + Sources/Sidebar/SidebarDockedPaneHost.swift | 32 ++++++++++++++++--- Sources/Sidebar/SidebarLayoutModel.swift | 3 ++ Sources/Sidebar/SidebarToggleAnimator.swift | 2 ++ .../SidebarHiddenPresentationTests.swift | 4 +-- 5 files changed, 36 insertions(+), 6 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index a01385e59089..b479b15f8389 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1968,6 +1968,7 @@ struct ContentView: View { isPresented: listIsPresented, isRevealed: sidebarState.occupiesLayout, presentationMode: sidebarState.presentationMode, + layout: sidebarLayout, content: AnyView(sidebarEnvironment(sidebarView(isPresented: listIsPresented, isRevealed: sidebarState.occupiesLayout) .environment(\.colorScheme, appearance.sidebarContentColorScheme) .environment(\.sidebarReadabilityBackdrop, appearance.sidebarReadabilityBackdrop))) diff --git a/Sources/Sidebar/SidebarDockedPaneHost.swift b/Sources/Sidebar/SidebarDockedPaneHost.swift index 566f94dbd63d..4abbac506da4 100644 --- a/Sources/Sidebar/SidebarDockedPaneHost.swift +++ b/Sources/Sidebar/SidebarDockedPaneHost.swift @@ -23,6 +23,7 @@ struct SidebarDockedPaneHost: NSViewRepresentable { /// landing frame stays as light as the keypress. let isRevealed: Bool let presentationMode: SidebarPresentationMode + let layout: SidebarLayoutModel let content: AnyView final class ContainerView: NSView { @@ -32,6 +33,20 @@ struct SidebarDockedPaneHost: NSViewRepresentable { fileprivate var isRevealed = false fileprivate var presentationMode: SidebarPresentationMode = .docked fileprivate var deferredContent: AnyView? + private weak var table: SidebarWorkspaceTableContainerView? + + /// Called by the toggle animator: rows animate from a show's first + /// frame and pause once a hide lands. SwiftUI's `isRevealed` agrees + /// after the landing. + func setRowsOnScreen(_ onScreen: Bool) { + if table == nil { table = Self.firstTable(in: hostingView) } + table?.clipView.workspaceController?.setRowsOnScreen(onScreen) + } + + private static func firstTable(in view: NSView) -> SidebarWorkspaceTableContainerView? { + if let table = view as? SidebarWorkspaceTableContainerView { return table } + return view.subviews.lazy.compactMap { firstTable(in: $0) }.first + } init(content: AnyView) { hostingView = NSHostingView(rootView: content) @@ -58,6 +73,7 @@ struct SidebarDockedPaneHost: NSViewRepresentable { view.isPresented = isPresented view.isRevealed = isRevealed view.presentationMode = presentationMode + layout.dockedPane = view apply(parkedOffset, to: view) return view } @@ -77,15 +93,23 @@ struct SidebarDockedPaneHost: NSViewRepresentable { view.isRevealed = isRevealed let pending = view.deferredContent == nil view.deferredContent = content + // Default mode only: the landing's commit spins the run loop + // in event tracking mode, and this must not run inside it. if pending { - DispatchQueue.main.async { [weak view] in - guard let view, let content = view.deferredContent else { return } - view.deferredContent = nil - view.hostingView.rootView = content + RunLoop.main.perform(inModes: [.default]) { [weak view] in + MainActor.assumeIsolated { + guard let view, let content = view.deferredContent else { return } + view.deferredContent = nil + view.hostingView.rootView = content + } } } return } + } else if view.deferredContent != nil, !contentInputsChanged, view.isRevealed == isRevealed { + // The rest of the landing's update: ride the pending push. + view.deferredContent = content + return } view.deferredContent = nil view.isPresented = isPresented diff --git a/Sources/Sidebar/SidebarLayoutModel.swift b/Sources/Sidebar/SidebarLayoutModel.swift index c1d5269d0af9..2f8d55953fa3 100644 --- a/Sources/Sidebar/SidebarLayoutModel.swift +++ b/Sources/Sidebar/SidebarLayoutModel.swift @@ -21,6 +21,9 @@ final class SidebarLayoutModel: ObservableObject { /// ContentView's body. While false, the pane stays laid out at full /// width, parked just past the window's leading edge. @Published var docksSidebar = true + /// The docked pane's host, so the toggle animator can start row + /// animations at a show's first frame without a SwiftUI pass. + weak var dockedPane: SidebarDockedPaneHost.ContainerView? /// How far the window ground reaches past the leading edge, so a toggle /// slide (which translates the content root by up to the sidebar width) diff --git a/Sources/Sidebar/SidebarToggleAnimator.swift b/Sources/Sidebar/SidebarToggleAnimator.swift index 51d9d464d265..2bfd8ac02487 100644 --- a/Sources/Sidebar/SidebarToggleAnimator.swift +++ b/Sources/Sidebar/SidebarToggleAnimator.swift @@ -151,6 +151,7 @@ final class SidebarToggleAnimator: ObservableObject { if let slide { machine.slideDidStart(generation: slide.generation, at: CACurrentMediaTime()) } case let .animate(slide): CATransaction.begin() + if slide.landsVisible { layout?.dockedPane?.setRowsOnScreen(true) } addSlideAnimation(slide, in: window) CATransaction.commit() CATransaction.flush() @@ -165,6 +166,7 @@ final class SidebarToggleAnimator: ObservableObject { case .finishHide: CATransaction.begin() CATransaction.setDisableActions(true) + layout?.dockedPane?.setRowsOnScreen(false) removeSlideAnimations() CATransaction.commit() // ContentView hears about the hide once the motion is over; diff --git a/cmuxTests/SidebarHiddenPresentationTests.swift b/cmuxTests/SidebarHiddenPresentationTests.swift index c97c4368b0ab..658ba8684c54 100644 --- a/cmuxTests/SidebarHiddenPresentationTests.swift +++ b/cmuxTests/SidebarHiddenPresentationTests.swift @@ -223,7 +223,7 @@ struct SidebarHiddenPresentationTests { /// Ensures a hidden docked sidebar keeps its table and stays current /// (rows and Cloud identity apply while hidden) with its row animations /// paused, and that a reveal applies at most once. - @Test + @Test(.timeLimit(.minutes(1))) func visibilityToggleKeepsAppKitTableContainerMounted() async throws { _ = NSApplication.shared @@ -355,7 +355,7 @@ struct SidebarHiddenPresentationTests { tabManager.addWorkspace(initialSurface: .cloudVMLoading, select: false, autoWelcomeIfNeeded: false) await drainMainRunLoop(for: window) #expect( - initialContainer.tableView.numberOfRows == initialRowCount + 1, + initialContainer.tableView.numberOfRows > initialRowCount, "A hidden docked list stays current: workspace updates still apply while hidden." ) let hiddenRowCells = descendants(of: SidebarWorkspaceRowTableCellView.self, in: initialContainer) From cbe6b507c628834273d0f91ab760e2d082f5cfca Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 22:05:01 +1300 Subject: [PATCH 53/69] sidebar: aside-style hover circle on the group plus and the row close, same tint - SidebarHeaderGlyphButton gets an opt-in hover fill: a circle on its 16pt bounds in the surface's foreground, 15% on hover, 22% pressed, glyph lifted to 65%. the group header plus and the row close button use it, in both active and inactive rows - the row close button rests at the same secondary label colour as the plus (it was 70% of the selected foreground on active rows) - hover settles from the live pointer on every reveal and conceal, so a click that reflows rows, a drag, or a row scrolling under a still pointer can't strand it --- .../Cells/SidebarGroupHeaderRowView.swift | 1 + .../Cells/SidebarHeaderGlyphButton.swift | 35 +++++++++++++++++-- .../Cells/SidebarWorkspaceRowCellView.swift | 4 +-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index a4bcd2c8d8ae..a11295f00ab9 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -249,6 +249,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { weight: .medium ) plusButton.contentTintColor = colorResolver.resolvedColor(.secondaryLabelColor, for: colorScheme) + plusButton.hoverFillColor = colorResolver.resolvedColor(.labelColor, for: colorScheme) plusButton.setAccessibilityLabel(String( localized: "workspaceGroup.newWorkspaceInGroup.a11y", defaultValue: "New workspace in group" diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift b/Sources/Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift index 365fcab6778c..cb479f62d761 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift @@ -36,6 +36,16 @@ final class SidebarHeaderGlyphButton: NSButton { didSet { if !highlightsOnHover { setHovering(false) } } } + /// The Aside-style hover instead (group header plus, row close): a + /// circle on the bounds in this colour, 15% on hover and 22% pressed, + /// with the glyph lifted to 65% of it. Owners pass the foreground of the + /// surface it sits on, so the circle reads on an active row too. + var hoverFillColor: NSColor? { + didSet { if isHovering { applyHoverAppearance() } } + } + + private var hasHoverFeedback: Bool { highlightsOnHover || hoverFillColor != nil } + var glyphImage: NSImage? { didSet { image = glyphImage } } @@ -90,10 +100,14 @@ final class SidebarHeaderGlyphButton: NSButton { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) - guard highlightsOnHover, isEnabled else { return } + guard hasHoverFeedback, isEnabled else { return } setHovering(true) } + override var isHighlighted: Bool { + didSet { if hoverFillColor != nil, isHighlighted != oldValue { applyHoverAppearance() } } + } + override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) setHovering(false) @@ -101,7 +115,7 @@ final class SidebarHeaderGlyphButton: NSButton { override func layout() { super.layout() - if highlightsOnHover { + if hasHoverFeedback { layer?.cornerRadius = min(bounds.width, bounds.height) / 2 } } @@ -115,6 +129,14 @@ final class SidebarHeaderGlyphButton: NSButton { private func applyHoverAppearance() { wantsLayer = true layer?.cornerRadius = min(bounds.width, bounds.height) / 2 + if let hoverFillColor { + let alpha: CGFloat = isHovering ? (isHighlighted ? 0.22 : 0.15) : 0 + layer?.backgroundColor = hoverFillColor.withAlphaComponent(alpha).cgColor + isApplyingHoverTint = true + contentTintColor = isHovering ? hoverFillColor.withAlphaComponent(0.65) : restingTintColor + isApplyingHoverTint = false + return + } // Dynamic colours resolve against the drawing appearance, so the // circle and glyph pick up the sidebar's own light/dark scheme. effectiveAppearance.performAsCurrentDrawingAppearance { @@ -147,5 +169,14 @@ final class SidebarHeaderGlyphButton: NSButton { isEnabled = revealed alphaValue = revealed ? 1 : 0 isHidden = !revealed + // Hover follows the pointer, not the last enter/exit pair: a click + // that reflows rows, a drag, or a row scrolling under a still + // pointer can skip the exit, and every reveal or conceal settles it. + setHovering(revealed && hasHoverFeedback && pointerIsInside) + } + + private var pointerIsInside: Bool { + guard let window else { return false } + return bounds.contains(convert(window.mouseLocationOutsideOfEventStream, from: nil)) } } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index fee9742bf17e..82a1a77d5c96 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -342,7 +342,6 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { closeButton.setAccessibilityIdentifier("sidebarWorkspaceCloseButton") closeButton.setAccessibilityElement(false) closeButton.concealImmediately() - closeButton.highlightsOnHover = true contentContainer.addSubview(closeButton) contentContainer.addSubview(descriptionView) descriptionView.onOpenLink = { [weak self] url in @@ -638,7 +637,8 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { closeButton.glyphImage = RenderableSystemSymbol.configuredAppKitImage( systemName: "xmark", pointSize: model.scaled(9), weight: .medium ) - closeButton.contentTintColor = palette.secondary(0.7) + closeButton.contentTintColor = palette.semantic(.secondaryLabelColor) + closeButton.hoverFillColor = palette.semantic(.labelColor) let closeButtonTooltip = snapshot.isPinned ? String(localized: "sidebar.pinnedWorkspaceProtected.tooltip", defaultValue: "Pinned workspace — protected from Close") : String(localized: "sidebar.closeWorkspace.tooltip", defaultValue: "Close workspace") From e390ad93db0251e850b76768ddcab82163abb49f Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 22:05:01 +1300 Subject: [PATCH 54/69] sidebar tint: light mode gets a light glass while the stock tint is in place the stock #393939 at 72% applied in both appearances, so in light mode the sidebar stayed a dark glass under dark text. with no light override and the stock hex, light mode now uses #F2F2F2 (the stock opacity maps to 72% too, a chosen opacity is kept). main's light #000000 at 18% assumed a light material under it; the compositor ground is the raw desktop blur. dark is unchanged and a chosen colour wins in both modes. test: stockSidebarTintIsAppearanceAware --- .../SidebarBackdropSettingsSnapshot.swift | 18 +++++++++----- .../WindowChromeSidebarTintDefaults.swift | 7 ++++++ .../WindowAppearanceResolverTests.swift | 24 +++++++++++++++++++ 3 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift index b4e3ca94393f..d6390828bc01 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/SidebarBackdropSettingsSnapshot.swift @@ -123,18 +123,24 @@ public struct SidebarBackdropSettingsSnapshot { } /// The tint colour with its opacity baked in, honouring the per-scheme - /// overrides. - private var resolvedTintColor: NSColor { - let resolvedHex: String + /// overrides. Light mode swaps the stock dark tint for the light default; + /// a chosen colour, or a chosen opacity, still wins. + var resolvedTintColor: NSColor { + var resolvedHex = tintHex + var opacity = tintOpacity if colorScheme == .dark, let tintHexDark { resolvedHex = tintHexDark } else if colorScheme == .light, let tintHexLight { resolvedHex = tintHexLight - } else { - resolvedHex = tintHex + } else if colorScheme == .light { + let stock = WindowChromeSidebarTintDefaults() + if tintHex.caseInsensitiveCompare(stock.hex) == .orderedSame { + resolvedHex = WindowChromeSidebarTintDefaults.light.hex + if tintOpacity == stock.opacity { opacity = WindowChromeSidebarTintDefaults.light.opacity } + } } return (NSColor(hex: resolvedHex) ?? NSColor(hex: tintHex) ?? .black) - .withAlphaComponent(tintOpacity) + .withAlphaComponent(opacity) } /// Stable identity for AppKit mutations. diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift index b3abaf69a1fe..9844f9b7d84e 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift @@ -11,6 +11,13 @@ public struct WindowChromeSidebarTintDefaults: Sendable { /// `sidebarAppearance.matchTerminalBackground` catalog default. public static let matchesTerminalBackground = false + /// What light mode uses while the stock tint is in place. The stock + /// #393939 is a dark glass and light mode turns the sidebar text dark, + /// so it gets the mirror: a light glass at the same strength. (Main's + /// light #000000 at 18% assumed a light material under it; the + /// compositor ground is the raw desktop blur, often dark.) + public static let light = WindowChromeSidebarTintDefaults(hex: "#F2F2F2", opacity: 0.72) + /// Creates sidebar tint defaults. public init( hex: String = "#393939", diff --git a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift index 1269080fc0c9..f1cb07d171a3 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift @@ -234,6 +234,30 @@ import Testing #expect(plan.glass?.tintColor.hexString(includeAlpha: true) == "#272822FF") } + /// The stock tint is a dark glass, so light mode swaps it for the light + /// default; dark keeps it, and a chosen colour or opacity still wins. + @Test func stockSidebarTintIsAppearanceAware() { + func tint(_ scheme: ColorScheme, hex: String = WindowChromeSidebarTintDefaults().hex, + opacity: Double = WindowChromeSidebarTintDefaults().opacity) -> (String, Double) { + let color = SidebarBackdropSettingsSnapshot( + materialRawValue: WindowChromeSidebarMaterialOption.hudWindow.rawValue, + blendModeRawValue: "behindWindow", stateRawValue: "active", + tintHex: hex, tintHexLight: nil, tintHexDark: nil, tintOpacity: opacity, + cornerRadius: 0, blurOpacity: 1, colorScheme: scheme, + compositorGlass: true, compositorBlurRadius: 12 + ).resolvedTintColor + return (color.hexString(), Double(color.alphaComponent)) + } + func expect(_ actual: (String, Double), _ hex: String, _ alpha: Double) { + #expect(actual.0 == hex) + #expect(abs(actual.1 - alpha) < 0.001) + } + expect(tint(.dark), "#393939", 0.72) + expect(tint(.light), "#F2F2F2", 0.72) + expect(tint(.light, hex: "#FF0000"), "#FF0000", 0.72) + expect(tint(.light, opacity: 0.4), "#F2F2F2", 0.4) + } + private func makeSettings( unifySurfaceBackdrops: Bool, sidebarBlendMode: String, From 2ed7d64418eabbd9b53991e81828f8f7ceb3d8f3 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 22:41:41 +1300 Subject: [PATCH 55/69] sidebar: row hover wash on workspace rows and group headers, no stuck hover around drags - an edgeless soft wash on the row under the pointer (the glass selection's shape, 5% dark / 3.5% light, tunable via sidebarRowHoverFillOpacity[Light]); selection, multi-selection and custom fills win, the header plus draws on top - gated by the sidebarRowHover default (on unless turned off), read where the wash is painted and followed live through KVO: the loaded-cell sweep repaints every row. hover-revealed chrome (close X, header plus) is unaffected - drags: hover is cleared at drag start and before the lift snapshot, suppressed while the drag runs, and re-derived from the live pointer after the drop and after the settle; the sweep covers every loaded row view - the hover code lives in SidebarRowHover.swift and SidebarWorkspaceTableController+Hover.swift, so the cell files and the controller stay inside the length budget --- .../Cells/SidebarGroupHeaderRowView.swift | 24 ++-- .../AppKitList/Cells/SidebarRowHover.swift | 118 ++++++++++++++++++ .../Cells/SidebarWorkspaceRowCellView.swift | 36 ++---- ...idebarWorkspaceTableController+Hover.swift | 69 ++++++++++ ...WorkspaceTableController+ReorderLift.swift | 6 + ...ceTableController+ReorderLiftRebuild.swift | 2 + .../SidebarWorkspaceTableController.swift | 62 +++------ Sources/Sidebar/SidebarGlassSelection.swift | 47 ++++++- cmux.xcodeproj/project.pbxproj | 8 ++ 9 files changed, 293 insertions(+), 79 deletions(-) create mode 100644 Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift create mode 100644 Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+Hover.swift diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index a11295f00ab9..a009e4435be6 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -14,6 +14,8 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { /// Selection fill and edge layer. Internal so tests read its paint directly. let backgroundView = NSView() + /// Row hover wash; see `SidebarRowHover.swift`. + let rowHoverLayer = CALayer() private let pinImageView = NSImageView() private let chevronButton = SidebarHeaderGlyphButton() private let iconImageView = NSImageView() @@ -29,12 +31,12 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { private let bottomDropIndicator = SidebarReorderIndicatorView() private let hintPill = SidebarShortcutHintPillView() - private var model: SidebarGroupHeaderRowModel? + private(set) var model: SidebarGroupHeaderRowModel? private var actions: SidebarGroupHeaderRowActions? /// Mirrors the table controller's flag, as workspace row cells do, so a /// cell configured while the sidebar is hidden does not restart the pulse. private var isPresentationActive = true - private var isPointerHovering = false + var isPointerHovering = false private var contextMenuVisible = false private var contextMenuDidOpen: (() -> Void)? private var contextMenuDidClose: (() -> Void)? @@ -63,6 +65,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { backgroundView.layer?.cornerRadius = 4 backgroundView.layer?.cornerCurve = .continuous addSubview(backgroundView) + SidebarGlassSelection.installHoverLayer(rowHoverLayer, in: backgroundView) pinImageView.imageScaling = .scaleProportionallyDown addSubview(pinImageView) @@ -103,6 +106,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { suspendPresentation() model = nil isPointerHovering = false + rowHoverLayer.isHidden = true plusButton.concealImmediately() hintPill.resetForReuse() } @@ -282,6 +286,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { // its exact resting appearance (the dim was a ghost-era cue). alphaValue = 1 updatePlusVisibility() + updateRowHoverFill() setAccessibilityIdentifier("sidebarWorkspaceGroup.\(model.groupId.uuidString)") setAccessibilityLabel(model.name) } @@ -304,20 +309,11 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { } #endif - private func updatePlusVisibility() { + func updatePlusVisibility() { let showsHint = model?.shortcutHintText != nil plusButton.setRevealed(isPointerHovering && !contextMenuVisible && !showsHint) } - /// Authoritative hover enforcement: the controller sweeps visible cells - /// so hover-revealed chrome cannot strand on rows the pointer left - /// (row-index/id races during churn made per-transition repaints miss). - func enforcePointerHovering(_ hovering: Bool) { - guard isPointerHovering != hovering else { return } - isPointerHovering = hovering - updatePlusVisibility() - } - /// Optimistic press treatment: paints the anchor-active header visuals /// instantly (group clicks focus the anchor workspace); the next /// authoritative configure reconciles. @@ -332,6 +328,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { applySelectionEdge(model.anchorActiveEdgeColor) CATransaction.commit() nameField.textColor = labelColor + updateRowHoverFill() } /// Modifier-click preview: paints the same dim membership tint as an @@ -344,6 +341,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { backgroundView.layer?.backgroundColor = headerMultiSelectionBackgroundColor(for: model).cgColor applySelectionEdge(model.multiSelectionBackgroundStyle.edgeColor) CATransaction.commit() + updateRowHoverFill() } /// Plain-click counterpart: clears active and multi-selected header paint @@ -362,6 +360,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { for: colorScheme, opacity: 0.9 ) + updateRowHoverFill() } /// Rollback for optimistic press paint: reapplies the stored model @@ -446,6 +445,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { let outerPad = SidebarWorkspaceListMetrics.rowOuterHorizontalPadding let bgFrame = NSRect(x: outerPad, y: 0, width: bounds.width - outerPad * 2, height: bounds.height) backgroundView.frame = bgFrame + rowHoverLayer.frame = backgroundView.bounds let contentMaxX = bgFrame.maxX - SidebarWorkspaceListMetrics.rowContentHorizontalPadding let midY = bounds.height / 2 var x = bgFrame.minX diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift b/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift new file mode 100644 index 000000000000..fd32b973e8a5 --- /dev/null +++ b/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift @@ -0,0 +1,118 @@ +import AppKit +import SwiftUI + +// Row hover: an edgeless soft wash on the row under the pointer, the glass +// selection's shape at a lower strength (Aside-style), on workspace rows and +// group headers. Settings > Sidebar > Row Hover (`sidebarRowHover`) turns the +// wash off; hover-revealed chrome (close button, header plus) is unaffected. + +extension SidebarWorkspaceRowTableCellView { + /// Authoritative hover enforcement: the controller sweeps loaded cells + /// so hover-revealed chrome cannot strand on rows the pointer left + /// (row-index/id races during churn made per-transition repaints miss). + func enforcePointerHovering(_ hovering: Bool) { + // Same hover still repaints: a drag may have held the wash back, or + // the setting changed. + guard isPointerHovering != hovering else { return updateHoverFill() } + isPointerHovering = hovering + // Full re-apply: hover gates more than the close button (the + // trailing badge and spinner hide while the close button shows), and + // re-deriving that subset here would drift from applyModel. + if let model { + applyModel(paintedModel(model)) + needsLayout = true + } else { + updateCloseVisibility() + } + } + + /// Shows the wash when nothing else fills the row: selection, + /// multi-selection and custom row fills win, and a running reorder drag + /// shows none. Only touches the wash layer, so it is safe on every + /// pointer move. + func updateHoverFill(colorScheme: ColorScheme? = nil) { + var color: CGColor? + if isPointerHovering, let model, SidebarGlassSelection.isRowHoverEnabled(), + !SidebarGlassSelection.isReorderDragRunning(around: self), + (backgroundView.layer?.backgroundColor?.alpha ?? 0) == 0, + (backgroundView.layer?.borderWidth ?? 0) == 0 { + let painted = paintedModel(model) + if !painted.isActive, !painted.isMultiSelected { + color = SidebarGlassSelection.hoverFill( + for: colorScheme ?? palette(painted).colorScheme + ).cgColor + } + } + if let color { hoverLayer.backgroundColor = color } + if hoverLayer.isHidden != (color == nil) { hoverLayer.isHidden = color == nil } + } + + override func viewDidUnhide() { + super.viewDidUnhide() + SidebarGlassSelection.reresolveHoverAfterUnhide(of: self) { [weak self] in self?.updateHoverFill() } + } +} + +/// The header's active and multi-selected paint win over the wash; the plus +/// button's own hover draws on top of it. +extension SidebarGroupHeaderTableCellView { + /// See the workspace row's `enforcePointerHovering`. + func enforcePointerHovering(_ hovering: Bool) { + guard isPointerHovering != hovering else { return updateRowHoverFill() } + isPointerHovering = hovering + updatePlusVisibility() + updateRowHoverFill() + } + + /// Only touches the wash layer, so it is safe on every pointer move. + func updateRowHoverFill() { + var color: CGColor? + if isPointerHovering, let model, !model.isAnchorActive, !model.isMultiSelected, + SidebarGlassSelection.isRowHoverEnabled(), + (backgroundView.layer?.backgroundColor?.alpha ?? 0) == 0, + (backgroundView.layer?.borderWidth ?? 0) == 0, + !SidebarGlassSelection.isReorderDragRunning(around: self) { + color = SidebarGlassSelection.hoverFill(for: model.colorSchemeIsDark ? .dark : .light).cgColor + } + if let color { rowHoverLayer.backgroundColor = color } + rowHoverLayer.cornerRadius = backgroundView.layer?.cornerRadius ?? 4 + if rowHoverLayer.isHidden != (color == nil) { rowHoverLayer.isHidden = color == nil } + } + + override func viewDidUnhide() { + super.viewDidUnhide() + SidebarGlassSelection.reresolveHoverAfterUnhide(of: self) { [weak self] in self?.updateRowHoverFill() } + } +} + +extension SidebarGlassSelection { + /// Settings > Sidebar > Row Hover. UI-only, on unless turned off. + static let rowHoverKey = "sidebarRowHover" + + static func isRowHoverEnabled(defaults: UserDefaults = .standard) -> Bool { + defaults.object(forKey: rowHoverKey) as? Bool ?? true + } + + /// The wash layer under a row's content: a sublayer of its fill view, so + /// it shares the fill's frame, group indent and corners, and the reorder + /// lift, which copies only the fill view's own paint, never carries it. + @MainActor + static func installHoverLayer(_ layer: CALayer, in fillView: NSView) { + layer.cornerCurve = .continuous + layer.isHidden = true + // Hover snaps on and off like the selection; no implicit fades. + layer.actions = [ + "backgroundColor": NSNull(), "hidden": NSNull(), "bounds": NSNull(), + "position": NSNull(), "cornerRadius": NSNull(), + ] + fillView.layer?.addSublayer(layer) + } +} + +extension UserDefaults { + /// KVO handle for `SidebarGlassSelection.rowHoverKey`; the property name + /// must match the key. + @objc dynamic var sidebarRowHover: Bool { + object(forKey: SidebarGlassSelection.rowHoverKey) as? Bool ?? true + } +} diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 82a1a77d5c96..8bdc43b495b2 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -16,6 +16,8 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { // Chrome let backgroundView = NSView() + /// Row hover wash; see `SidebarRowHover.swift`. + let hoverLayer = CALayer() private let railView = NSView() private let topDropIndicator = SidebarReorderIndicatorView() private let bottomDropIndicator = SidebarReorderIndicatorView() @@ -62,11 +64,11 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { private let statusPopoverPresenter = SidebarRowSwiftUIPopoverPresenter() private var lastStatusPopoverModel: SidebarWorkspaceStatusPopoverModel? - private var model: SidebarWorkspaceRowModel? + private(set) var model: SidebarWorkspaceRowModel? private var selectionChromeObservers: [NSObjectProtocol] = [] private var workspaceSelectionChromeObserver: NSObjectProtocol? private var actions: SidebarAppKitRowActions? - private var isPointerHovering = false + var isPointerHovering = false private var contextMenuVisible = false private var contextMenuDidOpen: (() -> Void)? private var contextMenuDidClose: (() -> Void)? @@ -127,7 +129,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { /// preview bailout), a different workspace, or reuse. private var optimisticSelection: (isActive: Bool, isMultiSelected: Bool)? - private func paintedModel(_ model: SidebarWorkspaceRowModel) -> SidebarWorkspaceRowModel { + func paintedModel(_ model: SidebarWorkspaceRowModel) -> SidebarWorkspaceRowModel { guard let optimisticSelection else { return model } var painted = model painted.isActive = optimisticSelection.isActive @@ -243,6 +245,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } else { backgroundView.layer?.borderWidth = 0 } + updateHoverFill(colorScheme: palette.colorScheme) } private func repaintSelectionChrome() { @@ -319,6 +322,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { backgroundView.layer?.cornerCurve = .continuous backgroundView.layer?.borderWidth = 0 addSubview(backgroundView) + SidebarGlassSelection.installHoverLayer(hoverLayer, in: backgroundView) railView.wantsLayer = true addSubview(railView) addSubview(contentContainer) @@ -390,6 +394,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { // closed). Snap its close button hidden so it cannot fade out on // whichever row AppKit hands this cell to next. isPointerHovering = false + hoverLayer.isHidden = true closeButton.setAccessibilityElement(false) closeButton.concealImmediately() hintPill.resetForReuse() @@ -504,7 +509,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } } - private func palette(_ model: SidebarWorkspaceRowModel) -> SidebarRowPalette { + func palette(_ model: SidebarWorkspaceRowModel) -> SidebarRowPalette { SidebarRowPalette( model: model, isSelectionEmphasized: NSApp.isActive && (window.map { $0.isKeyWindow || $0.isMainWindow } ?? true), @@ -512,7 +517,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { ) } - private func applyModel(_ model: SidebarWorkspaceRowModel) { + func applyModel(_ model: SidebarWorkspaceRowModel) { #if DEBUG applyModelProbeForTesting?(model) #endif @@ -865,29 +870,12 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { && !(model.showsShortcutHints || model.settings.alwaysShowShortcutHints) } - private func updateCloseVisibility() { + func updateCloseVisibility() { let revealed = showsCloseNow closeButton.setRevealed(revealed) closeButton.setAccessibilityElement(revealed) } - /// Authoritative hover enforcement: the controller sweeps visible cells - /// so hover-revealed chrome cannot strand on rows the pointer left - /// (row-index/id races during churn made per-transition repaints miss). - func enforcePointerHovering(_ hovering: Bool) { - guard isPointerHovering != hovering else { return } - isPointerHovering = hovering - // Full re-apply: hover gates more than the close button (the - // trailing badge and spinner hide while the close button shows), and - // re-deriving that subset here would drift from applyModel. - if let model { - applyModel(paintedModel(model)) - needsLayout = true - } else { - updateCloseVisibility() - } - } - private func configureCompactStatusGlyph(model: SidebarWorkspaceRowModel, palette: SidebarRowPalette) { let glyph = model.snapshot.compactStatusGlyph?.applyingUnread( model.unreadCount, @@ -1534,6 +1522,8 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { // nesting ("can't tell when a workspace is in a group"). let bgX = outerPad + (model.isGrouped ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) backgroundView.frame = NSRect(x: bgX, y: 0, width: max(0, width - outerPad - bgX), height: y) + hoverLayer.frame = backgroundView.bounds + hoverLayer.cornerRadius = backgroundView.layer?.cornerRadius ?? 6 railView.frame = NSRect(x: bgX + 4 - 1, y: 5, width: 3, height: max(0, y - 10)) railView.layer?.cornerRadius = 1.5 let indicatorBounds = NSRect(x: 0, y: 0, width: width, height: y) diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+Hover.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+Hover.swift new file mode 100644 index 000000000000..f83ef790521b --- /dev/null +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+Hover.swift @@ -0,0 +1,69 @@ +import AppKit + +/// Row hover state: the loaded-cell sweep, the pause while rows are off +/// screen or a drag runs, and the live Row Hover setting. +extension SidebarWorkspaceTableController { + /// A hidden docked pane keeps applying content, so its reveal shows + /// current rows, but its spinners, status pulses and hover pause. + func setRowsOnScreen(_ onScreen: Bool) { + guard rowsAreOnScreen != onScreen else { return } + rowsAreOnScreen = onScreen + if !onScreen { setHoveredRowId(nil) } + let animates = isPresentationActive && onScreen + containerView?.tableView.enumerateAvailableRowViews { rowView, _ in + (rowView.view(atColumn: 0) as? SidebarWorkspaceRowTableCellView)?.setPresentationActive(animates) + (rowView.view(atColumn: 0) as? SidebarGroupHeaderTableCellView)?.setPresentationActive(animates) + } + } + + /// True from the drag's first lift until its session ends. + var isHoverSuspendedForDrag: Bool { + isWorkspaceDragSourceActive || reorderLiftSession != nil + } + + /// Clears hover on every loaded row before a drag shows its lift, so the + /// lift snapshot never bakes a hover fill in and no row keeps one. + func suspendHoverForDrag() { + setHoveredRowId(nil) + enforceHoverOnVisibleCells() + } + + /// Re-derives hover from the live pointer once a drag has fully torn + /// down (drop apply, lift end, settle glide), covering every loaded row. + func rederiveHoverAfterDrag() { + recomputeHoveredRow() + enforceHoverOnVisibleCells() + } + + /// Authoritative pass over every loaded cell so hover chrome (row fill, + /// close button, header plus) cannot strand: per-transition repaints + /// resolve ids against a rows array that can mutate in the same tick + /// (content churn scrolling rows under a parked pointer), and a missed + /// repaint left multiple rows showing hover chrome at once. Loaded, not + /// just visible: prepared rows past the viewport scroll in without a + /// reconfigure, and a row view animating out (row -1) must drop hover. + func enforceHoverOnVisibleCells() { + guard let table = containerView?.tableView else { return } + table.enumerateAvailableRowViews { rowView, row in + let rowId = rows.indices.contains(row) ? rows[row].id : nil + let hovering = rowId != nil && hoveredRowId == rowId && contextMenuRowId != rowId + switch rowView.view(atColumn: 0) { + case let cell as SidebarGroupHeaderTableCellView: + cell.enforcePointerHovering(hovering) + case let cell as SidebarWorkspaceRowTableCellView: + cell.enforcePointerHovering(hovering) + default: + break + } + } + } + + /// Settings > Sidebar > Row Hover applies live: the sweep repaints every + /// loaded row's wash (same-hover enforcement repaints it). + func observeRowHoverSetting() { + guard rowHoverObservation == nil else { return } + rowHoverObservation = UserDefaults.standard.observe(\.sidebarRowHover, options: [.new]) { [weak self] _, _ in + Task { @MainActor [weak self] in self?.enforceHoverOnVisibleCells() } + } + } +} diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift index 8d26b046ca16..3267f50bb0e3 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift @@ -314,6 +314,8 @@ extension SidebarWorkspaceTableController { MainActor.assumeIsolated { guard let self, let layer, self.reorderLiftSession == nil else { return } layer.zPosition = 0 + // The block has landed under a still pointer. + self.rederiveHoverAfterDrag() } } layer.removeAnimation(forKey: "cmux.reorderShift") @@ -326,6 +328,10 @@ extension SidebarWorkspaceTableController { func installReorderLiftSnapshots(table: NSTableView, sourceRange: Range) { removeReorderLiftSnapshots() + suspendHoverForDrag() + // Hover just left the lifted rows (close button back to its badge); + // lay them out before they are snapshotted. + for row in sourceRange { table.rowView(atRow: row, makeIfNecessary: false)?.layoutSubtreeIfNeeded() } let scale = table.window?.backingScaleFactor ?? 2 guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) else { return } for row in sourceRange { diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift index f8b0c8780d4e..0ad5579f4d42 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLiftRebuild.swift @@ -73,6 +73,8 @@ extension SidebarWorkspaceTableController { /// workspace may be gone (closed mid-drag); then the lift simply ends. func rebuildReorderLift(from carry: ReorderLiftCarryOver) { endReorderLift(animated: false) + // The update may have swapped row views; none may come back hovered. + suspendHoverForDrag() updateReorderLift(windowPoint: carry.windowPoint, workspaceId: carry.workspaceId, carry: carry) } diff --git a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift index efd1ea215b54..ef62d3d0f47c 100644 --- a/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift +++ b/Sources/Sidebar/AppKitList/SidebarWorkspaceTableController.swift @@ -33,13 +33,14 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// unrelated invalidation — historically an app deactivate/reactivate /// (issue #9690). var onDeferredRowClickAwaitingApply: (() -> Void)? - private var hoveredRowId: SidebarWorkspaceRenderItemID? - private var contextMenuRowId: SidebarWorkspaceRenderItemID? + private(set) var hoveredRowId: SidebarWorkspaceRenderItemID? + private(set) var contextMenuRowId: SidebarWorkspaceRenderItemID? private var workspaceIds: [UUID] = [] private var selectedScrollTargetWorkspaceId: UUID? - private var isPresentationActive = true - /// See `setRowsOnScreen`. - private var rowsAreOnScreen = true + private(set) var isPresentationActive = true + /// See `setRowsOnScreen`. Row hover: `+Hover`. + var rowsAreOnScreen = true + var rowHoverObservation: NSKeyValueObservation? private var structuralUpdateDepth = 0 private var deferredPumpHeightRowIds: Set = [] private var deferredStructuralHeightRows = IndexSet() @@ -51,7 +52,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// promoted; caching this map keeps multi-row drag work linear and /// consistent across every dragged item. private var dragWorkspaceGroupAnchorIds: [UUID: UUID]? - private var isWorkspaceDragSourceActive = false + private(set) var isWorkspaceDragSourceActive = false // Keep the source table alive until AppKit delivers the terminal callback. // SwiftUI may remove the representable (fullscreen/display changes) while // the native drag still owns that table. @@ -217,6 +218,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS containerView = container let table = container.tableView + observeRowHoverSetting() table.workspaceController = self container.clipView.workspaceController = self table.dataSource = self @@ -1593,6 +1595,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS releaseRetainedWorkspaceDragContainerIfPossible() } isWorkspaceDragSourceActive = true + suspendHoverForDrag() SidebarReorderInteractionState.shared.setDragging(true, owner: self) workspaceDragSourceCompletionReceived = false if let sourceTableView { @@ -1760,6 +1763,9 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS clearPendingWorkspaceDragWriters() isWorkspaceDragSourceActive = false SidebarReorderInteractionState.shared.setDragging(false, owner: self) + // After this callback returns: the drop's apply and the lift's end + // run first, then hover follows the pointer again. + DispatchQueue.main.async { [weak self] in self?.rederiveHoverAfterDrag() } let sessionId = activeWorkspaceDragSessionId ?? pendingWorkspaceDragSessionId let capabilityValue = activeWorkspaceDragCapabilityValue ?? { guard let sessionId, @@ -2128,6 +2134,12 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS /// `windowPoint` is the location an event just delivered; recomputes /// without one read the live pointer. func recomputeHoveredRow(windowPoint: NSPoint? = nil) { + // No row hovers while a reorder drag runs: tracking events stop for + // the session, so any hover set now would strand when it ends. + if isHoverSuspendedForDrag { + setHoveredRowId(nil) + return + } guard contextMenuRowId == nil, rowsAreOnScreen, let table = containerView?.tableView else { return @@ -2337,20 +2349,7 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS return containerView.clipView.bounds.width } - /// A hidden docked pane keeps applying content, so its reveal shows - /// current rows, but its spinners, status pulses and hover pause. - func setRowsOnScreen(_ onScreen: Bool) { - guard rowsAreOnScreen != onScreen else { return } - rowsAreOnScreen = onScreen - if !onScreen { setHoveredRowId(nil) } - let animates = isPresentationActive && onScreen - containerView?.tableView.enumerateAvailableRowViews { rowView, _ in - (rowView.view(atColumn: 0) as? SidebarWorkspaceRowTableCellView)?.setPresentationActive(animates) - (rowView.view(atColumn: 0) as? SidebarGroupHeaderTableCellView)?.setPresentationActive(animates) - } - } - - private func setHoveredRowId(_ next: SidebarWorkspaceRenderItemID?) { + func setHoveredRowId(_ next: SidebarWorkspaceRenderItemID?) { guard hoveredRowId != next else { return } let previous = hoveredRowId hoveredRowId = next @@ -2467,29 +2466,6 @@ final class SidebarWorkspaceTableController: NSObject, NSTableViewDataSource, NS reconfigureVisibleRows(indexes) } - /// Authoritative pass over visible cells so hover-revealed chrome (close - /// button, header plus) cannot strand: per-transition repaints resolve - /// ids against a rows array that can mutate in the same tick (content - /// churn scrolling rows under a parked pointer), and a missed repaint - /// left multiple rows showing hover chrome at once. - private func enforceHoverOnVisibleCells() { - guard let table = containerView?.tableView else { return } - let visible = table.rows(in: table.visibleRect) - for row in visible.lowerBound..<(visible.lowerBound + visible.length) - where rows.indices.contains(row) { - let rowId = rows[row].id - let hovering = hoveredRowId == rowId && contextMenuRowId != rowId - switch table.view(atColumn: 0, row: row, makeIfNecessary: false) { - case let cell as SidebarGroupHeaderTableCellView: - cell.enforcePointerHovering(hovering) - case let cell as SidebarWorkspaceRowTableCellView: - cell.enforcePointerHovering(hovering) - default: - break - } - } - } - /// Row edits that keep cells (moves, inserts, removes) must refresh every /// loaded row view, not just the visible ones: NSTableView keeps prepared /// views above and below the viewport, and viewFor is not asked again diff --git a/Sources/Sidebar/SidebarGlassSelection.swift b/Sources/Sidebar/SidebarGlassSelection.swift index 25268125de0c..775fb378f138 100644 --- a/Sources/Sidebar/SidebarGlassSelection.swift +++ b/Sources/Sidebar/SidebarGlassSelection.swift @@ -4,7 +4,9 @@ import SwiftUI /// The sidebar's glass selection: a neutral translucent patch of the glass /// with a hairline edge, never the accent colour (Aside-style). Fill and edge /// strength can be tuned with the `sidebarGlassSelectionFillOpacity` and -/// `sidebarGlassSelectionEdgeOpacity` defaults. +/// `sidebarGlassSelectionEdgeOpacity` defaults. The hover wash on an +/// unselected row is the same patch, softer and without the edge +/// (`sidebarRowHoverFillOpacity`, `sidebarRowHoverFillOpacityLight`). enum SidebarGlassSelection { static func fill(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { SidebarAppearanceColorResolver().resolvedColor( @@ -24,6 +26,49 @@ enum SidebarGlassSelection { ) } + /// Fill for the row under the pointer. Separate dark and light keys, since + /// the same alpha reads much stronger as a black wash on light glass. + static func hoverFill(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { + let isDark = colorScheme == .dark + return SidebarAppearanceColorResolver().resolvedColor( + .labelColor, + for: colorScheme, + opacity: defaults.object(forKey: isDark ? "sidebarRowHoverFillOpacity" : "sidebarRowHoverFillOpacityLight") as? Double + ?? (isDark ? 0.05 : 0.035) + ) + } + + /// True while a reorder drag carries rows in the sidebar holding `view`. + /// Autoscroll moves rows under the pointer mid-drag and the controller + /// re-resolves hover then, but no row should light up until the button + /// is released. + @MainActor + static func isReorderDragRunning(around view: NSView) -> Bool { + guard NSEvent.pressedMouseButtons != 0 else { return false } + return sidebarTable(around: view)?.reorderPinnedRowsRect != nil + } + + /// A reorder lift hides the dragged rows' cells and unhides them when the + /// drag ends, possibly with the pointer far away and no exit event + /// delivered (tracking areas stop during a drag session). Re-resolves + /// hover from the live pointer once that teardown finishes, so it cannot + /// strand. Also covers the sidebar being shown again. + @MainActor + static func reresolveHoverAfterUnhide(of view: NSView, then repaint: @escaping @MainActor () -> Void) { + DispatchQueue.main.async { [weak view] in + guard let view, !view.isHiddenOrHasHiddenAncestor else { return } + sidebarTable(around: view)?.workspaceController?.recomputeHoveredRow() + repaint() + } + } + + @MainActor + private static func sidebarTable(around view: NSView) -> SidebarWorkspaceTableViewImpl? { + var current = view.superview + while let candidate = current, !(candidate is NSTableView) { current = candidate.superview } + return current as? SidebarWorkspaceTableViewImpl + } + /// Text on a translucent selection. The patch is a tint on the pane, not /// a surface, so text keeps the pane's own label colour, which the patch's /// hue encodes (white patch on dark glass, black patch on light). Nil for diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 8e4a002be52c..a079b5b99997 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3344,6 +3344,7 @@ B804A0350000000000000035 /* SidebarRowChecklistTransparentButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0350000000000000035 /* SidebarRowChecklistTransparentButton.swift */; }; B804A0380000000000000038 /* SidebarRowContentContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0380000000000000038 /* SidebarRowContentContainerView.swift */; }; AABBCC00000000000000020A /* SidebarRowDragGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC000000000000000209 /* SidebarRowDragGate.swift */; }; + 65A7759ACF1FD239AE693856 /* SidebarRowHover.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC3A3F26569F35C84E312C0 /* SidebarRowHover.swift */; }; B804A03794950000000000B1 /* SidebarRowInlineRenameSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B03794950000000000B1 /* SidebarRowInlineRenameSession.swift */; }; 3D0EDA5CB6EA473DB4DB9464 /* SidebarRowPaletteTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59BA833A044542BD9A96F3E1 /* SidebarRowPaletteTests.swift */; }; B804A0370000000000000037 /* SidebarRowPressedDim.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0370000000000000037 /* SidebarRowPressedDim.swift */; }; @@ -3456,6 +3457,7 @@ B804A0020000000000000002 /* SidebarWorkspaceTableCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */; }; B804A00B000000000000000B /* SidebarWorkspaceTableClipView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */; }; B804A0030000000000000003 /* SidebarWorkspaceTableContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */; }; + 178C3E6D72C556D956E85AF7 /* SidebarWorkspaceTableController+Hover.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4900A4BB76868E72446D737 /* SidebarWorkspaceTableController+Hover.swift */; }; 16E6608F4FECE7B4F9FCE09A /* SidebarWorkspaceTableController+ReorderDragGhost.swift in Sources */ = {isa = PBXBuildFile; fileRef = 944B5844794AC86FE82F96AE /* SidebarWorkspaceTableController+ReorderDragGhost.swift */; }; 4F8E29036B2C8FEC8D54E33D /* SidebarWorkspaceTableController+ReorderDrop.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80734AD866B727FCEA3F6F3 /* SidebarWorkspaceTableController+ReorderDrop.swift */; }; 1B6F8636FAA4AF557CAFCE67 /* SidebarWorkspaceTableController+ReorderLift.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */; }; @@ -7812,6 +7814,7 @@ B804B0350000000000000035 /* SidebarRowChecklistTransparentButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowChecklistTransparentButton.swift; sourceTree = ""; }; B804B0380000000000000038 /* SidebarRowContentContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowContentContainerView.swift; sourceTree = ""; }; AABBCC000000000000000209 /* SidebarRowDragGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarRowDragGate.swift; sourceTree = ""; }; + 2CC3A3F26569F35C84E312C0 /* SidebarRowHover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowHover.swift; sourceTree = ""; }; B804B03794950000000000B1 /* SidebarRowInlineRenameSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowInlineRenameSession.swift; sourceTree = ""; }; 59BA833A044542BD9A96F3E1 /* SidebarRowPaletteTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarRowPaletteTests.swift"; sourceTree = ""; }; B804B0370000000000000037 /* SidebarRowPressedDim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarRowPressedDim.swift; sourceTree = ""; }; @@ -7923,6 +7926,7 @@ B804B0020000000000000002 /* SidebarWorkspaceTableCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableCellView.swift; sourceTree = ""; }; B804B00B000000000000000B /* SidebarWorkspaceTableClipView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableClipView.swift; sourceTree = ""; }; B804B0030000000000000003 /* SidebarWorkspaceTableContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/SidebarWorkspaceTableContainerView.swift; sourceTree = ""; }; + F4900A4BB76868E72446D737 /* SidebarWorkspaceTableController+Hover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+Hover.swift"; sourceTree = ""; }; 944B5844794AC86FE82F96AE /* SidebarWorkspaceTableController+ReorderDragGhost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDragGhost.swift"; sourceTree = ""; }; E80734AD866B727FCEA3F6F3 /* SidebarWorkspaceTableController+ReorderDrop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderDrop.swift"; sourceTree = ""; }; 8B85730BDFE4C5F97476FDCE /* SidebarWorkspaceTableController+ReorderLift.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sidebar/AppKitList/SidebarWorkspaceTableController+ReorderLift.swift"; sourceTree = ""; }; @@ -12098,6 +12102,8 @@ A16A1380FA28417F95C922EA /* WorkspaceGroupNewWorkspaceTarget.swift */, 11E0CF443B31591C215D6955 /* TextViewUndoRegistrationLifetime.swift */, 4252144220782379B2B347BE /* SettingsHostingView.swift */, + 2CC3A3F26569F35C84E312C0 /* SidebarRowHover.swift */, + F4900A4BB76868E72446D737 /* SidebarWorkspaceTableController+Hover.swift */, ); path = Sources; sourceTree = ""; @@ -16242,6 +16248,7 @@ B804A0350000000000000035 /* SidebarRowChecklistTransparentButton.swift in Sources */, B804A0380000000000000038 /* SidebarRowContentContainerView.swift in Sources */, AABBCC00000000000000020A /* SidebarRowDragGate.swift in Sources */, + 65A7759ACF1FD239AE693856 /* SidebarRowHover.swift in Sources */, B804A03794950000000000B1 /* SidebarRowInlineRenameSession.swift in Sources */, B804A0370000000000000037 /* SidebarRowPressedDim.swift in Sources */, C12791000000000000000003 /* SidebarRowSnapshotCache.swift in Sources */, @@ -16325,6 +16332,7 @@ B804A0020000000000000002 /* SidebarWorkspaceTableCellView.swift in Sources */, B804A00B000000000000000B /* SidebarWorkspaceTableClipView.swift in Sources */, B804A0030000000000000003 /* SidebarWorkspaceTableContainerView.swift in Sources */, + 178C3E6D72C556D956E85AF7 /* SidebarWorkspaceTableController+Hover.swift in Sources */, 16E6608F4FECE7B4F9FCE09A /* SidebarWorkspaceTableController+ReorderDragGhost.swift in Sources */, 4F8E29036B2C8FEC8D54E33D /* SidebarWorkspaceTableController+ReorderDrop.swift in Sources */, 1B6F8636FAA4AF557CAFCE67 /* SidebarWorkspaceTableController+ReorderLift.swift in Sources */, From f78101449a44200492073ec89c0ef5f0bd0b0984 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 22:41:41 +1300 Subject: [PATCH 56/69] settings: Row Hover toggle in Settings > Sidebar writes sidebarRowHover (UI-only, on by default) from a private row in the section file, so the package interface is unchanged. search entry next to the other sidebar appearance rows. strings in the app catalog in all 19 locales --- ...tedSettingEntry+SidebarCustomization.swift | 1 + .../SidebarSection+Customization.swift | 24 ++ Resources/Localizable.xcstrings | 238 ++++++++++++++++++ 3 files changed, 263 insertions(+) diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+SidebarCustomization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+SidebarCustomization.swift index 4d646ccdd10b..fe13f3bf9bc8 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+SidebarCustomization.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Navigation/CuratedSettingEntry+SidebarCustomization.swift @@ -20,6 +20,7 @@ extension Array where Element == CuratedSettingEntry { .init(section: .sidebarAppearance, id: "sidebar-liquid-glass", title: String(localized: "settings.sidebar.liquidGlass", defaultValue: "Liquid Glass"), synonyms: "sidebarAppearance.compositorGlass sidebarCompositorGlass liquid glass blur compositor frosted transparency see-through backdrop"), .init(section: .sidebarAppearance, id: "sidebar-tint-color", title: String(localized: "settings.sidebar.tintColor", defaultValue: "Tint Color"), synonyms: "sidebarAppearance.tintColor sidebarTintHex tint colour color glass hex grey gray"), .init(section: .sidebarAppearance, id: "sidebar-glass-blur", title: String(localized: "settings.sidebar.blurOpacity", defaultValue: "Blur Opacity"), synonyms: "blur opacity sidebarAppearance.glassBlurRadius sidebarGlassBlurRadius blur radius glass frosted clear see-through transparency backdrop compositor"), + .init(section: .sidebarAppearance, id: "sidebar-row-hover", title: String(localized: "settings.sidebar.rowHover", defaultValue: "Row Hover"), synonyms: "sidebarRowHover row hover highlight pointer mouse wash workspace list"), .init(section: .sidebarAppearance, id: "sidebar-peek-reveal", title: String(localized: "settings.sidebar.peekReveal", defaultValue: "Sidebar Peek Reveal Speed"), synonyms: "sidebar.peekReveal peek hover reveal edge dwell instant quick relaxed sensitivity floating sidebar"), .init(section: .sidebarAppearance, id: "sidebar-peek-disabled", title: String(localized: "settings.sidebar.peekDisabled", defaultValue: "Disable Sidebar Peek"), synonyms: "sidebar.peekDisabled peek hover reveal disable never auto hide floating sidebar edge"), .init(section: .sidebarAppearance, id: "sidebar-row-density", title: String(localized: "settings.sidebar.rowDensity", defaultValue: "Row Density"), synonyms: "sidebar.rowDensity density compact cozy spacious row height padding workspace list"), diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift index 75ef4a593877..7a5d29b695cc 100644 --- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift +++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Sections/SidebarSection+Customization.swift @@ -151,6 +151,9 @@ extension SidebarSection { } SettingsCardDivider() + SidebarRowHoverSettingRow() + SettingsCardDivider() + SettingsCardRow( configurationReview: .settingsOnly, String(localized: "settings.sidebar.peekReveal", defaultValue: "Sidebar Peek Reveal Speed"), @@ -267,3 +270,24 @@ extension SidebarSection { } } } + +/// Settings > Sidebar > Row Hover (`sidebarRowHover`, UI-only, on by +/// default). Its own view so the key stays private to this file; the sidebar +/// rows read it from UserDefaults and follow it live. +private struct SidebarRowHoverSettingRow: View { + @AppStorage("sidebarRowHover") private var rowHover = true + + var body: some View { + SettingsCardRow( + configurationReview: .settingsOnly, + String(localized: "settings.sidebar.rowHover", defaultValue: "Row Hover"), + subtitle: String(localized: "settings.sidebar.rowHover.subtitle", defaultValue: "Highlight rows under the pointer.") + ) { + Toggle("", isOn: $rowHover) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + .settingsSearchAnchors(["setting:sidebarAppearance:sidebar-row-hover"]) + } +} diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 86d79277b759..42ac20c41c9a 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -368627,6 +368627,244 @@ } } }, + "settings.sidebar.rowHover": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمييز الصفوف" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Isticanje redova" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fremhæv rækker" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeilen-Hover" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Row Hover" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resaltar filas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Survol des lignes" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Evidenzia righe" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "行のホバー" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "행 호버" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Uthev rader" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podświetlanie wierszy" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Destacar linhas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсветка строк" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไฮไลต์แถว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Satır vurgulama" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підсвічування рядків" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "行悬停高亮" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "列懸停醒目提示" + } + } + } + }, + "settings.sidebar.rowHover.subtitle": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمييز الصفوف الموجودة تحت المؤشر." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Istakni redove ispod pokazivača." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Fremhæv rækker under markøren." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zeilen unter dem Zeiger hervorheben." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Highlight rows under the pointer." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Resalta las filas bajo el puntero." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Mettre en évidence les lignes sous le pointeur." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Evidenzia le righe sotto il puntatore." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ポインタの下にある行をハイライトします。" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "포인터 아래의 행을 강조 표시합니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Uthev rader under pekeren." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Podświetlaj wiersze pod wskaźnikiem." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Destaca as linhas sob o ponteiro." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Подсвечивать строки под указателем." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไฮไลต์แถวที่อยู่ใต้ตัวชี้" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İşaretçinin altındaki satırları vurgula." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Підсвічувати рядки під вказівником." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "高亮显示指针下方的行。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "醒目提示指標下方的列。" + } + } + } + }, "settings.sidebar.tintColor": { "extractionState": "manual", "localizations": { From d17ffd015bb7a3bdac3504f538b5a76666e9f2f9 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 23:22:35 +1300 Subject: [PATCH 57/69] tests: the row hover setting's search anchor is an explicit row anchor --- .../CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift index 9b1c2a8f6d91..87a6e23d0629 100644 --- a/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift +++ b/Packages/macOS/CmuxSettingsUI/Tests/CmuxSettingsUITests/SettingsRowAnchorResolutionTests.swift @@ -219,6 +219,7 @@ struct SettingsRowAnchorResolutionTests { "setting:workspaceColors:palette", "setting:sidebarAppearance:sidebar-liquid-glass", "setting:sidebarAppearance:sidebar-glass-blur", + "setting:sidebarAppearance:sidebar-row-hover", "setting:sidebarAppearance:sidebar-peek-reveal", "setting:sidebarAppearance:sidebar-peek-disabled", "setting:sidebarAppearance:sidebar-row-density", From f29a9926e02ea6542b87720b481201d09fd2b736 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 23:34:24 +1300 Subject: [PATCH 58/69] sidebar light mode: match aside's light sidebar on the stock tint only in light mode, only while the stock tint is in place (a chosen tint, a light-mode tint, a custom selection colour or matching the terminal opt out); dark is unchanged - ground: #D1D4D5 at 88%, a cool mid-light gray (~#CACDCE over the desktop) - selected row and active group header: a solid white pill with a black 25% hairline and a soft 1 pt drop shadow; text on it goes near-black through the existing contrast path - hover: an edgeless white wash at 62% between the ground and the pill, driven by sidebarRowHoverFillOpacityLight (a black wash, capped at 20%, outside this look) - unselected row titles: black at 69% (~#414141 on the ground) - the glass selection's opacity tuning keys only shape the translucent patch, which this look does not use --- .../WindowChromeSidebarTintDefaults.swift | 8 ++-- .../WindowAppearanceResolverTests.swift | 4 +- .../Cells/SidebarGroupHeaderRowView.swift | 1 + .../Cells/SidebarWorkspaceRowCellView.swift | 1 + .../SidebarWorkspaceRowSupportViews.swift | 2 +- Sources/Sidebar/SidebarGlassSelection.swift | 44 ++++++++++++++++--- 6 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift index 9844f9b7d84e..41ffc4616dd6 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Sources/CmuxAppKitSupportUI/WindowChrome/Sidebar/WindowChromeSidebarTintDefaults.swift @@ -13,10 +13,10 @@ public struct WindowChromeSidebarTintDefaults: Sendable { /// What light mode uses while the stock tint is in place. The stock /// #393939 is a dark glass and light mode turns the sidebar text dark, - /// so it gets the mirror: a light glass at the same strength. (Main's - /// light #000000 at 18% assumed a light material under it; the - /// compositor ground is the raw desktop blur, often dark.) - public static let light = WindowChromeSidebarTintDefaults(hex: "#F2F2F2", opacity: 0.72) + /// so it gets a light glass: Aside's cool mid-light gray ground (~#CACDCE + /// over a typical wallpaper). Main's light #000000 at 18% assumed a light + /// material under it; the compositor ground is the raw desktop blur. + public static let light = WindowChromeSidebarTintDefaults(hex: "#D1D4D5", opacity: 0.88) /// Creates sidebar tint defaults. public init( diff --git a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift index f1cb07d171a3..d4023bba0996 100644 --- a/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift +++ b/Packages/macOS/CmuxAppKitSupportUI/Tests/CmuxAppKitSupportUITests/WindowChrome/Appearance/WindowAppearanceResolverTests.swift @@ -253,9 +253,9 @@ import Testing #expect(abs(actual.1 - alpha) < 0.001) } expect(tint(.dark), "#393939", 0.72) - expect(tint(.light), "#F2F2F2", 0.72) + expect(tint(.light), "#D1D4D5", 0.88) expect(tint(.light, hex: "#FF0000"), "#FF0000", 0.72) - expect(tint(.light, opacity: 0.4), "#F2F2F2", 0.4) + expect(tint(.light, opacity: 0.4), "#D1D4D5", 0.4) } private func makeSettings( diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index a009e4435be6..fff67631f0de 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -377,6 +377,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { private func headerBackgroundColor(for model: SidebarGroupHeaderRowModel) -> NSColor { if model.isAnchorActive { let colorScheme: ColorScheme = model.colorSchemeIsDark ? .dark : .light + if SidebarGlassSelection.usesStockLightLook(colorScheme) { return .white } return SidebarAppearanceColorResolver().resolvedColor( .labelColor, for: colorScheme, diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 8bdc43b495b2..5c3ab94d2b5b 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -245,6 +245,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } else { backgroundView.layer?.borderWidth = 0 } + SidebarGlassSelection.applySelectionShadow(to: backgroundView, model.isActive && settings.selectionColorHex == nil && SidebarGlassSelection.usesStockLightLook(palette.colorScheme)) updateHoverFill(colorScheme: palette.colorScheme) } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift index d229bf711f77..82fcd35e6b71 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift @@ -46,7 +46,7 @@ struct SidebarRowPalette { } var primaryText: NSColor { - model.isActive ? selectedForeground(1.0) : semantic(.labelColor) + model.isActive ? selectedForeground(1.0) : semantic(.labelColor, opacity: SidebarGlassSelection.usesStockLightLook(colorScheme) ? 0.69 : nil) } func secondary( diff --git a/Sources/Sidebar/SidebarGlassSelection.swift b/Sources/Sidebar/SidebarGlassSelection.swift index 775fb378f138..2df777189f75 100644 --- a/Sources/Sidebar/SidebarGlassSelection.swift +++ b/Sources/Sidebar/SidebarGlassSelection.swift @@ -8,8 +8,22 @@ import SwiftUI /// unselected row is the same patch, softer and without the edge /// (`sidebarRowHoverFillOpacity`, `sidebarRowHoverFillOpacityLight`). enum SidebarGlassSelection { + /// Light mode on the stock tint takes Aside's light look: a solid white + /// pill with a dark hairline, a faint white hover wash, and dark gray row + /// titles (black at 69%, ~#414141 on the ground) instead of near-black. + /// It owns its values: the opacity tuning keys below shape the + /// translucent patch, which it does not use. + /// A chosen tint, a light-mode tint or matching the terminal opts out. + static func usesStockLightLook(_ colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> Bool { + guard colorScheme == .light, defaults.string(forKey: "sidebarTintHexLight") == nil, + !(defaults.object(forKey: "sidebarMatchTerminalBackground") as? Bool ?? false) else { return false } + let stock = SidebarTintDefaults().hex + return (defaults.string(forKey: "sidebarTintHex") ?? stock).caseInsensitiveCompare(stock) == .orderedSame + } + static func fill(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { - SidebarAppearanceColorResolver().resolvedColor( + if usesStockLightLook(colorScheme, defaults: defaults) { return .white } + return SidebarAppearanceColorResolver().resolvedColor( .labelColor, for: colorScheme, opacity: defaults.object(forKey: "sidebarGlassSelectionFillOpacity") as? Double @@ -18,7 +32,8 @@ enum SidebarGlassSelection { } static func edge(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { - SidebarAppearanceColorResolver().resolvedColor( + if usesStockLightLook(colorScheme, defaults: defaults) { return NSColor.black.withAlphaComponent(0.25) } + return SidebarAppearanceColorResolver().resolvedColor( .labelColor, for: colorScheme, opacity: defaults.object(forKey: "sidebarGlassSelectionEdgeOpacity") as? Double @@ -27,17 +42,36 @@ enum SidebarGlassSelection { } /// Fill for the row under the pointer. Separate dark and light keys, since - /// the same alpha reads much stronger as a black wash on light glass. + /// the same alpha reads much stronger as a black wash on light glass. In + /// the stock light look the light key is the strength of a white wash + /// between the gray ground and the white pill (default 62%); elsewhere it + /// is a black wash, capped so a white-wash value cannot turn heavy. static func hoverFill(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { let isDark = colorScheme == .dark + let stored = defaults.object(forKey: isDark ? "sidebarRowHoverFillOpacity" : "sidebarRowHoverFillOpacityLight") as? Double + if usesStockLightLook(colorScheme, defaults: defaults) { + return NSColor.white.withAlphaComponent(stored ?? 0.62) + } return SidebarAppearanceColorResolver().resolvedColor( .labelColor, for: colorScheme, - opacity: defaults.object(forKey: isDark ? "sidebarRowHoverFillOpacity" : "sidebarRowHoverFillOpacityLight") as? Double - ?? (isDark ? 0.05 : 0.035) + opacity: min(stored ?? (isDark ? 0.05 : 0.035), 0.2) ) } + /// The light look's white pill sits on a very soft 1 pt drop shadow. + /// Set through the view: AppKit owns a layer-backed view's layer shadow. + @MainActor + static func applySelectionShadow(to view: NSView, _ on: Bool) { + guard (view.shadow != nil) != on else { return } + guard on else { view.shadow = nil; return } + let shadow = NSShadow() + shadow.shadowColor = NSColor.black.withAlphaComponent(0.14) + shadow.shadowOffset = NSSize(width: 0, height: -1) + shadow.shadowBlurRadius = 1.5 + view.shadow = shadow + } + /// True while a reorder drag carries rows in the sidebar holding `view`. /// Autoscroll moves rows under the pointer mid-drag and the controller /// re-resolves hover then, but no row should light up until the button From 74175cda79ca1a3e96357b2f7a24a5fbe901df74 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 23:38:21 +1300 Subject: [PATCH 59/69] sidebar light mode: the selected pill is white at 90% over the ground, not solid lands around 250 on the gray ground; the hairline, the soft shadow and the 0.62 hover wash below it are unchanged. the active group header uses the same fill --- .../Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift | 2 +- Sources/Sidebar/SidebarGlassSelection.swift | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index fff67631f0de..df42be8da81b 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -377,7 +377,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { private func headerBackgroundColor(for model: SidebarGroupHeaderRowModel) -> NSColor { if model.isAnchorActive { let colorScheme: ColorScheme = model.colorSchemeIsDark ? .dark : .light - if SidebarGlassSelection.usesStockLightLook(colorScheme) { return .white } + if SidebarGlassSelection.usesStockLightLook(colorScheme) { return SidebarGlassSelection.fill(for: colorScheme) } return SidebarAppearanceColorResolver().resolvedColor( .labelColor, for: colorScheme, diff --git a/Sources/Sidebar/SidebarGlassSelection.swift b/Sources/Sidebar/SidebarGlassSelection.swift index 2df777189f75..5ece2fa88d75 100644 --- a/Sources/Sidebar/SidebarGlassSelection.swift +++ b/Sources/Sidebar/SidebarGlassSelection.swift @@ -8,7 +8,7 @@ import SwiftUI /// unselected row is the same patch, softer and without the edge /// (`sidebarRowHoverFillOpacity`, `sidebarRowHoverFillOpacityLight`). enum SidebarGlassSelection { - /// Light mode on the stock tint takes Aside's light look: a solid white + /// Light mode on the stock tint takes Aside's light look: a near-white /// pill with a dark hairline, a faint white hover wash, and dark gray row /// titles (black at 69%, ~#414141 on the ground) instead of near-black. /// It owns its values: the opacity tuning keys below shape the @@ -22,7 +22,8 @@ enum SidebarGlassSelection { } static func fill(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { - if usesStockLightLook(colorScheme, defaults: defaults) { return .white } + // White at 90% over the gray ground (~250): a pill, not a glare. + if usesStockLightLook(colorScheme, defaults: defaults) { return NSColor.white.withAlphaComponent(0.9) } return SidebarAppearanceColorResolver().resolvedColor( .labelColor, for: colorScheme, From 0f1fa9b440f65eefdfa0216de7e5a430caf52ed7 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 23:38:21 +1300 Subject: [PATCH 60/69] tests: compute the row animation checks before #expect #expect decomposes allSatisfy(\.isPresentationActive) and types the key path as a throwing function, which failed the test target's compile --- cmuxTests/SidebarHiddenPresentationTests.swift | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/cmuxTests/SidebarHiddenPresentationTests.swift b/cmuxTests/SidebarHiddenPresentationTests.swift index 658ba8684c54..961c12792873 100644 --- a/cmuxTests/SidebarHiddenPresentationTests.swift +++ b/cmuxTests/SidebarHiddenPresentationTests.swift @@ -360,10 +360,8 @@ struct SidebarHiddenPresentationTests { ) let hiddenRowCells = descendants(of: SidebarWorkspaceRowTableCellView.self, in: initialContainer) #expect(!hiddenRowCells.isEmpty) - #expect( - hiddenRowCells.allSatisfy { !$0.isPresentationActive }, - "While hidden, row spinners and status pulses must not run." - ) + let hiddenRowsAnimate = hiddenRowCells.contains { $0.isPresentationActive } + #expect(!hiddenRowsAnimate, "While hidden, row spinners and status pulses must not run.") var cloudChangeIterator = focusedWorkspace.cloudBindingState.changes().makeAsyncIterator() _ = await cloudChangeIterator.next() focusedWorkspace.cloudVMBinding = WorkspaceCloudVMBinding(vmID: "vivid-newt", isBase: true) @@ -425,10 +423,9 @@ struct SidebarHiddenPresentationTests { initialContainer.tableView.numberOfRows > initialRowCount, "Reopening must reconcile the retained table from the current workspace model." ) - #expect( - descendants(of: SidebarWorkspaceRowTableCellView.self, in: initialContainer).allSatisfy(\.isPresentationActive), - "Revealing resumes row spinners and status pulses." - ) + let revealedRowsAnimate = descendants(of: SidebarWorkspaceRowTableCellView.self, in: initialContainer) + .allSatisfy { $0.isPresentationActive } + #expect(revealedRowsAnimate, "Revealing resumes row spinners and status pulses.") #expect( revealRowInputProjections <= tabManager.tabs.count, """ From 0dd9382029b53bb46a1e49444274fb3044c05622 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 23:41:43 +1300 Subject: [PATCH 61/69] sidebar light mode: halve the selected pill's drop shadow black at 7% (was 14%), a barely-there lift; tunable through sidebarSelectionShadowOpacityLight. the hairline is unchanged --- Sources/Sidebar/SidebarGlassSelection.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/Sidebar/SidebarGlassSelection.swift b/Sources/Sidebar/SidebarGlassSelection.swift index 5ece2fa88d75..7e594ffe7037 100644 --- a/Sources/Sidebar/SidebarGlassSelection.swift +++ b/Sources/Sidebar/SidebarGlassSelection.swift @@ -60,14 +60,16 @@ enum SidebarGlassSelection { ) } - /// The light look's white pill sits on a very soft 1 pt drop shadow. - /// Set through the view: AppKit owns a layer-backed view's layer shadow. + /// The light look's pill sits on a barely-there 1 pt drop shadow + /// (`sidebarSelectionShadowOpacityLight`, default 7%). Set through the + /// view: AppKit owns a layer-backed view's layer shadow. @MainActor - static func applySelectionShadow(to view: NSView, _ on: Bool) { - guard (view.shadow != nil) != on else { return } - guard on else { view.shadow = nil; return } + static func applySelectionShadow(to view: NSView, _ on: Bool, defaults: UserDefaults = .standard) { + let opacity = on ? (defaults.object(forKey: "sidebarSelectionShadowOpacityLight") as? Double ?? 0.07) : 0 + guard abs((view.shadow?.shadowColor?.alphaComponent ?? 0) - opacity) > 0.001 else { return } + guard opacity > 0 else { view.shadow = nil; return } let shadow = NSShadow() - shadow.shadowColor = NSColor.black.withAlphaComponent(0.14) + shadow.shadowColor = NSColor.black.withAlphaComponent(opacity) shadow.shadowOffset = NSSize(width: 0, height: -1) shadow.shadowBlurRadius = 1.5 view.shadow = shadow From 69b4a75c58840e069c402137e757d48eaf677c6e Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 23:47:15 +1300 Subject: [PATCH 62/69] sidebar light mode: a pressed or dragged group header takes the light pill showOptimisticAnchorActive (press feedback and the active look while dragging a group) painted label colour at 8% directly, a gray patch in the light look; it now uses the header's own active fill --- .../AppKitList/Cells/SidebarGroupHeaderRowView.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index df42be8da81b..62b2c4d083c1 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -324,7 +324,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { CATransaction.begin() CATransaction.setDisableActions(true) backgroundView.layer?.cornerRadius = 4 - backgroundView.layer?.backgroundColor = labelColor.withAlphaComponent(0.08).cgColor + backgroundView.layer?.backgroundColor = headerBackgroundColor(for: model, forcingActive: true).cgColor applySelectionEdge(model.anchorActiveEdgeColor) CATransaction.commit() nameField.textColor = labelColor @@ -374,8 +374,8 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { applyModel(model) } - private func headerBackgroundColor(for model: SidebarGroupHeaderRowModel) -> NSColor { - if model.isAnchorActive { + private func headerBackgroundColor(for model: SidebarGroupHeaderRowModel, forcingActive: Bool = false) -> NSColor { + if model.isAnchorActive || forcingActive { let colorScheme: ColorScheme = model.colorSchemeIsDark ? .dark : .light if SidebarGlassSelection.usesStockLightLook(colorScheme) { return SidebarGlassSelection.fill(for: colorScheme) } return SidebarAppearanceColorResolver().resolvedColor( From de2277dd396e73ff23914f551bce429f01540ee4 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Thu, 8 Oct 2026 23:52:18 +1300 Subject: [PATCH 63/69] sidebar light mode: glassy white hover on rows and group headers layers only, no blur: a white fill a touch brighter at the top (48% mid, 10% spread) and a 0.5 pt white inner rim at 55%, like the selection's glass edge; no shadow. tunable through sidebarRowHoverFillOpacityLight, sidebarRowHoverGradientLight and sidebarRowHoverRimLight. the hover layers are gradient layers; dark keeps its flat wash --- .../Cells/SidebarGroupHeaderRowView.swift | 2 +- .../AppKitList/Cells/SidebarRowHover.swift | 48 ++++++++++++++----- .../Cells/SidebarWorkspaceRowCellView.swift | 2 +- 3 files changed, 39 insertions(+), 13 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 62b2c4d083c1..7b83ac9daf48 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -15,7 +15,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { /// Selection fill and edge layer. Internal so tests read its paint directly. let backgroundView = NSView() /// Row hover wash; see `SidebarRowHover.swift`. - let rowHoverLayer = CALayer() + let rowHoverLayer = CAGradientLayer() private let pinImageView = NSImageView() private let chevronButton = SidebarHeaderGlyphButton() private let iconImageView = NSImageView() diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift b/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift index fd32b973e8a5..bb5e1936d5f5 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift @@ -31,20 +31,18 @@ extension SidebarWorkspaceRowTableCellView { /// shows none. Only touches the wash layer, so it is safe on every /// pointer move. func updateHoverFill(colorScheme: ColorScheme? = nil) { - var color: CGColor? + var scheme: ColorScheme? if isPointerHovering, let model, SidebarGlassSelection.isRowHoverEnabled(), !SidebarGlassSelection.isReorderDragRunning(around: self), (backgroundView.layer?.backgroundColor?.alpha ?? 0) == 0, (backgroundView.layer?.borderWidth ?? 0) == 0 { let painted = paintedModel(model) if !painted.isActive, !painted.isMultiSelected { - color = SidebarGlassSelection.hoverFill( - for: colorScheme ?? palette(painted).colorScheme - ).cgColor + scheme = colorScheme ?? palette(painted).colorScheme } } - if let color { hoverLayer.backgroundColor = color } - if hoverLayer.isHidden != (color == nil) { hoverLayer.isHidden = color == nil } + if let scheme { SidebarGlassSelection.paintHover(hoverLayer, for: scheme) } + if hoverLayer.isHidden != (scheme == nil) { hoverLayer.isHidden = scheme == nil } } override func viewDidUnhide() { @@ -66,17 +64,17 @@ extension SidebarGroupHeaderTableCellView { /// Only touches the wash layer, so it is safe on every pointer move. func updateRowHoverFill() { - var color: CGColor? + var scheme: ColorScheme? if isPointerHovering, let model, !model.isAnchorActive, !model.isMultiSelected, SidebarGlassSelection.isRowHoverEnabled(), (backgroundView.layer?.backgroundColor?.alpha ?? 0) == 0, (backgroundView.layer?.borderWidth ?? 0) == 0, !SidebarGlassSelection.isReorderDragRunning(around: self) { - color = SidebarGlassSelection.hoverFill(for: model.colorSchemeIsDark ? .dark : .light).cgColor + scheme = model.colorSchemeIsDark ? .dark : .light } - if let color { rowHoverLayer.backgroundColor = color } + if let scheme { SidebarGlassSelection.paintHover(rowHoverLayer, for: scheme) } rowHoverLayer.cornerRadius = backgroundView.layer?.cornerRadius ?? 4 - if rowHoverLayer.isHidden != (color == nil) { rowHoverLayer.isHidden = color == nil } + if rowHoverLayer.isHidden != (scheme == nil) { rowHoverLayer.isHidden = scheme == nil } } override func viewDidUnhide() { @@ -86,6 +84,33 @@ extension SidebarGroupHeaderTableCellView { } extension SidebarGlassSelection { + /// Paints a row's hover wash. The stock light look gets a glass wash, + /// layers only: a white fill a touch brighter at the top and a 0.5 pt + /// white inner rim like the selection's glass edge. Tunable through + /// `sidebarRowHoverFillOpacityLight` (mid fill, default 48%), + /// `sidebarRowHoverGradientLight` (top-to-bottom spread, default 10%) and + /// `sidebarRowHoverRimLight` (rim, default 55%). Elsewhere a flat fill. + @MainActor + static func paintHover(_ layer: CAGradientLayer, for colorScheme: ColorScheme, defaults: UserDefaults = .standard) { + guard usesStockLightLook(colorScheme, defaults: defaults) else { + layer.colors = nil + layer.borderWidth = 0 + layer.backgroundColor = hoverFill(for: colorScheme, defaults: defaults).cgColor + return + } + func value(_ key: String, _ fallback: Double) -> CGFloat { CGFloat(defaults.object(forKey: key) as? Double ?? fallback) } + let mid = value("sidebarRowHoverFillOpacityLight", 0.48) + let spread = value("sidebarRowHoverGradientLight", 0.1) + layer.backgroundColor = nil + // Unflipped layer space: y = 1 is the top. + layer.startPoint = CGPoint(x: 0.5, y: 1) + layer.endPoint = CGPoint(x: 0.5, y: 0) + layer.colors = [NSColor.white.withAlphaComponent(mid + spread / 2).cgColor, + NSColor.white.withAlphaComponent(max(0, mid - spread / 2)).cgColor] + layer.borderWidth = 0.5 + layer.borderColor = NSColor.white.withAlphaComponent(value("sidebarRowHoverRimLight", 0.55)).cgColor + } + /// Settings > Sidebar > Row Hover. UI-only, on unless turned off. static let rowHoverKey = "sidebarRowHover" @@ -103,7 +128,8 @@ extension SidebarGlassSelection { // Hover snaps on and off like the selection; no implicit fades. layer.actions = [ "backgroundColor": NSNull(), "hidden": NSNull(), "bounds": NSNull(), - "position": NSNull(), "cornerRadius": NSNull(), + "position": NSNull(), "cornerRadius": NSNull(), "colors": NSNull(), + "borderColor": NSNull(), "borderWidth": NSNull(), ] fillView.layer?.addSublayer(layer) } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 5c3ab94d2b5b..7fec118cace1 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -17,7 +17,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { // Chrome let backgroundView = NSView() /// Row hover wash; see `SidebarRowHover.swift`. - let hoverLayer = CALayer() + let hoverLayer = CAGradientLayer() private let railView = NSView() private let topDropIndicator = SidebarReorderIndicatorView() private let bottomDropIndicator = SidebarReorderIndicatorView() From b7b23f9a26d0ec41ccde648010f9b24bd82f6dfc Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 00:39:09 +1300 Subject: [PATCH 64/69] sidebar focus boundary: measure the responder within its bounds since the docked list moved into its own unclipped hosting view, a sidebar field's visibleRect came back window-sized, its midpoint fell outside the sidebar, and hiding the sidebar while renaming no longer handed focus back to the terminal. clamp the visible rect to the responder's bounds --- Sources/Sidebar/SidebarFocusBoundaryReference.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Sources/Sidebar/SidebarFocusBoundaryReference.swift b/Sources/Sidebar/SidebarFocusBoundaryReference.swift index efe97846f0a3..49820df6efbd 100644 --- a/Sources/Sidebar/SidebarFocusBoundaryReference.swift +++ b/Sources/Sidebar/SidebarFocusBoundaryReference.swift @@ -25,7 +25,11 @@ final class SidebarFocusBoundaryReference { return false } let boundaryFrame = boundaryView.convert(boundaryView.bounds, to: nil) - let responderFrame = responderView.convert(responderView.visibleRect, to: nil) + // Clamped to bounds: an unclipped hierarchy (macOS 14+ views don't + // clip to bounds by default) reports a visibleRect far larger than + // the view, whose midpoint can fall outside the sidebar. + let visible = responderView.visibleRect.intersection(responderView.bounds) + let responderFrame = responderView.convert(visible.isEmpty ? responderView.bounds : visible, to: nil) guard !boundaryFrame.isEmpty, !responderFrame.isEmpty else { return false } return boundaryFrame.contains(NSPoint(x: responderFrame.midX, y: responderFrame.midY)) } From 0b09950289c89ddcfbdebe69d17b638bde630e9a Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 00:39:09 +1300 Subject: [PATCH 65/69] tests: light selection is the aside pill on the stock tint dark keeps the translucent white patch; light on the stock tint is white at 90% with a 25% hairline, and a chosen tint keeps the translucent black patch --- cmuxTests/SidebarGlassSelectionTests.swift | 35 +++++++++++++++++----- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/cmuxTests/SidebarGlassSelectionTests.swift b/cmuxTests/SidebarGlassSelectionTests.swift index c92a172da7ea..39bca47704a4 100644 --- a/cmuxTests/SidebarGlassSelectionTests.swift +++ b/cmuxTests/SidebarGlassSelectionTests.swift @@ -15,23 +15,44 @@ import Testing /// A configured `sidebarSelectionColorHex` still wins as a solid fill. @Suite struct SidebarGlassSelectionTests { - @Test(arguments: [(ColorScheme.light, CGFloat(0)), (.dark, CGFloat(1))]) - func solidFillSelectionIsNeutralTranslucentGlass(scheme: ColorScheme, expectedWhite: CGFloat) throws { + @Test + func darkSolidFillSelectionIsNeutralTranslucentGlass() throws { let color = try #require(sidebarSelectedWorkspaceBackgroundNSColor( - for: scheme, + for: .dark, sidebarSelectionColorHex: nil, activeTabIndicatorStyle: .solidFill ).usingColorSpace(.sRGB)) - let glass = try #require(SidebarGlassSelection.fill(for: scheme).usingColorSpace(.sRGB)) + let glass = try #require(SidebarGlassSelection.fill(for: .dark).usingColorSpace(.sRGB)) #expect(color.hexString(includeAlpha: true) == glass.hexString(includeAlpha: true)) - #expect(abs(color.redComponent - expectedWhite) < 0.001) - #expect(abs(color.greenComponent - expectedWhite) < 0.001) - #expect(abs(color.blueComponent - expectedWhite) < 0.001) + #expect(abs(color.redComponent - 1) < 0.001) + #expect(abs(color.greenComponent - 1) < 0.001) + #expect(abs(color.blueComponent - 1) < 0.001) #expect(color.alphaComponent > 0) #expect(color.alphaComponent < 0.5, "Glass selection is translucent") } + /// Light mode on the stock tint is Aside's light look: a near-white pill + /// (white at 90%) with a dark hairline. A chosen tint keeps the neutral + /// translucent patch (black, under half opacity). + @Test + func lightSelectionIsAsidePillOnTheStockTintAndGlassOtherwise() throws { + let suite = "SidebarGlassSelectionTests.\(UUID().uuidString)" + let defaults = try #require(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + + let pill = try #require(SidebarGlassSelection.fill(for: .light, defaults: defaults).usingColorSpace(.sRGB)) + #expect(pill.redComponent > 0.999 && pill.greenComponent > 0.999 && pill.blueComponent > 0.999) + #expect(abs(pill.alphaComponent - 0.9) < 0.001) + let hairline = try #require(SidebarGlassSelection.edge(for: .light, defaults: defaults).usingColorSpace(.sRGB)) + #expect(hairline.redComponent < 0.001 && abs(hairline.alphaComponent - 0.25) < 0.001) + + defaults.set("#FF0000", forKey: "sidebarTintHex") + let glass = try #require(SidebarGlassSelection.fill(for: .light, defaults: defaults).usingColorSpace(.sRGB)) + #expect(glass.redComponent < 0.001 && glass.greenComponent < 0.001 && glass.blueComponent < 0.001) + #expect(glass.alphaComponent > 0 && glass.alphaComponent < 0.5, "Glass selection is translucent") + } + @Test(arguments: [ColorScheme.light, .dark]) func selectionIsGlassUnlessSubtleSelectionOrAConfiguredColor(scheme: ColorScheme) { #expect(!SettingCatalog().workspaceColors.subtleSelection.defaultValue) From ed36bfd3533cd2b6328a527c6c710eec3bd2bde2 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 00:39:09 +1300 Subject: [PATCH 66/69] tests: name what drives each sidebar pass on reveal, helpers in a support file the reveal projection check reports which known frames ran each pass; the run loop and toggle helpers move to SidebarHiddenPresentationTests+Support to keep the file inside its length budget --- cmux.xcodeproj/project.pbxproj | 4 ++ ...debarHiddenPresentationTests+Support.swift | 43 +++++++++++++++++++ .../SidebarHiddenPresentationTests.swift | 30 +++++-------- 3 files changed, 57 insertions(+), 20 deletions(-) create mode 100644 cmuxTests/SidebarHiddenPresentationTests+Support.swift diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index a079b5b99997..f8f93ab10553 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -3287,6 +3287,7 @@ B804A0210000000000000021 /* SidebarGroupHeaderRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B804B0210000000000000021 /* SidebarGroupHeaderRowView.swift */; }; 0F11723899EA5A1AEDF2F6CB /* SidebarHeaderGlyphButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = C9935562ACC010FC73782536 /* SidebarHeaderGlyphButton.swift */; }; B8F266246A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */; }; + D179E15015F14DA2E35D6F33 /* SidebarHiddenPresentationTests+Support.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03AE4BF29977BB12B3645EFC /* SidebarHiddenPresentationTests+Support.swift */; }; B8624C010000000000000001 /* SidebarHiddenPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8624D010000000000000001 /* SidebarHiddenPresentationTests.swift */; }; 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */; }; AABBCC00000000000000020C /* SidebarInlineRenameAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC00000000000000020B /* SidebarInlineRenameAction.swift */; }; @@ -7757,6 +7758,7 @@ B804B0210000000000000021 /* SidebarGroupHeaderRowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift; sourceTree = ""; }; C9935562ACC010FC73782536 /* SidebarHeaderGlyphButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/AppKitList/Cells/SidebarHeaderGlyphButton.swift; sourceTree = ""; }; B8F266256A1A3D9A45BD840F /* SidebarHelpMenuUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHelpMenuUITests.swift; sourceTree = ""; }; + 03AE4BF29977BB12B3645EFC /* SidebarHiddenPresentationTests+Support.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SidebarHiddenPresentationTests+Support.swift"; sourceTree = ""; }; B8624D010000000000000001 /* SidebarHiddenPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarHiddenPresentationTests.swift; sourceTree = ""; }; 51D800000000000000000002 /* SidebarIdentifierFormattingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarIdentifierFormattingTests.swift; sourceTree = ""; }; AABBCC00000000000000020B /* SidebarInlineRenameAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar/SidebarInlineRenameAction.swift; sourceTree = ""; }; @@ -13644,6 +13646,7 @@ 67015335076C61C9D20E1D6A /* CmuxMainStartupTests.swift */, 07DB2FB4809697C47BBD0341 /* TextViewUndoRegistrationLifetimeTests.swift */, 519C34B311CB68481382511A /* SidebarRetainedProviderScopeTests.swift */, + 03AE4BF29977BB12B3645EFC /* SidebarHiddenPresentationTests+Support.swift */, ); path = cmuxTests; sourceTree = ""; @@ -18492,6 +18495,7 @@ B8624C060000000000000006 /* SidebarFocusBoundaryLifecycleTests.swift in Sources */, 8175A0010000000000000001 /* SidebarGitProcessCompositionTests.swift in Sources */, 550C0F088E89B0C35370402C /* SidebarGlassSelectionTests.swift in Sources */, + D179E15015F14DA2E35D6F33 /* SidebarHiddenPresentationTests+Support.swift in Sources */, B8624C010000000000000001 /* SidebarHiddenPresentationTests.swift in Sources */, 51D800000000000000000001 /* SidebarIdentifierFormattingTests.swift in Sources */, AABBCC000000000000000206 /* SidebarInlineRenameKeyResolverTests.swift in Sources */, diff --git a/cmuxTests/SidebarHiddenPresentationTests+Support.swift b/cmuxTests/SidebarHiddenPresentationTests+Support.swift new file mode 100644 index 000000000000..0b66efe37105 --- /dev/null +++ b/cmuxTests/SidebarHiddenPresentationTests+Support.swift @@ -0,0 +1,43 @@ +import AppKit +import Combine +import Foundation +@testable import cmux_DEV + +/// Run loop and toggle helpers for `SidebarHiddenPresentationTests`. +extension SidebarHiddenPresentationTests { + /// Waits for a toggle to land. The slide commits `isVisible` when its + /// Core Animation spring reports it stopped; the instant path already + /// has. Then drains, so the landing's follow-up updates apply. + func awaitToggleLanding(of state: SidebarState, in window: NSWindow, drains: Int = 20) async { + let target = state.requestedVisibility + if state.isVisible != target { + for await visible in state.$isVisible.values where visible == target { break } + } + await drainMainRunLoop(for: window, iterations: drains) + } + + /// Names the known frames on the stack, for telling which host or + /// observation drove a sidebar body pass. + static func passOrigin() -> String { + let markers = [ + "SidebarDockedPaneHost", "SidebarPeekPanel", "NSHostingView", "updateNSView", "ContentView", + "ObservableObject", "objectWillChange", "FileExplorer", "Binding", "layoutSubtree", + "displayIfNeeded", "__CFRunLoopDoObservers", "__CFRunLoopRun", "_dispatch_main_queue", + ] + var seen: [String] = [] + for frame in Thread.callStackSymbols { + for marker in markers where frame.contains(marker) && !seen.contains(marker) { + seen.append(marker) + } + } + return seen.joined(separator: ">") + } + + func drainMainRunLoop(for window: NSWindow, iterations: Int = 20) async { + for _ in 0.. Date: Fri, 9 Oct 2026 01:25:39 +1300 Subject: [PATCH 67/69] sidebar: read the sidebar selection from the environment, not a projected binding the docked list lives in its own hosting view, and each ContentView update pushed a fresh `$sidebarSelectionState.selection` binding, which SwiftUI took as a change and re-ran VerticalTabsSidebar's body despite its equatable gate: three full row projections per reveal (CI named the passes: hosting view layout, `_selection changed`). the sidebar now reads SidebarSelectionState from the environment (both hosted environments inject it) through a computed `selection` --- Sources/ContentView.swift | 10 ++++++---- cmuxTests/SidebarLazyLayoutScaleTests.swift | 1 - cmuxTests/SidebarSnapshotOwnerTests.swift | 1 - 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index b479b15f8389..b9e7e4651341 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1731,7 +1731,6 @@ struct ContentView: View { }, observedWindowReference: observedWindowReference, chromeBackgroundColor: windowAppearanceSnapshot.resolvedChromeBackgroundColor, - selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, sidebarRenderWorkerClient: $sidebarRenderWorkerClient ) return Group { @@ -11457,7 +11456,10 @@ struct VerticalTabsSidebar: View, Equatable { // unread invalidation boundary, so this O(workspaces) root stays inert. var notificationStore: TerminalNotificationStore { .shared } @EnvironmentObject var cmuxConfigStore: CmuxConfigStore - @Binding var selection: SidebarSelection + // Read from the environment, not a projected binding: in its own hosting + // view a fresh `$state.selection` per push re-ran this body every time. + @EnvironmentObject var sidebarSelectionState: SidebarSelectionState + var selection: SidebarSelection { get { sidebarSelectionState.selection } nonmutating set { sidebarSelectionState.selection = newValue } } @Binding var selectedTabIds: Set @Binding var lastSidebarSelectionIndex: Int? @Binding var sidebarRenderWorkerClient: RenderWorkerClient? @@ -13152,7 +13154,7 @@ struct VerticalTabsSidebar: View, Equatable { SidebarEmptyArea( rowSpacing: tabRowSpacing, - selection: $selection, + selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, dragAutoScrollController: dragAutoScrollController, @@ -14285,7 +14287,7 @@ struct VerticalTabsSidebar: View, Equatable { .background(alignment: .top) { SidebarEmptyArea( rowSpacing: tabRowSpacing, - selection: $selection, + selection: $sidebarSelectionState.selection, selectedTabIds: $selectedTabIds, lastSidebarSelectionIndex: $lastSidebarSelectionIndex, dragAutoScrollController: dragAutoScrollController, diff --git a/cmuxTests/SidebarLazyLayoutScaleTests.swift b/cmuxTests/SidebarLazyLayoutScaleTests.swift index 45cb10d3aa7e..6ab9a8cc057e 100644 --- a/cmuxTests/SidebarLazyLayoutScaleTests.swift +++ b/cmuxTests/SidebarLazyLayoutScaleTests.swift @@ -172,7 +172,6 @@ final class SidebarLazyLayoutScaleTests { onTogglePresentationMode: {}, observedWindowReference: WeakWindowReference(), chromeBackgroundColor: .black, - selection: .constant(.tabs), selectedTabIds: .constant([]), lastSidebarSelectionIndex: .constant(nil), sidebarRenderWorkerClient: .constant(nil) diff --git a/cmuxTests/SidebarSnapshotOwnerTests.swift b/cmuxTests/SidebarSnapshotOwnerTests.swift index 58a8a0826310..0edc13777bfd 100644 --- a/cmuxTests/SidebarSnapshotOwnerTests.swift +++ b/cmuxTests/SidebarSnapshotOwnerTests.swift @@ -81,7 +81,6 @@ struct SidebarSnapshotOwnerTests { onTogglePresentationMode: {}, observedWindowReference: WeakWindowReference(), chromeBackgroundColor: .black, - selection: .constant(.tabs), selectedTabIds: .constant([]), lastSidebarSelectionIndex: .constant(nil), sidebarRenderWorkerClient: .constant(nil), From 8b24a2b154bfeef91bca101d343243b0ff9f2cbd Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 08:15:42 +1300 Subject: [PATCH 68/69] sidebar: light active pill is glass, like the hover the stock light pill was a flat 90% white sticker with a dark 25% hairline. it is now a brighter piece of the hover's glass: 74% white with a top-bright gradient, a 0.5 pt white inner rim, an 8% outer edge and a 5% shadow, all layers on the existing wash layer. the active group header matches, the reorder lift carries it, and dark mode, custom tints and selection colours are untouched. each value is tunable through a sidebarSelection*Light key. Co-Authored-By: Claude Opus 5.5 --- .../Cells/SidebarGroupHeaderRowView.swift | 2 +- .../AppKitList/Cells/SidebarRowHover.swift | 107 ++++++++++++++---- ...ebarWorkspaceRowCellView+ReorderLift.swift | 8 ++ .../Cells/SidebarWorkspaceRowCellView.swift | 4 +- Sources/Sidebar/SidebarGlassSelection.swift | 29 +++-- cmuxTests/SidebarGlassSelectionTests.swift | 11 +- 6 files changed, 120 insertions(+), 41 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift index 7b83ac9daf48..723dfc9b589b 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarGroupHeaderRowView.swift @@ -446,7 +446,7 @@ final class SidebarGroupHeaderTableCellView: NSTableCellView { let outerPad = SidebarWorkspaceListMetrics.rowOuterHorizontalPadding let bgFrame = NSRect(x: outerPad, y: 0, width: bounds.width - outerPad * 2, height: bounds.height) backgroundView.frame = bgFrame - rowHoverLayer.frame = backgroundView.bounds + SidebarGlassSelection.fitOverlay(rowHoverLayer) let contentMaxX = bgFrame.maxX - SidebarWorkspaceListMetrics.rowContentHorizontalPadding let midY = bounds.height / 2 var x = bgFrame.minX diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift b/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift index bb5e1936d5f5..d147f971da59 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift @@ -26,23 +26,22 @@ extension SidebarWorkspaceRowTableCellView { } } - /// Shows the wash when nothing else fills the row: selection, + /// Paints the wash layer: the selection glass on the stock light pill, + /// else the hover wash when nothing else fills the row (selection, /// multi-selection and custom row fills win, and a running reorder drag - /// shows none. Only touches the wash layer, so it is safe on every - /// pointer move. + /// shows none). Only touches the fill view's layers, so it is safe on + /// every pointer move. func updateHoverFill(colorScheme: ColorScheme? = nil) { - var scheme: ColorScheme? + var hovering = false if isPointerHovering, let model, SidebarGlassSelection.isRowHoverEnabled(), !SidebarGlassSelection.isReorderDragRunning(around: self), (backgroundView.layer?.backgroundColor?.alpha ?? 0) == 0, (backgroundView.layer?.borderWidth ?? 0) == 0 { let painted = paintedModel(model) - if !painted.isActive, !painted.isMultiSelected { - scheme = colorScheme ?? palette(painted).colorScheme - } + hovering = !painted.isActive && !painted.isMultiSelected } - if let scheme { SidebarGlassSelection.paintHover(hoverLayer, for: scheme) } - if hoverLayer.isHidden != (scheme == nil) { hoverLayer.isHidden = scheme == nil } + let scheme = colorScheme ?? (model?.colorSchemeIsDark == true ? .dark : .light) + SidebarGlassSelection.paintOverlay(hoverLayer, in: backgroundView, for: scheme, hovering: hovering) } override func viewDidUnhide() { @@ -62,19 +61,16 @@ extension SidebarGroupHeaderTableCellView { updateRowHoverFill() } - /// Only touches the wash layer, so it is safe on every pointer move. + /// Selection glass or hover wash, as on workspace rows. Only touches the + /// fill view's layers, so it is safe on every pointer move. func updateRowHoverFill() { - var scheme: ColorScheme? - if isPointerHovering, let model, !model.isAnchorActive, !model.isMultiSelected, - SidebarGlassSelection.isRowHoverEnabled(), - (backgroundView.layer?.backgroundColor?.alpha ?? 0) == 0, - (backgroundView.layer?.borderWidth ?? 0) == 0, - !SidebarGlassSelection.isReorderDragRunning(around: self) { - scheme = model.colorSchemeIsDark ? .dark : .light - } - if let scheme { SidebarGlassSelection.paintHover(rowHoverLayer, for: scheme) } - rowHoverLayer.cornerRadius = backgroundView.layer?.cornerRadius ?? 4 - if rowHoverLayer.isHidden != (scheme == nil) { rowHoverLayer.isHidden = scheme == nil } + let hovering = isPointerHovering && model.map { !$0.isAnchorActive && !$0.isMultiSelected } == true + && SidebarGlassSelection.isRowHoverEnabled() + && (backgroundView.layer?.backgroundColor?.alpha ?? 0) == 0 + && (backgroundView.layer?.borderWidth ?? 0) == 0 + && !SidebarGlassSelection.isReorderDragRunning(around: self) + let scheme: ColorScheme = model?.colorSchemeIsDark == true ? .dark : .light + SidebarGlassSelection.paintOverlay(rowHoverLayer, in: backgroundView, for: scheme, hovering: hovering) } override func viewDidUnhide() { @@ -84,6 +80,62 @@ extension SidebarGroupHeaderTableCellView { } extension SidebarGlassSelection { + /// Paints a cell's wash layer (a sublayer of its fill view): the + /// selection glass while the fill view wears the stock light pill, else + /// the hover wash while `hovering`, else nothing. Owns the pill's edge + /// width and shadow, and keeps the layer fitted inside the fill view's + /// edge, so every selection paint path (row, header, optimistic press) + /// gets the glass by painting the pill's base fill. + @MainActor + static func paintOverlay( + _ layer: CAGradientLayer, in fillView: NSView, for colorScheme: ColorScheme, + hovering: Bool, defaults: UserDefaults = .standard + ) { + let glass = wearsLightPill(fillView.layer, for: colorScheme, defaults: defaults) + if glass, let fill = fillView.layer { + let edge = edge(for: colorScheme, defaults: defaults) + fill.borderWidth = edge.alphaComponent > 0 ? 0.5 : 0 + fill.borderColor = edge.cgColor + paintSelectionGlass(layer, defaults: defaults) + } else if hovering { + paintHover(layer, for: colorScheme, defaults: defaults) + } + applySelectionShadow(to: fillView, glass, defaults: defaults) + fitOverlay(layer) + if layer.isHidden != !(glass || hovering) { layer.isHidden = !(glass || hovering) } + } + + /// True when `fill` is painted with the stock light pill's base, the + /// active row and active group header look (multi-selection and custom + /// selection colours paint other fills). + static func wearsLightPill(_ fill: CALayer?, for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> Bool { + guard let color = fill?.backgroundColor, usesStockLightLook(colorScheme, defaults: defaults) else { return false } + return color == self.fill(for: colorScheme, defaults: defaults).cgColor + } + + /// The stock light pill's glass, over the fill view's base (white at the + /// pill's bottom alpha): a gradient that lifts the top to the pill's top + /// alpha, and a bright inner rim (`sidebarSelectionRimLight`, default + /// 80%), stronger than the hover's. + @MainActor + static func paintSelectionGlass(_ layer: CAGradientLayer, defaults: UserDefaults = .standard) { + let pill = lightPill(defaults) + // White over white composites to 1 - (1 - a)(1 - b). + let lift = 1 - (1 - pill.top) / max(0.001, 1 - pill.bottom) + let rim = defaults.object(forKey: "sidebarSelectionRimLight") as? Double ?? 0.8 + paintGlass(layer, top: lift, bottom: 0, rim: CGFloat(rim)) + } + + /// Insets a wash layer inside its fill view's border, so the selection + /// rim sits just inside the pill's outer edge rather than under it. + static func fitOverlay(_ layer: CALayer) { + guard let fill = layer.superlayer else { return } + let inset = fill.borderWidth + let frame = fill.bounds.insetBy(dx: inset, dy: inset) + if layer.frame != frame { layer.frame = frame } + layer.cornerRadius = max(0, fill.cornerRadius - inset) + } + /// Paints a row's hover wash. The stock light look gets a glass wash, /// layers only: a white fill a touch brighter at the top and a 0.5 pt /// white inner rim like the selection's glass edge. Tunable through @@ -101,14 +153,21 @@ extension SidebarGlassSelection { func value(_ key: String, _ fallback: Double) -> CGFloat { CGFloat(defaults.object(forKey: key) as? Double ?? fallback) } let mid = value("sidebarRowHoverFillOpacityLight", 0.48) let spread = value("sidebarRowHoverGradientLight", 0.1) + paintGlass(layer, top: mid + spread / 2, bottom: mid - spread / 2, rim: value("sidebarRowHoverRimLight", 0.55)) + } + + /// White glass, layers only: a top-to-bottom white gradient and a 0.5 pt + /// white inner rim. + @MainActor + private static func paintGlass(_ layer: CAGradientLayer, top: CGFloat, bottom: CGFloat, rim: CGFloat) { layer.backgroundColor = nil // Unflipped layer space: y = 1 is the top. layer.startPoint = CGPoint(x: 0.5, y: 1) layer.endPoint = CGPoint(x: 0.5, y: 0) - layer.colors = [NSColor.white.withAlphaComponent(mid + spread / 2).cgColor, - NSColor.white.withAlphaComponent(max(0, mid - spread / 2)).cgColor] + layer.colors = [NSColor.white.withAlphaComponent(top).cgColor, + NSColor.white.withAlphaComponent(max(0, bottom)).cgColor] layer.borderWidth = 0.5 - layer.borderColor = NSColor.white.withAlphaComponent(value("sidebarRowHoverRimLight", 0.55)).cgColor + layer.borderColor = NSColor.white.withAlphaComponent(rim).cgColor } /// Settings > Sidebar > Row Hover. UI-only, on unless turned off. diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift index 4721cb166f91..a4e8e83796b0 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView+ReorderLift.swift @@ -16,6 +16,14 @@ extension SidebarWorkspaceRowTableCellView { fill.cornerCurve = source.cornerCurve fill.borderWidth = source.borderWidth fill.borderColor = source.borderColor + // The stock light pill's glass lives on the wash layer; carry it. + if SidebarGlassSelection.wearsLightPill(source, for: model?.colorSchemeIsDark == true ? .dark : .light) { + let glass = CAGradientLayer() + fill.addSublayer(glass) + SidebarGlassSelection.paintSelectionGlass(glass) + SidebarGlassSelection.fitOverlay(glass) + glass.cornerCurve = source.cornerCurve + } return fill } diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 7fec118cace1..0995558161ef 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -245,7 +245,6 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { } else { backgroundView.layer?.borderWidth = 0 } - SidebarGlassSelection.applySelectionShadow(to: backgroundView, model.isActive && settings.selectionColorHex == nil && SidebarGlassSelection.usesStockLightLook(palette.colorScheme)) updateHoverFill(colorScheme: palette.colorScheme) } @@ -1523,8 +1522,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { // nesting ("can't tell when a workspace is in a group"). let bgX = outerPad + (model.isGrouped ? SidebarWorkspaceGroupingMetrics.memberIndent : 0) backgroundView.frame = NSRect(x: bgX, y: 0, width: max(0, width - outerPad - bgX), height: y) - hoverLayer.frame = backgroundView.bounds - hoverLayer.cornerRadius = backgroundView.layer?.cornerRadius ?? 6 + SidebarGlassSelection.fitOverlay(hoverLayer) railView.frame = NSRect(x: bgX + 4 - 1, y: 5, width: 3, height: max(0, y - 10)) railView.layer?.cornerRadius = 1.5 let indicatorBounds = NSRect(x: 0, y: 0, width: width, height: y) diff --git a/Sources/Sidebar/SidebarGlassSelection.swift b/Sources/Sidebar/SidebarGlassSelection.swift index 7e594ffe7037..6cc0daae1a56 100644 --- a/Sources/Sidebar/SidebarGlassSelection.swift +++ b/Sources/Sidebar/SidebarGlassSelection.swift @@ -8,9 +8,10 @@ import SwiftUI /// unselected row is the same patch, softer and without the edge /// (`sidebarRowHoverFillOpacity`, `sidebarRowHoverFillOpacityLight`). enum SidebarGlassSelection { - /// Light mode on the stock tint takes Aside's light look: a near-white - /// pill with a dark hairline, a faint white hover wash, and dark gray row - /// titles (black at 69%, ~#414141 on the ground) instead of near-black. + /// Light mode on the stock tint takes Aside's light look: a white glass + /// pill (see `paintSelectionGlass`), a fainter glass hover wash, and dark + /// gray row titles (black at 69%, ~#414141 on the ground) instead of + /// near-black. /// It owns its values: the opacity tuning keys below shape the /// translucent patch, which it does not use. /// A chosen tint, a light-mode tint or matching the terminal opts out. @@ -22,8 +23,9 @@ enum SidebarGlassSelection { } static func fill(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { - // White at 90% over the gray ground (~250): a pill, not a glare. - if usesStockLightLook(colorScheme, defaults: defaults) { return NSColor.white.withAlphaComponent(0.9) } + // The pill's base, white at its gradient's bottom (66%); the glass + // overlay brightens the top. + if usesStockLightLook(colorScheme, defaults: defaults) { return NSColor.white.withAlphaComponent(lightPill(defaults).bottom) } return SidebarAppearanceColorResolver().resolvedColor( .labelColor, for: colorScheme, @@ -32,8 +34,19 @@ enum SidebarGlassSelection { ) } + /// The stock light pill's white at its top and bottom: a mid strength + /// (`sidebarSelectionFillOpacityLight`, default 74%) spread by the + /// top-to-bottom gradient (`sidebarSelectionGradientLight`, default 16%). + static func lightPill(_ defaults: UserDefaults = .standard) -> (top: CGFloat, bottom: CGFloat) { + let mid = defaults.object(forKey: "sidebarSelectionFillOpacityLight") as? Double ?? 0.74 + let spread = defaults.object(forKey: "sidebarSelectionGradientLight") as? Double ?? 0.16 + return (CGFloat(min(1, mid + spread / 2)), CGFloat(max(0, mid - spread / 2))) + } + static func edge(for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> NSColor { - if usesStockLightLook(colorScheme, defaults: defaults) { return NSColor.black.withAlphaComponent(0.25) } + if usesStockLightLook(colorScheme, defaults: defaults) { + return NSColor.black.withAlphaComponent(defaults.object(forKey: "sidebarSelectionEdgeOpacityLight") as? Double ?? 0.08) + } return SidebarAppearanceColorResolver().resolvedColor( .labelColor, for: colorScheme, @@ -61,11 +74,11 @@ enum SidebarGlassSelection { } /// The light look's pill sits on a barely-there 1 pt drop shadow - /// (`sidebarSelectionShadowOpacityLight`, default 7%). Set through the + /// (`sidebarSelectionShadowOpacityLight`, default 5%). Set through the /// view: AppKit owns a layer-backed view's layer shadow. @MainActor static func applySelectionShadow(to view: NSView, _ on: Bool, defaults: UserDefaults = .standard) { - let opacity = on ? (defaults.object(forKey: "sidebarSelectionShadowOpacityLight") as? Double ?? 0.07) : 0 + let opacity = on ? (defaults.object(forKey: "sidebarSelectionShadowOpacityLight") as? Double ?? 0.05) : 0 guard abs((view.shadow?.shadowColor?.alphaComponent ?? 0) - opacity) > 0.001 else { return } guard opacity > 0 else { view.shadow = nil; return } let shadow = NSShadow() diff --git a/cmuxTests/SidebarGlassSelectionTests.swift b/cmuxTests/SidebarGlassSelectionTests.swift index 39bca47704a4..95e150906906 100644 --- a/cmuxTests/SidebarGlassSelectionTests.swift +++ b/cmuxTests/SidebarGlassSelectionTests.swift @@ -32,9 +32,10 @@ struct SidebarGlassSelectionTests { #expect(color.alphaComponent < 0.5, "Glass selection is translucent") } - /// Light mode on the stock tint is Aside's light look: a near-white pill - /// (white at 90%) with a dark hairline. A chosen tint keeps the neutral - /// translucent patch (black, under half opacity). + /// Light mode on the stock tint is Aside's light look: a white glass pill + /// whose base is white at 66% (the gradient's bottom) with a faint dark + /// edge. A chosen tint keeps the neutral translucent patch (black, under + /// half opacity). @Test func lightSelectionIsAsidePillOnTheStockTintAndGlassOtherwise() throws { let suite = "SidebarGlassSelectionTests.\(UUID().uuidString)" @@ -43,9 +44,9 @@ struct SidebarGlassSelectionTests { let pill = try #require(SidebarGlassSelection.fill(for: .light, defaults: defaults).usingColorSpace(.sRGB)) #expect(pill.redComponent > 0.999 && pill.greenComponent > 0.999 && pill.blueComponent > 0.999) - #expect(abs(pill.alphaComponent - 0.9) < 0.001) + #expect(abs(pill.alphaComponent - 0.66) < 0.001) let hairline = try #require(SidebarGlassSelection.edge(for: .light, defaults: defaults).usingColorSpace(.sRGB)) - #expect(hairline.redComponent < 0.001 && abs(hairline.alphaComponent - 0.25) < 0.001) + #expect(hairline.redComponent < 0.001 && abs(hairline.alphaComponent - 0.08) < 0.001) defaults.set("#FF0000", forKey: "sidebarTintHex") let glass = try #require(SidebarGlassSelection.fill(for: .light, defaults: defaults).usingColorSpace(.sRGB)) From 03af860df633784e654cc6ac155275f8bc17dda3 Mon Sep 17 00:00:00 2001 From: Lucas Ribeiro Date: Fri, 9 Oct 2026 08:22:49 +1300 Subject: [PATCH 69/69] sidebar: light titles are one black on every row with the stock light look, row titles now take the pill's near-black on every row instead of near-black on the active row and a 69% gray elsewhere, and secondary lines keep their resting colour on the glass pill, so the pill alone marks the active row. dark mode, custom tints and selection colours keep their own contrast rule. Co-Authored-By: Claude Opus 5.5 --- .../AppKitList/Cells/SidebarRowHover.swift | 22 +++++++++++++++++-- .../Cells/SidebarWorkspaceRowCellView.swift | 2 +- .../SidebarWorkspaceRowSupportViews.swift | 4 ++-- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift b/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift index d147f971da59..69df5170bfe6 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarRowHover.swift @@ -109,8 +109,13 @@ extension SidebarGlassSelection { /// active row and active group header look (multi-selection and custom /// selection colours paint other fills). static func wearsLightPill(_ fill: CALayer?, for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> Bool { - guard let color = fill?.backgroundColor, usesStockLightLook(colorScheme, defaults: defaults) else { return false } - return color == self.fill(for: colorScheme, defaults: defaults).cgColor + isLightPill(fill?.backgroundColor, for: colorScheme, defaults: defaults) + } + + /// True when `color` is the stock light pill's base fill. + static func isLightPill(_ color: CGColor?, for colorScheme: ColorScheme, defaults: UserDefaults = .standard) -> Bool { + guard let color, usesStockLightLook(colorScheme, defaults: defaults) else { return false } + return color == fill(for: colorScheme, defaults: defaults).cgColor } /// The stock light pill's glass, over the fill view's base (white at the @@ -201,3 +206,16 @@ extension UserDefaults { object(forKey: SidebarGlassSelection.rowHoverKey) as? Bool ?? true } } + +extension SidebarRowPalette { + /// True while the active row wears the stock light glass pill. Text + /// then takes one colour on every row: titles are the pill's near-black + /// on all rows and secondary lines keep their resting colour on the + /// pill, so the pill alone marks the active row. + var usesLightPillText: Bool { + SidebarGlassSelection.isLightPill(selectedBackground.cgColor, for: colorScheme) + } + + /// Whether secondary lines take the selected foreground. + var usesSelectedText: Bool { model.isActive && !usesLightPillText } +} diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift index 0995558161ef..a95986abc9e8 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowCellView.swift @@ -910,7 +910,7 @@ final class SidebarWorkspaceRowTableCellView: NSTableCellView { isDark: palette.colorScheme == .dark ) let entryColor: NSColor - if model.isActive { + if palette.usesSelectedText { entryColor = explicitColor != nil ? palette.selectedForeground(1.0) : palette.secondary(0.95).withAlphaComponent(0.84) diff --git a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift index 82fcd35e6b71..536cd8daead7 100644 --- a/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift +++ b/Sources/Sidebar/AppKitList/Cells/SidebarWorkspaceRowSupportViews.swift @@ -46,14 +46,14 @@ struct SidebarRowPalette { } var primaryText: NSColor { - model.isActive ? selectedForeground(1.0) : semantic(.labelColor, opacity: SidebarGlassSelection.usesStockLightLook(colorScheme) ? 0.69 : nil) + model.isActive || usesLightPillText ? selectedForeground(1.0) : semantic(.labelColor, opacity: SidebarGlassSelection.usesStockLightLook(colorScheme) ? 0.69 : nil) } func secondary( _ selectedOpacity: CGFloat = 0.75, inactiveOpacity: CGFloat? = nil ) -> NSColor { - model.isActive + usesSelectedText ? selectedForeground(selectedOpacity) : SidebarAppearanceColorResolver().readableSecondaryColor( .secondaryLabelColor,