Compare commits
2 Commits
f5e1ba1104
...
27fad1d67c
| Author | SHA1 | Date | |
|---|---|---|---|
|
27fad1d67c
|
|||
|
9a7710d675
|
@@ -16,10 +16,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
) throws {
|
) throws {
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
|
// The merged environment (process + `[env]`) is shared by every screen:
|
||||||
|
// scripts see it as their process environment, and themes expand `$VAR`
|
||||||
|
// references in colors/fonts against it.
|
||||||
|
let environment = PHEnvironment.process.merging(config.env)
|
||||||
|
|
||||||
for screen in screens {
|
for screen in screens {
|
||||||
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))
|
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))
|
||||||
|
|
||||||
controllers.append(
|
controllers.append(
|
||||||
@@ -28,6 +33,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
|
|||||||
screen: screen,
|
screen: screen,
|
||||||
theme: theme,
|
theme: theme,
|
||||||
blocks: blocks,
|
blocks: blocks,
|
||||||
|
environment: environment,
|
||||||
debug: debug
|
debug: debug
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import Foundation
|
|||||||
final class PHBlock: ObservableObject, Decodable, Identifiable {
|
final class PHBlock: ObservableObject, Decodable, Identifiable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
|
||||||
let command: String
|
let name: String
|
||||||
let name: String?
|
let _command: String?
|
||||||
let textName: String?
|
let textName: String?
|
||||||
@Published var text: PHThemeText = .default
|
@Published var text: PHThemeText = .default
|
||||||
let styleName: String?
|
let styleName: String?
|
||||||
@@ -14,6 +14,10 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
|||||||
let centered: Bool?
|
let centered: Bool?
|
||||||
var debug: Bool = false
|
var debug: Bool = false
|
||||||
|
|
||||||
|
var command: String {
|
||||||
|
_command ?? PHPaths.configDirectory.appending(path: "scripts/\(name)").relativePath
|
||||||
|
}
|
||||||
|
|
||||||
var visible: Bool {
|
var visible: Bool {
|
||||||
label != nil && !label!.isEmpty
|
label != nil && !label!.isEmpty
|
||||||
}
|
}
|
||||||
@@ -22,6 +26,10 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
|||||||
/// `nil` when omitted from config.
|
/// `nil` when omitted from config.
|
||||||
let events: [PHEvent]?
|
let events: [PHEvent]?
|
||||||
|
|
||||||
|
/// Variables made visible to block scripts. Defaults to phbar's process
|
||||||
|
/// environment; `BarController` overlays the `[env]` section on top.
|
||||||
|
var environment: PHEnvironment = .process
|
||||||
|
|
||||||
/// Event source registry used to subscribe to system events. Defaults to the
|
/// Event source registry used to subscribe to system events. Defaults to the
|
||||||
/// shared instance; inject a custom one for testing.
|
/// shared instance; inject a custom one for testing.
|
||||||
var registry: PHEventRegistry = .shared
|
var registry: PHEventRegistry = .shared
|
||||||
@@ -39,7 +47,8 @@ final class PHBlock: ObservableObject, Decodable, Identifiable {
|
|||||||
var updateScheduled = false
|
var updateScheduled = false
|
||||||
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case command, name
|
case name
|
||||||
|
case _command = "command"
|
||||||
case textName = "text"
|
case textName = "text"
|
||||||
case styleName = "style"
|
case styleName = "style"
|
||||||
case refresh, events, centered
|
case refresh, events, centered
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ extension PHBlock {
|
|||||||
guard kind == .text else { return nil }
|
guard kind == .text else { return nil }
|
||||||
|
|
||||||
let command = command
|
let command = command
|
||||||
|
let environment = self.environment
|
||||||
|
|
||||||
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
|
let lines: [Substring]? = await Task.detached(priority: .userInitiated) {
|
||||||
let process = Process()
|
let process = Process()
|
||||||
@@ -92,8 +93,9 @@ extension PHBlock {
|
|||||||
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||||
process.arguments = ["-c", command]
|
process.arguments = ["-c", command]
|
||||||
|
|
||||||
// Inherit the parent's environment (includes $PATH, etc.)
|
// Scripts inherit phbar's process environment with the `[env]` section
|
||||||
process.environment = ProcessInfo.processInfo.environment
|
// overlaid, so user-defined variables and PATH extensions are visible.
|
||||||
|
process.environment = environment.variables
|
||||||
if let gestureEvent {
|
if let gestureEvent {
|
||||||
let type = gestureEvent.type.rawValue
|
let type = gestureEvent.type.rawValue
|
||||||
process.environment?.updateValue(type, forKey: PHGestureEvent.typeKey)
|
process.environment?.updateValue(type, forKey: PHGestureEvent.typeKey)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ enum PHBlockKind: String {
|
|||||||
|
|
||||||
extension PHBlock {
|
extension PHBlock {
|
||||||
var kind: PHBlockKind {
|
var kind: PHBlockKind {
|
||||||
if command.starts(with: "_space") { return .space }
|
if name.starts(with: "_space") { return .space }
|
||||||
return .text
|
return .text
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension PHConfig {
|
||||||
|
/// Return a copy with each `[env]` value expanded against `environment`
|
||||||
|
/// (defaulting to the process environment).
|
||||||
|
///
|
||||||
|
/// Unresolved references are left intact (see `PHEnvironment.expand`).
|
||||||
|
func resolvingEnv(in environment: PHEnvironment = .process) -> PHConfig {
|
||||||
|
guard let env, !env.isEmpty else { return self }
|
||||||
|
return PHConfig(
|
||||||
|
theme: theme,
|
||||||
|
window: window,
|
||||||
|
blocks: blocks,
|
||||||
|
env: env.mapValues(environment.expand),
|
||||||
|
monitors: monitors
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,8 +46,12 @@ extension PHConfig {
|
|||||||
static private func load(from contents: String) throws -> PHConfig {
|
static private func load(from contents: String) throws -> PHConfig {
|
||||||
do {
|
do {
|
||||||
let decoder = TOMLDecoder()
|
let decoder = TOMLDecoder()
|
||||||
let configFile = try decoder.decode(PHConfig.self, from: contents)
|
let config = try decoder.decode(PHConfig.self, from: contents)
|
||||||
return configFile
|
// Expand `$VAR` references in `[env]` values against the process
|
||||||
|
// environment (e.g. `PATH = "$PATH:/opt/homebrew/bin"`). Themes are
|
||||||
|
// decoded against the merged result later, so their `$VAR` references
|
||||||
|
// resolve the same set.
|
||||||
|
return config.resolvingEnv()
|
||||||
} catch {
|
} catch {
|
||||||
throw phbar.Error("Failed to parse config file", underlyingError: error)
|
throw phbar.Error("Failed to parse config file", underlyingError: error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,3 +21,17 @@ blocks = "default"
|
|||||||
# theme = "external"
|
# theme = "external"
|
||||||
# window = "clock"
|
# window = "clock"
|
||||||
# blocks = "external"
|
# blocks = "external"
|
||||||
|
|
||||||
|
# Optional: environment variables passed to block scripts when they run.
|
||||||
|
# - scripts always inherit phbar's own process environment (e.g. $PATH)
|
||||||
|
# - entries listed here take precedence, overriding any inherited value
|
||||||
|
# that shares the same name
|
||||||
|
# - `$VAR` / `${VAR}` references in a value expand against the inherited
|
||||||
|
# environment at load time, so you can extend variables (e.g.
|
||||||
|
# `PATH = "$PATH:/opt/homebrew/bin"`) or pull in colors/fonts used by a
|
||||||
|
# theme (e.g. `ACCENT = "#ff0000"`, then `color = "$ACCENT"` in the theme)
|
||||||
|
#
|
||||||
|
# [env]
|
||||||
|
# TEST = "ciao"
|
||||||
|
# TEST_2 = "hello world"
|
||||||
|
# PATH = "$PATH:/opt/homebrew/bin"
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// A resolved set of environment variables with `$VAR` expansion support.
|
||||||
|
///
|
||||||
|
/// Two concerns meet here:
|
||||||
|
///
|
||||||
|
/// 1. **Scripts** — block commands run with these variables in their
|
||||||
|
/// environment, built by layering the `[env]` section on top of phbar's own
|
||||||
|
/// process environment (see `PHBlock.compute`).
|
||||||
|
/// 2. **Config templating** — `@EnvExpanded` fields (e.g. theme colors, fonts)
|
||||||
|
/// expand `$VAR` references against a `PHEnvironment` injected through
|
||||||
|
/// `TOMLDecoder.userInfo` at decode time.
|
||||||
|
///
|
||||||
|
/// The environment is therefore resolved once (process → merged with `[env]`)
|
||||||
|
/// and reused for both, so a variable defined in `[env]` is a single source of
|
||||||
|
/// truth visible to scripts and themes alike.
|
||||||
|
struct PHEnvironment: Sendable {
|
||||||
|
let variables: [String: String]
|
||||||
|
|
||||||
|
/// phbar's own process environment.
|
||||||
|
static let process = PHEnvironment(variables: ProcessInfo.processInfo.environment)
|
||||||
|
|
||||||
|
/// `CodingUserInfoKey` used to thread a `PHEnvironment` into `Decodable`
|
||||||
|
/// types (and the `@EnvExpanded` wrapper) without global state.
|
||||||
|
static let userInfoKey = CodingUserInfoKey(rawValue: "phbar.environment")!
|
||||||
|
|
||||||
|
/// Layer `overrides` (typically the `[env]` section) on top of this set.
|
||||||
|
/// Existing values with the same name are replaced.
|
||||||
|
func merging(_ overrides: [String: String]?) -> PHEnvironment {
|
||||||
|
guard let overrides, !overrides.isEmpty else { return self }
|
||||||
|
return PHEnvironment(variables: variables.merging(overrides) { _, override in override })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand `$VAR` and `${VAR}` references against `variables`.
|
||||||
|
///
|
||||||
|
/// Unresolved references are left intact rather than blanked, so a typo
|
||||||
|
/// stays visible (e.g. a literal `"$TYPO"` in a color) instead of silently
|
||||||
|
/// vanishing.
|
||||||
|
func expand(_ value: String) -> String {
|
||||||
|
var result = value
|
||||||
|
|
||||||
|
// `${VAR}` first, so the `$VAR` pass never sees its inner name.
|
||||||
|
result = result.replacing(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/) { match in
|
||||||
|
variables[String(match.1)] ?? String(match.0)
|
||||||
|
}
|
||||||
|
// `$VAR`
|
||||||
|
result = result.replacing(/\$([A-Za-z_][A-Za-z0-9_]*)/) { match in
|
||||||
|
variables[String(match.1)] ?? String(match.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opt-in property wrapper that expands `$VAR` / `${VAR}` references in a
|
||||||
|
/// decoded string against the `PHEnvironment` carried in `decoder.userInfo`
|
||||||
|
/// (falling back to the process environment when none is injected).
|
||||||
|
///
|
||||||
|
/// Apply only where config templating is wanted — never on `command`, whose
|
||||||
|
/// `$`-references belong to the shell at run time, not to phbar at load time.
|
||||||
|
@propertyWrapper
|
||||||
|
struct EnvExpanded: Decodable, Equatable {
|
||||||
|
private let value: String
|
||||||
|
|
||||||
|
var wrappedValue: String { value }
|
||||||
|
|
||||||
|
init(wrappedValue: String) {
|
||||||
|
self.value = wrappedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
init(from decoder: Decoder) throws {
|
||||||
|
let raw = try decoder.singleValueContainer().decode(String.self)
|
||||||
|
let environment = (decoder.userInfo[PHEnvironment.userInfoKey] as? PHEnvironment) ?? .process
|
||||||
|
self.value = environment.expand(raw)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,13 +4,16 @@ 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.
|
/// Falls back to the bundled theme when the file is absent or parsing fails.
|
||||||
static func load(_ theme: String?) throws -> PHTheme {
|
///
|
||||||
|
/// - Parameter environment: Used to expand `$VAR` references in `@EnvExpanded`
|
||||||
|
/// fields (colors, fonts); defaults to the process environment.
|
||||||
|
static func load(_ theme: String?, environment: PHEnvironment = .process) throws -> PHTheme {
|
||||||
guard let theme else { return try loadFromBundle() }
|
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)
|
return try load(from: url, environment: environment)
|
||||||
} else {
|
} else {
|
||||||
return try loadFromBundle()
|
return try loadFromBundle()
|
||||||
}
|
}
|
||||||
@@ -27,18 +30,18 @@ extension PHTheme {
|
|||||||
else {
|
else {
|
||||||
throw phbar.Error("Failed to load theme file")
|
throw phbar.Error("Failed to load theme file")
|
||||||
}
|
}
|
||||||
return try load(from: defaultConfig)
|
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) throws -> PHTheme {
|
static private func load(from url: URL, environment: PHEnvironment) throws -> PHTheme {
|
||||||
do {
|
do {
|
||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
|
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
|
||||||
throw phbar.Error("the file is empty")
|
throw phbar.Error("the file is empty")
|
||||||
}
|
}
|
||||||
return try load(from: contents)
|
return try load(from: contents, environment: environment)
|
||||||
} catch {
|
} catch {
|
||||||
throw phbar.Error("Failed to load theme file", underlyingError: error)
|
throw phbar.Error("Failed to load theme file", underlyingError: error)
|
||||||
}
|
}
|
||||||
@@ -46,9 +49,10 @@ extension PHTheme {
|
|||||||
|
|
||||||
/// Load theme from a TOML string.
|
/// Load theme from a TOML string.
|
||||||
/// If the content fails to parse, the execution is interrupted.
|
/// If the content fails to parse, the execution is interrupted.
|
||||||
static private func load(from contents: String) throws -> PHTheme {
|
static private func load(from contents: String, environment: PHEnvironment) throws -> PHTheme {
|
||||||
do {
|
do {
|
||||||
let decoder = TOMLDecoder()
|
let decoder = TOMLDecoder()
|
||||||
|
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("Failed to parse theme file", underlyingError: error)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SwiftUI
|
|||||||
struct PHThemeStyleColor: Decodable, ShapeStyle {
|
struct PHThemeStyleColor: Decodable, ShapeStyle {
|
||||||
typealias Resolved = SwiftUI.Color
|
typealias Resolved = SwiftUI.Color
|
||||||
|
|
||||||
let color: String
|
@EnvExpanded var color: String
|
||||||
let alpha: Double?
|
let alpha: Double?
|
||||||
|
|
||||||
var uiColor: SwiftUI.Color {
|
var uiColor: SwiftUI.Color {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import SwiftUI
|
|||||||
|
|
||||||
struct PHThemeText: Decodable {
|
struct PHThemeText: Decodable {
|
||||||
let name: String
|
let name: String
|
||||||
let fontFamily: String
|
@EnvExpanded var fontFamily: String
|
||||||
let size: Double
|
let size: Double
|
||||||
let weight: PHThemeTextWeight
|
let weight: PHThemeTextWeight
|
||||||
let style: PHThemeTextStyle
|
let style: PHThemeTextStyle
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ final class BarController: ObservableObject {
|
|||||||
blocks.filter { $0.centered == true }
|
blocks.filter { $0.centered == true }
|
||||||
}
|
}
|
||||||
|
|
||||||
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], debug: Bool) {
|
init(config: PHConfig, screen: NSScreen, theme: PHTheme, blocks: [PHBlock], environment: PHEnvironment = .process, debug: Bool) {
|
||||||
self.config = config
|
self.config = config
|
||||||
self.screen = screen
|
self.screen = screen
|
||||||
self.theme = theme
|
self.theme = theme
|
||||||
@@ -26,6 +26,7 @@ final class BarController: ObservableObject {
|
|||||||
for block in blocks {
|
for block in blocks {
|
||||||
block.text = text(for: block)
|
block.text = text(for: block)
|
||||||
block.style = style(for: block)
|
block.style = style(for: block)
|
||||||
|
block.environment = environment
|
||||||
block.debug = debug
|
block.debug = debug
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ struct SpaceBlockView: View {
|
|||||||
@ObservedObject var block: PHBlock
|
@ObservedObject var block: PHBlock
|
||||||
|
|
||||||
var width: CGFloat? {
|
var width: CGFloat? {
|
||||||
guard let arg = block.command.split(separator: " ").last else { return nil }
|
guard let arg = block.name.split(separator: " ").last else { return nil }
|
||||||
guard let width = Double(arg) else { return nil }
|
guard let width = Double(arg) else { return nil }
|
||||||
return CGFloat(width)
|
return CGFloat(width)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,9 +20,11 @@ import Testing
|
|||||||
let blocks = try PHBlock.load(from: """
|
let blocks = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "printf a"
|
command = "printf a"
|
||||||
|
name = "a"
|
||||||
|
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "printf b"
|
command = "printf b"
|
||||||
|
name = "b"
|
||||||
""")
|
""")
|
||||||
|
|
||||||
let config = try PHConfig.load()
|
let config = try PHConfig.load()
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import Testing
|
|||||||
|
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "echo world"
|
command = "echo world"
|
||||||
|
name = "world"
|
||||||
"""
|
"""
|
||||||
|
|
||||||
let blocks = try PHBlock.load(from: toml)
|
let blocks = try PHBlock.load(from: toml)
|
||||||
@@ -25,7 +26,7 @@ import Testing
|
|||||||
#expect(blocks[0].name == "greeting")
|
#expect(blocks[0].name == "greeting")
|
||||||
#expect(blocks[0].refresh == 5.0)
|
#expect(blocks[0].refresh == 5.0)
|
||||||
#expect(blocks[0].label == nil)
|
#expect(blocks[0].label == nil)
|
||||||
#expect(blocks[1].name == nil)
|
#expect(blocks[1].name == "world")
|
||||||
#expect(blocks[1].refresh == nil)
|
#expect(blocks[1].refresh == nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,11 +74,28 @@ private func makeBlocksConfigDir() throws -> URL {
|
|||||||
|
|
||||||
// MARK: - Compute
|
// MARK: - Compute
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
@Test func computeSeesBlockEnvironment() async throws {
|
||||||
|
// The `[env]` section (overlaid by `BarController`) must reach the script's
|
||||||
|
// process environment — this is the wiring the `env` config documents.
|
||||||
|
let block = try PHBlock.load(from: """
|
||||||
|
[[block]]
|
||||||
|
name = "env"
|
||||||
|
command = "echo $PHBAR_TEST_VAR"
|
||||||
|
""")[0]
|
||||||
|
block.environment = PHEnvironment(variables: ["PHBAR_TEST_VAR": "panini"])
|
||||||
|
|
||||||
|
let output = await block.compute()
|
||||||
|
|
||||||
|
#expect(output == "panini")
|
||||||
|
}
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
@Test func computeReturnsCommandStdout() async throws {
|
@Test func computeReturnsCommandStdout() async throws {
|
||||||
let blocks = try PHBlock.load(from: """
|
let blocks = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "printf panini"
|
command = "printf panini"
|
||||||
|
name = "panini"
|
||||||
""")
|
""")
|
||||||
|
|
||||||
let output = await blocks[0].compute()
|
let output = await blocks[0].compute()
|
||||||
@@ -95,6 +113,7 @@ private func makeBlocksConfigDir() throws -> URL {
|
|||||||
let blocks = try PHBlock.load(from: """
|
let blocks = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "pwd"
|
command = "pwd"
|
||||||
|
name = "pwd"
|
||||||
""")
|
""")
|
||||||
|
|
||||||
let output = await blocks[0].compute()
|
let output = await blocks[0].compute()
|
||||||
@@ -112,6 +131,7 @@ private func makeBlocksConfigDir() throws -> URL {
|
|||||||
let block = try PHBlock.load(from: """
|
let block = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "printf panini"
|
command = "printf panini"
|
||||||
|
name = "panini"
|
||||||
""")[0]
|
""")[0]
|
||||||
|
|
||||||
#expect(block.label == nil)
|
#expect(block.label == nil)
|
||||||
@@ -126,6 +146,7 @@ private func makeBlocksConfigDir() throws -> URL {
|
|||||||
let block = try PHBlock.load(from: """
|
let block = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "printf hi"
|
command = "printf hi"
|
||||||
|
name = "hi"
|
||||||
""")[0]
|
""")[0]
|
||||||
|
|
||||||
block.startAutoRefresh()
|
block.startAutoRefresh()
|
||||||
@@ -149,6 +170,7 @@ private func makeBlocksConfigDir() throws -> URL {
|
|||||||
let toml = """
|
let toml = """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
name = "counter"
|
||||||
refresh = 0.05
|
refresh = 0.05
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import Foundation
|
||||||
|
import Testing
|
||||||
|
import TOML
|
||||||
|
|
||||||
|
@testable import phbar
|
||||||
|
|
||||||
|
// MARK: - expand
|
||||||
|
|
||||||
|
@Test func envExpandDollarBraceForm() {
|
||||||
|
let env = PHEnvironment(variables: ["ACCENT": "#ff0000"])
|
||||||
|
#expect(env.expand("${ACCENT}") == "#ff0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envExpandDollarForm() {
|
||||||
|
let env = PHEnvironment(variables: ["FOO": "bar"])
|
||||||
|
#expect(env.expand("$FOO") == "bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envExpandsEmbeddedReferences() {
|
||||||
|
let env = PHEnvironment(variables: ["HOME": "/Users/tom", "BIN": "/opt/bin"])
|
||||||
|
#expect(env.expand("$HOME/bin:${BIN}/extra") == "/Users/tom/bin:/opt/bin/extra")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envExpandLeavesUnresolvedIntact() {
|
||||||
|
// Unresolved references stay literal instead of being blanked, so a typo
|
||||||
|
// remains visible.
|
||||||
|
let env = PHEnvironment(variables: [:])
|
||||||
|
#expect(env.expand("$MISSING and ${ALSO_MISSING}") == "$MISSING and ${ALSO_MISSING}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envExpandIgnoresNonIdentifierDollar() {
|
||||||
|
let env = PHEnvironment(variables: ["FOO": "bar"])
|
||||||
|
// A `$` not followed by an identifier name is left untouched.
|
||||||
|
#expect(env.expand("cost: $5 and $FOO") == "cost: $5 and bar")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - merging
|
||||||
|
|
||||||
|
@Test func envMergingOverridesExistingKeys() {
|
||||||
|
let env = PHEnvironment(variables: ["PATH": "/usr/bin", "HOME": "/u"])
|
||||||
|
let merged = env.merging(["PATH": "/usr/bin:/extra"])
|
||||||
|
#expect(merged.variables["PATH"] == "/usr/bin:/extra")
|
||||||
|
#expect(merged.variables["HOME"] == "/u")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envMergingNilReturnsSameVariables() {
|
||||||
|
let env = PHEnvironment(variables: ["A": "1"])
|
||||||
|
#expect(env.merging(nil).variables == ["A": "1"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - [env] value expansion (config load)
|
||||||
|
|
||||||
|
private func makeConfig(env: [String: String]?) -> PHConfig {
|
||||||
|
PHConfig(theme: "default", window: "default", blocks: nil, env: env, monitors: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolvingEnvExpandsValuesAgainstGivenEnvironment() {
|
||||||
|
// `PATH = "$PATH:/extra"` extends an inherited variable.
|
||||||
|
let config = makeConfig(env: [
|
||||||
|
"PATH": "$PATH:/opt/phbar/bin",
|
||||||
|
"WORKDIR": "${TMPDIR}/phbar",
|
||||||
|
"PLAIN": "literal",
|
||||||
|
])
|
||||||
|
let resolved = config.resolvingEnv(in: PHEnvironment(variables: [
|
||||||
|
"PATH": "/usr/bin",
|
||||||
|
"TMPDIR": "/var/tmp",
|
||||||
|
]))
|
||||||
|
|
||||||
|
#expect(resolved.env?["PATH"] == "/usr/bin:/opt/phbar/bin")
|
||||||
|
#expect(resolved.env?["WORKDIR"] == "/var/tmp/phbar")
|
||||||
|
#expect(resolved.env?["PLAIN"] == "literal")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolvingEnvDefaultsToProcessEnvironment() {
|
||||||
|
// `$TMPDIR` is set in phbar's launch environment, so this also exercises
|
||||||
|
// real process-env expansion end to end.
|
||||||
|
guard let tmpdir = ProcessInfo.processInfo.environment["TMPDIR"] else { return }
|
||||||
|
let config = makeConfig(env: ["OUT": "$TMPDIR"])
|
||||||
|
let resolved = config.resolvingEnv()
|
||||||
|
#expect(resolved.env?["OUT"] == tmpdir)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func resolvingEnvLeavesConfigUntouchedWhenEnvAbsent() {
|
||||||
|
let config = makeConfig(env: nil)
|
||||||
|
#expect(config.resolvingEnv(in: .process).env == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - @EnvExpanded wrapper
|
||||||
|
|
||||||
|
/// Stand-in Decodable that mirrors how a theme field opts into expansion.
|
||||||
|
private struct Color: Decodable {
|
||||||
|
@EnvExpanded var hex: String
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envExpandedResolvesAgainstInjectedEnvironment() throws {
|
||||||
|
let decoder = TOMLDecoder()
|
||||||
|
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["ACCENT": "#ff0000"])
|
||||||
|
|
||||||
|
let color = try decoder.decode(Color.self, from: #"hex = "$ACCENT""#)
|
||||||
|
|
||||||
|
#expect(color.hex == "#ff0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envExpandedFallsBackToLaunchEnvironment() throws {
|
||||||
|
// Without injected userInfo the wrapper falls back to phbar's launch
|
||||||
|
// environment (`ProcessInfo`), which is a snapshot taken at process start.
|
||||||
|
guard let path = ProcessInfo.processInfo.environment["PATH"] else { return }
|
||||||
|
|
||||||
|
let color = try TOMLDecoder().decode(Color.self, from: #"hex = "$PATH""#)
|
||||||
|
|
||||||
|
#expect(color.hex == path)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func envExpandedLeavesUnresolvedIntact() throws {
|
||||||
|
let color = try TOMLDecoder().decode(Color.self, from: #"hex = "$NEVER_SET""#)
|
||||||
|
|
||||||
|
#expect(color.hex == "$NEVER_SET")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Theme integration
|
||||||
|
|
||||||
|
@Test func themeStyleColorExpandsEnvReference() throws {
|
||||||
|
let decoder = TOMLDecoder()
|
||||||
|
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["ACCENT": "#ff0000"])
|
||||||
|
|
||||||
|
let style = try decoder.decode(PHThemeStyleColor.self, from: #"color = "$ACCENT""#)
|
||||||
|
|
||||||
|
#expect(style.color == "#ff0000")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test func themeTextFontExpandsEnvReference() throws {
|
||||||
|
let decoder = TOMLDecoder()
|
||||||
|
decoder.userInfo[PHEnvironment.userInfoKey] = PHEnvironment(variables: ["FONT": "Comic Code"])
|
||||||
|
|
||||||
|
let text = try decoder.decode(
|
||||||
|
PHThemeText.self,
|
||||||
|
from: """
|
||||||
|
name = "default"
|
||||||
|
font = "$FONT"
|
||||||
|
size = 14
|
||||||
|
weight = "regular"
|
||||||
|
style = "normal"
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
#expect(text.fontFamily == "Comic Code")
|
||||||
|
}
|
||||||
@@ -32,6 +32,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
let toml = """
|
let toml = """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "echo hi"
|
command = "echo hi"
|
||||||
|
name = "a"
|
||||||
events = ["volume", "network", "appearance", "power", "mpd"]
|
events = ["volume", "network", "appearance", "power", "mpd"]
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
let blocks = try PHBlock.load(from: """
|
let blocks = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "echo hi"
|
command = "echo hi"
|
||||||
|
name = "b"
|
||||||
events = ["totally_made_up"]
|
events = ["totally_made_up"]
|
||||||
""")
|
""")
|
||||||
|
|
||||||
@@ -58,6 +60,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
let blocks = try PHBlock.load(from: """
|
let blocks = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "echo hi"
|
command = "echo hi"
|
||||||
|
name = "c"
|
||||||
""")
|
""")
|
||||||
|
|
||||||
#expect(blocks[0].events == nil)
|
#expect(blocks[0].events == nil)
|
||||||
@@ -161,6 +164,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
let block = try PHBlock.load(from: """
|
let block = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
name = "d"
|
||||||
events = ["volume"]
|
events = ["volume"]
|
||||||
""")[0]
|
""")[0]
|
||||||
block.registry = registry
|
block.registry = registry
|
||||||
@@ -197,6 +201,7 @@ private final class FakeEventSource: PHEventSource {
|
|||||||
let block = try PHBlock.load(from: """
|
let block = try PHBlock.load(from: """
|
||||||
[[block]]
|
[[block]]
|
||||||
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
command = "n=$(cat \(path) 2>/dev/null || echo 0); n=$((n+1)); echo $n > \(path); echo $n"
|
||||||
|
name = "e"
|
||||||
refresh = 0.2
|
refresh = 0.2
|
||||||
events = ["volume"]
|
events = ["volume"]
|
||||||
""")[0]
|
""")[0]
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ private struct DimensionWrapper: Decodable {
|
|||||||
styles: nil
|
styles: nil
|
||||||
)
|
)
|
||||||
let config = PHConfig(theme: "default", window: "default", blocks: nil, env: nil, monitors: nil)
|
let config = PHConfig(theme: "default", window: "default", blocks: nil, env: nil, monitors: nil)
|
||||||
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
|
||||||
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
let frame = BarWindow.computeFrame(from: controller)
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
@@ -128,7 +128,7 @@ private struct DimensionWrapper: Decodable {
|
|||||||
styles: nil
|
styles: nil
|
||||||
)
|
)
|
||||||
let config = PHConfig(theme: "default", window: "abs", blocks: nil, env: nil, monitors: nil)
|
let config = PHConfig(theme: "default", window: "abs", blocks: nil, env: nil, monitors: nil)
|
||||||
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
|
||||||
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
let frame = BarWindow.computeFrame(from: controller)
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
@@ -155,7 +155,7 @@ private struct DimensionWrapper: Decodable {
|
|||||||
styles: nil
|
styles: nil
|
||||||
)
|
)
|
||||||
let config = PHConfig(theme: "default", window: "inset", blocks: nil, env: nil, monitors: nil)
|
let config = PHConfig(theme: "default", window: "inset", blocks: nil, env: nil, monitors: nil)
|
||||||
let blocks = try PHBlock.load(from: "[[block]]\ncommand = \"echo x\"")
|
let blocks = try PHBlock.load(from: "[[block]]\nname = \"x\"\ncommand = \"echo x\"")
|
||||||
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
let controller = BarController(config: config, screen: screen, theme: theme, blocks: blocks, debug: false)
|
||||||
|
|
||||||
let frame = BarWindow.computeFrame(from: controller)
|
let frame = BarWindow.computeFrame(from: controller)
|
||||||
|
|||||||
Reference in New Issue
Block a user