make theme persistent across monitors

This commit is contained in:
2026-07-13 16:43:05 +02:00
parent 1a2eddcd5d
commit a49f78cbb0
6 changed files with 40 additions and 55 deletions
+3 -1
View File
@@ -22,6 +22,8 @@ extension PHBar {
mutating func run() throws { mutating func run() throws {
let config = try PHConfig.load() let config = try PHConfig.load()
let environment = PHEnvironment.process.merging(config.env)
let theme = try PHTheme.load(config.theme, environment: environment)
// 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))
@@ -29,7 +31,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, debug: debug) let delegate = PHBarDelegate(config: config, theme: theme, debug: debug)
let app = NSApplication.shared let app = NSApplication.shared
app.setActivationPolicy(.accessory) app.setActivationPolicy(.accessory)
app.delegate = delegate app.delegate = delegate
+7 -2
View File
@@ -19,9 +19,14 @@ final class PHBarDelegate: NSObject, NSApplicationDelegate {
/// `config` is captured here (not re-read per screen) so all bars share one /// `config` is captured here (not re-read per screen) so all bars share one
/// resolved config + environment for their lifetime. Live reload swaps the /// resolved config + environment for their lifetime. Live reload swaps the
/// whole factory via `reloadAll()`. /// whole factory via `reloadAll()`.
init(config: PHConfig, debug: Bool) { init(config: PHConfig, theme: PHTheme, debug: Bool) {
let environment = PHEnvironment.process.merging(config.env) let environment = PHEnvironment.process.merging(config.env)
self.factory = PHBarFactory(config: config, environment: environment, debug: debug) self.factory = PHBarFactory(
config: config,
theme: theme,
environment: environment,
debug: debug
)
super.init() super.init()
} }
@@ -1,19 +1,23 @@
extension PHBarDelegate { extension PHBarDelegate {
// MARK: - Reload (entry points for a future `phbar reload`) // MARK: - Reload (entry points for a future `phbar reload`)
/// Re-read config from disk and rebuild every bar against it. Screens are /// Re-read config and theme from disk and rebuild every bar against them.
/// kept; only theme/blocks/window are re-resolved. This is the global path /// Screens are kept; only theme/blocks/window are re-resolved.
/// for picking up config edits at runtime. /// This is the global path for picking up config edits at runtime.
func reloadAll() { func reloadAll() {
let config: PHConfig let config: PHConfig
let theme: PHTheme
do { do {
config = try PHConfig.load() config = try PHConfig.load()
let environment = PHEnvironment.process.merging(config.env)
theme = try PHTheme.load(config.theme, environment: environment)
} catch { } catch {
stderr("reload failed, could not read config: \(error)") stderr("reload failed, could not read config: \(error)")
return return
} }
factory = PHBarFactory( factory = PHBarFactory(
config: config, config: config,
theme: theme,
environment: PHEnvironment.process.merging(config.env), environment: PHEnvironment.process.merging(config.env),
debug: factory.debug debug: factory.debug
) )
+4 -13
View File
@@ -1,31 +1,22 @@
import AppKit import AppKit
/// Builds a `BarController` for a screen by resolving its theme, blocks, and /// Builds a `BarController` for a screen by resolving its blocks and
/// window from the config. /// window from the config.
///
/// Extracted from `AppDelegate` so the exact same resolution drives initial
/// launch, hot-plugged monitors (`syncScreens`), and live reload
/// (`reloadAll` / `reload(screenNamed:)`). Each screen's theme/blocks are
/// resolved independently, so a failure on one screen can be skipped without
/// taking down the others.
@MainActor @MainActor
struct PHBarFactory { struct PHBarFactory {
let config: PHConfig let config: PHConfig
let theme: PHTheme
let environment: PHEnvironment let environment: PHEnvironment
let debug: Bool let debug: Bool
/// Resolve theme + blocks for `screen` and build its controller. /// Resolve blocks 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.
/// - Throws: `PHTheme.load` / `PHBlock.load` errors for the resolved names. /// - Throws: `PHBlock.load` error for the resolved names.
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 theme = try PHTheme.load(
config.theme(screenName: name, screenIndex: index),
environment: environment
)
let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index)) let blocks = try PHBlock.load(config.blocks(screenName: name, screenIndex: index))
return BarController( return BarController(
config: config, config: config,
@@ -5,12 +5,10 @@ import Foundation
/// 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. /// (`theme`, `window`, or `blocks`) so a monitor can override just one of them.
struct PHConfigMonitorOverride: Decodable { struct PHConfigMonitorOverride: Decodable {
let theme: String?
let window: String? let window: String?
let blocks: String? let blocks: String?
init(theme: String? = nil, window: String? = nil, blocks: String? = nil) { init(window: String? = nil, blocks: String? = nil) {
self.theme = theme
self.window = window self.window = window
self.blocks = blocks self.blocks = blocks
} }
@@ -30,12 +28,6 @@ extension PHConfig {
return nil return nil
} }
/// Theme name for a screen: the override's `theme` if set, otherwise the
/// global `theme`.
func theme(screenName: String, screenIndex: Int?) -> String {
monitorOverride(screenName: screenName, screenIndex: screenIndex)?.theme ?? theme
}
/// Window definition name for a screen: the override's `window` if set, /// Window definition 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 {
@@ -3,48 +3,39 @@ import TOML
extension PHTheme { extension PHTheme {
/// Load theme from the resolved config directory (`PHPaths`/themes). /// Load theme from the resolved config directory (`PHPaths`/themes).
/// Falls back to the bundled theme when the file is absent or parsing fails. /// If the file doesn't exist or fails to parse, the execution is interrupted.
/// ///
/// - Parameter environment: Used to expand `$VAR` references in `@EnvExpanded` /// - Parameter environment: Used to expand `$VAR` references in `@EnvExpanded`
/// fields (colors, fonts); defaults to the process environment. /// fields (colors, fonts); defaults to the process environment.
static func load(_ theme: String?, environment: PHEnvironment = .process) throws -> PHTheme { static func load(_ theme: String, environment: PHEnvironment = .process) throws -> PHTheme {
guard let theme else { return try loadFromBundle() }
let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml") let url = PHPaths.configDirectory.appending(path: "themes").appending(path: "\(theme).toml")
if FileManager.default.fileExists(atPath: url.relativePath) { if FileManager.default.fileExists(atPath: url.relativePath) {
return try load(from: url, environment: environment) return try load(from: url, environment: environment)
} else { } else {
return try loadFromBundle() throw PHBar.Error(
} """
} Theme file not found.
Make sure to have a file named `\(theme).toml` inside the
directory: `\(url.deletingLastPathComponent().relativePath)`
/// Load the bundled theme. Tip: If you've never used phbar before, run the `phbar install` command
/// If the file doesn't exist or fails to parse, the execution is interrupted. to automatically generate the required configuration files.
static private func loadFromBundle() throws -> PHTheme { """
guard )
let defaultConfig = Bundle.module.url(
forResource: "theme",
withExtension: "toml"
)?.resolvingSymlinksInPath()
else {
throw PHBar.Error("Failed to load theme file")
} }
return try load(from: defaultConfig, environment: .process)
} }
/// Load theme from a file at URL. /// Load theme from a file at URL.
/// If the file doesn't exist or fails to parse, the execution is interrupted. /// If the file doesn't exist or fails to parse, the execution is interrupted.
static private func load(from url: URL, environment: PHEnvironment) throws -> PHTheme { static private func load(from url: URL, environment: PHEnvironment) throws -> PHTheme {
do { guard let data = try? Data(contentsOf: url),
let data = try Data(contentsOf: url) let contents = String(data: data, encoding: .utf8), !contents.isEmpty
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else { else {
throw PHBar.Error("the file is empty") throw PHBar.Error("Theme file not readable or empty.")
} }
return try load(from: contents, environment: environment) return try load(from: contents, environment: environment)
} catch {
throw PHBar.Error("Failed to load theme file", underlyingError: error)
}
} }
/// Load theme from a TOML string. /// Load theme from a TOML string.
@@ -55,7 +46,7 @@ extension PHTheme {
decoder.userInfo[PHEnvironment.userInfoKey] = environment decoder.userInfo[PHEnvironment.userInfoKey] = environment
return try decoder.decode(PHTheme.self, from: contents) return try decoder.decode(PHTheme.self, from: contents)
} catch { } catch {
throw PHBar.Error("Failed to parse theme file", underlyingError: error) throw PHBar.Error("Theme file not valid", underlyingError: error)
} }
} }
} }