diff --git a/.github/pr-proof/menubar-conditionals-editor.png b/.github/pr-proof/menubar-conditionals-editor.png new file mode 100644 index 0000000000..601988c644 Binary files /dev/null and b/.github/pr-proof/menubar-conditionals-editor.png differ diff --git a/.github/pr-proof/menubar-conditionals-runtime.log b/.github/pr-proof/menubar-conditionals-runtime.log new file mode 100644 index 0000000000..4536873874 --- /dev/null +++ b/.github/pr-proof/menubar-conditionals-runtime.log @@ -0,0 +1,33 @@ +CodexBar menu bar conditional tokens - runtime proof +Captured: 2026-08-19T20:12:13+01:00 +Bundle: ./CodexBar.app (release, packaged via Scripts/package_app.sh) +Provider shown: Claude (usage 87%) + +=== 1. Starter library seeds on the fresh-install path === +The menuBarLayoutConditionals key was deleted before launch, then the app was started. +The five shipped rules appear in Settings > Menu Bar > Conditionals +(see menubar-conditionals-editor.png), wrapping across two rows. + +menuBarLayoutConditionals: +-> Seeding happens in memory only; nothing is written at load. A library the user + empties therefore stays empty, because any edit/remove writes the key and the + stored value wins from then on. + +=== 2. Downgrade projection keeps the legacy blob 0.53.x-decodable === +Layout under test is stacked, and line 2 holds only a conditional reference: + +menuBarLayoutV2 (current): {"lines":[[{"icon":{}},{"percent":{"window":"automatic"}}],[{"conditional":{"id":"B715B1D1-8C1D-4E99-8050-2B5A4EF4B684"}}]]} +menuBarLayout (legacy) : {"lines":[[{"icon":{}},{"percent":{"window":"automatic"}}]]} + +-> The legacy blob carries no "conditional" token, so an older decoder reads the + layout instead of failing on an unknown case and discarding the whole blob. + +=== 3. A hidden branch collapses its line === +Session usage sits below the shipped rule's 50% threshold, so line 2 resolves to +.hidden and the whole line drops out. + +status item AXTitle: Claude icon, Usage 87% + +-> One line announced. No "Line 2" segment, no blank trailing line, and the title + keeps single-line typography. The editor's Live preview shows "87%" on one row + while the Menu bar strip still lists two rows (screenshot above). diff --git a/Sources/CodexBar/CodexbarApp.swift b/Sources/CodexBar/CodexbarApp.swift index 26f7a0170c..686acf65a7 100644 --- a/Sources/CodexBar/CodexbarApp.swift +++ b/Sources/CodexBar/CodexbarApp.swift @@ -458,6 +458,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.installDebugMemoryPressureObserverIfNeeded() #endif self.ensureStatusController() + self.closeSwiftUISettingsPlaceholderWindow() self.observeSettingsApplicationMenuLanguage() self.scheduleSettingsApplicationMenuValidation( missingItemRetriesRemaining: Self.settingsMenuReadinessRetryCount, @@ -497,6 +498,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } + /// The SwiftUI `Settings` scene exists only to own the app-menu Settings command; the real + /// settings window is AppKit-managed (`SettingsWindowController`). macOS can still present or + /// state-restore the scene's empty placeholder window at launch — close it and keep it out of + /// state restoration so it cannot come back on the next launch. + private func closeSwiftUISettingsPlaceholderWindow() { + DispatchQueue.main.async { + for window in NSApp.windows + where window.identifier?.rawValue.hasPrefix("com_apple_SwiftUI_Settings") == true + { + window.isRestorable = false + window.close() + } + } + } + func applicationWillTerminate(_ notification: Notification) { self.cloudSyncCoordinator?.stop() self.memoryPressureMonitor.stop() diff --git a/Sources/CodexBar/MenuBarLayout.swift b/Sources/CodexBar/MenuBarLayout.swift index 089c9757eb..5ffc303540 100644 --- a/Sources/CodexBar/MenuBarLayout.swift +++ b/Sources/CodexBar/MenuBarLayout.swift @@ -8,6 +8,40 @@ enum PercentWindow: String, CaseIterable, Codable, Hashable, Sendable { case automatic } +/// Deliberately mirrors `PercentWindow` but stays a separate type so predicate persistence +/// is decoupled from render-window naming. +enum MenuBarConditionalMetric: String, CaseIterable, Codable, Hashable, Sendable { + case session + case weekly + case scopedWeekly + case automatic +} + +enum MenuBarConditionalComparison: String, CaseIterable, Codable, Hashable, Sendable { + case greaterThan + case greaterThanOrEqual + case lessThan + case lessThanOrEqual + + var symbol: String { + switch self { + case .greaterThan: ">" + case .greaterThanOrEqual: ">=" + case .lessThan: "<" + case .lessThanOrEqual: "<=" + } + } + + func evaluate(_ value: Double, _ threshold: Double) -> Bool { + switch self { + case .greaterThan: value > threshold + case .greaterThanOrEqual: value >= threshold + case .lessThan: value < threshold + case .lessThanOrEqual: value <= threshold + } + } +} + enum MenuBarLayoutLane: String, CaseIterable, Codable, Hashable, Sendable { case primary case secondary @@ -31,6 +65,164 @@ enum MenuBarLayoutLane: String, CaseIterable, Codable, Hashable, Sendable { } } +enum MenuBarConditionalCombinator: String, CaseIterable, Codable, Hashable, Sendable { + case and + case or +} + +struct MenuBarConditionalPredicate: Codable, Hashable, Sendable { + var metric: MenuBarConditionalMetric + var comparison: MenuBarConditionalComparison + var threshold: Double +} + +struct MenuBarConditionalClause: Codable, Hashable, Sendable { + /// nil for the first clause; ignored-on-eval if set on the first. + var combinator: MenuBarConditionalCombinator? + var predicate: MenuBarConditionalPredicate +} + +struct MenuBarLayoutConditional: Codable, Hashable, Sendable { + let id: UUID + var name: String + var clauses: [MenuBarConditionalClause] // 1...4 after normalization + var thenToken: MenuBarLayoutToken + var elseToken: MenuBarLayoutToken + + init( + id: UUID = UUID(), + name: String = "", + clauses: [MenuBarConditionalClause], + thenToken: MenuBarLayoutToken, + elseToken: MenuBarLayoutToken) + { + self.id = id + self.name = name + self.clauses = clauses + self.thenToken = thenToken + self.elseToken = elseToken + self.normalize() + } + + private mutating func normalize() { + var normalized = self.clauses.prefix(4).map { clause in + var clause = clause + clause.predicate.threshold = min(max(clause.predicate.threshold, 0), 100) + return clause + } + if normalized.isEmpty { + normalized = [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))] + } + normalized[0].combinator = nil + self.clauses = Array(normalized) + } + + /// Custom Codable so older persisted conditionals without `name` or `id` still decode. + private enum CodingKeys: String, CodingKey { + case id, name, clauses, thenToken, elseToken + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID() + self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "" + self.clauses = try container.decode([MenuBarConditionalClause].self, forKey: .clauses) + self.thenToken = try container.decode(MenuBarLayoutToken.self, forKey: .thenToken) + self.elseToken = try container.decode(MenuBarLayoutToken.self, forKey: .elseToken) + self.normalize() + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(self.id, forKey: .id) + try container.encode(self.name, forKey: .name) + try container.encode(self.clauses, forKey: .clauses) + try container.encode(self.thenToken, forKey: .thenToken) + try container.encode(self.elseToken, forKey: .elseToken) + } + + static func makeDefault() -> MenuBarLayoutConditional { + MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))], + thenToken: .percent(window: .session), + elseToken: .hidden) + } + + /// Conditionals seeded into the library on a fresh install so the palette ships with useful, + /// editable starting points instead of an empty list. + /// + /// Identities are fixed rather than generated: a layout that places a shipped conditional keeps + /// resolving across launches, and once the user edits or clears the library the stored array wins, + /// so a deleted entry is never reseeded. + /// + /// Thresholds compare the window's **used** percentage, matching `evaluatesTrue`. + static func shippedLibrary() -> [MenuBarLayoutConditional] { + [ + MenuBarLayoutConditional( + id: self.fixedID("B715B1D1-8C1D-4E99-8050-2B5A4EF4B684"), + name: L("menu_bar_layout_conditional_default_session_busy"), + clauses: [self.clause(.session, .greaterThan, 50)], + thenToken: .percent(window: .session), + elseToken: .hidden), + MenuBarLayoutConditional( + id: self.fixedID("A1C3C131-1BB8-4248-A482-FAF3E403E6E1"), + name: L("menu_bar_layout_conditional_default_weekly_high"), + clauses: [self.clause(.weekly, .greaterThanOrEqual, 90)], + thenToken: .percent(window: .weekly), + elseToken: .hidden), + MenuBarLayoutConditional( + id: self.fixedID("DBF99E1D-D5ED-4D55-AD24-A4171479DA3A"), + name: L("menu_bar_layout_conditional_default_session_spent"), + clauses: [self.clause(.session, .greaterThanOrEqual, 95)], + thenToken: .resetCountdown, + elseToken: .percent(window: .session)), + MenuBarLayoutConditional( + id: self.fixedID("CB1EBADE-B813-4B70-A32D-0FC742DC97A6"), + name: L("menu_bar_layout_conditional_default_either_high"), + clauses: [ + self.clause(.session, .greaterThan, 80), + self.clause(.weekly, .greaterThan, 80, combinator: .or), + ], + thenToken: .resetCountdown, + elseToken: .hidden), + MenuBarLayoutConditional( + id: self.fixedID("4A4E53F8-CABC-4413-B7AA-6C6452A69FEC"), + name: L("menu_bar_layout_conditional_default_scoped_weekly"), + clauses: [self.clause(.scopedWeekly, .greaterThan, 60)], + thenToken: .percent(window: .scopedWeekly), + elseToken: .hidden), + ] + } + + private static func clause( + _ metric: MenuBarConditionalMetric, + _ comparison: MenuBarConditionalComparison, + _ threshold: Double, + combinator: MenuBarConditionalCombinator? = nil) + -> MenuBarConditionalClause + { + MenuBarConditionalClause( + combinator: combinator, + predicate: MenuBarConditionalPredicate( + metric: metric, + comparison: comparison, + threshold: threshold)) + } + + /// The shipped identities are compile-time constants, so a malformed one is a programmer error + /// rather than something to paper over with a fresh identity that would dangle placed references. + private static func fixedID(_ string: String) -> UUID { + guard let id = UUID(uuidString: string) else { + preconditionFailure("Shipped conditional identity must be a valid UUID: \(string)") + } + return id + } +} + struct MenuBarLayoutLaneLabels: Hashable { let primary: String let secondary: String @@ -74,12 +266,28 @@ enum MenuBarLayoutToken: Codable, Hashable, Sendable { case cost30d case separatorDot case space + /// Renders nothing; used as a conditional branch value to hide output for the other case. + case hidden + /// References a library conditional by UUID. The conditional's content (clauses, branches) lives in + /// the conditionals library; the layout stores only its identity. + case conditional(id: UUID) var selectedLane: MenuBarLayoutLane? { if case let .lanePercent(lane) = self { return lane } return nil } + /// Tokens added after 0.53.x that an older decoder has no case for at all. `legacyCompatible` + /// cannot map them onto an existing case without inventing content, so the layout projection + /// drops them instead: an older release then decodes the rest of the layout rather than + /// failing the whole blob and losing the user's arrangement. + var hasLegacyRepresentation: Bool { + switch self { + case .conditional, .hidden: false + default: true + } + } + /// Maps `lanePercent` onto tokens a 0.53.x decoder already understands so a downgrade keeps a /// layout instead of dropping the whole blob. Direct lanes follow the provider's semantic /// windows: Kimi's primary is weekly, so a Kimi override does not swap 7-day and 5-hour. @@ -183,10 +391,21 @@ struct MenuBarLayout: Codable, Hashable, Sendable { Set(self.lines.joined().compactMap(\.selectedLane)) } + /// Older-readable projection of this layout. Tokens an older decoder cannot represent are + /// dropped rather than mapped; a line left empty by that filtering is dropped too, and a layout + /// with nothing left falls back to `defaultLayout` via `MenuBarLayout(lines:)` normalization. func legacyCompatible(for provider: UsageProvider? = nil) -> MenuBarLayout { - MenuBarLayout(lines: self.lines.map { line in - line.map { $0.legacyCompatible(for: provider) } - }) + let projected = self.lines.map { line in + line + .filter(\.hasLegacyRepresentation) + .map { $0.legacyCompatible(for: provider) } + } + // Keep a trailing empty line only when the layout was already stacked with an empty line, + // so an older release does not inherit a blank stacked row created purely by filtering. + let compacted = projected.enumerated().filter { index, line in + !line.isEmpty || self.lines[index].isEmpty + }.map(\.element) + return MenuBarLayout(lines: compacted) } } @@ -485,3 +704,51 @@ enum MenuBarLayoutPersistence { return preferred } } + +extension MenuBarLayout { + /// Every token in the layout plus all tokens reachable through conditional branches (depth-capped). + func flattenedTokens(conditionals: [MenuBarLayoutConditional]) -> [MenuBarLayoutToken] { + let byID = Dictionary(conditionals.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) + var tokens: [MenuBarLayoutToken] = [] + for token in self.lines.joined() { + token.appendFlattened(into: &tokens, conditionals: byID, depth: 0) + } + return tokens + } + + /// Returns a layout with every `.conditional(id:)` token matching `id` removed from both lines, + /// or nil when nothing referenced it (so callers never materialize an unchanged stored layout). + func removingConditional(id: UUID) -> MenuBarLayout? { + var changed = false + let filtered = self.lines.map { line in + line.filter { token in + if case let .conditional(tokenID) = token, tokenID == id { + changed = true + return false + } + return true + } + } + guard changed else { return nil } + return MenuBarLayout(lines: filtered) + } +} + +extension MenuBarLayoutToken { + static let maxConditionalDepth = 8 + + func appendFlattened( + into tokens: inout [MenuBarLayoutToken], + conditionals: [UUID: MenuBarLayoutConditional], + depth: Int) + { + if self == .hidden { return } + tokens.append(self) + guard depth < Self.maxConditionalDepth, + case let .conditional(id) = self, + let conditional = conditionals[id] + else { return } + conditional.thenToken.appendFlattened(into: &tokens, conditionals: conditionals, depth: depth + 1) + conditional.elseToken.appendFlattened(into: &tokens, conditionals: conditionals, depth: depth + 1) + } +} diff --git a/Sources/CodexBar/MenuBarLayoutConditionalEditor.swift b/Sources/CodexBar/MenuBarLayoutConditionalEditor.swift new file mode 100644 index 0000000000..d468d936dd --- /dev/null +++ b/Sources/CodexBar/MenuBarLayoutConditionalEditor.swift @@ -0,0 +1,338 @@ +import CodexBarCore +import SwiftUI + +struct MenuBarLayoutConditionalDraft: Identifiable { + enum Mode: Hashable { + case create + case edit(UUID) + } + + /// Sheet-presentation identity only, unrelated to `conditional.id`. + let id: UUID + let mode: Mode + var conditional: MenuBarLayoutConditional + + init(mode: Mode, conditional: MenuBarLayoutConditional) { + self.id = UUID() + self.mode = mode + self.conditional = conditional + } +} + +@MainActor +struct MenuBarLayoutConditionalEditorSheet: View { + @Environment(\.dismiss) private var dismiss + @State private var conditional: MenuBarLayoutConditional + + let draft: MenuBarLayoutConditionalDraft + let provider: UsageProvider? + let existingNames: Set + let onSave: (MenuBarLayoutConditionalDraft) -> Void + + init( + draft: MenuBarLayoutConditionalDraft, + provider: UsageProvider?, + existingNames: Set, + onSave: @escaping (MenuBarLayoutConditionalDraft) -> Void) + { + self.draft = draft + self.provider = provider + self.existingNames = existingNames + self.onSave = onSave + self._conditional = State(initialValue: draft.conditional) + } + + private var trimmedName: String { + self.conditional.name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + private var reservedNames: Set { + var names = self.existingNames + // The entry's own current name is allowed so an edit can be saved unchanged. + names.remove(self.draft.conditional.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + return names + } + + private var nameIsValid: Bool { + !self.trimmedName.isEmpty && !self.reservedNames.contains(self.trimmedName.lowercased()) + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text(L("menu_bar_layout_conditional_name")) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + TextField(L("menu_bar_layout_conditional_name_placeholder"), text: self.$conditional.name) + .textFieldStyle(.roundedBorder) + if !self.nameIsValid { + Text(L("menu_bar_layout_conditional_name_error")) + .font(.caption) + .foregroundStyle(.red) + } + } + + Text(L("menu_bar_layout_conditional_if")) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + + ForEach(self.conditional.clauses.indices, id: \.self) { index in + HStack(spacing: 6) { + if index > 0 { + Picker("", selection: self.combinatorBinding(index)) { + Text(L("menu_bar_layout_conditional_and")).tag(MenuBarConditionalCombinator.and) + Text(L("menu_bar_layout_conditional_or")).tag(MenuBarConditionalCombinator.or) + } + .labelsHidden() + .fixedSize() + } + + Picker("", selection: self.metricBinding(index)) { + ForEach(MenuBarConditionalMetric.allCases, id: \.self) { metric in + Text(metric.editorLabel).tag(metric) + } + } + .labelsHidden() + + Picker("", selection: self.comparisonBinding(index)) { + ForEach(MenuBarConditionalComparison.allCases, id: \.self) { comparison in + Text(comparison.symbol).tag(comparison) + } + } + .labelsHidden() + + TextField("", value: self.thresholdBinding(index), format: .number) + .textFieldStyle(.roundedBorder) + .frame(width: 44) + .monospacedDigit() + Stepper(value: self.thresholdBinding(index), in: 0...100, step: 1) { + EmptyView() + } + .labelsHidden() + Text("%") + + if self.conditional.clauses.count > 1 { + Button { + self.conditional.clauses.remove(at: index) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.plain) + } + } + } + + Button(L("menu_bar_layout_conditional_add_condition")) { + self.conditional.clauses.append( + MenuBarConditionalClause( + combinator: .and, + predicate: MenuBarConditionalPredicate( + metric: .automatic, + comparison: .greaterThan, + threshold: 0))) + } + .disabled(self.conditional.clauses.count >= 4) + .buttonStyle(.link) + + HStack { + Text(L("menu_bar_layout_conditional_then")) + self.tokenMenu(selection: self.thenBinding) + } + HStack { + Text(L("menu_bar_layout_conditional_else")) + self.tokenMenu(selection: self.elseBinding) + } + + Text(self.conditional.editorSummary(provider: self.provider)) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + + Divider() + + HStack { + Spacer() + Button(L("Cancel"), role: .cancel) { + self.dismiss() + } + Button(L("menu_bar_layout_conditional_save")) { + self.onSave( + MenuBarLayoutConditionalDraft( + mode: self.draft.mode, + conditional: self.conditional)) + self.dismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!self.nameIsValid) + } + } + .padding(16) + .frame(width: 460) + } + + private func combinatorBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return .and } + return self.conditional.clauses[index].combinator ?? .and + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + self.conditional.clauses[index].combinator = $0 + }) + } + + private func metricBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return .session } + return self.conditional.clauses[index].predicate.metric + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + self.conditional.clauses[index].predicate.metric = $0 + }) + } + + private func comparisonBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return .greaterThan } + return self.conditional.clauses[index].predicate.comparison + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + self.conditional.clauses[index].predicate.comparison = $0 + }) + } + + private func thresholdBinding(_ index: Int) -> Binding { + Binding( + get: { + guard self.conditional.clauses.indices.contains(index) else { return 0 } + return self.conditional.clauses[index].predicate.threshold + }, + set: { + guard self.conditional.clauses.indices.contains(index) else { return } + self.conditional.clauses[index].predicate.threshold = min(max($0, 0), 100) + }) + } + + private var thenBinding: Binding { + Binding( + get: { self.conditional.thenToken }, + set: { self.conditional.thenToken = $0 }) + } + + private var elseBinding: Binding { + Binding( + get: { self.conditional.elseToken }, + set: { self.conditional.elseToken = $0 }) + } + + private func tokenMenu(selection: Binding) -> some View { + Menu { + ForEach(Self.selectableTokens, id: \.self) { token in + Button { + selection.wrappedValue = token + } label: { + Label( + token.editorLabel(provider: self.provider), + systemImage: token.editorSystemImage) + } + } + } label: { + MenuBarLayoutChipLabel( + title: selection.wrappedValue.editorLabel(provider: self.provider), + systemImage: selection.wrappedValue.editorSystemImage, + isSelected: false) + } + } + + private static let selectableTokens: [MenuBarLayoutToken] = [ + .icon, + .providerName, + .accountLabel, + .percent(window: .session), + .percent(window: .weekly), + .percent(window: .scopedWeekly), + .percent(window: .automatic), + .usageBar, + .pace(window: .session), + .pace(window: .weekly), + .pace(window: .automatic), + .resetCountdown, + .resetAbsolute, + .runsOut, + .runsOutCompact, + .balance, + .costToday, + .cost30d, + .separatorDot, + .space, + .hidden, + ] +} + +extension MenuBarLayoutConditional { + /// The chip label: the required name, falling back to a generic label if somehow empty. + var displayName: String { + let trimmed = self.name.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? L("menu_bar_layout_token_conditional") : trimmed + } + + /// Produces a summary string reflecting the left-fold AND/OR evaluation order. Mixed + /// combinators parenthesize their accumulator so the reading matches `evaluatesTrue`. + private func conditionText() -> String { + guard let first = self.clauses.first else { return "" } + let mixed = Set(self.clauses.dropFirst().compactMap(\.combinator)).count > 1 + var text = Self.predicateText(first.predicate) + for clause in self.clauses.dropFirst() { + let joiner = (clause.combinator ?? .and) == .and + ? L("menu_bar_layout_conditional_and") + : L("menu_bar_layout_conditional_or") + let pred = Self.predicateText(clause.predicate) + text = mixed ? "(\(text)) \(joiner) \(pred)" : "\(text) \(joiner) \(pred)" + } + return text + } + + private static func predicateText(_ predicate: MenuBarConditionalPredicate) -> String { + "\(predicate.metric.editorLabel) \(predicate.comparison.symbol) \(Int(predicate.threshold.rounded()))%" + } + + func editorSummary(provider: UsageProvider?) -> String { + let condition = self.conditionText() + return L( + "menu_bar_layout_conditional_summary", + condition, + self.thenToken.editorLabel(provider: provider), + self.elseToken.editorLabel(provider: provider)) + } + + /// Generates a unique copy name that avoids collisions with existing library entries. + static func uniqueCopyName(basedOn name: String, existingNames: Set) -> String { + let base = name.trimmingCharacters(in: .whitespacesAndNewlines) + let stem = base.isEmpty ? L("menu_bar_layout_token_conditional") : base + var candidate = L("menu_bar_layout_conditional_copy_name", stem) + var n = 2 + while existingNames.contains(candidate.lowercased()) { + candidate = L("menu_bar_layout_conditional_copy_name_numbered", stem, n) + n += 1 + } + return candidate + } +} + +extension MenuBarConditionalMetric { + var editorLabel: String { + switch self { + case .session: L("menu_bar_layout_token_session") + case .weekly: L("menu_bar_layout_token_weekly") + case .scopedWeekly: L("menu_bar_layout_token_scoped_weekly") + case .automatic: L("menu_bar_layout_token_auto") + } + } +} diff --git a/Sources/CodexBar/MenuBarLayoutEditor.swift b/Sources/CodexBar/MenuBarLayoutEditor.swift index f5e747abd2..d08bf0a4fa 100644 --- a/Sources/CodexBar/MenuBarLayoutEditor.swift +++ b/Sources/CodexBar/MenuBarLayoutEditor.swift @@ -179,6 +179,7 @@ struct MenuBarLayoutEditor: View { @State private var scope: MenuBarLayoutEditorScope = .all @State private var selectedPosition: MenuBarLayoutPosition? + @State private var conditionalDraft: MenuBarLayoutConditionalDraft? private var layout: MenuBarLayout { switch self.scope { @@ -297,6 +298,8 @@ struct MenuBarLayoutEditor: View { self.palette(group) } + self.conditionalsPalette + Divider() self.displayOptions @@ -305,6 +308,17 @@ struct MenuBarLayoutEditor: View { .onDeleteCommand { self.removeSelectedToken() } + .sheet(item: self.$conditionalDraft) { draft in + MenuBarLayoutConditionalEditorSheet( + draft: draft, + provider: self.persistenceProvider, + existingNames: Set( + self.settings.menuBarLayoutConditionals.map { + $0.name.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + }), + onSave: self.saveConditionalDraft) + } + .onChange(of: self.scope) { _, _ in self.selectedPosition = nil } @@ -423,9 +437,7 @@ struct MenuBarLayoutEditor: View { self.selectedPosition = position } label: { MenuBarLayoutChipLabel( - title: token.editorLabel( - provider: self.persistenceProvider, - snapshot: self.persistenceSnapshot), + title: self.chipTitle(for: token), systemImage: token.editorSystemImage, isSelected: self.selectedPosition == position) .draggable(MenuBarLayoutDragItem.placed(token, at: position, in: self.layout)) @@ -439,9 +451,19 @@ struct MenuBarLayoutEditor: View { .dropDestination(for: MenuBarLayoutDragItem.self) { items, _ in self.insert(items.first, at: position) } - .accessibilityLabel(token.editorAccessibilityLabel( - provider: self.persistenceProvider, - snapshot: self.persistenceSnapshot)) + .accessibilityLabel(self.chipAccessibilityLabel(for: token)) + .contextMenu { + if case let .conditional(id) = token, + let conditional = self.settings.menuBarLayoutConditionals + .first(where: { $0.id == id }) + { + Button(L("menu_bar_layout_conditional_edit")) { + self.conditionalDraft = MenuBarLayoutConditionalDraft( + mode: .edit(id), + conditional: conditional) + } + } + } .accessibilityHint(L("menu_bar_layout_chip_hint")) .accessibilityAction(named: L("Remove")) { self.remove(at: position) @@ -554,6 +576,89 @@ struct MenuBarLayoutEditor: View { } } + private var conditionalsPalette: some View { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(L("menu_bar_layout_group_conditionals")) + .font(.caption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + Spacer() + Button { + self.conditionalDraft = MenuBarLayoutConditionalDraft( + mode: .create, + conditional: .makeDefault()) + } label: { + Image(systemName: "plus.circle") + } + .buttonStyle(.plain) + .help(L("menu_bar_layout_conditional_add")) + .accessibilityLabel(L("menu_bar_layout_conditional_add")) + } + + if self.settings.menuBarLayoutConditionals.isEmpty { + Text(L("menu_bar_layout_conditional_none")) + .font(.caption) + .foregroundStyle(.tertiary) + } else { + MenuBarLayoutChipFlowLayout(spacing: 6) { + ForEach(self.settings.menuBarLayoutConditionals, id: \.id) { conditional in + self.conditionalPaletteChip(conditional: conditional) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + + private func conditionalPaletteChip( + conditional: MenuBarLayoutConditional) + -> some View + { + Button { + self.write(MenuBarLayoutEditorMutations.append(.conditional(id: conditional.id), to: self.layout)) + } label: { + MenuBarLayoutChipLabel( + title: conditional.displayName, + systemImage: "switch.2", + isSelected: false) + } + .buttonStyle(.plain) + .draggable(MenuBarLayoutDragItem.palette(.conditional(id: conditional.id))) + .focusable() + .onKeyPress(keys: [.space, .return], phases: [.down]) { _ in + self.write(MenuBarLayoutEditorMutations.append(.conditional(id: conditional.id), to: self.layout)) + return .handled + } + .accessibilityLabel(conditional.displayName) + .contextMenu { + Button(L("menu_bar_layout_conditional_edit")) { + self.conditionalDraft = MenuBarLayoutConditionalDraft( + mode: .edit(conditional.id), + conditional: conditional) + } + Button(L("menu_bar_layout_conditional_duplicate")) { + self.duplicateConditional(conditional) + } + Button(L("menu_bar_layout_conditional_remove"), role: .destructive) { + self.settings.removeMenuBarLayoutConditional(id: conditional.id) + } + } + } + + private func duplicateConditional(_ conditional: MenuBarLayoutConditional) { + let existingNames = Set(self.settings.menuBarLayoutConditionals.map { $0.name.lowercased() }) + let copyName = MenuBarLayoutConditional.uniqueCopyName( + basedOn: conditional.name, + existingNames: existingNames) + let copy = MenuBarLayoutConditional( + name: copyName, + clauses: conditional.clauses, + thenToken: conditional.thenToken, + elseToken: conditional.elseToken) + self.settings.menuBarLayoutConditionals.append(copy) + } + private var displayOptions: some View { HStack(spacing: 18) { Picker(L("menu_bar_layout_size"), selection: self.sizeBinding) { @@ -626,6 +731,35 @@ struct MenuBarLayoutEditor: View { self.selectedPosition = nil } + private func saveConditionalDraft(_ draft: MenuBarLayoutConditionalDraft) { + switch draft.mode { + case .create: + self.settings.menuBarLayoutConditionals.append(draft.conditional) + case let .edit(id): + guard let index = self.settings.menuBarLayoutConditionals.firstIndex(where: { $0.id == id }) + else { return } + self.settings.menuBarLayoutConditionals[index] = draft.conditional + } + } + + private func chipTitle(for token: MenuBarLayoutToken) -> String { + if case let .conditional(id) = token { + return self.settings.menuBarLayoutConditionals + .first(where: { $0.id == id })?.displayName + ?? L("menu_bar_layout_token_conditional") + } + return token.editorLabel(provider: self.persistenceProvider, snapshot: self.persistenceSnapshot) + } + + private func chipAccessibilityLabel(for token: MenuBarLayoutToken) -> String { + if case .conditional = token { + return self.chipTitle(for: token) + } + return token.editorAccessibilityLabel( + provider: self.persistenceProvider, + snapshot: self.persistenceSnapshot) + } + private func write(_ layout: MenuBarLayout) { MenuBarLayoutEditorPersistence.activate( layout, @@ -634,7 +768,7 @@ struct MenuBarLayoutEditor: View { } } -private struct MenuBarLayoutChipLabel: View { +struct MenuBarLayoutChipLabel: View { let title: String let systemImage: String let isSelected: Bool @@ -659,6 +793,76 @@ private struct MenuBarLayoutChipLabel: View { } } +/// Left-aligned wrapping row layout for palette chips. +/// +/// The conditionals palette holds user-named chips of widely varying width. An adaptive +/// `LazyVGrid` would size them into equal columns and spread the leftover pane width between +/// them, and a plain `HStack` would push later chips outside the settings pane; this places each +/// chip at its natural width and wraps to the next row. +struct MenuBarLayoutChipFlowLayout: Layout { + var spacing: CGFloat = 6 + + /// Pure packing rule: subview indices grouped into rows that each stay within `maxWidth`. + /// Kept separate from `Layout` so the wrapping contract is testable without faking subviews. + /// A chip wider than `maxWidth` still occupies its own row rather than being dropped. + static func rows(widths: [CGFloat], maxWidth: CGFloat, spacing: CGFloat) -> [[Int]] { + var rows: [[Int]] = [] + var current: [Int] = [] + var currentWidth: CGFloat = 0 + for (index, width) in widths.enumerated() { + let advance = current.isEmpty ? width : spacing + width + if !current.isEmpty, currentWidth + advance > maxWidth { + rows.append(current) + current = [index] + currentWidth = width + continue + } + current.append(index) + currentWidth += advance + } + if !current.isEmpty { + rows.append(current) + } + return rows + } + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let rows = Self.rows( + widths: sizes.map(\.width), + maxWidth: proposal.width ?? .infinity, + spacing: self.spacing) + let rowWidths = rows.map { row in + row.reduce(CGFloat.zero) { $0 + sizes[$1].width } + + CGFloat(max(0, row.count - 1)) * self.spacing + } + let rowHeights = rows.map { row in + row.reduce(CGFloat.zero) { max($0, sizes[$1].height) } + } + return CGSize( + width: rowWidths.max() ?? 0, + height: rowHeights.reduce(0, +) + CGFloat(max(0, rows.count - 1)) * self.spacing) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let sizes = subviews.map { $0.sizeThatFits(.unspecified) } + let rows = Self.rows(widths: sizes.map(\.width), maxWidth: bounds.width, spacing: self.spacing) + var y = bounds.minY + for row in rows { + let rowHeight = row.reduce(CGFloat.zero) { max($0, sizes[$1].height) } + var x = bounds.minX + for index in row { + let size = sizes[index] + subviews[index].place( + at: CGPoint(x: x, y: y + (rowHeight - size.height) / 2), + proposal: ProposedViewSize(size)) + x += size.width + self.spacing + } + y += rowHeight + self.spacing + } + } +} + @MainActor struct MenuBarLayoutPreview: View { let layout: MenuBarLayout @@ -683,6 +887,7 @@ struct MenuBarLayoutPreview: View { size: self.settings.menuBarLayoutSize, highContrast: self.settings.menuBarHighContrastOnInactiveDisplays, showUsed: self.settings.usageBarsShowUsed, + conditionals: self.settings.menuBarLayoutConditionals, appearanceName: "preview", isDebugApp: false, now: minute, @@ -765,19 +970,13 @@ struct MenuBarLayoutPreview: View { automaticText: provider == .mistral && automaticRenderWindow == nil ? StatusItemController.mistralSpendDisplayText(snapshot: snapshot) : nil, - sessionPace: self.store.menuBarLayoutPaceText( - provider: provider, - window: session, - now: now), + sessionPace: self.store.menuBarLayoutPaceText(provider: provider, window: session, now: now), weeklyPace: self.store.menuBarLayoutPaceText( provider: provider, window: weekly, now: now, minimumElapsedPercent: 1), - automaticPace: self.store.menuBarLayoutPaceText( - provider: provider, - window: automatic, - now: now), + automaticPace: self.store.menuBarLayoutPaceText(provider: provider, window: automatic, now: now), runsOut: runsOut, balance: MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot), costToday: costToday.map { @@ -955,6 +1154,8 @@ extension MenuBarLayoutToken { case .cost30d: L("menu_bar_layout_token_cost_30d") case .separatorDot: "·" case .space: L("menu_bar_layout_token_space") + case .conditional: L("menu_bar_layout_token_conditional") + case .hidden: L("menu_bar_layout_conditional_hide") } } @@ -992,6 +1193,8 @@ extension MenuBarLayoutToken { case .cost30d: "calendar.badge.clock" case .separatorDot: "smallcircle.filled.circle" case .space: "space" + case .conditional: "switch.2" + case .hidden: "eye.slash" } } } diff --git a/Sources/CodexBar/MenuBarLayoutRenderer.swift b/Sources/CodexBar/MenuBarLayoutRenderer.swift index 5fea3d11a2..1966dade0b 100644 --- a/Sources/CodexBar/MenuBarLayoutRenderer.swift +++ b/Sources/CodexBar/MenuBarLayoutRenderer.swift @@ -55,6 +55,7 @@ struct MenuBarLayoutRenderOptions: Hashable { let size: MenuBarLayoutSize let highContrast: Bool let showUsed: Bool + let conditionals: [MenuBarLayoutConditional] let appearanceName: String let isDebugApp: Bool /// Whether the provider's latest refresh failed; when true the shown snapshot is stale @@ -71,6 +72,7 @@ struct MenuBarLayoutRenderOptions: Hashable { size: MenuBarLayoutSize, highContrast: Bool, showUsed: Bool, + conditionals: [MenuBarLayoutConditional], appearanceName: String, isDebugApp: Bool, isStale: Bool = false, @@ -80,6 +82,7 @@ struct MenuBarLayoutRenderOptions: Hashable { self.size = size self.highContrast = highContrast self.showUsed = showUsed + self.conditionals = conditionals self.appearanceName = appearanceName self.isDebugApp = isDebugApp self.isStale = isStale @@ -94,6 +97,7 @@ struct MenuBarLayoutRenderKey: Hashable { let size: MenuBarLayoutSize let highContrast: Bool let showUsed: Bool + let conditionals: [MenuBarLayoutConditional] let appearanceName: String let isDebugApp: Bool let isStale: Bool @@ -193,6 +197,7 @@ final class MenuBarLayoutRenderer { size: options.size, highContrast: options.highContrast, showUsed: options.showUsed, + conditionals: options.conditionals, appearanceName: options.appearanceName, isDebugApp: options.isDebugApp, isStale: options.isStale, @@ -214,7 +219,21 @@ final class MenuBarLayoutRenderer { options: MenuBarLayoutRenderOptions) -> MenuBarLayoutRenderedTitle { - let isStacked = layout.lines.count == 2 + let conditionalsByID = Dictionary( + options.conditionals.map { ($0.id, $0) }, + uniquingKeysWith: { first, _ in first }) + + // Pre-resolve conditionals so .hidden branches vanish and adjacent-space checks see + // only the tokens that will actually render. A line left with nothing to render is dropped + // entirely: keeping it would emit a stray newline, hold the title in stacked typography, + // and announce a blank line to VoiceOver. + let renderedLines = layout.lines + .map { line in + line.compactMap { Self.resolvedDisplayToken($0, data: data, conditionals: conditionalsByID) } + } + .filter { !$0.isEmpty } + + let isStacked = renderedLines.count == 2 let font = NSFont.systemFont(ofSize: Self.fontSize(size: options.size, isStacked: isStacked)) let foregroundColor = if options.highContrast { NSColor.labelColor @@ -240,6 +259,7 @@ final class MenuBarLayoutRenderer { attributes[.baselineOffset] = baseBaselineOffset + CGFloat(options.verticalAdjustment) let result = NSMutableAttributedString() var accessibilityLines: [String] = [] + // Only surface a leading icon via `button.image` when an actual image is available and the // high-contrast contract does not require the icon to stay inside the attributed title. // AppKit dims `button.image` on inactive displays, but high-contrast layouts keep icon + text @@ -247,18 +267,18 @@ final class MenuBarLayoutRenderer { // missing icon the token still renders its placeholder inside the title. let leadingIcon: NSImage? = if options.highContrast { nil - } else if layout.lines.first?.first == .icon, icon != nil { + } else if renderedLines.first?.first == .icon, icon != nil { icon.map { Self.offsetLeadingIcon($0, adjustment: options.verticalAdjustment) } } else { nil } - for (lineIndex, line) in layout.lines.enumerated() { + for (lineIndex, renderedLine) in renderedLines.enumerated() { if lineIndex > 0 { result.append(NSAttributedString(string: "\n", attributes: attributes)) } var accessibilityParts: [String] = [] - for (tokenIndex, token) in line.enumerated() { + for (tokenIndex, token) in renderedLine.enumerated() { // The leading icon is surfaced as `button.image` so AppKit applies the system's // active/inactive display tinting; it is not repeated inside the attributed title, // but its accessibility description must survive for VoiceOver. @@ -266,7 +286,7 @@ final class MenuBarLayoutRenderer { accessibilityParts.append(Self.iconAccessibilityText(data: data)) continue } - if tokenIndex > 0, token != .space, line[tokenIndex - 1] != .space { + if tokenIndex > 0, token != .space, renderedLine[tokenIndex - 1] != .space { result.append(NSAttributedString(string: "\u{2009}", attributes: attributes)) } let renderedItem = Self.renderItem( @@ -289,7 +309,12 @@ final class MenuBarLayoutRenderer { if options.isDebugApp { result.append(NSAttributedString(string: " D", attributes: attributes)) - accessibilityLines[accessibilityLines.count - 1].append(", \(L("Debug"))") + // Every line can collapse when a conditional hides the only token in the layout. + if accessibilityLines.isEmpty { + accessibilityLines.append(L("Debug")) + } else { + accessibilityLines[accessibilityLines.count - 1].append(", \(L("Debug"))") + } } let accessibilityLabel = accessibilityLines.enumerated().map { index, line in index == 0 ? line : "\(L("menu_bar_layout_line", index + 1)), \(line)" @@ -300,6 +325,27 @@ final class MenuBarLayoutRenderer { leadingIcon: leadingIcon) } + /// nil == resolved to .hidden (render nothing, no separator). A returned .conditional + /// means the id is dangling or the depth cap was hit; renderItem shows the placeholder. + private static func resolvedDisplayToken( + _ token: MenuBarLayoutToken, + data: MenuBarLayoutRenderData, + conditionals: [UUID: MenuBarLayoutConditional], + depth: Int = 0) + -> MenuBarLayoutToken? + { + switch token { + case .hidden: return nil + case let .conditional(id): + guard depth < MenuBarLayoutToken.maxConditionalDepth, + let conditional = conditionals[id] + else { return token } + let branch = conditional.evaluatesTrue(data: data) ? conditional.thenToken : conditional.elseToken + return self.resolvedDisplayToken(branch, data: data, conditionals: conditionals, depth: depth + 1) + default: return token + } + } + private static func renderItem( _ item: MenuBarLayoutToken, data: MenuBarLayoutRenderData, @@ -338,34 +384,7 @@ final class MenuBarLayoutRenderer { value.addAttributes(style.attributes, range: NSRange(location: 0, length: value.length)) return (value, Self.iconAccessibilityText(data: data)) case let .percent(window): - let rateWindow = Self.window(window, data: data) - let resolvedValue = Self.percentValue( - window: window, - rateWindow: rateWindow, - automaticText: data.automaticText, - showUsed: options.showUsed) - let prefix: String - let accessibilityPrefix: String - switch window { - case .session: - prefix = Self.sessionPrefix(rateWindow) - accessibilityPrefix = L("Session") - case .weekly: - let secondaryLabel = Self.secondaryLabel(data: data) - prefix = secondaryLabel.flatMap(\.first).map { String($0).uppercased() } ?? "W" - accessibilityPrefix = secondaryLabel ?? L("Weekly") - case .scopedWeekly: - prefix = data.scopedWeeklyTitle.map { String($0.prefix(1)).uppercased() } ?? "F" - accessibilityPrefix = data.scopedWeeklyTitle ?? L("Scoped weekly") - case .automatic: - prefix = "" - accessibilityPrefix = L("Usage") - } - let display = prefix.isEmpty ? resolvedValue.text : "\(prefix) \(resolvedValue.text)" - let accessibility = resolvedValue.isAvailable - ? L("%@ %@", accessibilityPrefix, resolvedValue.text) - : L("%@ unavailable", accessibilityPrefix) - return self.textToken(display, accessibilityText: accessibility, attributes: style.attributes) + return self.renderPercent(window, data: data, style: style, options: options) case let .pace(window): let accessibilityPrefix = Self.paceAccessibilityPrefix(window, data: data) return self.optionalTextToken( @@ -425,11 +444,56 @@ final class MenuBarLayoutRenderer { return self.textToken("·", accessibilityText: nil, attributes: style.attributes) case .space: return self.textToken(" ", accessibilityText: nil, attributes: style.attributes) + case .hidden: + return self.textToken("", accessibilityText: nil, attributes: style.attributes) + case .conditional: + // Reachable only for dangling id or depth cap; the resolver already expanded known conditionals. + return self.textToken( + self.missingValue, + accessibilityText: L("menu_bar_layout_conditional_unavailable"), + attributes: style.attributes) case .providerName, .accountLabel, .lanePercent: preconditionFailure("Provider text tokens should render before the main switch") } } + private static func renderPercent( + _ window: PercentWindow, + data: MenuBarLayoutRenderData, + style: TokenStyle, + options: MenuBarLayoutRenderOptions) + -> (value: NSAttributedString, accessibilityText: String?) + { + let rateWindow = Self.window(window, data: data) + let resolvedValue = Self.percentValue( + window: window, + rateWindow: rateWindow, + automaticText: data.automaticText, + showUsed: options.showUsed) + let prefix: String + let accessibilityPrefix: String + switch window { + case .session: + prefix = Self.sessionPrefix(rateWindow) + accessibilityPrefix = L("Session") + case .weekly: + let secondaryLabel = Self.secondaryLabel(data: data) + prefix = secondaryLabel.flatMap(\.first).map { String($0).uppercased() } ?? "W" + accessibilityPrefix = secondaryLabel ?? L("Weekly") + case .scopedWeekly: + prefix = data.scopedWeeklyTitle.map { String($0.prefix(1)).uppercased() } ?? "F" + accessibilityPrefix = data.scopedWeeklyTitle ?? L("Scoped weekly") + case .automatic: + prefix = "" + accessibilityPrefix = L("Usage") + } + let display = prefix.isEmpty ? resolvedValue.text : "\(prefix) \(resolvedValue.text)" + let accessibility = resolvedValue.isAvailable + ? L("%@ %@", accessibilityPrefix, resolvedValue.text) + : L("%@ unavailable", accessibilityPrefix) + return self.textToken(display, accessibilityText: accessibility, attributes: style.attributes) + } + private static func iconAccessibilityText(data: MenuBarLayoutRenderData) -> String { L("%@ icon", data.providerName ?? L("Provider")) } @@ -677,3 +741,33 @@ final class MenuBarLayoutRenderer { return size == .small ? 14 : 18 } } + +extension MenuBarLayoutConditional { + /// Left-fold over clauses; a predicate on a missing window (nil) evaluates false. + func evaluatesTrue(data: MenuBarLayoutRenderData) -> Bool { + guard let first = self.clauses.first else { return false } + var result = Self.test(first.predicate, data: data) + for clause in self.clauses.dropFirst() { + let value = Self.test(clause.predicate, data: data) + switch clause.combinator { + case .or: result = result || value + case .and, .none: result = result && value + } + } + return result + } + + private static func test(_ predicate: MenuBarConditionalPredicate, data: MenuBarLayoutRenderData) -> Bool { + guard let value = Self.value(for: predicate.metric, in: data) else { return false } + return predicate.comparison.evaluate(value, predicate.threshold) + } + + private static func value(for metric: MenuBarConditionalMetric, in data: MenuBarLayoutRenderData) -> Double? { + switch metric { + case .session: data.session?.usedPercent + case .weekly: data.weekly?.usedPercent + case .scopedWeekly: data.scopedWeekly?.usedPercent + case .automatic: data.automatic?.usedPercent + } + } +} diff --git a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings index fe3d7d6976..63ed2b14f8 100644 --- a/Sources/CodexBar/Resources/ar.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ar.lproj/Localizable.strings @@ -1498,3 +1498,31 @@ "Copy JSON" = "نسخ JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "شرطي"; +"menu_bar_layout_group_conditionals" = "الشروط"; +"menu_bar_layout_conditional_add" = "شرطي جديد"; +"menu_bar_layout_conditional_edit" = "تحرير الشرطي"; +"menu_bar_layout_conditional_remove" = "إزالة من المكتبة"; +"menu_bar_layout_conditional_save" = "حفظ"; +"menu_bar_layout_conditional_if" = "إذا كان"; +"menu_bar_layout_conditional_then" = "فعندئذ اعرض"; +"menu_bar_layout_conditional_else" = "وإلا اعرض"; +"menu_bar_layout_conditional_and" = "و"; +"menu_bar_layout_conditional_none" = "لا توجد شروط بعد"; +"menu_bar_layout_conditional_hide" = "إخفاء"; +"menu_bar_layout_conditional_name" = "الاسم"; +"menu_bar_layout_conditional_name_placeholder" = "مثال: فحص الجلسة"; +"menu_bar_layout_conditional_name_error" = "أدخل اسماً فريداً"; +"menu_bar_layout_conditional_duplicate" = "تكرار"; +"menu_bar_layout_conditional_or" = "أو"; +"menu_bar_layout_conditional_add_condition" = "إضافة شرط"; +"menu_bar_layout_conditional_summary" = "إذا كان %1$@ فعندئذ %2$@ وإلا %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (نسخة)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (نسخة %2$d)"; +"menu_bar_layout_conditional_unavailable" = "الشرط غير متاح"; +"menu_bar_layout_conditional_default_session_busy" = "استُخدم أكثر من 50٪ من الجلسة"; +"menu_bar_layout_conditional_default_weekly_high" = "استُخدم أكثر من 90٪ من الأسبوع"; +"menu_bar_layout_conditional_default_session_spent" = "الجلسة على وشك النفاد"; +"menu_bar_layout_conditional_default_either_high" = "الجلسة أو الأسبوع في مستوى مرتفع"; +"menu_bar_layout_conditional_default_scoped_weekly" = "نافذة الطراز الأسبوعية فوق 60٪"; diff --git a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings index 5ea594bb77..87b11c2002 100644 --- a/Sources/CodexBar/Resources/ca.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ca.lproj/Localizable.strings @@ -1497,3 +1497,31 @@ "Copy JSON" = "Copia JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Condicional"; +"menu_bar_layout_group_conditionals" = "Condicionals"; +"menu_bar_layout_conditional_add" = "Condicional nou"; +"menu_bar_layout_conditional_edit" = "Edita el condicional"; +"menu_bar_layout_conditional_remove" = "Elimina de la biblioteca"; +"menu_bar_layout_conditional_save" = "Desa"; +"menu_bar_layout_conditional_if" = "Si"; +"menu_bar_layout_conditional_then" = "Mostra llavors"; +"menu_bar_layout_conditional_else" = "Mostra en cas contrari"; +"menu_bar_layout_conditional_and" = "i"; +"menu_bar_layout_conditional_none" = "Encara no hi ha condicionals"; +"menu_bar_layout_conditional_hide" = "Amaga"; +"menu_bar_layout_conditional_name" = "Nom"; +"menu_bar_layout_conditional_name_placeholder" = "p. ex. Comprovació de la sessió"; +"menu_bar_layout_conditional_name_error" = "Introdueix un nom únic"; +"menu_bar_layout_conditional_duplicate" = "Duplica"; +"menu_bar_layout_conditional_or" = "o"; +"menu_bar_layout_conditional_add_condition" = "Afegeix una condició"; +"menu_bar_layout_conditional_summary" = "Si %1$@ llavors %2$@ en cas contrari %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (còpia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (còpia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condicional no disponible"; +"menu_bar_layout_conditional_default_session_busy" = "Sessió per damunt del 50 % utilitzat"; +"menu_bar_layout_conditional_default_weekly_high" = "Setmana per damunt del 90 % utilitzat"; +"menu_bar_layout_conditional_default_session_spent" = "Sessió gairebé esgotada"; +"menu_bar_layout_conditional_default_either_high" = "Sessió o setmana en nivell alt"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Setmana per model per damunt del 60 %"; diff --git a/Sources/CodexBar/Resources/de.lproj/Localizable.strings b/Sources/CodexBar/Resources/de.lproj/Localizable.strings index 6a95f70fd7..54fc476ad7 100644 --- a/Sources/CodexBar/Resources/de.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/de.lproj/Localizable.strings @@ -1495,3 +1495,30 @@ "Copy JSON" = "JSON kopieren"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Bedingt"; +"menu_bar_layout_group_conditionals" = "Bedingungen"; +"menu_bar_layout_conditional_add" = "Neue Bedingung"; +"menu_bar_layout_conditional_edit" = "Bedingung bearbeiten"; +"menu_bar_layout_conditional_remove" = "Aus der Bibliothek entfernen"; +"menu_bar_layout_conditional_save" = "Speichern"; +"menu_bar_layout_conditional_if" = "Wenn"; +"menu_bar_layout_conditional_then" = "Dann anzeigen"; +"menu_bar_layout_conditional_else" = "Sonst anzeigen"; +"menu_bar_layout_conditional_and" = "und"; +"menu_bar_layout_conditional_or" = "oder"; +"menu_bar_layout_conditional_add_condition" = "Bedingung hinzufügen"; +"menu_bar_layout_conditional_none" = "Noch keine Bedingungen"; +"menu_bar_layout_conditional_hide" = "Ausblenden"; +"menu_bar_layout_conditional_name" = "Name"; +"menu_bar_layout_conditional_name_placeholder" = "z. B. Sitzungsprüfung"; +"menu_bar_layout_conditional_name_error" = "Geben Sie einen eindeutigen Namen ein"; +"menu_bar_layout_conditional_duplicate" = "Duplizieren"; +"menu_bar_layout_conditional_summary" = "Wenn %1$@ dann %2$@ sonst %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (Kopie)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (Kopie %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Bedingung nicht verfügbar"; +"menu_bar_layout_conditional_default_session_busy" = "Sitzung über 50 % genutzt"; +"menu_bar_layout_conditional_default_weekly_high" = "Woche über 90 % genutzt"; +"menu_bar_layout_conditional_default_session_spent" = "Sitzung fast aufgebraucht"; +"menu_bar_layout_conditional_default_either_high" = "Sitzung oder Woche läuft hoch"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Modellfenster über 60 % genutzt"; diff --git a/Sources/CodexBar/Resources/en.lproj/Localizable.strings b/Sources/CodexBar/Resources/en.lproj/Localizable.strings index 62fefa4b41..50454d2fc2 100644 --- a/Sources/CodexBar/Resources/en.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/en.lproj/Localizable.strings @@ -1498,3 +1498,30 @@ "5-hour usage" = "5-hour usage"; "7-day usage" = "7-day usage"; "Total usage" = "Total usage"; +"menu_bar_layout_token_conditional" = "Conditional"; +"menu_bar_layout_group_conditionals" = "Conditionals"; +"menu_bar_layout_conditional_add" = "New Conditional"; +"menu_bar_layout_conditional_edit" = "Edit Conditional"; +"menu_bar_layout_conditional_remove" = "Remove from Library"; +"menu_bar_layout_conditional_save" = "Save"; +"menu_bar_layout_conditional_if" = "If"; +"menu_bar_layout_conditional_then" = "Then show"; +"menu_bar_layout_conditional_else" = "Else show"; +"menu_bar_layout_conditional_and" = "and"; +"menu_bar_layout_conditional_or" = "or"; +"menu_bar_layout_conditional_add_condition" = "Add Condition"; +"menu_bar_layout_conditional_none" = "No conditionals yet"; +"menu_bar_layout_conditional_hide" = "Hide"; +"menu_bar_layout_conditional_duplicate" = "Duplicate"; +"menu_bar_layout_conditional_name" = "Name"; +"menu_bar_layout_conditional_name_placeholder" = "e.g. Session check"; +"menu_bar_layout_conditional_name_error" = "Enter a unique name"; +"menu_bar_layout_conditional_summary" = "If %1$@ then %2$@ else %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copy)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copy %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Conditional unavailable"; +"menu_bar_layout_conditional_default_session_busy" = "Session over 50% used"; +"menu_bar_layout_conditional_default_weekly_high" = "Weekly over 90% used"; +"menu_bar_layout_conditional_default_session_spent" = "Session nearly spent"; +"menu_bar_layout_conditional_default_either_high" = "Session or weekly running high"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Scoped weekly over 60% used"; diff --git a/Sources/CodexBar/Resources/es.lproj/Localizable.strings b/Sources/CodexBar/Resources/es.lproj/Localizable.strings index 4ea0cbfbce..2896701fd2 100644 --- a/Sources/CodexBar/Resources/es.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/es.lproj/Localizable.strings @@ -1493,3 +1493,30 @@ "Copy JSON" = "Copiar JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Condicional"; +"menu_bar_layout_group_conditionals" = "Condicionales"; +"menu_bar_layout_conditional_add" = "Nueva condición"; +"menu_bar_layout_conditional_edit" = "Editar condición"; +"menu_bar_layout_conditional_remove" = "Quitar de la biblioteca"; +"menu_bar_layout_conditional_save" = "Guardar"; +"menu_bar_layout_conditional_if" = "Si"; +"menu_bar_layout_conditional_then" = "Mostrar entonces"; +"menu_bar_layout_conditional_else" = "Si no, mostrar"; +"menu_bar_layout_conditional_and" = "y"; +"menu_bar_layout_conditional_or" = "o"; +"menu_bar_layout_conditional_add_condition" = "Añadir condición"; +"menu_bar_layout_conditional_none" = "Aún no hay condiciones"; +"menu_bar_layout_conditional_hide" = "Ocultar"; +"menu_bar_layout_conditional_name" = "Nombre"; +"menu_bar_layout_conditional_name_placeholder" = "p. ej. Comprobación de sesión"; +"menu_bar_layout_conditional_name_error" = "Introduce un nombre único"; +"menu_bar_layout_conditional_duplicate" = "Duplicar"; +"menu_bar_layout_conditional_summary" = "Si %1$@ entonces %2$@ si no %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condicional no disponible"; +"menu_bar_layout_conditional_default_session_busy" = "Sesión por encima del 50 % usado"; +"menu_bar_layout_conditional_default_weekly_high" = "Semana por encima del 90 % usado"; +"menu_bar_layout_conditional_default_session_spent" = "Sesión casi agotada"; +"menu_bar_layout_conditional_default_either_high" = "Sesión o semana en nivel alto"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Semana por modelo por encima del 60 %"; diff --git a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings index 04982bf3fd..7e5c7ab5a2 100644 --- a/Sources/CodexBar/Resources/fa.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fa.lproj/Localizable.strings @@ -1498,3 +1498,31 @@ "Copy JSON" = "کپی JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "شرطی"; +"menu_bar_layout_group_conditionals" = "شروط"; +"menu_bar_layout_conditional_add" = "شرطی جدید"; +"menu_bar_layout_conditional_edit" = "ویرایش شرطی"; +"menu_bar_layout_conditional_remove" = "حذف از کتابخانه"; +"menu_bar_layout_conditional_save" = "ذخیره"; +"menu_bar_layout_conditional_if" = "اگر"; +"menu_bar_layout_conditional_then" = "سپس نمایش بده"; +"menu_bar_layout_conditional_else" = "در غیر این صورت نمایش بده"; +"menu_bar_layout_conditional_and" = "و"; +"menu_bar_layout_conditional_none" = "هنوز شرطی وجود ندارد"; +"menu_bar_layout_conditional_hide" = "پنهان کن"; +"menu_bar_layout_conditional_name" = "نام"; +"menu_bar_layout_conditional_name_placeholder" = "مثلاً: بررسی جلسه"; +"menu_bar_layout_conditional_name_error" = "نامی منحصربهفرد وارد کنید"; +"menu_bar_layout_conditional_duplicate" = "تکرار"; +"menu_bar_layout_conditional_or" = "یا"; +"menu_bar_layout_conditional_add_condition" = "افزودن شرط"; +"menu_bar_layout_conditional_summary" = "اگر %1$@ سپس %2$@ در غیر این صورت %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (رونوشت)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (رونوشت %2$d)"; +"menu_bar_layout_conditional_unavailable" = "شرط در دسترس نیست"; +"menu_bar_layout_conditional_default_session_busy" = "بیش از ۵۰٪ نشست مصرف شده"; +"menu_bar_layout_conditional_default_weekly_high" = "بیش از ۹۰٪ هفته مصرف شده"; +"menu_bar_layout_conditional_default_session_spent" = "نشست تقریباً تمام شده"; +"menu_bar_layout_conditional_default_either_high" = "نشست یا هفته در سطح بالا"; +"menu_bar_layout_conditional_default_scoped_weekly" = "پنجره هفتگی مدل بیش از ۶۰٪"; diff --git a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings index da6e117695..93cf7293a1 100644 --- a/Sources/CodexBar/Resources/fr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/fr.lproj/Localizable.strings @@ -1494,3 +1494,30 @@ "Copy JSON" = "Copier le JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Conditionnel"; +"menu_bar_layout_group_conditionals" = "Conditionnels"; +"menu_bar_layout_conditional_add" = "Nouveau conditionnel"; +"menu_bar_layout_conditional_edit" = "Modifier le conditionnel"; +"menu_bar_layout_conditional_remove" = "Retirer de la bibliothèque"; +"menu_bar_layout_conditional_save" = "Enregistrer"; +"menu_bar_layout_conditional_if" = "Si"; +"menu_bar_layout_conditional_then" = "Alors afficher"; +"menu_bar_layout_conditional_else" = "Sinon afficher"; +"menu_bar_layout_conditional_and" = "et"; +"menu_bar_layout_conditional_or" = "ou"; +"menu_bar_layout_conditional_add_condition" = "Ajouter une condition"; +"menu_bar_layout_conditional_none" = "Aucun conditionnel pour l\u2019instant"; +"menu_bar_layout_conditional_hide" = "Masquer"; +"menu_bar_layout_conditional_name" = "Nom"; +"menu_bar_layout_conditional_name_placeholder" = "p. ex. Vérification de session"; +"menu_bar_layout_conditional_name_error" = "Saisissez un nom unique"; +"menu_bar_layout_conditional_duplicate" = "Dupliquer"; +"menu_bar_layout_conditional_summary" = "Si %1$@ alors %2$@ sinon %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copie)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copie %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condition indisponible"; +"menu_bar_layout_conditional_default_session_busy" = "Session au-delà de 50 % utilisés"; +"menu_bar_layout_conditional_default_weekly_high" = "Semaine au-delà de 90 % utilisés"; +"menu_bar_layout_conditional_default_session_spent" = "Session presque épuisée"; +"menu_bar_layout_conditional_default_either_high" = "Session ou semaine à un niveau élevé"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Semaine par modèle au-delà de 60 %"; diff --git a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings index 1be1e0f687..3668989160 100644 --- a/Sources/CodexBar/Resources/gl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/gl.lproj/Localizable.strings @@ -1494,3 +1494,30 @@ "Copy JSON" = "Copiar JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Condicional"; +"menu_bar_layout_group_conditionals" = "Condicionais"; +"menu_bar_layout_conditional_add" = "Nova condición"; +"menu_bar_layout_conditional_edit" = "Editar a condición"; +"menu_bar_layout_conditional_remove" = "Retirar da biblioteca"; +"menu_bar_layout_conditional_save" = "Gardar"; +"menu_bar_layout_conditional_if" = "Se"; +"menu_bar_layout_conditional_then" = "Entón mostrar"; +"menu_bar_layout_conditional_else" = "Se non, mostrar"; +"menu_bar_layout_conditional_and" = "e"; +"menu_bar_layout_conditional_or" = "ou"; +"menu_bar_layout_conditional_add_condition" = "Engadir condición"; +"menu_bar_layout_conditional_none" = "Aínda non hai condicións"; +"menu_bar_layout_conditional_hide" = "Agochar"; +"menu_bar_layout_conditional_name" = "Nome"; +"menu_bar_layout_conditional_name_placeholder" = "p. ex. Comprobación de sesión"; +"menu_bar_layout_conditional_name_error" = "Introduce un nome único"; +"menu_bar_layout_conditional_duplicate" = "Duplicar"; +"menu_bar_layout_conditional_summary" = "Se %1$@ entón %2$@ se non %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condicional non dispoñible"; +"menu_bar_layout_conditional_default_session_busy" = "Sesión por riba do 50 % usado"; +"menu_bar_layout_conditional_default_weekly_high" = "Semana por riba do 90 % usado"; +"menu_bar_layout_conditional_default_session_spent" = "Sesión case esgotada"; +"menu_bar_layout_conditional_default_either_high" = "Sesión ou semana en nivel alto"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Semana por modelo por riba do 60 %"; diff --git a/Sources/CodexBar/Resources/id.lproj/Localizable.strings b/Sources/CodexBar/Resources/id.lproj/Localizable.strings index 0655d21fcc..bd2421d10b 100644 --- a/Sources/CodexBar/Resources/id.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/id.lproj/Localizable.strings @@ -1498,3 +1498,30 @@ "Copy JSON" = "Salin JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Bersyarat"; +"menu_bar_layout_group_conditionals" = "Kondisional"; +"menu_bar_layout_conditional_add" = "Kondisional Baru"; +"menu_bar_layout_conditional_edit" = "Edit Kondisional"; +"menu_bar_layout_conditional_remove" = "Hapus dari Perpustakaan"; +"menu_bar_layout_conditional_save" = "Simpan"; +"menu_bar_layout_conditional_if" = "Jika"; +"menu_bar_layout_conditional_then" = "Lalu tampilkan"; +"menu_bar_layout_conditional_else" = "Jika tidak, tampilkan"; +"menu_bar_layout_conditional_and" = "dan"; +"menu_bar_layout_conditional_none" = "Belum ada kondisional"; +"menu_bar_layout_conditional_hide" = "Sembunyikan"; +"menu_bar_layout_conditional_name" = "Nama"; +"menu_bar_layout_conditional_name_placeholder" = "mis. Pemeriksaan sesi"; +"menu_bar_layout_conditional_name_error" = "Masukkan nama yang unik"; +"menu_bar_layout_conditional_duplicate" = "Duplikat"; +"menu_bar_layout_conditional_or" = "atau"; +"menu_bar_layout_conditional_add_condition" = "Tambah Kondisi"; +"menu_bar_layout_conditional_summary" = "Jika %1$@ maka %2$@, jika tidak %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (salinan)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (salinan %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Kondisional tidak tersedia"; +"menu_bar_layout_conditional_default_session_busy" = "Sesi di atas 50% terpakai"; +"menu_bar_layout_conditional_default_weekly_high" = "Mingguan di atas 90% terpakai"; +"menu_bar_layout_conditional_default_session_spent" = "Sesi hampir habis"; +"menu_bar_layout_conditional_default_either_high" = "Sesi atau mingguan sedang tinggi"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Mingguan per model di atas 60%"; diff --git a/Sources/CodexBar/Resources/it.lproj/Localizable.strings b/Sources/CodexBar/Resources/it.lproj/Localizable.strings index 7055d3bc59..ebef69276e 100644 --- a/Sources/CodexBar/Resources/it.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/it.lproj/Localizable.strings @@ -1498,3 +1498,30 @@ "Copy JSON" = "Copia JSON"; "Sources" = "Origini"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Condizionale"; +"menu_bar_layout_group_conditionals" = "Condizionali"; +"menu_bar_layout_conditional_add" = "Nuovo condizionale"; +"menu_bar_layout_conditional_edit" = "Modifica condizionale"; +"menu_bar_layout_conditional_remove" = "Rimuovi dalla libreria"; +"menu_bar_layout_conditional_save" = "Salva"; +"menu_bar_layout_conditional_if" = "Se"; +"menu_bar_layout_conditional_then" = "Allora mostra"; +"menu_bar_layout_conditional_else" = "Altrimenti mostra"; +"menu_bar_layout_conditional_and" = "e"; +"menu_bar_layout_conditional_or" = "o"; +"menu_bar_layout_conditional_add_condition" = "Aggiungi condizione"; +"menu_bar_layout_conditional_none" = "Ancora nessun condizionale"; +"menu_bar_layout_conditional_hide" = "Nascondi"; +"menu_bar_layout_conditional_name" = "Nome"; +"menu_bar_layout_conditional_name_placeholder" = "ad es. Controllo sessione"; +"menu_bar_layout_conditional_name_error" = "Inserisci un nome univoco"; +"menu_bar_layout_conditional_duplicate" = "Duplica"; +"menu_bar_layout_conditional_summary" = "Se %1$@ allora %2$@ altrimenti %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (copia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (copia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condizionale non disponibile"; +"menu_bar_layout_conditional_default_session_busy" = "Sessione oltre il 50% usato"; +"menu_bar_layout_conditional_default_weekly_high" = "Settimana oltre il 90% usato"; +"menu_bar_layout_conditional_default_session_spent" = "Sessione quasi esaurita"; +"menu_bar_layout_conditional_default_either_high" = "Sessione o settimana a livello alto"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Settimana per modello oltre il 60%"; diff --git a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings index 69db7dd75a..15d61acc4f 100644 --- a/Sources/CodexBar/Resources/ja.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ja.lproj/Localizable.strings @@ -1495,3 +1495,30 @@ "Copy JSON" = "JSONをコピー"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "条件付き"; +"menu_bar_layout_group_conditionals" = "条件"; +"menu_bar_layout_conditional_add" = "新しい条件"; +"menu_bar_layout_conditional_edit" = "条件を編集"; +"menu_bar_layout_conditional_remove" = "ライブラリから削除"; +"menu_bar_layout_conditional_save" = "保存"; +"menu_bar_layout_conditional_if" = "もし"; +"menu_bar_layout_conditional_then" = "表示"; +"menu_bar_layout_conditional_else" = "それ以外は表示"; +"menu_bar_layout_conditional_and" = "かつ"; +"menu_bar_layout_conditional_none" = "条件はまだありません"; +"menu_bar_layout_conditional_hide" = "非表示"; +"menu_bar_layout_conditional_name" = "名前"; +"menu_bar_layout_conditional_name_placeholder" = "例:セッションチェック"; +"menu_bar_layout_conditional_name_error" = "一意の名前を入力してください"; +"menu_bar_layout_conditional_duplicate" = "複製"; +"menu_bar_layout_conditional_or" = "または"; +"menu_bar_layout_conditional_add_condition" = "条件を追加"; +"menu_bar_layout_conditional_summary" = "もし %1$@ なら %2$@ を表示し、それ以外は %3$@ を表示"; +"menu_bar_layout_conditional_copy_name" = "%@(コピー)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@(コピー %2$d)"; +"menu_bar_layout_conditional_unavailable" = "条件を利用できません"; +"menu_bar_layout_conditional_default_session_busy" = "セッション使用率 50% 超"; +"menu_bar_layout_conditional_default_weekly_high" = "週間使用率 90% 超"; +"menu_bar_layout_conditional_default_session_spent" = "セッションがほぼ上限"; +"menu_bar_layout_conditional_default_either_high" = "セッションまたは週間が高水準"; +"menu_bar_layout_conditional_default_scoped_weekly" = "モデル別週間の使用率 60% 超"; diff --git a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings index 966c957b34..1308e5d3c1 100644 --- a/Sources/CodexBar/Resources/ko.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ko.lproj/Localizable.strings @@ -1464,3 +1464,30 @@ "Copy JSON" = "JSON 복사"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "조건부"; +"menu_bar_layout_group_conditionals" = "조건"; +"menu_bar_layout_conditional_add" = "새 조건"; +"menu_bar_layout_conditional_edit" = "조건 편집"; +"menu_bar_layout_conditional_remove" = "라이브러리에서 제거"; +"menu_bar_layout_conditional_save" = "저장"; +"menu_bar_layout_conditional_if" = "만약"; +"menu_bar_layout_conditional_then" = "표시"; +"menu_bar_layout_conditional_else" = "그 외에는 표시"; +"menu_bar_layout_conditional_and" = "그리고"; +"menu_bar_layout_conditional_or" = "또는"; +"menu_bar_layout_conditional_add_condition" = "조건 추가"; +"menu_bar_layout_conditional_none" = "아직 조건이 없습니다"; +"menu_bar_layout_conditional_hide" = "숨기기"; +"menu_bar_layout_conditional_name" = "이름"; +"menu_bar_layout_conditional_name_placeholder" = "예: 세션 확인"; +"menu_bar_layout_conditional_name_error" = "고유한 이름을 입력하세요"; +"menu_bar_layout_conditional_duplicate" = "복제"; +"menu_bar_layout_conditional_summary" = "만약 %1$@이면 %2$@을 표시하고, 그렇지 않으면 %3$@을 표시"; +"menu_bar_layout_conditional_copy_name" = "%@ (복사본)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (복사본 %2$d)"; +"menu_bar_layout_conditional_unavailable" = "조건을 사용할 수 없음"; +"menu_bar_layout_conditional_default_session_busy" = "세션 50% 초과 사용"; +"menu_bar_layout_conditional_default_weekly_high" = "주간 90% 초과 사용"; +"menu_bar_layout_conditional_default_session_spent" = "세션 거의 소진"; +"menu_bar_layout_conditional_default_either_high" = "세션 또는 주간 사용량 높음"; +"menu_bar_layout_conditional_default_scoped_weekly" = "모델별 주간 60% 초과 사용"; diff --git a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings index 23fc696aac..b10406a972 100644 --- a/Sources/CodexBar/Resources/nl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/nl.lproj/Localizable.strings @@ -1494,3 +1494,30 @@ "Copy JSON" = "JSON kopiëren"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Voorwaardelijk"; +"menu_bar_layout_group_conditionals" = "Voorwaarden"; +"menu_bar_layout_conditional_add" = "Nieuwe voorwaarde"; +"menu_bar_layout_conditional_edit" = "Voorwaarde bewerken"; +"menu_bar_layout_conditional_remove" = "Verwijder uit bibliotheek"; +"menu_bar_layout_conditional_save" = "Bewaar"; +"menu_bar_layout_conditional_if" = "Als"; +"menu_bar_layout_conditional_then" = "Toon dan"; +"menu_bar_layout_conditional_else" = "Anders tonen"; +"menu_bar_layout_conditional_and" = "en"; +"menu_bar_layout_conditional_or" = "of"; +"menu_bar_layout_conditional_add_condition" = "Voorwaarde toevoegen"; +"menu_bar_layout_conditional_none" = "Nog geen voorwaarden"; +"menu_bar_layout_conditional_hide" = "Verbergen"; +"menu_bar_layout_conditional_name" = "Naam"; +"menu_bar_layout_conditional_name_placeholder" = "bijv. Sessiecontrole"; +"menu_bar_layout_conditional_name_error" = "Voer een unieke naam in"; +"menu_bar_layout_conditional_duplicate" = "Dupliceren"; +"menu_bar_layout_conditional_summary" = "Als %1$@ dan %2$@ anders %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (kopie)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (kopie %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Voorwaarde niet beschikbaar"; +"menu_bar_layout_conditional_default_session_busy" = "Sessie boven 50% gebruikt"; +"menu_bar_layout_conditional_default_weekly_high" = "Week boven 90% gebruikt"; +"menu_bar_layout_conditional_default_session_spent" = "Sessie bijna verbruikt"; +"menu_bar_layout_conditional_default_either_high" = "Sessie of week loopt hoog op"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Modelweek boven 60% gebruikt"; diff --git a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings index 1278b23f75..7bc8a23a12 100644 --- a/Sources/CodexBar/Resources/pl.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pl.lproj/Localizable.strings @@ -1498,3 +1498,30 @@ "Copy JSON" = "Kopiuj JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Warunkowy"; +"menu_bar_layout_group_conditionals" = "Warunki"; +"menu_bar_layout_conditional_add" = "Nowy warunek"; +"menu_bar_layout_conditional_edit" = "Edytuj warunek"; +"menu_bar_layout_conditional_remove" = "Usuń z biblioteki"; +"menu_bar_layout_conditional_save" = "Zapisz"; +"menu_bar_layout_conditional_if" = "Jeśli"; +"menu_bar_layout_conditional_then" = "Pokaż"; +"menu_bar_layout_conditional_else" = "W przeciwnym razie pokaż"; +"menu_bar_layout_conditional_and" = "i"; +"menu_bar_layout_conditional_none" = "Brak jeszcze warunków"; +"menu_bar_layout_conditional_hide" = "Ukryj"; +"menu_bar_layout_conditional_name" = "Nazwa"; +"menu_bar_layout_conditional_name_placeholder" = "np. Sprawdzenie sesji"; +"menu_bar_layout_conditional_name_error" = "Wprowadź unikalną nazwę"; +"menu_bar_layout_conditional_duplicate" = "Duplikuj"; +"menu_bar_layout_conditional_or" = "lub"; +"menu_bar_layout_conditional_add_condition" = "Dodaj warunek"; +"menu_bar_layout_conditional_summary" = "Jeśli %1$@, to %2$@, w przeciwnym razie %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (kopia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (kopia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Warunek niedostępny"; +"menu_bar_layout_conditional_default_session_busy" = "Sesja powyżej 50% zużycia"; +"menu_bar_layout_conditional_default_weekly_high" = "Tydzień powyżej 90% zużycia"; +"menu_bar_layout_conditional_default_session_spent" = "Sesja prawie wyczerpana"; +"menu_bar_layout_conditional_default_either_high" = "Sesja lub tydzień na wysokim poziomie"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Tydzień modelu powyżej 60% zużycia"; diff --git a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings index 3b2861a7d4..1b252cd357 100644 --- a/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/pt-BR.lproj/Localizable.strings @@ -1495,3 +1495,30 @@ "Copy JSON" = "Copiar JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Condicional"; +"menu_bar_layout_group_conditionals" = "Condicionais"; +"menu_bar_layout_conditional_add" = "Novo condicional"; +"menu_bar_layout_conditional_edit" = "Editar condicional"; +"menu_bar_layout_conditional_remove" = "Remover da biblioteca"; +"menu_bar_layout_conditional_save" = "Salvar"; +"menu_bar_layout_conditional_if" = "Se"; +"menu_bar_layout_conditional_then" = "Então mostrar"; +"menu_bar_layout_conditional_else" = "Caso contrário mostrar"; +"menu_bar_layout_conditional_and" = "e"; +"menu_bar_layout_conditional_or" = "ou"; +"menu_bar_layout_conditional_add_condition" = "Adicionar condição"; +"menu_bar_layout_conditional_none" = "Nenhum condicional ainda"; +"menu_bar_layout_conditional_hide" = "Ocultar"; +"menu_bar_layout_conditional_name" = "Nome"; +"menu_bar_layout_conditional_name_placeholder" = "ex.: Verificação de sessão"; +"menu_bar_layout_conditional_name_error" = "Digite um nome exclusivo"; +"menu_bar_layout_conditional_duplicate" = "Duplicar"; +"menu_bar_layout_conditional_summary" = "Se %1$@ então %2$@, senão %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (cópia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (cópia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Condicional indisponível"; +"menu_bar_layout_conditional_default_session_busy" = "Sessão acima de 50% usado"; +"menu_bar_layout_conditional_default_weekly_high" = "Semana acima de 90% usado"; +"menu_bar_layout_conditional_default_session_spent" = "Sessão quase esgotada"; +"menu_bar_layout_conditional_default_either_high" = "Sessão ou semana em nível alto"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Semana por modelo acima de 60%"; diff --git a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings index a57313121e..9552cc6ffe 100644 --- a/Sources/CodexBar/Resources/ru.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/ru.lproj/Localizable.strings @@ -1496,3 +1496,30 @@ "Copy JSON" = "Копировать JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; +"menu_bar_layout_token_conditional" = "Условный"; +"menu_bar_layout_group_conditionals" = "Условия"; +"menu_bar_layout_conditional_add" = "Новое условие"; +"menu_bar_layout_conditional_edit" = "Изменить условие"; +"menu_bar_layout_conditional_remove" = "Удалить из библиотеки"; +"menu_bar_layout_conditional_save" = "Сохранить"; +"menu_bar_layout_conditional_if" = "Если"; +"menu_bar_layout_conditional_then" = "Показать"; +"menu_bar_layout_conditional_else" = "Иначе показать"; +"menu_bar_layout_conditional_and" = "и"; +"menu_bar_layout_conditional_or" = "или"; +"menu_bar_layout_conditional_add_condition" = "Добавить условие"; +"menu_bar_layout_conditional_none" = "Условий пока нет"; +"menu_bar_layout_conditional_hide" = "Скрыть"; +"menu_bar_layout_conditional_name" = "Название"; +"menu_bar_layout_conditional_name_placeholder" = "напр., Проверка сеанса"; +"menu_bar_layout_conditional_name_error" = "Введите уникальное название"; +"menu_bar_layout_conditional_duplicate" = "Дублировать"; +"menu_bar_layout_conditional_summary" = "Если %1$@, то показать %2$@, иначе показать %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (копия)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (копия %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Условие недоступно"; +"menu_bar_layout_conditional_default_session_busy" = "Сессия использована более 50 %"; +"menu_bar_layout_conditional_default_weekly_high" = "Неделя использована более 90 %"; +"menu_bar_layout_conditional_default_session_spent" = "Сессия почти исчерпана"; +"menu_bar_layout_conditional_default_either_high" = "Сессия или неделя на высоком уровне"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Недельное окно модели более 60 %"; diff --git a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings index e12d53e6b4..9dbc581e7b 100644 --- a/Sources/CodexBar/Resources/sv.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/sv.lproj/Localizable.strings @@ -1493,3 +1493,31 @@ "Copy JSON" = "Kopiera JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Villkor"; +"menu_bar_layout_group_conditionals" = "Villkor"; +"menu_bar_layout_conditional_add" = "Nytt villkor"; +"menu_bar_layout_conditional_edit" = "Redigera villkor"; +"menu_bar_layout_conditional_remove" = "Ta bort från bibliotek"; +"menu_bar_layout_conditional_save" = "Spara"; +"menu_bar_layout_conditional_if" = "Om"; +"menu_bar_layout_conditional_then" = "Visa då"; +"menu_bar_layout_conditional_else" = "Visa annars"; +"menu_bar_layout_conditional_and" = "och"; +"menu_bar_layout_conditional_or" = "eller"; +"menu_bar_layout_conditional_add_condition" = "Lägg till villkor"; +"menu_bar_layout_conditional_none" = "Inga villkor ännu"; +"menu_bar_layout_conditional_hide" = "Dölj"; +"menu_bar_layout_conditional_name" = "Namn"; +"menu_bar_layout_conditional_name_placeholder" = "t.ex. Sessionskontroll"; +"menu_bar_layout_conditional_name_error" = "Ange ett unikt namn"; +"menu_bar_layout_conditional_duplicate" = "Duplicera"; +"menu_bar_layout_conditional_summary" = "Om %1$@ visa %2$@ annars %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (kopia)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (kopia %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Villkor otillgängligt"; +"menu_bar_layout_conditional_default_session_busy" = "Session över 50 % använt"; +"menu_bar_layout_conditional_default_weekly_high" = "Vecka över 90 % använt"; +"menu_bar_layout_conditional_default_session_spent" = "Sessionen nästan förbrukad"; +"menu_bar_layout_conditional_default_either_high" = "Session eller vecka ligger högt"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Modellvecka över 60 % använt"; diff --git a/Sources/CodexBar/Resources/th.lproj/Localizable.strings b/Sources/CodexBar/Resources/th.lproj/Localizable.strings index bdcb5834d9..b693aebb91 100644 --- a/Sources/CodexBar/Resources/th.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/th.lproj/Localizable.strings @@ -1498,3 +1498,31 @@ "Copy JSON" = "คัดลอก JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "ตามเงื่อนไข"; +"menu_bar_layout_group_conditionals" = "เงื่อนไข"; +"menu_bar_layout_conditional_add" = "เงื่อนไขใหม่"; +"menu_bar_layout_conditional_edit" = "แก้ไขเงื่อนไข"; +"menu_bar_layout_conditional_remove" = "ลบออกจากไลบรารี"; +"menu_bar_layout_conditional_save" = "บันทึก"; +"menu_bar_layout_conditional_if" = "ถ้า"; +"menu_bar_layout_conditional_then" = "จากนั้นแสดง"; +"menu_bar_layout_conditional_else" = "มิฉะนั้นแสดง"; +"menu_bar_layout_conditional_and" = "และ"; +"menu_bar_layout_conditional_or" = "หรือ"; +"menu_bar_layout_conditional_add_condition" = "เพิ่มเงื่อนไข"; +"menu_bar_layout_conditional_none" = "ยังไม่มีเงื่อนไข"; +"menu_bar_layout_conditional_hide" = "ซ่อน"; +"menu_bar_layout_conditional_name" = "ชื่อ"; +"menu_bar_layout_conditional_name_placeholder" = "เช่น ตรวจสอบเซสชัน"; +"menu_bar_layout_conditional_name_error" = "ป้อนชื่อที่ไม่ซ้ำกัน"; +"menu_bar_layout_conditional_duplicate" = "ทำสำเนา"; +"menu_bar_layout_conditional_summary" = "ถ้า %1$@ จากนั้น %2$@ มิฉะนั้น %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (สำเนา)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (สำเนา %2$d)"; +"menu_bar_layout_conditional_unavailable" = "เงื่อนไขไม่พร้อมใช้งาน"; +"menu_bar_layout_conditional_default_session_busy" = "ใช้เซสชันเกิน 50%"; +"menu_bar_layout_conditional_default_weekly_high" = "ใช้รายสัปดาห์เกิน 90%"; +"menu_bar_layout_conditional_default_session_spent" = "เซสชันเกือบหมด"; +"menu_bar_layout_conditional_default_either_high" = "เซสชันหรือรายสัปดาห์อยู่ในระดับสูง"; +"menu_bar_layout_conditional_default_scoped_weekly" = "รายสัปดาห์ตามโมเดลเกิน 60%"; diff --git a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings index 2a19dbce2f..1c829a3ec2 100644 --- a/Sources/CodexBar/Resources/tr.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/tr.lproj/Localizable.strings @@ -1496,3 +1496,31 @@ "Copy JSON" = "JSON kopyala"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Koşullu"; +"menu_bar_layout_group_conditionals" = "Koşullular"; +"menu_bar_layout_conditional_add" = "Yeni Koşullu"; +"menu_bar_layout_conditional_edit" = "Koşulluyu Düzenle"; +"menu_bar_layout_conditional_remove" = "Kitaplıktan Kaldır"; +"menu_bar_layout_conditional_save" = "Kaydet"; +"menu_bar_layout_conditional_if" = "Eğer"; +"menu_bar_layout_conditional_then" = "ise göster"; +"menu_bar_layout_conditional_else" = "değilse göster"; +"menu_bar_layout_conditional_and" = "ve"; +"menu_bar_layout_conditional_or" = "veya"; +"menu_bar_layout_conditional_add_condition" = "Koşul Ekle"; +"menu_bar_layout_conditional_none" = "Henüz koşullu yok"; +"menu_bar_layout_conditional_hide" = "Gizle"; +"menu_bar_layout_conditional_name" = "Ad"; +"menu_bar_layout_conditional_name_placeholder" = "örn. Oturum kontrolü"; +"menu_bar_layout_conditional_name_error" = "Benzersiz bir ad girin"; +"menu_bar_layout_conditional_duplicate" = "Çoğalt"; +"menu_bar_layout_conditional_summary" = "Eğer %1$@ ise %2$@, değilse %3$@ göster"; +"menu_bar_layout_conditional_copy_name" = "%@ (kopya)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (kopya %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Koşul kullanılamıyor"; +"menu_bar_layout_conditional_default_session_busy" = "Oturumun %50'sinden fazlası kullanıldı"; +"menu_bar_layout_conditional_default_weekly_high" = "Haftanın %90'ından fazlası kullanıldı"; +"menu_bar_layout_conditional_default_session_spent" = "Oturum neredeyse tükendi"; +"menu_bar_layout_conditional_default_either_high" = "Oturum veya hafta yükseldi"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Model haftasının %60'ından fazlası kullanıldı"; diff --git a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings index 787098b6f0..6bd0d6a105 100644 --- a/Sources/CodexBar/Resources/uk.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/uk.lproj/Localizable.strings @@ -1494,3 +1494,31 @@ "Copy JSON" = "Копіювати JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Умовний"; +"menu_bar_layout_group_conditionals" = "Умовні"; +"menu_bar_layout_conditional_add" = "Нова умова"; +"menu_bar_layout_conditional_edit" = "Редагувати умову"; +"menu_bar_layout_conditional_remove" = "Видалити з бібліотеки"; +"menu_bar_layout_conditional_save" = "Зберегти"; +"menu_bar_layout_conditional_if" = "Якщо"; +"menu_bar_layout_conditional_then" = "Тоді показати"; +"menu_bar_layout_conditional_else" = "Інакше показати"; +"menu_bar_layout_conditional_and" = "і"; +"menu_bar_layout_conditional_or" = "або"; +"menu_bar_layout_conditional_add_condition" = "Додати умову"; +"menu_bar_layout_conditional_none" = "Умов поки немає"; +"menu_bar_layout_conditional_hide" = "Приховати"; +"menu_bar_layout_conditional_name" = "Назва"; +"menu_bar_layout_conditional_name_placeholder" = "напр., Перевірка сеансу"; +"menu_bar_layout_conditional_name_error" = "Введіть унікальну назву"; +"menu_bar_layout_conditional_duplicate" = "Дублювати"; +"menu_bar_layout_conditional_summary" = "Якщо %1$@ тоді %2$@ інакше %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (копія)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (копія %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Умова недоступна"; +"menu_bar_layout_conditional_default_session_busy" = "Сесію використано понад 50 %"; +"menu_bar_layout_conditional_default_weekly_high" = "Тиждень використано понад 90 %"; +"menu_bar_layout_conditional_default_session_spent" = "Сесія майже вичерпана"; +"menu_bar_layout_conditional_default_either_high" = "Сесія або тиждень на високому рівні"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Тижневе вікно моделі понад 60 %"; diff --git a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings index 13e4b7693c..522d40b09b 100644 --- a/Sources/CodexBar/Resources/vi.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/vi.lproj/Localizable.strings @@ -1495,3 +1495,31 @@ "Copy JSON" = "Sao chép JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "Điều kiện"; +"menu_bar_layout_group_conditionals" = "Điều kiện"; +"menu_bar_layout_conditional_add" = "Điều kiện mới"; +"menu_bar_layout_conditional_edit" = "Sửa điều kiện"; +"menu_bar_layout_conditional_remove" = "Xóa khỏi thư viện"; +"menu_bar_layout_conditional_save" = "Lưu"; +"menu_bar_layout_conditional_if" = "Nếu"; +"menu_bar_layout_conditional_then" = "Thì hiển thị"; +"menu_bar_layout_conditional_else" = "Ngược lại hiển thị"; +"menu_bar_layout_conditional_and" = "và"; +"menu_bar_layout_conditional_or" = "hoặc"; +"menu_bar_layout_conditional_add_condition" = "Thêm điều kiện"; +"menu_bar_layout_conditional_none" = "Chưa có điều kiện nào"; +"menu_bar_layout_conditional_hide" = "Ẩn"; +"menu_bar_layout_conditional_name" = "Tên"; +"menu_bar_layout_conditional_name_placeholder" = "ví dụ: Kiểm tra phiên"; +"menu_bar_layout_conditional_name_error" = "Nhập tên duy nhất"; +"menu_bar_layout_conditional_duplicate" = "Nhân bản"; +"menu_bar_layout_conditional_summary" = "Nếu %1$@ thì %2$@ ngược lại %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@ (bản sao)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@ (bản sao %2$d)"; +"menu_bar_layout_conditional_unavailable" = "Điều kiện không khả dụng"; +"menu_bar_layout_conditional_default_session_busy" = "Phiên đã dùng trên 50%"; +"menu_bar_layout_conditional_default_weekly_high" = "Tuần đã dùng trên 90%"; +"menu_bar_layout_conditional_default_session_spent" = "Phiên gần cạn"; +"menu_bar_layout_conditional_default_either_high" = "Phiên hoặc tuần đang ở mức cao"; +"menu_bar_layout_conditional_default_scoped_weekly" = "Tuần theo mô hình đã dùng trên 60%"; diff --git a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings index 59b95da728..3943c2abc8 100644 --- a/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hans.lproj/Localizable.strings @@ -1472,3 +1472,31 @@ "Total usage" = "总用量"; "claude_oauth_keychain_access_revoked" = "Claude Code 轮换令牌后撤销了对 Claude 钥匙串的访问权限。点击“刷新”以重新授权,或将 Claude 用量来源切换为 CLI/Web。"; "claude_showing_last_known_usage" = "正在显示 %@ 采集的最后已知用量。"; + +"menu_bar_layout_token_conditional" = "条件"; +"menu_bar_layout_group_conditionals" = "条件"; +"menu_bar_layout_conditional_add" = "新建条件"; +"menu_bar_layout_conditional_edit" = "编辑条件"; +"menu_bar_layout_conditional_remove" = "从库中移除"; +"menu_bar_layout_conditional_save" = "保存"; +"menu_bar_layout_conditional_if" = "如果"; +"menu_bar_layout_conditional_then" = "则显示"; +"menu_bar_layout_conditional_else" = "否则显示"; +"menu_bar_layout_conditional_and" = "和"; +"menu_bar_layout_conditional_or" = "或"; +"menu_bar_layout_conditional_add_condition" = "添加条件"; +"menu_bar_layout_conditional_none" = "暂无条件"; +"menu_bar_layout_conditional_hide" = "隐藏"; +"menu_bar_layout_conditional_name" = "名称"; +"menu_bar_layout_conditional_name_placeholder" = "例如:会话检查"; +"menu_bar_layout_conditional_name_error" = "请输入唯一名称"; +"menu_bar_layout_conditional_duplicate" = "复制"; +"menu_bar_layout_conditional_summary" = "如果 %1$@ 则 %2$@ 否则 %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@(副本)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@(副本 %2$d)"; +"menu_bar_layout_conditional_unavailable" = "条件不可用"; +"menu_bar_layout_conditional_default_session_busy" = "会话已用超过 50%"; +"menu_bar_layout_conditional_default_weekly_high" = "每周已用超过 90%"; +"menu_bar_layout_conditional_default_session_spent" = "会话即将用尽"; +"menu_bar_layout_conditional_default_either_high" = "会话或每周用量偏高"; +"menu_bar_layout_conditional_default_scoped_weekly" = "模型专属每周已用超过 60%"; diff --git a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings index 371a194ad4..493aacdec6 100644 --- a/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/CodexBar/Resources/zh-Hant.lproj/Localizable.strings @@ -1525,3 +1525,31 @@ "Copy JSON" = "複製 JSON"; "Sources" = "Sources"; "OpenCodex" = "OpenCodex"; + +"menu_bar_layout_token_conditional" = "條件"; +"menu_bar_layout_group_conditionals" = "條件"; +"menu_bar_layout_conditional_add" = "新增條件"; +"menu_bar_layout_conditional_edit" = "編輯條件"; +"menu_bar_layout_conditional_remove" = "從庫中移除"; +"menu_bar_layout_conditional_save" = "儲存"; +"menu_bar_layout_conditional_if" = "如果"; +"menu_bar_layout_conditional_then" = "則顯示"; +"menu_bar_layout_conditional_else" = "否則顯示"; +"menu_bar_layout_conditional_and" = "和"; +"menu_bar_layout_conditional_or" = "或"; +"menu_bar_layout_conditional_add_condition" = "新增條件"; +"menu_bar_layout_conditional_none" = "暫無條件"; +"menu_bar_layout_conditional_hide" = "隱藏"; +"menu_bar_layout_conditional_name" = "名稱"; +"menu_bar_layout_conditional_name_placeholder" = "例如:工作階段檢查"; +"menu_bar_layout_conditional_name_error" = "請輸入唯一名稱"; +"menu_bar_layout_conditional_duplicate" = "複製"; +"menu_bar_layout_conditional_summary" = "如果 %1$@ 則 %2$@ 否則 %3$@"; +"menu_bar_layout_conditional_copy_name" = "%@(副本)"; +"menu_bar_layout_conditional_copy_name_numbered" = "%1$@(副本 %2$d)"; +"menu_bar_layout_conditional_unavailable" = "條件不可用"; +"menu_bar_layout_conditional_default_session_busy" = "工作階段已用超過 50%"; +"menu_bar_layout_conditional_default_weekly_high" = "每週已用超過 90%"; +"menu_bar_layout_conditional_default_session_spent" = "工作階段即將用盡"; +"menu_bar_layout_conditional_default_either_high" = "工作階段或每週用量偏高"; +"menu_bar_layout_conditional_default_scoped_weekly" = "模型專屬每週已用超過 60%"; diff --git a/Sources/CodexBar/SettingsStore+Defaults.swift b/Sources/CodexBar/SettingsStore+Defaults.swift index 29ae5a6cf1..0c39453205 100644 --- a/Sources/CodexBar/SettingsStore+Defaults.swift +++ b/Sources/CodexBar/SettingsStore+Defaults.swift @@ -420,6 +420,28 @@ extension SettingsStore { } } + var menuBarLayoutConditionals: [MenuBarLayoutConditional] { + get { self.defaultsState.menuBarLayoutConditionals } + set { + self.defaultsState.menuBarLayoutConditionals = newValue + self.persistMenuBarLayoutConditionals() + } + } + + func removeMenuBarLayoutConditional(id: UUID) { + self.menuBarLayoutConditionals.removeAll { $0.id == id } + if let stored = self.defaultsState.storedMenuBarLayout, + let stripped = stored.removingConditional(id: id) + { + self.menuBarLayout = stripped + } + for (key, layout) in self.defaultsState.menuBarLayoutOverridesRaw { + guard let stripped = layout.removingConditional(id: id) else { continue } + self.defaultsState.menuBarLayoutOverridesRaw[key] = stripped + } + self.persistMenuBarLayoutOverrides() + } + var hasStoredMenuBarLayout: Bool { self.defaultsState.storedMenuBarLayout != nil } @@ -504,6 +526,11 @@ extension SettingsStore { self.userDefaults.set(blobs.legacy, forKey: MenuBarLayoutUserDefaultsKey.layout) } + private func persistMenuBarLayoutConditionals() { + guard let data = try? JSONEncoder().encode(self.defaultsState.menuBarLayoutConditionals) else { return } + self.userDefaults.set(data, forKey: "menuBarLayoutConditionals") + } + private func persistMenuBarLayoutOverrides() { guard let blobs = try? MenuBarLayoutPersistence.encodedOverrides(self.defaultsState.menuBarLayoutOverridesRaw) else { return } diff --git a/Sources/CodexBar/SettingsStore.swift b/Sources/CodexBar/SettingsStore.swift index f805cae43c..ebb6c5b120 100644 --- a/Sources/CodexBar/SettingsStore.swift +++ b/Sources/CodexBar/SettingsStore.swift @@ -537,6 +537,7 @@ extension SettingsStore { let multiAccountMenuLayoutRaw = Self.loadMultiAccountMenuLayoutRaw(userDefaults: userDefaults) let resolvedPreferences = Self.loadMenuBarMetricPreferences(userDefaults: userDefaults) let storedMenuBarLayout = Self.loadMenuBarLayout(userDefaults: userDefaults) + let menuBarLayoutConditionals = Self.loadMenuBarLayoutConditionals(userDefaults: userDefaults) let menuBarLayoutOverridesRaw = Self.loadMenuBarLayoutOverrides(userDefaults: userDefaults) let menuBarLayoutSizeRaw = userDefaults.string(forKey: "menuBarLayoutSize") ?? MenuBarLayoutSize.regular.rawValue @@ -681,6 +682,7 @@ extension SettingsStore { multiAccountMenuLayoutRaw: multiAccountMenuLayoutRaw, menuBarMetricPreferencesRaw: resolvedPreferences, storedMenuBarLayout: storedMenuBarLayout, + menuBarLayoutConditionals: menuBarLayoutConditionals, menuBarLayoutOverridesRaw: menuBarLayoutOverridesRaw, menuBarLayoutSizeRaw: menuBarLayoutSizeRaw, menuBarLayoutGapRaw: menuBarLayoutGapRaw, @@ -872,6 +874,15 @@ extension SettingsStore { into: userDefaults) } + private static func loadMenuBarLayoutConditionals(userDefaults: UserDefaults) -> [MenuBarLayoutConditional] { + // A missing key means a fresh install, so hand back the shipped library. Any edit, add, or + // removal writes the key, so a library the user deliberately emptied is never reseeded. + guard let data = userDefaults.data(forKey: "menuBarLayoutConditionals") else { + return MenuBarLayoutConditional.shippedLibrary() + } + return (try? JSONDecoder().decode([MenuBarLayoutConditional].self, from: data)) ?? [] + } + private static func loadMenuBarLayoutOverrides(userDefaults: UserDefaults) -> [String: MenuBarLayout] { MenuBarLayoutPersistence.loadOverrides( current: self.decodeMenuBarLayoutOverrides( diff --git a/Sources/CodexBar/SettingsStoreState.swift b/Sources/CodexBar/SettingsStoreState.swift index b5e994e621..5a291c136c 100644 --- a/Sources/CodexBar/SettingsStoreState.swift +++ b/Sources/CodexBar/SettingsStoreState.swift @@ -39,6 +39,7 @@ struct SettingsDefaultsState { var multiAccountMenuLayoutRaw: String var menuBarMetricPreferencesRaw: [String: String] var storedMenuBarLayout: MenuBarLayout? + var menuBarLayoutConditionals: [MenuBarLayoutConditional] var menuBarLayoutOverridesRaw: [String: MenuBarLayout] var menuBarLayoutSizeRaw: String var menuBarLayoutGapRaw: String diff --git a/Sources/CodexBar/StatusItemController+Animation.swift b/Sources/CodexBar/StatusItemController+Animation.swift index 3ad23d1d7a..2ad7f1b211 100644 --- a/Sources/CodexBar/StatusItemController+Animation.swift +++ b/Sources/CodexBar/StatusItemController+Animation.swift @@ -1254,8 +1254,8 @@ extension StatusItemController { if !layoutResolution.usesLegacyRendering, self.settings.menuBarIconStyle == .iconAndPercent { - let showsReset = layoutResolution.layout.lines - .joined() + let showsReset = layoutResolution.layout + .flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) .contains { $0 == .resetCountdown || $0 == .resetAbsolute } guard showsReset else { return [] } let window = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: now).automatic diff --git a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift index 89de2699c2..27ee6c68e0 100644 --- a/Sources/CodexBar/StatusItemController+CountdownRefresh.swift +++ b/Sources/CodexBar/StatusItemController+CountdownRefresh.swift @@ -23,7 +23,7 @@ extension StatusItemController { if !resolution.usesLegacyRendering, self.settings.menuBarIconStyle == .iconAndPercent { - let tokens = resolution.layout.lines.joined() + let tokens = resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) if tokens.contains(.resetCountdown) { countdownResetDates.append(contentsOf: resetDates) } @@ -152,10 +152,12 @@ extension StatusItemController { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering, self.settings.menuBarIconStyle == .iconAndPercent, - resolution.layout.lines.joined().contains(where: { - if case .pace(window: .weekly) = $0 { return true } - return false - }) + resolution.layout + .flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .contains(where: { + if case .pace(window: .weekly) = $0 { return true } + return false + }) else { return nil } let snapshot = self.store.menuBarSnapshot(for: provider.instanceID) guard let window = self.menuBarLayoutWindows( diff --git a/Sources/CodexBar/StatusItemController+IconObservation.swift b/Sources/CodexBar/StatusItemController+IconObservation.swift index fa79018dde..ea599f1a06 100644 --- a/Sources/CodexBar/StatusItemController+IconObservation.swift +++ b/Sources/CodexBar/StatusItemController+IconObservation.swift @@ -89,8 +89,9 @@ extension StatusItemController { { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering, - resolution.layout.lines.joined().contains(.accountLabel), - let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) + resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .contains(.accountLabel), + let accountLabel = self.menuBarLayoutAccountLabel(provider: provider, snapshot: snapshot) else { return nil } var hasher = Hasher() @@ -102,7 +103,7 @@ extension StatusItemController { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering else { return nil } - let tokens = resolution.layout.lines.joined() + let tokens = resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) let showsToday = tokens.contains(.costToday) let showsLast30Days = tokens.contains(.cost30d) guard showsToday || showsLast30Days else { return nil } @@ -121,7 +122,8 @@ extension StatusItemController { { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering, - resolution.layout.lines.joined().contains(.balance) + resolution.layout.flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .contains(.balance) else { return nil } return MenuBarLayoutBalanceResolver.balance(provider: provider, snapshot: snapshot) } @@ -138,10 +140,12 @@ extension StatusItemController { let resolution = self.settings.menuBarLayoutResolution(for: provider) guard !resolution.usesLegacyRendering else { return nil } - let paceWindows = Set(resolution.layout.lines.joined().compactMap { token -> PercentWindow? in - guard case let .pace(window) = token else { return nil } - return window - }) + let paceWindows = Set(resolution.layout + .flattenedTokens(conditionals: self.settings.menuBarLayoutConditionals) + .compactMap { token -> PercentWindow? in + guard case let .pace(window) = token else { return nil } + return window + }) guard !paceWindows.isEmpty else { return nil } let windows = self.menuBarLayoutWindows(provider: provider, snapshot: snapshot, now: Date()) diff --git a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift index 38d832da82..8bf8e9d717 100644 --- a/Sources/CodexBar/StatusItemController+MenuBarLayout.swift +++ b/Sources/CodexBar/StatusItemController+MenuBarLayout.swift @@ -41,6 +41,7 @@ extension StatusItemController { size: self.settings.menuBarLayoutSize, highContrast: self.shouldUseHighContrastStatusItemContent, showUsed: self.settings.usageBarsShowUsed, + conditionals: self.settings.menuBarLayoutConditionals, appearanceName: appearanceName, isDebugApp: Self.isDebugApp(bundleIdentifier: Bundle.main.bundleIdentifier), isStale: self.store.isStale(provider: provider), diff --git a/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift b/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift index b309af992d..f970d3c698 100644 --- a/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutEditorTests.swift @@ -175,6 +175,25 @@ struct MenuBarLayoutEditorTests { #expect(decoded == payload) } + @Test + func `conditional drag payload round trips`() throws { + let payload = MenuBarLayoutDragItem.palette(.conditional(id: UUID())) + + let data = try JSONEncoder().encode(payload) + #expect(try JSONDecoder().decode(MenuBarLayoutDragItem.self, from: data) == payload) + } + + @Test + func `conditional token appends like palette tokens`() { + let initial = MenuBarLayout(lines: [[.icon, .resetCountdown]]) + let conditionalID = UUID() + + let appended = MenuBarLayoutEditorMutations.append( + .conditional(id: conditionalID), + to: initial) + #expect(appended.lines == [[.icon, .resetCountdown, .conditional(id: conditionalID)]]) + } + @Test func `balance token is provider aware`() throws { let row = try ProviderDetailSection.Row(label: "Remaining", value: "$12.34") @@ -189,4 +208,31 @@ struct MenuBarLayoutEditorTests { #expect(MenuBarLayoutBalanceResolver.balance(provider: .codex, snapshot: snapshot) == nil) #expect(MenuBarLayoutToken.balance.editorLabel(provider: .openrouter) == L("Balance")) } + + @Test + func `conditional palette chips wrap instead of overflowing the pane`() { + let spacing: CGFloat = 6 + + // Two 100pt chips fit in 220pt (100 + 6 + 100); the third has to wrap. + #expect(MenuBarLayoutChipFlowLayout.rows( + widths: [100, 100, 100], + maxWidth: 220, + spacing: spacing) == [[0, 1], [2]]) + + // Long localized names still get placed on their own row rather than dropped. + #expect(MenuBarLayoutChipFlowLayout.rows( + widths: [400], + maxWidth: 220, + spacing: spacing) == [[0]]) + #expect(MenuBarLayoutChipFlowLayout.rows( + widths: [400, 120], + maxWidth: 220, + spacing: spacing) == [[0], [1]]) + + // Chips that fit stay on one row, so a short library keeps hugging the leading edge. + #expect(MenuBarLayoutChipFlowLayout.rows( + widths: [80, 90], + maxWidth: 220, + spacing: spacing) == [[0, 1]]) + } } diff --git a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift index e5a2a2ddad..f0e373bf65 100644 --- a/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutRendererTests.swift @@ -6,6 +6,7 @@ import Testing @MainActor @Suite(.serialized) +// swiftlint:disable:next type_body_length struct MenuBarLayoutRendererTests { private let now = Date(timeIntervalSince1970: 1_752_768_000) @@ -498,6 +499,7 @@ struct MenuBarLayoutRendererTests { size: .regular, highContrast: false, showUsed: false, + conditionals: [], appearanceName: "aqua", isDebugApp: false, now: self.now)) @@ -554,6 +556,7 @@ struct MenuBarLayoutRendererTests { size: options.size, highContrast: true, showUsed: options.showUsed, + conditionals: options.conditionals, appearanceName: options.appearanceName, isDebugApp: options.isDebugApp, now: options.now) @@ -609,6 +612,464 @@ struct MenuBarLayoutRendererTests { .attribute(.foregroundColor, at: 0, effectiveRange: nil) as? NSColor == .controlTextColor) } + @Test + func `conditional renders then branch when predicate true`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = self.data() + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session)]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "5h 25%") + } + + @Test + func `conditional renders else branch when predicate false`() { + let renderer = MenuBarLayoutRenderer() + // Session is 25%, so a > 50 threshold fails and the else branch must win. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = self.data() + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.resetCountdown]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "in 2h") + } + + @Test + func `hidden branch renders nothing`() { + let renderer = MenuBarLayoutRenderer() + // Session is 25%, so > 50 fails and the else branch (.hidden) wins, contributing nothing. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .hidden) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional])) + #expect(output.attributedTitle.string.isEmpty) + } + + @Test + func `conditional and requires all predicates`() { + let renderer = MenuBarLayoutRenderer() + let data = self.data() + + // Session 25% > 0 (true) AND weekly 60% > 70 (false) -> whole clause false. + let falseConditional = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .session, comparison: .greaterThan, threshold: 0), + self.clause(metric: .weekly, comparison: .greaterThan, threshold: 70, combinator: .and), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let falseOutput = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: falseConditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [falseConditional])) + let falseControl = renderer.render( + layout: MenuBarLayout(lines: [[.resetCountdown]]), + data: data, + icon: nil, + options: self.options()) + #expect(falseOutput.attributedTitle.string == falseControl.attributedTitle.string) + + // Session 25% > 0 (true) AND weekly 60% > 50 (true) -> all predicates pass. + let trueConditional = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .session, comparison: .greaterThan, threshold: 0), + self.clause(metric: .weekly, comparison: .greaterThan, threshold: 50, combinator: .and), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let trueOutput = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: trueConditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [trueConditional])) + let trueControl = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session)]]), + data: data, + icon: nil, + options: self.options()) + #expect(trueOutput.attributedTitle.string == trueControl.attributedTitle.string) + #expect(trueOutput.attributedTitle.string == "5h 25%") + } + + @Test + func `conditional or accepts any predicate`() { + let renderer = MenuBarLayoutRenderer() + // Weekly 60% > 70 (false) OR session 25% > 0 (true) -> whole clause true. + let conditional = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .weekly, comparison: .greaterThan, threshold: 70), + self.clause(metric: .session, comparison: .greaterThan, threshold: 0, combinator: .or), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = self.data() + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session)]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "5h 25%") + } + + @Test + func `conditional with missing metric window falls to else`() { + let renderer = MenuBarLayoutRenderer() + // Session window is nil, so the session predicate evaluates false (not "0 > 0"). + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = MenuBarLayoutRenderData( + provider: .codex, + iconKey: "missing", + providerName: nil, + accountLabel: nil, + laneLabels: MenuBarLayoutLaneLabels(provider: .codex, snapshot: nil), + primary: nil, + secondary: nil, + tertiary: nil, + session: nil, + weekly: nil, + scopedWeekly: nil, + scopedWeeklyTitle: nil, + automatic: nil, + automaticText: nil, + sessionPace: nil, + weeklyPace: nil, + automaticPace: nil, + runsOut: nil, + balance: nil, + costToday: nil, + cost30d: nil) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.resetCountdown]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "–") + } + + @Test + func `nested conditional depth cap renders placeholder`() { + let renderer = MenuBarLayoutRenderer() + // A conditionals entry may reference itself through its branches; the renderer caps + // traversal at maxConditionalDepth. + let selfID = UUID() + let looping = MenuBarLayoutConditional( + id: selfID, + name: "loop", + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .conditional(id: selfID), + elseToken: .space) + let data = self.data() + // The cap triggers before any branch is evaluated, independent of live values, + // so the missing-value placeholder is the expected title. + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: selfID)]]), + data: data, + icon: nil, + options: self.options(conditionals: [looping])) + + #expect(output.attributedTitle.string == "–") + } + + @Test + func `conditional accessibility announces the chosen branch`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let data = self.data() + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: data, + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session)]]), + data: data, + icon: nil, + options: self.options()) + + #expect(output.accessibilityLabel == control.accessibilityLabel) + #expect(output.accessibilityLabel == L("%@ %@", L("Session"), "25%")) + } + + @Test + func `hidden branch leaves no orphaned spacing`() { + let renderer = MenuBarLayoutRenderer() + // Session is 25%, so a > 50 threshold fails and the else branch (.hidden) wins: the + // conditional contributes nothing, and the neighbors must join without a stray separator. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .hidden) + let options = self.options(conditionals: [conditional]) + + let middle = renderer.render( + layout: MenuBarLayout(lines: [[ + .percent(window: .session), + .conditional(id: conditional.id), + .percent(window: .weekly), + ]]), + data: self.data(), + icon: nil, + options: options) + #expect(middle.attributedTitle.string == "5h 25%\u{2009}W 60%") + + let trailing = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .session), .conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: options) + #expect(trailing.attributedTitle.string == "5h 25%") + } + + @Test + func `a line emptied by a hidden branch collapses instead of rendering blank`() throws { + let renderer = MenuBarLayoutRenderer() + // Session is 25%, so > 50 fails and the else branch (.hidden) wins on the conditional line. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .hidden) + let options = self.options(conditionals: [conditional]) + + // Leading line hidden: no leading newline, and no empty VoiceOver line. + let leadingHidden = renderer.render( + layout: MenuBarLayout(lines: [ + [.conditional(id: conditional.id)], + [.percent(window: .weekly)], + ]), + data: self.data(), + icon: nil, + options: options) + #expect(leadingHidden.attributedTitle.string == "W 60%") + #expect(!leadingHidden.accessibilityLabel.contains(L("menu_bar_layout_line", 2))) + + // Trailing line hidden: no trailing newline. + let trailingHidden = renderer.render( + layout: MenuBarLayout(lines: [ + [.percent(window: .weekly)], + [.conditional(id: conditional.id)], + ]), + data: self.data(), + icon: nil, + options: options) + #expect(trailingHidden.attributedTitle.string == "W 60%") + + // A collapsed layout also drops stacked typography: it matches the single-line control. + let control = renderer.render( + layout: MenuBarLayout(lines: [[.percent(window: .weekly)]]), + data: self.data(), + icon: nil, + options: options) + let collapsedOffset = try #require(self.baselineOffset(in: leadingHidden.attributedTitle, at: 0)) + let controlOffset = try #require(self.baselineOffset(in: control.attributedTitle, at: 0)) + #expect(collapsedOffset == controlOffset) + #expect(leadingHidden.accessibilityLabel == control.accessibilityLabel) + } + + @Test + func `every line hidden renders an empty title without crashing`() { + let renderer = MenuBarLayoutRenderer() + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .hidden) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional])) + #expect(output.attributedTitle.string.isEmpty) + #expect(output.accessibilityLabel.isEmpty) + + // The debug marker has no line to attach to once everything collapsed. + let debug = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional], isDebugApp: true)) + #expect(debug.attributedTitle.string == " D") + #expect(debug.accessibilityLabel == L("Debug")) + } + + @Test + func `a conditional resolving to the icon surfaces it as the leading image`() throws { + let renderer = MenuBarLayoutRenderer() + let icon = NSImage(size: NSSize(width: 16, height: 16)) + icon.isTemplate = true + // Session is 25%, so > 0 passes and the then branch (.icon) wins in first position. + let conditional = MenuBarLayoutConditional( + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .icon, + elseToken: .hidden) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id), .percent(window: .automatic)]]), + data: self.data(), + icon: icon, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.icon, .percent(window: .automatic)]]), + data: self.data(), + icon: icon, + options: self.options()) + + // AppKit only dims `button.image` on inactive displays, so a resolved icon has to take the + // same path a literal `.icon` token takes rather than becoming an attributed attachment. + let resolvedIcon = try #require(output.leadingIcon) + let controlIcon = try #require(control.leadingIcon) + #expect(resolvedIcon === controlIcon) + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.attribute(.attachment, at: 0, effectiveRange: nil) == nil) + #expect(output.accessibilityLabel == control.accessibilityLabel) + } + + @Test + func `dangling conditional reference renders placeholder`() { + let renderer = MenuBarLayoutRenderer() + // An id not present in the library has nothing to resolve; the renderer must show the + // missing-value placeholder instead of emitting a branch. + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: UUID())]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [])) + + #expect(output.attributedTitle.string == "–") + #expect(output.accessibilityLabel.contains(L("menu_bar_layout_conditional_unavailable"))) + } + + @Test + func `library edit changes the rendered branch without touching the layout`() { + let renderer = MenuBarLayoutRenderer() + let id = UUID() + // The layout only stores the reference; the library entry decides the branch. + let layout = MenuBarLayout(lines: [[.conditional(id: id)]]) + + let conditionTrue = MenuBarLayoutConditional( + id: id, + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 0)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let first = renderer.render( + layout: layout, + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditionTrue])) + #expect(first.attributedTitle.string == "5h 25%") + + // Flip the comparison so the same layout now renders the else branch. + let conditionFalse = MenuBarLayoutConditional( + id: id, + clauses: [self.clause(metric: .session, comparison: .greaterThan, threshold: 50)], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let second = renderer.render( + layout: layout, + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditionFalse])) + #expect(second.attributedTitle.string != first.attributedTitle.string) + #expect(second.attributedTitle.string == "in 2h") + } + + @Test + func `mixed and or evaluates as a left fold`() { + let renderer = MenuBarLayoutRenderer() + // Session 25% > 0 (T) or weekly 60% > 90 (F) and automatic 50% > 90 (F). + // The left fold yields ((T ∨ F) ∧ F) = false, so the else branch wins; conventional + // precedence (T ∨ (F ∧ F) = true) would pick the then branch instead. + let conditional = MenuBarLayoutConditional( + clauses: [ + self.clause(metric: .session, comparison: .greaterThan, threshold: 0), + self.clause(metric: .weekly, comparison: .greaterThan, threshold: 90, combinator: .or), + self.clause(metric: .automatic, comparison: .greaterThan, threshold: 90, combinator: .and), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + + let output = renderer.render( + layout: MenuBarLayout(lines: [[.conditional(id: conditional.id)]]), + data: self.data(), + icon: nil, + options: self.options(conditionals: [conditional])) + let control = renderer.render( + layout: MenuBarLayout(lines: [[.resetCountdown]]), + data: self.data(), + icon: nil, + options: self.options()) + + #expect(output.attributedTitle.string == control.attributedTitle.string) + #expect(output.attributedTitle.string == "in 2h") + } + + private func clause( + metric: MenuBarConditionalMetric, + comparison: MenuBarConditionalComparison, + threshold: Double, + combinator: MenuBarConditionalCombinator? = nil) -> MenuBarConditionalClause + { + MenuBarConditionalClause( + combinator: combinator, + predicate: MenuBarConditionalPredicate(metric: metric, comparison: comparison, threshold: threshold)) + } + private func data( automaticUsedPercent: Double = 50, provider: UsageProvider = .codex, @@ -671,14 +1132,17 @@ struct MenuBarLayoutRendererTests { private func options( now: Date? = nil, verticalAdjustment: Int = 0, - isStale: Bool = false) -> MenuBarLayoutRenderOptions + isStale: Bool = false, + conditionals: [MenuBarLayoutConditional] = [], + isDebugApp: Bool = false) -> MenuBarLayoutRenderOptions { MenuBarLayoutRenderOptions( size: .regular, highContrast: false, showUsed: true, + conditionals: conditionals, appearanceName: "aqua", - isDebugApp: false, + isDebugApp: isDebugApp, isStale: isStale, now: now ?? self.now, verticalAdjustment: verticalAdjustment) diff --git a/Tests/CodexBarTests/MenuBarLayoutTests.swift b/Tests/CodexBarTests/MenuBarLayoutTests.swift index 78273444c3..3090fcdd16 100644 --- a/Tests/CodexBarTests/MenuBarLayoutTests.swift +++ b/Tests/CodexBarTests/MenuBarLayoutTests.swift @@ -3,6 +3,7 @@ import Foundation import Testing @testable import CodexBar +// swiftlint:disable:next type_body_length struct MenuBarLayoutTests { private struct UnnormalizedLayout: Encodable { let lines: [[MenuBarLayoutToken]] @@ -55,6 +56,267 @@ struct MenuBarLayoutTests { #expect(try JSONDecoder().decode(MenuBarLayoutToken.self, from: labeled) == .runsOut) } + @Test + func `conditional token codable round trips`() throws { + let conditional = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let layout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + + let data = try JSONEncoder().encode(layout) + let decoded = try JSONDecoder().decode(MenuBarLayout.self, from: data) + + #expect(decoded == layout) + let json = try #require(String(bytes: data, encoding: .utf8)) + #expect(json.contains("conditional")) + #expect(json.contains(conditional.id.uuidString)) + } + + @Test + func `flattened tokens include conditional branches`() { + let conditional = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let layout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + + let flattened = layout.flattenedTokens(conditionals: [conditional]) + + #expect(flattened.contains(.icon)) + #expect(flattened.contains(.conditional(id: conditional.id))) + #expect(flattened.contains(.percent(window: .session))) + #expect(flattened.contains(.resetCountdown)) + } + + @Test + func `conditional normalization clamps thresholds and clause count`() { + let predicate = MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0) + let manyClauses = (0..<6).map { index in + MenuBarConditionalClause( + combinator: index == 0 ? .or : .and, + predicate: MenuBarConditionalPredicate( + metric: .session, + comparison: .greaterThan, + threshold: 250)) + } + let conditional = MenuBarLayoutConditional( + clauses: manyClauses, + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + + #expect(conditional.clauses.count == 4) + #expect(conditional.clauses.allSatisfy { $0.predicate.threshold == 100 }) + + let empty = MenuBarLayoutConditional( + clauses: [], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + #expect(empty.clauses.count == 1) + #expect(empty.clauses[0].combinator == nil) + #expect(empty.clauses[0].predicate.metric == .session) + #expect(empty.clauses[0].predicate.comparison == .greaterThan) + #expect(empty.clauses[0].predicate.threshold == 0) + + let forcedFirst = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: .or, + predicate: MenuBarConditionalPredicate( + metric: .weekly, + comparison: .greaterThanOrEqual, + threshold: 5))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + #expect(forcedFirst.clauses[0].combinator == nil) + } + + @Test + @MainActor + func `conditional display name falls back when unnamed then uses its name`() { + let unnamed = MenuBarLayoutConditional.makeDefault() + #expect(!unnamed.displayName.isEmpty) + + let named = MenuBarLayoutConditional( + name: "Gate", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .automatic), + elseToken: .hidden) + #expect(named.displayName == "Gate") + } + + @Test + func `conditional name survives codable round trip and legacy form decodes unnamed`() throws { + let named = MenuBarLayoutConditional( + name: " Zeroth Gate ", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .automatic), + elseToken: .hidden) + + let data = try JSONEncoder().encode(named) + let decoded = try JSONDecoder().decode(MenuBarLayoutConditional.self, from: data) + #expect(decoded == named) + #expect(decoded.name == " Zeroth Gate ") + + // An older persisted conditional without a `name` key must decode with an empty name. + guard var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + Issue.record("expected a JSON object") + return + } + json.removeValue(forKey: "name") + let legacyData = try JSONSerialization.data(withJSONObject: json) + let legacy = try JSONDecoder().decode(MenuBarLayoutConditional.self, from: legacyData) + #expect(legacy.name.isEmpty) + } + + @Test + @MainActor + func `conditionals library and layout persist across reload`() { + let suite = "MenuBarLayoutTests-conditional-persistence" + let settings = testSettingsStore(suiteName: suite) + let conditional = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let defaultEntry = MenuBarLayoutConditional.makeDefault() + let layout = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + + settings.menuBarLayoutConditionals = [conditional, defaultEntry] + settings.setMenuBarLayout(layout, for: nil) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals == [conditional, defaultEntry]) + #expect(reloaded.menuBarLayout == layout) + } + + @Test + @MainActor + func `a fresh install ships an editable conditionals library`() { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-shipped-conditionals") + + let shipped = MenuBarLayoutConditional.shippedLibrary() + #expect(!shipped.isEmpty) + #expect(settings.menuBarLayoutConditionals == shipped) + + // Identities are fixed, so a placed reference keeps resolving on the next launch. + #expect(MenuBarLayoutConditional.shippedLibrary().map(\.id) == shipped.map(\.id)) + + // Each entry has to be a usable, distinctly named starting point. + #expect(Set(shipped.map(\.id)).count == shipped.count) + #expect(Set(shipped.map { $0.name.lowercased() }).count == shipped.count) + #expect(shipped.allSatisfy { !$0.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }) + #expect(shipped.allSatisfy { !$0.clauses.isEmpty && $0.clauses[0].combinator == nil }) + } + + @Test + @MainActor + func `clearing the shipped conditionals library survives a reload`() { + let suite = "MenuBarLayoutTests-shipped-conditionals-cleared" + let settings = testSettingsStore(suiteName: suite) + #expect(!settings.menuBarLayoutConditionals.isEmpty) + + // Removing every shipped entry is a deliberate choice; the next launch must not reseed. + for conditional in settings.menuBarLayoutConditionals { + settings.removeMenuBarLayoutConditional(id: conditional.id) + } + #expect(settings.menuBarLayoutConditionals.isEmpty) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals.isEmpty) + } + + @Test + func `unique copy name walks numbered suffixes`() { + let existing: Set = ["gate (copy)"] + #expect(MenuBarLayoutConditional.uniqueCopyName(basedOn: "Gate", existingNames: existing) == "Gate (copy 2)") + + // An empty stem falls back to the generic conditional label rather than a bare suffix. + let generic = MenuBarLayoutConditional.uniqueCopyName(basedOn: " ", existingNames: []) + #expect(!generic.isEmpty) + } + + @Test + func `conditional summary parenthesizes mixed combinators`() { + let mixed = MenuBarLayoutConditional( + clauses: [ + MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0)), + MenuBarConditionalClause( + combinator: .or, + predicate: MenuBarConditionalPredicate(metric: .weekly, comparison: .greaterThan, threshold: 50)), + MenuBarConditionalClause( + combinator: .and, + predicate: MenuBarConditionalPredicate( + metric: .automatic, + comparison: .greaterThan, + threshold: 80)), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + #expect(mixed.editorSummary(provider: nil).contains("(")) + + let uniform = MenuBarLayoutConditional( + clauses: [ + MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 0)), + MenuBarConditionalClause( + combinator: .and, + predicate: MenuBarConditionalPredicate(metric: .weekly, comparison: .greaterThan, threshold: 50)), + ], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + #expect(!uniform.editorSummary(provider: nil).contains("(")) + } + + @Test + @MainActor + func `removing a library conditional strips references everywhere`() { + let suite = "MenuBarLayoutTests-removing-conditional" + let settings = testSettingsStore(suiteName: suite) + let conditional = MenuBarLayoutConditional( + id: UUID(), + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .session), + elseToken: .resetCountdown) + let global = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)]]) + let override = MenuBarLayout(lines: [[.conditional(id: conditional.id), .percent(window: .weekly)]]) + + settings.menuBarLayoutConditionals = [conditional] + settings.setMenuBarLayout(global, for: nil) + settings.setMenuBarLayout(override, for: .claude) + + settings.removeMenuBarLayoutConditional(id: conditional.id) + + #expect(settings.menuBarLayoutConditionals.isEmpty) + #expect(Self.hasNoConditionalReference(settings.menuBarLayout)) + #expect(Self.hasNoConditionalReference(settings.menuBarLayout(for: .claude))) + + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayoutConditionals.isEmpty) + #expect(Self.hasNoConditionalReference(reloaded.menuBarLayout)) + #expect(Self.hasNoConditionalReference(reloaded.menuBarLayout(for: .claude))) + } + + private static func hasNoConditionalReference(_ layout: MenuBarLayout) -> Bool { + !layout.lines.flatMap(\.self).contains { token in + if case .conditional = token { return true } + return false + } + } + @Test func `decoding normalizes empty and extra lines`() throws { let emptyData = try JSONEncoder().encode(UnnormalizedLayout(lines: [])) @@ -145,6 +407,42 @@ struct MenuBarLayoutTests { #expect(layout.legacyCompatible().selectedLanes.isEmpty) } + @Test + func `conditional and hidden tokens drop out of the older-readable projection`() { + let conditional = MenuBarLayoutConditional( + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 30))], + thenToken: .percent(window: .session), + elseToken: .hidden) + + // A 0.53.x decoder has no case for these tokens, so the projection must not contain them; + // the surrounding arrangement has to survive. + let mixed = MenuBarLayout(lines: [[ + .icon, + .conditional(id: conditional.id), + .percent(window: .session), + .hidden, + ]]) + #expect(mixed.legacyCompatible() == MenuBarLayout(lines: [[.icon, .percent(window: .session)]])) + + // A line emptied purely by filtering must not survive as a blank stacked row. + let stacked = MenuBarLayout(lines: [ + [.icon, .percent(window: .weekly)], + [.conditional(id: conditional.id)], + ]) + #expect(stacked.legacyCompatible() == MenuBarLayout(lines: [[.icon, .percent(window: .weekly)]])) + + // Nothing left to project falls back to the default layout rather than an empty title. + let conditionalOnly = MenuBarLayout(lines: [[.conditional(id: conditional.id)]]) + #expect(conditionalOnly.legacyCompatible() == .defaultLayout) + + // A line the user left empty (line break added, no token dropped in yet) is not the same as + // one emptied by filtering, so it survives the projection unchanged. + let pendingSecondLine = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id)], []]) + #expect(pendingSecondLine.legacyCompatible() == MenuBarLayout(lines: [[.icon], []])) + } + @Test func `legacy layout JSON without lanePercent cannot decode current lane tokens`() throws { let current = try JSONEncoder().encode(MenuBarLayout(lines: [[ @@ -502,6 +800,51 @@ struct MenuBarLayoutTests { #expect(reloaded.menuBarLayoutOverrides[.claude] == claude) } + @Test + @MainActor + func `conditional layouts dual-write an older-readable fallback`() throws { + let settings = testSettingsStore(suiteName: "MenuBarLayoutTests-conditional-downgrade") + let conditional = MenuBarLayoutConditional( + name: "Gate", + clauses: [MenuBarConditionalClause( + combinator: nil, + predicate: MenuBarConditionalPredicate(metric: .session, comparison: .greaterThan, threshold: 50))], + thenToken: .percent(window: .session), + elseToken: .hidden) + let global = MenuBarLayout(lines: [[.icon, .conditional(id: conditional.id), .percent(window: .automatic)]]) + let cursor = MenuBarLayout(lines: [[.conditional(id: conditional.id), .percent(window: .weekly)]]) + + settings.menuBarLayoutConditionals = [conditional] + settings.setMenuBarLayout(global, for: nil) + settings.setMenuBarLayout(cursor, for: .cursor) + + let decoder = JSONDecoder() + let currentGlobal = try #require(settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.layoutCurrent)) + let legacyGlobal = try #require(settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.layout)) + let currentOverrides = try #require( + settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.overridesCurrent)) + let legacyOverrides = try #require(settings.userDefaults.data(forKey: MenuBarLayoutUserDefaultsKey.overrides)) + + // The legacy blob must stay decodable by the 0.53.x token surface, minus the new tokens. + #expect(try decoder.decode(PreLanePercentMenuBarLayout.self, from: legacyGlobal) == PreLanePercentMenuBarLayout( + lines: [[.icon, .percent(window: .automatic)]])) + #expect(throws: DecodingError.self) { + try decoder.decode(PreLanePercentMenuBarLayout.self, from: currentGlobal) + } + + let legacyMap = try decoder.decode([String: PreLanePercentMenuBarLayout].self, from: legacyOverrides) + #expect(legacyMap["cursor"] == PreLanePercentMenuBarLayout(lines: [[.percent(window: .weekly)]])) + #expect(throws: DecodingError.self) { + try decoder.decode([String: PreLanePercentMenuBarLayout].self, from: currentOverrides) + } + + // Upgrading again keeps the full-fidelity layout: the dual-write blobs agree. + let reloaded = Self.reloadSettingsStore(settings) + #expect(reloaded.menuBarLayout == global) + #expect(reloaded.menuBarLayoutOverrides[.cursor] == cursor) + #expect(reloaded.menuBarLayoutConditionals == [conditional]) + } + @Test @MainActor func `Kimi lane overrides dual-write reversed semantic windows`() throws { diff --git a/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift b/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift index 5830625d27..75e205a36d 100644 --- a/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift +++ b/Tests/CodexBarTests/MenuLayoutScreenshotRenderTests.swift @@ -117,6 +117,7 @@ final class MenuLayoutScreenshotRenderTests: XCTestCase { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "proof", isDebugApp: false, now: Self.now)) @@ -210,6 +211,7 @@ final class MenuLayoutScreenshotRenderTests: XCTestCase { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "proof", isDebugApp: false, now: now)) diff --git a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift index 04ace16921..0b61212f30 100644 --- a/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift +++ b/Tests/CodexBarTests/ProviderArchitectureGatekeeperTests.swift @@ -1741,7 +1741,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared renderer maps provider-owned presentation data into the generic UI model."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayout.swift", - line: 353, + line: 572, anchor: "ProviderDescriptorRegistry.descriptor(for: provider ?? .codex).presentation.primarySemanticWindow)", expectedProviderIDs: ["codex"], expectedReferenceCount: 2, @@ -1749,7 +1749,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayoutEditor.swift", - line: 672, + line: 876, anchor: "let provider = self.provider ?? .codex", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -1757,7 +1757,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact shared construct dispatches a provider-owned capability at the generic integration boundary."), AllowedProviderConstruct( path: "Sources/CodexBar/MenuBarLayoutEditor.swift", - line: 701, + line: 906, anchor: "if provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, @@ -2269,7 +2269,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/SettingsStore.swift", - line: 1163, + line: 1174, anchor: "if !seen.contains(.factory), let zaiIndex = ordered.firstIndex(of: .zai) {", expectedProviderIDs: ["factory", "minimax", "zai"], expectedReferenceCount: 8, @@ -2486,7 +2486,7 @@ struct ProviderArchitectureGatekeeperTests { reason: "This exact app-runtime bridge coordinates provider-owned state through the shared controller."), AllowedProviderConstruct( path: "Sources/CodexBar/StatusItemController+MenuBarLayout.swift", - line: 164, + line: 165, anchor: "if provider == .codex,", expectedProviderIDs: ["codex"], expectedReferenceCount: 1, diff --git a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift index 71d8ddb7e3..15c1408b4f 100644 --- a/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift +++ b/Tests/CodexBarTests/StatusItemBalanceDisplayTests.swift @@ -259,6 +259,7 @@ struct StatusItemBalanceDisplayTests { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "aqua", isDebugApp: false, now: Date())) @@ -1084,6 +1085,7 @@ extension StatusItemBalanceDisplayTests { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "aqua", isDebugApp: false, now: Date())) @@ -1139,6 +1141,7 @@ extension StatusItemBalanceDisplayTests { size: .regular, highContrast: false, showUsed: true, + conditionals: [], appearanceName: "aqua", isDebugApp: false, now: Date()))