wrap blocks around a layout concept
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user