Compare commits
5 Commits
87070dd62f
...
1867a4c0a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
1867a4c0a6
|
|||
|
a894449d15
|
|||
|
a49f78cbb0
|
|||
|
1a2eddcd5d
|
|||
|
909d890837
|
+1
-4
@@ -34,10 +34,7 @@ let package = Package(
|
|||||||
exclude: [
|
exclude: [
|
||||||
"Models/PHEvent/README.md"
|
"Models/PHEvent/README.md"
|
||||||
],
|
],
|
||||||
resources: [
|
resources: [.copy("config")]
|
||||||
.process("Models/PHConfig/config.toml"),
|
|
||||||
.process("Models/PHTheme/theme.toml"),
|
|
||||||
]
|
|
||||||
),
|
),
|
||||||
.testTarget(
|
.testTarget(
|
||||||
name: "phbarTests",
|
name: "phbarTests",
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import AppKit
|
|
||||||
import ArgumentParser
|
|
||||||
import Foundation
|
|
||||||
|
|
||||||
extension phbar {
|
|
||||||
struct start: ParsableCommand {
|
|
||||||
static let configuration = CommandConfiguration(
|
|
||||||
commandName: "start",
|
|
||||||
abstract: "Start the status bar."
|
|
||||||
)
|
|
||||||
|
|
||||||
@Flag(name: .long, help: "Display visual guides to help align elements.")
|
|
||||||
var debug: Bool = false
|
|
||||||
|
|
||||||
mutating func run() throws {
|
|
||||||
let config = try PHConfig.load()
|
|
||||||
|
|
||||||
// NOTE: Make sure NSApp.run() runs in the main thread
|
|
||||||
dispatchPrecondition(condition: .onQueue(.main))
|
|
||||||
|
|
||||||
try MainActor.assumeIsolated {
|
|
||||||
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
|
|
||||||
|
|
||||||
let delegate = PHBarDelegate(config: config, debug: debug)
|
|
||||||
let app = NSApplication.shared
|
|
||||||
app.setActivationPolicy(.accessory)
|
|
||||||
app.delegate = delegate
|
|
||||||
app.run()
|
|
||||||
|
|
||||||
throw ExitCode.success
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension PHBar {
|
||||||
|
struct Install: ParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "install",
|
||||||
|
abstract: "Start the status bar.",
|
||||||
|
discussion: """
|
||||||
|
Launch the bar as a non-stopping background process.
|
||||||
|
This command is meant to be used for both testing and production.
|
||||||
|
|
||||||
|
When testing, you can provide the `--debug` flag to get visual
|
||||||
|
guidance that helps you configure the blocks and a verbose output from
|
||||||
|
the shell scripts to debug the behaviour.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
private var source: URL? {
|
||||||
|
Bundle.module.url(forResource: "config", withExtension: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var destination: URL {
|
||||||
|
let environment = ProcessInfo.processInfo.environment
|
||||||
|
|
||||||
|
let homeDirectory = FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
let standardConfigDirectory = homeDirectory.appending(path: ".config")
|
||||||
|
let xdgConfigDirectory = environment["XDG_CONFIG_HOME"]?.trimmingCharacters(
|
||||||
|
in: .whitespaces)
|
||||||
|
var candidateDirectory: URL
|
||||||
|
|
||||||
|
if let xdgConfigDirectory, xdgConfigDirectory.hasPrefix("/") {
|
||||||
|
candidateDirectory = URL(fileURLWithPath: xdgConfigDirectory)
|
||||||
|
} else if FileManager.default.fileExists(atPath: standardConfigDirectory.relativePath) {
|
||||||
|
candidateDirectory = standardConfigDirectory
|
||||||
|
} else {
|
||||||
|
candidateDirectory = homeDirectory
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidateDirectory.appending(path: "phbar")
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func run() throws {
|
||||||
|
do {
|
||||||
|
guard let source else { throw PHBar.Error("Failed to locate bundled config") }
|
||||||
|
try FileManager.default.copyItem(at: source, to: destination)
|
||||||
|
try makeExecutable()
|
||||||
|
} catch {
|
||||||
|
throw PHBar.Error("Failed to create config directory", underlyingError: error)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw CleanExit.message("Config directory created at \(destination.relativePath)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeExecutable() throws {
|
||||||
|
let scriptsDirectory = destination.appending(path: "scripts", directoryHint: .isDirectory)
|
||||||
|
let scripts = try FileManager.default.contentsOfDirectory(
|
||||||
|
atPath: scriptsDirectory.relativePath
|
||||||
|
)
|
||||||
|
|
||||||
|
for script in scripts {
|
||||||
|
let path = scriptsDirectory.appending(path: script)
|
||||||
|
var attributes: [FileAttributeKey: Any] = [:]
|
||||||
|
attributes[.posixPermissions] = 0o755 // rwxr-xr-x
|
||||||
|
try FileManager.default.setAttributes(attributes, ofItemAtPath: path.relativePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import ArgumentParser
|
import ArgumentParser
|
||||||
|
|
||||||
extension phbar {
|
extension PHBar {
|
||||||
struct refresh: ParsableCommand {
|
struct Refresh: ParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "refresh",
|
commandName: "refresh",
|
||||||
abstract: "Refresh the running status bar."
|
abstract: "Refresh the running status bar."
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import AppKit
|
||||||
|
import ArgumentParser
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
extension PHBar {
|
||||||
|
struct Start: ParsableCommand {
|
||||||
|
static let configuration = CommandConfiguration(
|
||||||
|
commandName: "start",
|
||||||
|
abstract: "Start the status bar.",
|
||||||
|
discussion: """
|
||||||
|
Launch the bar as a non-stopping background process.
|
||||||
|
This command is meant to be used for both testing and production.
|
||||||
|
|
||||||
|
When testing, you can provide the `--debug` flag to get visual
|
||||||
|
guidance that helps you configure the blocks and a verbose output from
|
||||||
|
the shell scripts to debug the behaviour.
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
@Flag(name: .long, help: "Display visual guides and print verbose output.")
|
||||||
|
var debug: Bool = false
|
||||||
|
|
||||||
|
mutating func run() throws {
|
||||||
|
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
|
||||||
|
dispatchPrecondition(condition: .onQueue(.main))
|
||||||
|
|
||||||
|
try MainActor.assumeIsolated {
|
||||||
|
guard !NSScreen.screens.isEmpty else { throw CleanExit.message("No monitor found") }
|
||||||
|
|
||||||
|
let delegate = PHBarDelegate(config: config, theme: theme, debug: debug)
|
||||||
|
let app = NSApplication.shared
|
||||||
|
app.setActivationPolicy(.accessory)
|
||||||
|
app.delegate = delegate
|
||||||
|
app.run()
|
||||||
|
|
||||||
|
throw ExitCode.success
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ extension PHBarDelegate {
|
|||||||
do {
|
do {
|
||||||
controller = try factory.make(for: screen)
|
controller = try factory.make(for: screen)
|
||||||
} catch {
|
} catch {
|
||||||
stderr("skipping screen \(screen.localizedName): \(error)")
|
stderr("skipping screen \(screen.localizedName): \(error.localizedDescription)")
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
let window = BarWindow(controller: controller)
|
let window = BarWindow(controller: controller)
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ extension PHBlock {
|
|||||||
|
|
||||||
let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml")
|
let setFile = base.appending(path: "blocks").appending(path: "\(blocks).toml")
|
||||||
guard FileManager.default.fileExists(atPath: setFile.relativePath) else {
|
guard FileManager.default.fileExists(atPath: setFile.relativePath) else {
|
||||||
throw phbar.Error("Failed to load blocks '\(blocks)'")
|
throw PHBar.Error("Failed to load blocks '\(blocks)'")
|
||||||
}
|
}
|
||||||
return try load(from: setFile)
|
return try load(from: setFile)
|
||||||
}
|
}
|
||||||
@@ -30,11 +30,11 @@ extension PHBlock {
|
|||||||
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)
|
||||||
} catch {
|
} catch {
|
||||||
throw phbar.Error("Failed to load blocks file", underlyingError: error)
|
throw PHBar.Error("Failed to load blocks file", underlyingError: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,7 +46,7 @@ extension PHBlock {
|
|||||||
let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents)
|
let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents)
|
||||||
return wrapper.blocks
|
return wrapper.blocks
|
||||||
} catch {
|
} catch {
|
||||||
throw phbar.Error("Failed to parse blocks file", underlyingError: error)
|
throw PHBar.Error("Failed to parse blocks file", underlyingError: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,39 +3,34 @@ import TOML
|
|||||||
|
|
||||||
extension PHConfig {
|
extension PHConfig {
|
||||||
/// Load configuration from the resolved config directory
|
/// Load configuration from the resolved config directory
|
||||||
/// (see `PHPaths`). If the file doesn't exist or fails to parse, the
|
/// (see `PHPaths`). If the file doesn't exist or fails to parse,
|
||||||
/// bundled default is used.
|
/// 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")
|
||||||
|
|
||||||
if FileManager.default.fileExists(atPath: url.relativePath) {
|
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||||
return try load(from: url)
|
return try load(from: url)
|
||||||
} else {
|
} else {
|
||||||
return try loadFromBundle()
|
throw PHBar.Error(
|
||||||
}
|
"""
|
||||||
}
|
Configuration file not found.
|
||||||
|
Make sure to have a file named `config.toml` inside the
|
||||||
|
directory: `\(url.deletingLastPathComponent().relativePath)`
|
||||||
|
|
||||||
/// Load the bundled configuration.
|
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 -> PHConfig {
|
"""
|
||||||
guard
|
)
|
||||||
let defaultConfig = Bundle.module.url(
|
|
||||||
forResource: "config",
|
|
||||||
withExtension: "toml"
|
|
||||||
)?.resolvingSymlinksInPath()
|
|
||||||
else {
|
|
||||||
throw phbar.Error("Failed to load config file")
|
|
||||||
}
|
}
|
||||||
return try load(from: defaultConfig)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Decode configuration from a file at URL.
|
/// Decode configuration 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 -> PHConfig {
|
static private func load(from url: URL) throws -> PHConfig {
|
||||||
guard let data = try? Data(contentsOf: url),
|
guard let data = try? Data(contentsOf: url),
|
||||||
let contents = String(data: data, encoding: .utf8)
|
let contents = String(data: data, encoding: .utf8), !contents.isEmpty
|
||||||
else {
|
else {
|
||||||
throw phbar.Error("Failed to load config file")
|
throw PHBar.Error("Configuration file not readable or empty.")
|
||||||
}
|
}
|
||||||
|
|
||||||
return try load(from: contents)
|
return try load(from: contents)
|
||||||
@@ -53,7 +48,7 @@ extension PHConfig {
|
|||||||
// resolve the same set.
|
// resolve the same set.
|
||||||
return config.resolvingEnv()
|
return config.resolvingEnv()
|
||||||
} catch {
|
} catch {
|
||||||
throw phbar.Error("Failed to parse config file", underlyingError: error)
|
throw PHBar.Error("Configuration file not valid", underlyingError: error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,21 +8,42 @@ import ArgumentParser
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
@main
|
@main
|
||||||
struct phbar: ParsableCommand {
|
struct PHBar: ParsableCommand {
|
||||||
static let configuration = CommandConfiguration(
|
static let configuration = CommandConfiguration(
|
||||||
commandName: "phbar",
|
commandName: "phbar",
|
||||||
abstract: "Modular status bar for macOS.",
|
abstract: "Modular status bar for macOS.",
|
||||||
discussion: """
|
discussion: """
|
||||||
pmenu reads a list of newline-separated items from stdin and presents them to the user.
|
phbar renders a status bar on every visible monitor. Each status bar
|
||||||
When the user selects an item and presses Return, their choice is printed to stdout and pmenu terminates.
|
is dynamically built from the given theme, window configuration,
|
||||||
Entering text will narrow the items to those matching the tokens in the input.
|
and block set. Thanks to the modularity of phbar, each one of these
|
||||||
|
elements can be overwritten for any given monitor.
|
||||||
|
|
||||||
|
phbar relies on blocks to determine what is visible on a bar and how
|
||||||
|
it should look. Everything in phbar is a block, not just texts but
|
||||||
|
also spaces and dividers. Each block can have a different visual style,
|
||||||
|
refresh conditions, and content source. The content of a block, if any,
|
||||||
|
is computed by calling a shell script, giving you the maximum flexibility.
|
||||||
|
|
||||||
|
Run `phbar install` to create the required config directory filled with
|
||||||
|
a template to start with. The directory will be created in one of these
|
||||||
|
locations in order of priority: $XDG_CONFIG_HOME/phbar, ~/.config/phbar,
|
||||||
|
or ~/.phbar.
|
||||||
|
|
||||||
|
Edit `<config-directory>/blocks/default.toml` to customize the block set used
|
||||||
|
by default and `<config-directory>/themes/default.toml` to customize the look
|
||||||
|
and feel of the bar. Check out the full documentation to learn more about all
|
||||||
|
the possible customisations.
|
||||||
|
|
||||||
|
Run `phbar start` to launch the bar and verify the appearance and behaviour.
|
||||||
|
Once you're ready to go to production, you can create a launch agent that uses
|
||||||
|
the same command to start the bar automatically at login.
|
||||||
""",
|
""",
|
||||||
version: "1.0.0",
|
version: "1.0.0",
|
||||||
subcommands: [start.self]
|
subcommands: [Start.self, Install.self]
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
extension phbar {
|
extension PHBar {
|
||||||
struct Error: LocalizedError {
|
struct Error: LocalizedError {
|
||||||
let message: String
|
let message: String
|
||||||
let underlyingError: Swift.Error?
|
let underlyingError: Swift.Error?
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
[[block]]
|
||||||
|
name = "version"
|
||||||
|
style = "elevated"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
name = "_space 14"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
name = "attribution"
|
||||||
|
command = 'echo "by Panini House"'
|
||||||
|
text = "italic"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
name = "_space"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
name = "gesture"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
name = "_space 14"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
name = "clock"
|
||||||
|
refresh = 1
|
||||||
|
style = "elevated"
|
||||||
|
|
||||||
|
[[block]]
|
||||||
|
name = "greetings"
|
||||||
|
command = 'echo " Hi $USER", welcome to phbar!'
|
||||||
|
text = "italic"
|
||||||
|
centered = true
|
||||||
@@ -6,19 +6,17 @@ theme = "default"
|
|||||||
window = "default"
|
window = "default"
|
||||||
blocks = "default"
|
blocks = "default"
|
||||||
|
|
||||||
# Optional: override the theme, window, and/or block set per monitor.
|
# Optional: override the window and/or block set 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.
|
||||||
# Either field may be omitted to inherit its global value.
|
# Either field may be omitted to inherit its global value.
|
||||||
#
|
#
|
||||||
# [monitor."1"]
|
# [monitor."1"]
|
||||||
# theme = "default"
|
|
||||||
# window = "bottom"
|
# window = "bottom"
|
||||||
# blocks = "laptop"
|
# blocks = "laptop"
|
||||||
#
|
#
|
||||||
# [monitor."DELL U2723QE"]
|
# [monitor."DELL U2723QE"]
|
||||||
# theme = "external"
|
|
||||||
# window = "clock"
|
# window = "clock"
|
||||||
# blocks = "external"
|
# blocks = "external"
|
||||||
|
|
||||||
@@ -32,6 +30,5 @@ blocks = "default"
|
|||||||
# theme (e.g. `ACCENT = "#ff0000"`, then `color = "$ACCENT"` in the theme)
|
# theme (e.g. `ACCENT = "#ff0000"`, then `color = "$ACCENT"` in the theme)
|
||||||
#
|
#
|
||||||
# [env]
|
# [env]
|
||||||
# TEST = "ciao"
|
|
||||||
# TEST_2 = "hello world"
|
|
||||||
# PATH = "$PATH:/opt/homebrew/bin"
|
# PATH = "$PATH:/opt/homebrew/bin"
|
||||||
|
# MY_CUSTOM_VAR = "hello world"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo " $(date "+%a %d, %H:%M:%S")"
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
text="Click me"
|
||||||
|
|
||||||
|
case $GESTURE in
|
||||||
|
"left_mouse_down") text="That's left mouse button!" ;;
|
||||||
|
"right_mouse_down") text="That's right mouse button!" ;;
|
||||||
|
"other_mouse_down") text="That's mouse button $GESTURE_INFO!" ;;
|
||||||
|
"scroll_wheel") text="Yes, you can even scroll!" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
echo " $text"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
echo "phbar v$(phbar --version)"
|
||||||
@@ -8,39 +8,29 @@ anchor = "top"
|
|||||||
height = 30
|
height = 30
|
||||||
width = "100%"
|
width = "100%"
|
||||||
|
|
||||||
[[window]]
|
|
||||||
name = "bottom"
|
|
||||||
anchor = "bottom"
|
|
||||||
height = 30
|
|
||||||
width = "100%"
|
|
||||||
|
|
||||||
[[text]]
|
[[text]]
|
||||||
name = "default"
|
name = "default"
|
||||||
font = "Comic Code"
|
font = "monospace"
|
||||||
size = 14
|
size = 14
|
||||||
weight = "regular"
|
weight = "regular"
|
||||||
style = "normal"
|
style = "normal"
|
||||||
offset = -0.5
|
offset = -1
|
||||||
|
|
||||||
[[text]]
|
[[text]]
|
||||||
name = "italic"
|
name = "italic"
|
||||||
font = "Comic Code"
|
font = "monospace"
|
||||||
size = 14
|
size = 14
|
||||||
weight = "regular"
|
weight = "regular"
|
||||||
style = "italic"
|
style = "italic"
|
||||||
offset = -0.5
|
offset = -1
|
||||||
|
|
||||||
[[style]]
|
[[style]]
|
||||||
name = "default"
|
name = "default"
|
||||||
foreground = { color = "#aed3f3" }
|
foreground = { color = "#aed3f3" }
|
||||||
background = { color = "#010408", alpha = 0.825 }
|
background = { color = "#010408" }
|
||||||
|
|
||||||
[[style]]
|
[[style]]
|
||||||
name = "floating"
|
name = "elevated"
|
||||||
foreground = { color = "#aed3f3" }
|
|
||||||
|
|
||||||
[[style]]
|
|
||||||
name = "tinted"
|
|
||||||
foreground = { color = "#aed3f3" }
|
foreground = { color = "#aed3f3" }
|
||||||
background = { color = "#0f304a" }
|
background = { color = "#0f304a" }
|
||||||
padding = { left = 12.0, right = 12.0 }
|
padding = { left = 12.0, right = 12.0 }
|
||||||
@@ -7,7 +7,7 @@ import Testing
|
|||||||
// MARK: - Refresh command
|
// MARK: - Refresh command
|
||||||
|
|
||||||
@Test func refreshCommandConfigurationIsCorrect() async throws {
|
@Test func refreshCommandConfigurationIsCorrect() async throws {
|
||||||
let config = phbar.refresh.configuration
|
let config = PHBar.Refresh.configuration
|
||||||
#expect(config.commandName == "refresh")
|
#expect(config.commandName == "refresh")
|
||||||
#expect(config.abstract == "Refresh the running status bar.")
|
#expect(config.abstract == "Refresh the running status bar.")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user