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 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
+3 -6
View File
@@ -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
)
@@ -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
/// 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
)
@@ -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 {
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"
}
}
@@ -10,7 +10,7 @@ extension PHConfig {
return PHConfig(
theme: theme,
window: window,
blocks: blocks,
layout: layout,
env: env.mapValues(environment.expand),
monitors: monitors
)
@@ -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")
@@ -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
}
}
+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).
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);
+2 -2
View File
@@ -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)
+5 -5
View File
@@ -1,12 +1,12 @@
# Default window and block set for every monitor.
# - theme: a named theme at ~/.config/phbar/themes/<name>.toml
# - 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"
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)