From 30be4b29017036d8980a255078db305e6aeaee02 Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Mon, 13 Jul 2026 21:08:04 +0200 Subject: [PATCH] wrap blocks around a layout concept --- Sources/phbar/Commands/PHBar+Start.swift | 3 +- Sources/phbar/Models/PHBarDelegate.swift | 9 ++-- .../PHBarDelegate/PHBarDelegate+Reload.swift | 48 ----------------- Sources/phbar/Models/PHBarFactory.swift | 13 +++-- .../Models/PHBlock/PHBlock+Loading.swift | 52 ------------------ Sources/phbar/Models/PHConfig.swift | 4 +- .../phbar/Models/PHConfig/PHConfig+Env.swift | 2 +- .../Models/PHConfig/PHConfig+Loading.swift | 5 +- .../PHConfig/PHConfigMonitorOverride.swift | 21 ++++---- Sources/phbar/Models/PHLayout.swift | 7 +++ .../Models/PHLayout/PHLayout+Loading.swift | 54 +++++++++++++++++++ Sources/phbar/Models/PHPaths.swift | 5 ++ Sources/phbar/Views/BarController.swift | 4 +- Sources/phbar/config/config.toml | 10 ++-- .../config/{blocks => layouts}/default.toml | 0 Tests/phbarTests/PHConfigTests.swift | 22 ++++---- 16 files changed, 111 insertions(+), 148 deletions(-) delete mode 100644 Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Reload.swift delete mode 100644 Sources/phbar/Models/PHBlock/PHBlock+Loading.swift create mode 100644 Sources/phbar/Models/PHLayout.swift create mode 100644 Sources/phbar/Models/PHLayout/PHLayout+Loading.swift rename Sources/phbar/config/{blocks => layouts}/default.toml (100%) diff --git a/Sources/phbar/Commands/PHBar+Start.swift b/Sources/phbar/Commands/PHBar+Start.swift index 1115eb9..306787c 100644 --- a/Sources/phbar/Commands/PHBar+Start.swift +++ b/Sources/phbar/Commands/PHBar+Start.swift @@ -24,6 +24,7 @@ extension PHBar { let config = try PHConfig.load() let environment = PHEnvironment.process.merging(config.env) let theme = try PHTheme.load(config.theme, environment: environment) + let layouts = try PHLayout.load(["default", "mini"]) // NOTE: Make sure NSApp.run() runs in the main thread dispatchPrecondition(condition: .onQueue(.main)) @@ -31,7 +32,7 @@ extension PHBar { try MainActor.assumeIsolated { guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") } - let delegate = PHBarDelegate(config: config, theme: theme, debug: debug) + let delegate = PHBarDelegate(config: config, theme: theme, layouts: layouts, debug: debug) let app = NSApplication.shared app.setActivationPolicy(.accessory) app.delegate = delegate diff --git a/Sources/phbar/Models/PHBarDelegate.swift b/Sources/phbar/Models/PHBarDelegate.swift index 6307afc..629af3f 100644 --- a/Sources/phbar/Models/PHBarDelegate.swift +++ b/Sources/phbar/Models/PHBarDelegate.swift @@ -9,21 +9,18 @@ final class PHBarDelegate: NSObject, NSApplicationDelegate { /// Convenience accessor over `windows`. var controllers: [BarController] { windows.map(\.barController) } - /// Resolves a controller for any screen from the current config. Swapped by - /// `reloadAll()` so a live reload re-reads theme/blocks from disk. + /// Resolves a controller for any screen from the current config. var factory: PHBarFactory private var observers: [IPCObserver] = [] private var screenObserver: NSObjectProtocol? - /// `config` is captured here (not re-read per screen) so all bars share one - /// resolved config + environment for their lifetime. Live reload swaps the - /// whole factory via `reloadAll()`. - init(config: PHConfig, theme: PHTheme, debug: Bool) { + init(config: PHConfig, theme: PHTheme, layouts: [String : PHLayout], debug: Bool) { let environment = PHEnvironment.process.merging(config.env) self.factory = PHBarFactory( config: config, theme: theme, + layouts: layouts, environment: environment, debug: debug ) diff --git a/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Reload.swift b/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Reload.swift deleted file mode 100644 index 5260857..0000000 --- a/Sources/phbar/Models/PHBarDelegate/PHBarDelegate+Reload.swift +++ /dev/null @@ -1,48 +0,0 @@ -extension PHBarDelegate { - // MARK: - Reload (entry points for a future `phbar reload`) - - /// Re-read config and theme from disk and rebuild every bar against them. - /// Screens are kept; only theme/blocks/window are re-resolved. - /// This is the global path for picking up config edits at runtime. - func reloadAll() { - let config: PHConfig - let theme: PHTheme - do { - config = try PHConfig.load() - let environment = PHEnvironment.process.merging(config.env) - theme = try PHTheme.load(config.theme, environment: environment) - } catch { - stderr("reload failed, could not read config: \(error)") - return - } - factory = PHBarFactory( - config: config, - theme: theme, - environment: PHEnvironment.process.merging(config.env), - debug: factory.debug - ) - rebuildAllBars() - } - - /// Rebuild a single monitor's bar against the current factory. Per-monitor - /// path for picking up config edits at runtime. Returns `false` if no bar - /// currently runs on `name` (or its reload failed to load). - @discardableResult - func reload(screenNamed name: String) -> Bool { - guard let screen = window(named: name)?.barController.screen else { return false } - removeBar(for: screen) - return addBar(for: screen) - } - - /// Tear down every bar and rebuild against the current factory, preserving - /// the set of screens. - private func rebuildAllBars() { - let screens = controllers.map(\.screen) - for screen in screens { - removeBar(for: screen) - } - for screen in screens { - addBar(for: screen) - } - } -} diff --git a/Sources/phbar/Models/PHBarFactory.swift b/Sources/phbar/Models/PHBarFactory.swift index b43bdda..74e72fc 100644 --- a/Sources/phbar/Models/PHBarFactory.swift +++ b/Sources/phbar/Models/PHBarFactory.swift @@ -1,15 +1,16 @@ import AppKit -/// Builds a `BarController` for a screen by resolving its blocks and -/// window from the config. +/// Builds a `BarController` for a screen by resolving its layout +/// and window from the config. @MainActor struct PHBarFactory { let config: PHConfig let theme: PHTheme + let layouts: [String: PHLayout] let environment: PHEnvironment let debug: Bool - /// Resolve blocks for `screen` and build its controller. + /// Resolve layout for `screen` and build its controller. /// /// - Parameter screen: The screen to build a bar for. Its localized name /// and index in `NSScreen.screens` select any per-monitor config override. @@ -17,12 +18,14 @@ struct PHBarFactory { func make(for screen: NSScreen) throws -> BarController { let name = screen.localizedName let index = NSScreen.screens.firstIndex(of: screen) - let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index)) + guard let layout = layouts[config.layout(screenName: name, screenIndex: index)] else { + throw PHBar.Error("Layout `\(name)` not found.") + } return BarController( config: config, screen: screen, theme: theme, - blocks: blocks, + layout: layout, environment: environment, debug: debug ) diff --git a/Sources/phbar/Models/PHBlock/PHBlock+Loading.swift b/Sources/phbar/Models/PHBlock/PHBlock+Loading.swift deleted file mode 100644 index 8e726d9..0000000 --- a/Sources/phbar/Models/PHBlock/PHBlock+Loading.swift +++ /dev/null @@ -1,52 +0,0 @@ -import Foundation -import TOML - -extension PHBlock { - private struct Wrapper: Decodable { - let blocks: [PHBlock] - - private enum CodingKeys: String, CodingKey { - case blocks = "block" - } - } - - /// Load a named block set from `/blocks/.toml`. - /// - /// - Parameter configDirectory: Override the lookup root (used by tests); - /// defaults to the resolved config directory (see `PHPaths`). - static func load(_ blocks: String, in configDirectory: URL? = nil) throws -> [PHBlock] { - let base = configDirectory ?? PHPaths.configDirectory - - let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml") - guard FileManager.default.fileExists(atPath: setFile.relativePath) else { - throw PHBar.Error("Failed to load blocks '\(blocks)'") - } - return try load(from: setFile) - } - - /// Load theme from a file at URL. - /// If the file doesn't exist or fails to parse, the execution is interrupted. - static func load(from url: URL) throws -> [PHBlock] { - do { - let data = try Data(contentsOf: url) - guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else { - throw PHBar.Error("the file is empty") - } - return try load(from: contents) - } catch { - throw PHBar.Error("Failed to load blocks file", underlyingError: error) - } - } - - /// Load theme from a TOML string. - /// If the content fails to parse, the execution is interrupted. - static func load(from contents: String) throws -> [PHBlock] { - do { - let decoder = TOMLDecoder() - let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents) - return wrapper.blocks - } catch { - throw PHBar.Error("Failed to parse blocks file", underlyingError: error) - } - } -} diff --git a/Sources/phbar/Models/PHConfig.swift b/Sources/phbar/Models/PHConfig.swift index b996ec0..dc47cec 100644 --- a/Sources/phbar/Models/PHConfig.swift +++ b/Sources/phbar/Models/PHConfig.swift @@ -1,12 +1,12 @@ struct PHConfig: Decodable { let theme: String let window: String - let blocks: String? + let layout: String let env: [String: String]? let monitors: [String: PHConfigMonitorOverride]? private enum CodingKeys: String, CodingKey { - case theme, window, blocks, env + case theme, window, layout, env case monitors = "monitor" } } diff --git a/Sources/phbar/Models/PHConfig/PHConfig+Env.swift b/Sources/phbar/Models/PHConfig/PHConfig+Env.swift index a31e601..cfc171b 100644 --- a/Sources/phbar/Models/PHConfig/PHConfig+Env.swift +++ b/Sources/phbar/Models/PHConfig/PHConfig+Env.swift @@ -10,7 +10,7 @@ extension PHConfig { return PHConfig( theme: theme, window: window, - blocks: blocks, + layout: layout, env: env.mapValues(environment.expand), monitors: monitors ) diff --git a/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift b/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift index 5054acb..b49f033 100644 --- a/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift +++ b/Sources/phbar/Models/PHConfig/PHConfig+Loading.swift @@ -2,9 +2,8 @@ import Foundation import TOML extension PHConfig { - /// Load configuration from the resolved config directory - /// (see `PHPaths`). If the file doesn't exist or fails to parse, - /// the execution is interrupted. + /// Load configuration from the resolved config directory (see `PHPaths`). + /// If the file doesn't exist or fails to parse, the execution is interrupted. static func load() throws -> PHConfig { let url = PHPaths.configDirectory.appending(path: "config.toml") diff --git a/Sources/phbar/Models/PHConfig/PHConfigMonitorOverride.swift b/Sources/phbar/Models/PHConfig/PHConfigMonitorOverride.swift index e01bedb..78977dd 100644 --- a/Sources/phbar/Models/PHConfig/PHConfigMonitorOverride.swift +++ b/Sources/phbar/Models/PHConfig/PHConfigMonitorOverride.swift @@ -3,23 +3,20 @@ import Foundation /// A per-monitor entry from the config's `[monitor]` table. /// /// Either field is optional; an unset field inherits the matching global -/// (`theme`, `window`, or `blocks`) so a monitor can override just one of them. +/// (`window` or `layout`) so a monitor can override just one of them. struct PHConfigMonitorOverride: Decodable { let window: String? - let blocks: String? + let layout: String? - init(window: String? = nil, blocks: String? = nil) { + init(window: String? = nil, layout: String? = nil) { self.window = window - self.blocks = blocks + self.layout = layout } } extension PHConfig { /// The per-monitor override matching the given screen identity, if any. /// - /// This is the single entry point for per-monitor resolution: the - /// `theme`/`window`/`blocks` helpers below all delegate to it. Kept free of - /// AppKit so the resolution logic is testable without an `NSScreen`. /// Precedence: monitor name → monitor index. func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? { guard let monitors else { return nil } @@ -28,15 +25,15 @@ extension PHConfig { return nil } - /// Window definition name for a screen: the override's `window` if set, + /// Window name for a screen: the override's `window` if set, /// otherwise the global `window`. func window(screenName: String, screenIndex: Int?) -> String { monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window } - /// Block-set name for a screen: the override's `blocks` if set, otherwise - /// the global `blocks` (defaulting to `"default"`). - func blocks(screenName: String, screenIndex: Int?) -> String { - monitorOverride(screenName: screenName, screenIndex: screenIndex)?.blocks ?? (blocks ?? "default") + /// Layout name for a screen: the override's `layout` if set, + /// otherwise the global `layout`. + func layout(screenName: String, screenIndex: Int?) -> String { + monitorOverride(screenName: screenName, screenIndex: screenIndex)?.layout ?? layout } } diff --git a/Sources/phbar/Models/PHLayout.swift b/Sources/phbar/Models/PHLayout.swift new file mode 100644 index 0000000..1bca14d --- /dev/null +++ b/Sources/phbar/Models/PHLayout.swift @@ -0,0 +1,7 @@ +struct PHLayout: Decodable { + let blocks: [PHBlock] + + private enum CodingKeys: String, CodingKey { + case blocks = "block" + } +} diff --git a/Sources/phbar/Models/PHLayout/PHLayout+Loading.swift b/Sources/phbar/Models/PHLayout/PHLayout+Loading.swift new file mode 100644 index 0000000..9f52aad --- /dev/null +++ b/Sources/phbar/Models/PHLayout/PHLayout+Loading.swift @@ -0,0 +1,54 @@ +import Foundation +import TOML + +extension PHLayout { + /// Load the specified layouts from the resolved config directory (see `PHPaths`). + /// If any of theme doesn't exist or fails to parse, the execution is interrupted. + static func load(_ layouts: [String]) throws -> [String: PHLayout] { + var dictionary = [String: PHLayout]() + + for layout in layouts { + dictionary.updateValue(try load(layout), forKey: layout) + } + + return dictionary + } + + /// Load a layout from the resolved config directory (see `PHPaths`). + /// If the file doesn't exist or fails to parse, the execution is interrupted. + static func load(_ layout: String) throws -> PHLayout { + let url = PHPaths.layoutsDirectory.appending(path: "\(layout).toml") + + if FileManager.default.fileExists(atPath: url.relativePath) { + return try load(from: url) + } else { + let layout = url.lastPathComponent + throw PHBar.Error( + """ + Layout file not found. + Make sure to have a file named `\(layout)` inside the + directory: `\(PHPaths.layoutsDirectory.relativePath)` + + Tip: If you've never used phbar before, run the `phbar install` command + to automatically generate all the required configuration files. + """ + ) + } + } + + /// Load a layout from the file at URL. + /// If the file doesn't exist or fails to parse, the execution is interrupted. + static private func load(from url: URL) throws -> PHLayout { + do { + let data = try Data(contentsOf: url) + guard let contents = String(data: data, encoding: .utf8) else { + throw PHBar.Error("content is not UTF8 encoded.") + } + let decoder = TOMLDecoder() + return try decoder.decode(PHLayout.self, from: contents) + } catch { + let layout = url.lastPathComponent + throw PHBar.Error("Layout file `\(layout)` not readable", underlyingError: error) + } + } +} diff --git a/Sources/phbar/Models/PHPaths.swift b/Sources/phbar/Models/PHPaths.swift index 3777d37..7f12b9d 100644 --- a/Sources/phbar/Models/PHPaths.swift +++ b/Sources/phbar/Models/PHPaths.swift @@ -16,6 +16,11 @@ enum PHPaths { /// The resolved configuration directory (computed once, cached). static let configDirectory: URL = resolveConfigDirectory() + /// The resolved layouts directory (computed once, cached). + static let layoutsDirectory: URL = { + resolveConfigDirectory().appending(path: "layouts") + }() + /// Ordered candidate directories, priority high → low. /// /// - Parameter environment: Override the environment lookup (used by tests); diff --git a/Sources/phbar/Views/BarController.swift b/Sources/phbar/Views/BarController.swift index d0f45ea..e22d3f7 100644 --- a/Sources/phbar/Views/BarController.swift +++ b/Sources/phbar/Views/BarController.swift @@ -16,12 +16,12 @@ final class BarController: ObservableObject { blocks.filter { $0.centered == true } } - init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], environment: PHEnvironment = .process, debug: Bool) { + init(config: PHConfig, screen: NSScreen, theme: PHTheme, layout: PHLayout, environment: PHEnvironment = .process, debug: Bool) { self.config = config self.screen = screen self.theme = theme self.window = Self.window(for: config, screen: screen, from: theme) - self.blocks = blocks + self.blocks = layout.blocks for block in blocks { block.text = text(for: block) diff --git a/Sources/phbar/config/config.toml b/Sources/phbar/config/config.toml index f90bcfc..79274d9 100644 --- a/Sources/phbar/config/config.toml +++ b/Sources/phbar/config/config.toml @@ -1,12 +1,12 @@ # Default window and block set for every monitor. # - theme: a named theme at ~/.config/phbar/themes/.toml # - window: a [[window]] definition from the theme file -# - blocks: a named set at ~/.config/phbar/blocks/.toml +# - layout: a named layout at ~/.config/phbar/layouts/.toml theme = "default" window = "default" -blocks = "default" +layout = "default" -# Optional: override the window and/or block set per monitor. +# Optional: override the window and/or layout per monitor. # - quoted numeric key → NSScreen index # - string key → NSScreen.localizedName (stable across replugs) # Precedence: monitor name → monitor index → the globals above. @@ -14,11 +14,11 @@ blocks = "default" # # [monitor."1"] # window = "bottom" -# blocks = "laptop" +# layout = "laptop" # # [monitor."DELL U2723QE"] # window = "clock" -# blocks = "external" +# layout = "external" # Optional: environment variables passed to block scripts when they run. # - scripts always inherit phbar's own process environment (e.g. $PATH) diff --git a/Sources/phbar/config/blocks/default.toml b/Sources/phbar/config/layouts/default.toml similarity index 100% rename from Sources/phbar/config/blocks/default.toml rename to Sources/phbar/config/layouts/default.toml diff --git a/Tests/phbarTests/PHConfigTests.swift b/Tests/phbarTests/PHConfigTests.swift index 86c32c3..795fca0 100644 --- a/Tests/phbarTests/PHConfigTests.swift +++ b/Tests/phbarTests/PHConfigTests.swift @@ -28,19 +28,19 @@ private func makeConfig( } @Test func windowMatchesByIndex() { - let config = makeConfig(monitors: ["1": .init(window: "bottom", blocks: nil)]) + let config = makeConfig(monitors: ["1": .init(window: "bottom", layout: nil)]) #expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom") } @Test func windowMatchesByName() { - let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", blocks: nil)]) + let config = makeConfig(monitors: ["DELL U2723QE": .init(window: "clock", layout: nil)]) #expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock") } @Test func windowPrefersNameOverIndex() { let config = makeConfig(monitors: [ - "0": .init(window: "byIndex", blocks: nil), - "DELL": .init(window: "byName", blocks: nil), + "0": .init(window: "byIndex", layout: nil), + "DELL": .init(window: "byName", layout: nil), ]) #expect(config.window(screenName: "DELL", screenIndex: 0) == "byName") } @@ -48,17 +48,17 @@ private func makeConfig( // MARK: blocks @Test func blocksDefaultsToDefault() { - #expect(makeConfig().blocks(screenName: "S", screenIndex: 0) == "default") + #expect(makeConfig().layout(screenName: "S", screenIndex: 0) == "default") } @Test func blocksUsesGlobalWhenNoOverride() { let config = makeConfig(blocks: "main") - #expect(config.blocks(screenName: "S", screenIndex: 0) == "main") + #expect(config.layout(screenName: "S", screenIndex: 0) == "main") } @Test func blocksOverrideBeatsGlobal() { - let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, blocks: "alt")]) - #expect(config.blocks(screenName: "S", screenIndex: 1) == "alt") + let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, layout: "alt")]) + #expect(config.layout(screenName: "S", screenIndex: 1) == "alt") } // MARK: theme @@ -77,10 +77,10 @@ private func makeConfig( @Test func overrideInheritsUnsetFieldsFromGlobal() { // An override that sets only `blocks` still inherits the global window/theme. - let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, blocks: "laptop")]) + let config = makeConfig(theme: "voltage", window: "top", monitors: ["1": .init(window: nil, layout: "laptop")]) #expect(config.theme(screenName: "S", screenIndex: 1) == "voltage") #expect(config.window(screenName: "S", screenIndex: 1) == "top") - #expect(config.blocks(screenName: "S", screenIndex: 1) == "laptop") + #expect(config.layout(screenName: "S", screenIndex: 1) == "laptop") } @Test func themeWindowAndBlocksResolveIndependently() { @@ -107,6 +107,6 @@ private func makeConfig( } @Test func monitorOverrideReturnsNilForUnmatchedScreen() { - let config = makeConfig(monitors: ["DELL": .init(window: "clock", blocks: nil)]) + let config = makeConfig(monitors: ["DELL": .init(window: "clock", layout: nil)]) #expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil) }