From 9d09406d85ef59abb64a543b612394c489cff14b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vincent=20Gr=C3=A9goire?= Date: Sat, 1 Aug 2026 17:22:38 -0400 Subject: [PATCH] add hidden_modes to trim the mode picker Once modes are driven by the app in the focused pane, the mode list grows one entry per app and the picker becomes unusable as a manual switcher. Let modes be marked hidden: excluded from the picker and prev/next cycling, but still fully functional. Add an optional hidden_modes ([String]) at two levels: on ProfileConfig (per-profile modes) and at top level (for cross-profile shared modes), both decoded with decodeIfPresent ?? []. allModeNames, the single source feeding the picker and prev/next cycling, subtracts the union of the two lists. Hidden modes stay reachable via setMode, external context, and default_mode. If hiding would leave nothing, fall back to the full set so the picker is never an empty, inescapable panel. Closes #10 Co-Authored-By: Claude Opus 4.8 --- PadIO/ControllerManager.swift | 12 ++++++++++-- PadIO/MappingConfig.swift | 14 +++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/PadIO/ControllerManager.swift b/PadIO/ControllerManager.swift index 98cf5fc..c7afa87 100644 --- a/PadIO/ControllerManager.swift +++ b/PadIO/ControllerManager.swift @@ -695,11 +695,19 @@ final class ControllerManager: ObservableObject { return nil } - /// Returns the sorted union of profile mode names and shared mode names. + /// Returns the sorted union of profile mode names and shared mode names, minus any + /// modes hidden via `hidden_modes` (per-profile or top-level). This is the single + /// source feeding the mode picker and prev/next cycling — the manual switchers. + /// Hidden modes stay reachable via setMode, external context, and default_mode. + /// + /// Degenerate guard: if hiding would leave nothing, fall back to the full set so the + /// picker is never an empty, inescapable panel. private func allModeNames(profile: ProfileConfig, config: MappingConfig) -> [String] { var names = Set(profile.modes.keys) if let shared = config.sharedModes { names.formUnion(shared.keys) } - return names.sorted() + let hidden = Set(profile.hiddenModes).union(config.hiddenModes) + let visible = names.subtracting(hidden) + return (visible.isEmpty ? names : visible).sorted() } private func switchMode(_ modeName: String, profileName: String) { diff --git a/PadIO/MappingConfig.swift b/PadIO/MappingConfig.swift index b36d452..9dd75f2 100644 --- a/PadIO/MappingConfig.swift +++ b/PadIO/MappingConfig.swift @@ -97,6 +97,10 @@ struct MappingConfig: Codable, Sendable { var aliases: [String: ActionConfig]? /// Modes shared across profiles — any profile can reference these by name. var sharedModes: [String: ModeConfig]? + /// Mode names hidden from the mode picker across all profiles (typically shared modes + /// that are context-driven). Hidden modes stay reachable via setMode, external context, + /// and default_mode; only the picker listing is filtered. + var hiddenModes: [String] enum CodingKeys: String, CodingKey { case triggerThreshold = "trigger_threshold" @@ -107,9 +111,10 @@ struct MappingConfig: Codable, Sendable { case haptics case aliases case sharedModes = "shared_modes" + case hiddenModes = "hidden_modes" } - init(triggerThreshold: Double?, debugOverlay: Bool?, global: [String: ActionConfig], profiles: [String: ProfileConfig], menus: [String: MenuConfig], haptics: HapticsConfig? = nil, aliases: [String: ActionConfig]? = nil, sharedModes: [String: ModeConfig]? = nil) { + init(triggerThreshold: Double?, debugOverlay: Bool?, global: [String: ActionConfig], profiles: [String: ProfileConfig], menus: [String: MenuConfig], haptics: HapticsConfig? = nil, aliases: [String: ActionConfig]? = nil, sharedModes: [String: ModeConfig]? = nil, hiddenModes: [String] = []) { self.triggerThreshold = triggerThreshold self.debugOverlay = debugOverlay self.global = global @@ -118,6 +123,7 @@ struct MappingConfig: Codable, Sendable { self.haptics = haptics self.aliases = aliases self.sharedModes = sharedModes + self.hiddenModes = hiddenModes } init(from decoder: Decoder) throws { @@ -130,6 +136,7 @@ struct MappingConfig: Codable, Sendable { haptics = try container.decodeIfPresent(HapticsConfig.self, forKey: .haptics) aliases = try container.decodeIfPresent([String: ActionConfig].self, forKey: .aliases) sharedModes = try container.decodeIfPresent([String: ModeConfig].self, forKey: .sharedModes) + hiddenModes = try container.decodeIfPresent([String].self, forKey: .hiddenModes) ?? [] } static let empty = MappingConfig(triggerThreshold: nil, debugOverlay: nil, global: [:], profiles: [:], menus: [:]) @@ -148,6 +155,9 @@ struct ProfileConfig: Codable, Sendable { /// Maps an external context token (e.g. a process name from the herdr bridge) /// to a mode name. Applied when the token changes; see ControllerManager. let contextModes: [String: String] + /// Mode names hidden from the mode picker in this profile. Hidden modes stay reachable + /// via setMode, external context, and default_mode; only the picker listing is filtered. + let hiddenModes: [String] enum CodingKeys: String, CodingKey { case apps @@ -155,6 +165,7 @@ struct ProfileConfig: Codable, Sendable { case global case modes case contextModes = "context_modes" + case hiddenModes = "hidden_modes" } init(from decoder: Decoder) throws { @@ -164,6 +175,7 @@ struct ProfileConfig: Codable, Sendable { global = try container.decodeIfPresent([String: ActionConfig].self, forKey: .global) ?? [:] modes = try container.decodeIfPresent([String: ModeConfig].self, forKey: .modes) ?? [:] contextModes = try container.decodeIfPresent([String: String].self, forKey: .contextModes) ?? [:] + hiddenModes = try container.decodeIfPresent([String].self, forKey: .hiddenModes) ?? [] } }