wrap blocks around a layout concept

This commit is contained in:
2026-07-13 21:08:04 +02:00
parent ed01251ca5
commit 30be4b2901
16 changed files with 111 additions and 148 deletions
+2 -1
View File
@@ -24,6 +24,7 @@ extension PHBar {
let config = try PHConfig.load() let config = try PHConfig.load()
let environment = PHEnvironment.process.merging(config.env) let environment = PHEnvironment.process.merging(config.env)
let theme = try PHTheme.load(config.theme, environment: environment) 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 // NOTE: Make sure NSApp.run() runs in the main thread
dispatchPrecondition(condition: .onQueue(.main)) dispatchPrecondition(condition: .onQueue(.main))
@@ -31,7 +32,7 @@ extension PHBar {
try MainActor.assumeIsolated { try MainActor.assumeIsolated {
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") } 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 let app = NSApplication.shared
app.setActivationPolicy(.accessory) app.setActivationPolicy(.accessory)
app.delegate = delegate app.delegate = delegate
+3 -6
View File
@@ -9,21 +9,18 @@ final class PHBarDelegate: NSObject, NSApplicationDelegate {
/// Convenience accessor over `windows`. /// Convenience accessor over `windows`.
var controllers: [BarController] { windows.map(\.barController) } var controllers: [BarController] { windows.map(\.barController) }
/// Resolves a controller for any screen from the current config. Swapped by /// Resolves a controller for any screen from the current config.
/// `reloadAll()` so a live reload re-reads theme/blocks from disk.
var factory: PHBarFactory var factory: PHBarFactory
private var observers: [IPCObserver] = [] private var observers: [IPCObserver] = []
private var screenObserver: NSObjectProtocol? private var screenObserver: NSObjectProtocol?
/// `config` is captured here (not re-read per screen) so all bars share one init(config: PHConfig, theme: PHTheme, layouts: [String : PHLayout], debug: Bool) {
/// resolved config + environment for their lifetime. Live reload swaps the
/// whole factory via `reloadAll()`.
init(config: PHConfig, theme: PHTheme, debug: Bool) {
let environment = PHEnvironment.process.merging(config.env) let environment = PHEnvironment.process.merging(config.env)
self.factory = PHBarFactory( self.factory = PHBarFactory(
config: config, config: config,
theme: theme, theme: theme,
layouts: layouts,
environment: environment, environment: environment,
debug: debug debug: debug
) )
@@ -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)
}
}
}
+8 -5
View File
@@ -1,15 +1,16 @@
import AppKit import AppKit
/// Builds a `BarController` for a screen by resolving its blocks and /// Builds a `BarController` for a screen by resolving its layout
/// window from the config. /// and window from the config.
@MainActor @MainActor
struct PHBarFactory { struct PHBarFactory {
let config: PHConfig let config: PHConfig
let theme: PHTheme let theme: PHTheme
let layouts: [String: PHLayout]
let environment: PHEnvironment let environment: PHEnvironment
let debug: Bool 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 /// - Parameter screen: The screen to build a bar for. Its localized name
/// and index in `NSScreen.screens` select any per-monitor config override. /// and index in `NSScreen.screens` select any per-monitor config override.
@@ -17,12 +18,14 @@ struct PHBarFactory {
func make(for screen: NSScreen) throws -> BarController { func make(for screen: NSScreen) throws -> BarController {
let name = screen.localizedName let name = screen.localizedName
let index = NSScreen.screens.firstIndex(of: screen) 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( return BarController(
config: config, config: config,
screen: screen, screen: screen,
theme: theme, theme: theme,
blocks: blocks, layout: layout,
environment: environment, environment: environment,
debug: debug debug: debug
) )
@@ -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 `<configDirectory>/blocks/<name>.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)
}
}
}
+2 -2
View File
@@ -1,12 +1,12 @@
struct PHConfig: Decodable { struct PHConfig: Decodable {
let theme: String let theme: String
let window: String let window: String
let blocks: String? let layout: String
let env: [String: String]? let env: [String: String]?
let monitors: [String: PHConfigMonitorOverride]? let monitors: [String: PHConfigMonitorOverride]?
private enum CodingKeys: String, CodingKey { private enum CodingKeys: String, CodingKey {
case theme, window, blocks, env case theme, window, layout, env
case monitors = "monitor" case monitors = "monitor"
} }
} }
@@ -10,7 +10,7 @@ extension PHConfig {
return PHConfig( return PHConfig(
theme: theme, theme: theme,
window: window, window: window,
blocks: blocks, layout: layout,
env: env.mapValues(environment.expand), env: env.mapValues(environment.expand),
monitors: monitors monitors: monitors
) )
@@ -2,9 +2,8 @@ import Foundation
import TOML import TOML
extension PHConfig { extension PHConfig {
/// Load configuration from the resolved config directory /// Load configuration from the resolved config directory (see `PHPaths`).
/// (see `PHPaths`). If the file doesn't exist or fails to parse, /// If the file doesn't exist or fails to parse, the execution is interrupted.
/// the execution is interrupted.
static func load() throws -> PHConfig { static func load() throws -> PHConfig {
let url = PHPaths.configDirectory.appending(path: "config.toml") let url = PHPaths.configDirectory.appending(path: "config.toml")
@@ -3,23 +3,20 @@ import Foundation
/// A per-monitor entry from the config's `[monitor]` table. /// A per-monitor entry from the config's `[monitor]` table.
/// ///
/// Either field is optional; an unset field inherits the matching global /// 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 { struct PHConfigMonitorOverride: Decodable {
let window: String? 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.window = window
self.blocks = blocks self.layout = layout
} }
} }
extension PHConfig { extension PHConfig {
/// The per-monitor override matching the given screen identity, if any. /// 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. /// Precedence: monitor name monitor index.
func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? { func monitorOverride(screenName: String, screenIndex: Int?) -> PHConfigMonitorOverride? {
guard let monitors else { return nil } guard let monitors else { return nil }
@@ -28,15 +25,15 @@ extension PHConfig {
return nil 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`. /// otherwise the global `window`.
func window(screenName: String, screenIndex: Int?) -> String { func window(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window monitorOverride(screenName: screenName, screenIndex: screenIndex)?.window ?? window
} }
/// Block-set name for a screen: the override's `blocks` if set, otherwise /// Layout name for a screen: the override's `layout` if set,
/// the global `blocks` (defaulting to `"default"`). /// otherwise the global `layout`.
func blocks(screenName: String, screenIndex: Int?) -> String { func layout(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.blocks ?? (blocks ?? "default") monitorOverride(screenName: screenName, screenIndex: screenIndex)?.layout ?? layout
} }
} }
+7
View File
@@ -0,0 +1,7 @@
struct PHLayout: Decodable {
let blocks: [PHBlock]
private enum CodingKeys: String, CodingKey {
case blocks = "block"
}
}
@@ -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)
}
}
}
+5
View File
@@ -16,6 +16,11 @@ enum PHPaths {
/// The resolved configuration directory (computed once, cached). /// The resolved configuration directory (computed once, cached).
static let configDirectory: URL = resolveConfigDirectory() 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. /// Ordered candidate directories, priority high low.
/// ///
/// - Parameter environment: Override the environment lookup (used by tests); /// - Parameter environment: Override the environment lookup (used by tests);
+2 -2
View File
@@ -16,12 +16,12 @@ final class BarController: ObservableObject {
blocks.filter { $0.centered == true } 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.config = config
self.screen = screen self.screen = screen
self.theme = theme self.theme = theme
self.window = Self.window(for: config, screen: screen, from: theme) self.window = Self.window(for: config, screen: screen, from: theme)
self.blocks = blocks self.blocks = layout.blocks
for block in blocks { for block in blocks {
block.text = text(for: block) block.text = text(for: block)
+5 -5
View File
@@ -1,12 +1,12 @@
# Default window and block set for every monitor. # Default window and block set for every monitor.
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml # - theme: a named theme at ~/.config/phbar/themes/<name>.toml
# - window: a [[window]] definition from the theme file # - window: a [[window]] definition from the theme file
# - blocks: a named set at ~/.config/phbar/blocks/<name>.toml # - layout: a named layout at ~/.config/phbar/layouts/<name>.toml
theme = "default" theme = "default"
window = "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 # - quoted numeric key → NSScreen index
# - string key → NSScreen.localizedName (stable across replugs) # - string key → NSScreen.localizedName (stable across replugs)
# Precedence: monitor name → monitor index → the globals above. # Precedence: monitor name → monitor index → the globals above.
@@ -14,11 +14,11 @@ blocks = "default"
# #
# [monitor."1"] # [monitor."1"]
# window = "bottom" # window = "bottom"
# blocks = "laptop" # layout = "laptop"
# #
# [monitor."DELL U2723QE"] # [monitor."DELL U2723QE"]
# window = "clock" # window = "clock"
# blocks = "external" # layout = "external"
# Optional: environment variables passed to block scripts when they run. # Optional: environment variables passed to block scripts when they run.
# - scripts always inherit phbar's own process environment (e.g. $PATH) # - scripts always inherit phbar's own process environment (e.g. $PATH)
+11 -11
View File
@@ -28,19 +28,19 @@ private func makeConfig(
} }
@Test func windowMatchesByIndex() { @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") #expect(config.window(screenName: "Whatever", screenIndex: 1) == "bottom")
} }
@Test func windowMatchesByName() { @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") #expect(config.window(screenName: "DELL U2723QE", screenIndex: 0) == "clock")
} }
@Test func windowPrefersNameOverIndex() { @Test func windowPrefersNameOverIndex() {
let config = makeConfig(monitors: [ let config = makeConfig(monitors: [
"0": .init(window: "byIndex", blocks: nil), "0": .init(window: "byIndex", layout: nil),
"DELL": .init(window: "byName", blocks: nil), "DELL": .init(window: "byName", layout: nil),
]) ])
#expect(config.window(screenName: "DELL", screenIndex: 0) == "byName") #expect(config.window(screenName: "DELL", screenIndex: 0) == "byName")
} }
@@ -48,17 +48,17 @@ private func makeConfig(
// MARK: blocks // MARK: blocks
@Test func blocksDefaultsToDefault() { @Test func blocksDefaultsToDefault() {
#expect(makeConfig().blocks(screenName: "S", screenIndex: 0) == "default") #expect(makeConfig().layout(screenName: "S", screenIndex: 0) == "default")
} }
@Test func blocksUsesGlobalWhenNoOverride() { @Test func blocksUsesGlobalWhenNoOverride() {
let config = makeConfig(blocks: "main") let config = makeConfig(blocks: "main")
#expect(config.blocks(screenName: "S", screenIndex: 0) == "main") #expect(config.layout(screenName: "S", screenIndex: 0) == "main")
} }
@Test func blocksOverrideBeatsGlobal() { @Test func blocksOverrideBeatsGlobal() {
let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, blocks: "alt")]) let config = makeConfig(blocks: "main", monitors: ["1": .init(window: nil, layout: "alt")])
#expect(config.blocks(screenName: "S", screenIndex: 1) == "alt") #expect(config.layout(screenName: "S", screenIndex: 1) == "alt")
} }
// MARK: theme // MARK: theme
@@ -77,10 +77,10 @@ private func makeConfig(
@Test func overrideInheritsUnsetFieldsFromGlobal() { @Test func overrideInheritsUnsetFieldsFromGlobal() {
// An override that sets only `blocks` still inherits the global window/theme. // 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.theme(screenName: "S", screenIndex: 1) == "voltage")
#expect(config.window(screenName: "S", screenIndex: 1) == "top") #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() { @Test func themeWindowAndBlocksResolveIndependently() {
@@ -107,6 +107,6 @@ private func makeConfig(
} }
@Test func monitorOverrideReturnsNilForUnmatchedScreen() { @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) #expect(config.monitorOverride(screenName: "Unknown", screenIndex: 99) == nil)
} }