From d59b22e557fa5d69ea6be725a7e5bcd829588225 Mon Sep 17 00:00:00 2001 From: Tommaso Negri Date: Sun, 9 Aug 2026 23:58:42 +0200 Subject: rename project and add documentation guides --- Makefile | 21 +++++ Package.swift | 6 +- Sources/bar/Config.swift | 142 ++++++++++++++++++++++++++++++++ Sources/bar/Models/AppDelegate.swift | 47 +++++++++++ Sources/bar/Models/Block.swift | 107 ++++++++++++++++++++++++ Sources/bar/Models/Cell.swift | 75 +++++++++++++++++ Sources/bar/Models/HEX.swift | 114 ++++++++++++++++++++++++++ Sources/bar/Models/Notifier.swift | 90 +++++++++++++++++++++ Sources/bar/Models/Runner.swift | 72 +++++++++++++++++ Sources/bar/Models/Screen.swift | 34 ++++++++ Sources/bar/Views/BarView.swift | 29 +++++++ Sources/bar/Views/BarWindow.swift | 32 ++++++++ Sources/bar/Views/BlockView.swift | 60 ++++++++++++++ Sources/bar/Views/CellView.swift | 11 +++ Sources/bar/bar.docc/bar.md | 44 ++++++++++ Sources/bar/bar.docc/commands.md | 50 ++++++++++++ Sources/bar/bar.docc/customisation.md | 45 +++++++++++ Sources/bar/bar.docc/installation.md | 95 ++++++++++++++++++++++ Sources/bar/bar.docc/manual-updates.md | 28 +++++++ Sources/bar/bar.docc/philosophy.md | 34 ++++++++ Sources/bar/main.swift | 8 ++ Sources/phbar/Config.swift | 143 --------------------------------- Sources/phbar/Models/AppDelegate.swift | 47 ----------- Sources/phbar/Models/Block.swift | 107 ------------------------ Sources/phbar/Models/Cell.swift | 70 ---------------- Sources/phbar/Models/HEX.swift | 114 -------------------------- Sources/phbar/Models/Notifier.swift | 90 --------------------- Sources/phbar/Models/Runner.swift | 72 ----------------- Sources/phbar/Models/Screen.swift | 38 --------- Sources/phbar/Views/BarView.swift | 29 ------- Sources/phbar/Views/BarWindow.swift | 32 -------- Sources/phbar/Views/BlockView.swift | 60 -------------- Sources/phbar/Views/CellView.swift | 11 --- Sources/phbar/main.swift | 8 -- Tests/barTests/phbarTests.swift | 8 ++ Tests/phbarTests/phbarTests.swift | 8 -- 36 files changed, 1149 insertions(+), 832 deletions(-) create mode 100644 Makefile create mode 100644 Sources/bar/Config.swift create mode 100644 Sources/bar/Models/AppDelegate.swift create mode 100644 Sources/bar/Models/Block.swift create mode 100644 Sources/bar/Models/Cell.swift create mode 100644 Sources/bar/Models/HEX.swift create mode 100644 Sources/bar/Models/Notifier.swift create mode 100644 Sources/bar/Models/Runner.swift create mode 100644 Sources/bar/Models/Screen.swift create mode 100644 Sources/bar/Views/BarView.swift create mode 100644 Sources/bar/Views/BarWindow.swift create mode 100644 Sources/bar/Views/BlockView.swift create mode 100644 Sources/bar/Views/CellView.swift create mode 100644 Sources/bar/bar.docc/bar.md create mode 100644 Sources/bar/bar.docc/commands.md create mode 100644 Sources/bar/bar.docc/customisation.md create mode 100644 Sources/bar/bar.docc/installation.md create mode 100644 Sources/bar/bar.docc/manual-updates.md create mode 100644 Sources/bar/bar.docc/philosophy.md create mode 100644 Sources/bar/main.swift delete mode 100644 Sources/phbar/Config.swift delete mode 100644 Sources/phbar/Models/AppDelegate.swift delete mode 100644 Sources/phbar/Models/Block.swift delete mode 100644 Sources/phbar/Models/Cell.swift delete mode 100644 Sources/phbar/Models/HEX.swift delete mode 100644 Sources/phbar/Models/Notifier.swift delete mode 100644 Sources/phbar/Models/Runner.swift delete mode 100644 Sources/phbar/Models/Screen.swift delete mode 100644 Sources/phbar/Views/BarView.swift delete mode 100644 Sources/phbar/Views/BarWindow.swift delete mode 100644 Sources/phbar/Views/BlockView.swift delete mode 100644 Sources/phbar/Views/CellView.swift delete mode 100644 Sources/phbar/main.swift create mode 100644 Tests/barTests/phbarTests.swift delete mode 100644 Tests/phbarTests/phbarTests.swift diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..9f7cd43 --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +PREFIX ?= /usr/local/bin + +all: run + +run: + swift run -c debug + +build: + swift build -c release + +install: build + install -d $(PREFIX) + install ".build/release/bar" "$(PREFIX)/bar" + +uninstall: + rm -f "$(PREFIX)/bar" + +clean: + rm -rf .build + +.PHONY: all run build install uninstall clean diff --git a/Package.swift b/Package.swift index c93386b..cc8578a 100644 --- a/Package.swift +++ b/Package.swift @@ -3,14 +3,14 @@ import PackageDescription let package = Package( - name: "phbar", + name: "bar", platforms: [.macOS(.v26)], dependencies: [ .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0") ], targets: [ - .executableTarget(name: "phbar"), - .testTarget(name: "phbarTests", dependencies: ["phbar"]), + .executableTarget(name: "bar"), + .testTarget(name: "barTests", dependencies: ["bar"]), ], swiftLanguageModes: [.v6] ) diff --git a/Sources/bar/Config.swift b/Sources/bar/Config.swift new file mode 100644 index 0000000..2d9859b --- /dev/null +++ b/Sources/bar/Config.swift @@ -0,0 +1,142 @@ +import SwiftUI + +/// The top-level configuration for the bar. +/// +/// Set appearance, define blocks, and choose the bar position here. +/// This is the primary file a user edits to customise *bar*. +enum Config { + /// The bar height in points (pt). + /// + /// > Tip: Set this value to 30 to match the default macOS menu bar height. + static let height: Double = 30 + + /// The bar position on the screen. + static let position: Position = .top + + // NOTE: Customizing font + // + // You can use system default fonts thanks to the standard SwiftUI + // methods and access properties. However, you can also use your + // preferred custom font. + // + // E.g: `.custom("Comic Code", fixedSize: 14)` + // + // You may want to use a patched Nerd Font (https://www.nerdfonts.com) + // so you can have icons in your bar. Alternatively, you can display + // SF Symbols by copy and pasting them as glyphs. + + /// The font used for all text and icons in the bar. + static let font: Font = .system(size: 14, weight: .regular, design: .monospaced) + + // NOTE: Customizing colors + // + // You can use system default colors and the standard SwiftUI + // methods to define them. However, for convenience we added + // support for hex colors too (see ``HEX``). + // + // E.g: `.hex("#000000")` + + /// The color used for texts and icons. + static let foreground: Color = .hex("#FFFFFF") + + /// The color used for the bar background. + static let background: Color = .hex("#000000").opacity(0.75) + + /// The colors used to highlight a cell at different levels (see ``Cell``). + static let highlights: [Color] = [.blue, .purple] + + /// The blocks that make up the bar, listed in display order. + /// + /// Each block is positioned on the left or right side of the bar + /// and runs a shell command, optionally on a repeating interval. + /// See ``Config-swift.enum/Block`` for the full configuration options. + static let blocks: [Block] = [ + Block(name: "version", placement: .left, command: "echo '&!1 bar v1.0.0 '"), + Block(name: "ph", placement: .left, command: "echo ' by Panini House'"), + Block(name: "memory", placement: .right, command: "echo \"􀟱 Hi $USER, welcome to bar! | \""), + Block(name: "clock", placement: .right, command: "echo \"􀐬 $(date '+%a %d, %H:%M:%S') \"", interval: 1), + ] +} + +// MARK: Types reference + +extension Config { + /// The bar position on the screen. + enum Position: Sendable { + /// Anchor the bar at the top of the screen. + case top + + /// Anchor the bar at the bottom of the screen. + case bottom + } + + /// A block defined in the configuration. + /// + /// Represents one unit of the bar. Each block is a named command that + /// produces output on an optional timer. See ``bar/Block`` for the + /// runtime counterpart that manages execution and cell publishing. + struct Block: Sendable { + /// The block identifier used for manual updates. + /// + /// Some blocks do not need to be updated on an defined + /// interval. You might want instead to react to specific + /// events or "manually" update them. + /// In such cases, you can send a standard system + /// notification that tells *bar* to trigger an update + /// for the block identified by the name. + /// + /// E.g: `notifyutil -p bar.touch.` + /// + /// Be aware that nothing stops you from having multiple + /// blocks with the same name. In such case, triggering + /// a notification will cause all blocks with that name + /// to be re-evaluated. + let name: String + + /// Whether the block anchors to the left or right side of the bar. + let placement: Placement + + /// The shell command used for updating the content. + /// + /// You can write an expression directly in the block + /// definition or you can point to a proper executable + /// script somewhere on the system. + /// + /// The command process will inherit the environment + /// of the process that executed `bar` in the first place. + /// This means that running `bar` from your regular shell + /// or from launchctl might result in different behaviours. + /// Make sure to test both scenarios properly. + let command: String + + /// The interval, in seconds, at which the command is executed. + /// + /// If set to `nil`, the block will be evaluated just once + /// at launch. You can of course trigger a manual update via + /// a notification later on. + let interval: Double? + + /// Define a block of the bar. + /// + /// - Parameters: + /// - name: The block identifier used for manual updates. + /// - placement: Left or right side of the bar. + /// - command: The shell command used for updating the content. + /// - interval: The interval, in seconds, at which the command is executed. + /// + init(name: String, placement: Placement, command: String, interval: Double? = nil) { + self.name = name + self.placement = placement + self.command = command + self.interval = interval + } + + /// Where the block is anchored inside the bar. + enum Placement: Sendable { + /// Anchored to the left side of the bar. + case left + /// Anchored to the right side of the bar. + case right + } + } +} diff --git a/Sources/bar/Models/AppDelegate.swift b/Sources/bar/Models/AppDelegate.swift new file mode 100644 index 0000000..a7ecb5a --- /dev/null +++ b/Sources/bar/Models/AppDelegate.swift @@ -0,0 +1,47 @@ +import AppKit +import SwiftUI + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate { + private var windows: [BarWindow] = [] + private var blocks: [Block] = Config.blocks.map(\.resolved) + private var tokens: [UnsafeMutableRawPointer] = [] + + func applicationDidFinishLaunching(_ notification: Notification) { + for screen in NSScreen.screens { + let frame = Screen.barFrame(screen, position: Config.position, height: Config.height) + let window = BarWindow(contentRect: frame) + window.contentView = NSHostingView(rootView: BarView(blocks: blocks)) + window.orderFrontRegardless() + windows.append(window) + } + + // Register one listener per unique name; a single post fans out to every + // block with that name (see Notifier). + for name in Set(blocks.map(\.name)) { + let token = Notifier.register(name) { [weak self] in + self?.handleRefresh(name) + } + tokens.append(token) + } + + for block in blocks { block.start() } + } + + func applicationWillTerminate(_ notification: Notification) { + for block in blocks { block.stop() } + for token in tokens { Notifier.cancel(token) } + } + + /// Called from a Darwin notification handler. Marked `nonisolated` because + /// libnotify's delivery contract isn't statically visible to Swift, so we + /// hop back to the main actor explicitly. + nonisolated func handleRefresh(_ name: String) { + Task { @MainActor [weak self] in + guard let self else { return } + for block in self.blocks where block.name == name { + block.runScript() + } + } + } +} diff --git a/Sources/bar/Models/Block.swift b/Sources/bar/Models/Block.swift new file mode 100644 index 0000000..cb09a4b --- /dev/null +++ b/Sources/bar/Models/Block.swift @@ -0,0 +1,107 @@ +import Foundation + +/// A named, self-updating unit of the bar. +/// +/// Each block runs a shell command on an optional timer, +/// splits its stdout into ``Cell`` values, and publishes +/// them for rendering. Blocks are identified by ``name`` +/// so they can be refreshed on demand via Darwin +/// notifications (see ``Notifier``). +@MainActor +final class Block: ObservableObject, Identifiable { + /// Stable identity for SwiftUI diffing. + let id = UUID() + + /// Identifier used for manual updates via Darwin + /// notifications. + /// + /// Multiple blocks can share the same name. + /// In such case, a single notification will refresh + /// all of them. + let name: String + + /// Whether the block is anchored to the left or right + /// side of the bar. + let placement: Config.Block.Placement + + /// The shell command to execute for producing the cells. + /// + /// Can be an inline expression or a path to an executable + /// script. Inherits the environment of the `bar` process. + let command: String + + /// Refresh interval in seconds, or `nil` for a one-shot block. + /// + /// A one-shot block can still be manually refreshed via + /// a Darwin notification (see ``Notifier``). + let interval: Double? + + /// The current cells produced by the last command execution. + /// + /// SwiftUI observes this `@Published` property and re-renders + /// the view tree automatically. + @Published var cells: [Cell] = [] + + private var tick: Task? + + init(name: String, placement: Config.Block.Placement, command: String, interval: Double? = nil) { + self.name = name + self.placement = placement + self.command = command + self.interval = interval + } + + /// Initial paint + interval timer. + func start() { + runScript() + startTick() + } + + /// Re-run the command and update the cells on the main actor. + func runScript(env: [String: String] = [:]) { + let command = self.command + + var env = env + env.updateValue(name, forKey: "BLOCK") + + Task.detached(priority: .utility) { [weak self] in + let lines = Runner.run(command, env: env) + await self?.apply(lines) + } + } + + /// Stop the interval timer. + func stop() { + tick?.cancel() + } + + /// Replace the current cells with freshly parsed lines. + /// + /// Each line becomes a ``Cell``. This is always called + /// on the main actor so SwiftUI sees the update immediately. + @MainActor + private func apply(_ lines: [String]) { + self.cells = lines.map { Cell(rawValue: $0) } + } + + /// Start the interval timer that re-runs the command periodically. + /// + /// Does nothing when ``interval`` is `nil` or ≤ 0. + private func startTick() { + guard let interval, interval > 0 else { return } + tick = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(interval)) + if Task.isCancelled { break } + self?.runScript() + } + } + } +} + +extension Config.Block { + @MainActor + var resolved: bar.Block { + .init(name: name, placement: placement, command: command, interval: interval) + } +} diff --git a/Sources/bar/Models/Cell.swift b/Sources/bar/Models/Cell.swift new file mode 100644 index 0000000..9e0d46f --- /dev/null +++ b/Sources/bar/Models/Cell.swift @@ -0,0 +1,75 @@ +import Foundation +import RegexBuilder +import SwiftUI + +/// A thin wrapper around a single text line captured +/// by a block. +/// +/// When a ``Block`` executes the command it captures `stdout` +/// and splits each line into a ``Cell``. Each ``Cell`` +/// is then laid out horizontally respecting the order. +/// +/// ### Highlight +/// Besides the underlying text value, a cell holds an +/// highlight level (as Int) that, if true, applies the +/// special background color defined in ``Config``. +/// You can set the highlight at runtime by prefixing the +/// text line with the special notation `&!`, where +/// `` is a positive integer that can be used as an +/// index to pick an highlight. +/// +/// ```shell +/// echo "&!1 $(date "+%a %d, %H:%M:%S") " +/// ``` +/// +/// > Note: Count starts from 1. +/// +/// Highlight colors can have multiple use cases, but they +/// were imagined specifically for distinguishing cells +/// within a group. For example when you want to show +/// window manager workspaces and which one is active. +struct Cell: RawRepresentable, Identifiable { + /// Stable identity for SwiftUI diffing. + let id = UUID() + + /// The represented text content. + let rawValue: String + + /// The highlight level of the cell. + let highlight: Int + + /// The background color derived from the highlight level. + /// + /// Returns the `Config.highlights` color at the same index + /// if present, otherwise `.clear` so the underlying bar + /// background shows through. + var background: Color { + guard highlight != 0 && highlight <= Config.highlights.count else { return .clear } + + return Config.highlights[highlight - 1] + } + + init(rawValue: String) { + let pattern = Regex { + Optionally { + "&!" + Capture { OneOrMore(.digit) } + } + Capture { OneOrMore(.any) } + } + + guard let match = rawValue.firstMatch(of: pattern) else { + self.rawValue = rawValue + self.highlight = 0 + return + } + + if let rawHighlight = match.output.1, let highlight = Int(String(rawHighlight)) { + self.highlight = highlight + } else { + self.highlight = 0 + } + + self.rawValue = String(match.output.2) + } +} diff --git a/Sources/bar/Models/HEX.swift b/Sources/bar/Models/HEX.swift new file mode 100644 index 0000000..a262630 --- /dev/null +++ b/Sources/bar/Models/HEX.swift @@ -0,0 +1,114 @@ +import SwiftUI + +/// A color described by a hexadecimal color code. +/// +/// `HEX` is a lightweight, string-backed representation: the code is stored +/// verbatim and parsed only when converted to a SwiftUI color via `Color(hex:)` +/// or `Color.hex(_:)`, which resolve it in the sRGB color space. +/// +/// Supported formats (the leading `#` is optional): +/// - `#RGB` +/// - `#RGBA` +/// - `#RRGGBB` +/// - `#RRGGBBAA` +/// +/// Single-digit components are expanded by repeating the digit; for example, +/// `#3aF` is read as `#33AAFF`. When present, the trailing digits encode alpha +/// (opacity); otherwise opacity defaults to `1`. +struct HEX { + /// The raw hex code, exactly as supplied. It is not validated or normalized; + /// parsing is performed lazily by `Color.hex(_:)`. + var code: String + + /// Creates a hex color from the given code. + /// + /// - Parameter code: A hex color code. Stored verbatim; see ``HEX`` for the + /// accepted formats. + init(code: String) { + self.code = code + } +} + +extension SwiftUI.Color { + /// Creates a color from a ``HEX`` value. + init(hex: HEX) { + self = .hex(hex.code) + } + + /// Creates a color from a hex code, resolving it in the sRGB color space. + /// + /// Supported formats (the leading `#` is optional): `#RGB`, `#RGBA`, + /// `#RRGGBB`, and `#RRGGBBAA`. Single-digit components are expanded by + /// repeating the digit. When present, the trailing digits encode alpha + /// (opacity); otherwise opacity defaults to `1`. Malformed input resolves + /// to opaque black. + /// + /// - Parameter code: A hex color code. + static func hex(_ code: String) -> SwiftUI.Color { + var sanitized = code.trimmingCharacters(in: .whitespacesAndNewlines) + if sanitized.hasPrefix("#") { + sanitized.removeFirst() + } + + var rgba: UInt64 = 0 + Scanner(string: sanitized).scanHexInt64(&rgba) + + let red: Double + let green: Double + let blue: Double + let opacity: Double + + switch sanitized.count { + case 8: // RRGGBBAA + red = Double((rgba >> 24) & 0xFF) / 255.0 + green = Double((rgba >> 16) & 0xFF) / 255.0 + blue = Double((rgba >> 8) & 0xFF) / 255.0 + opacity = Double(rgba & 0xFF) / 255.0 + case 6: // RRGGBB + red = Double((rgba >> 16) & 0xFF) / 255.0 + green = Double((rgba >> 8) & 0xFF) / 255.0 + blue = Double(rgba & 0xFF) / 255.0 + opacity = 1.0 + case 4: // RGBA + red = Double(((rgba >> 12) & 0xF) * 0x11) / 255.0 + green = Double(((rgba >> 8) & 0xF) * 0x11) / 255.0 + blue = Double(((rgba >> 4) & 0xF) * 0x11) / 255.0 + opacity = Double((rgba & 0xF) * 0x11) / 255.0 + case 3: // RGB + red = Double(((rgba >> 8) & 0xF) * 0x11) / 255.0 + green = Double(((rgba >> 4) & 0xF) * 0x11) / 255.0 + blue = Double((rgba & 0xF) * 0x11) / 255.0 + opacity = 1.0 + default: + red = 0.0 + green = 0.0 + blue = 0.0 + opacity = 1.0 + } + + return Color(.sRGB, red: red, green: green, blue: blue, opacity: opacity) + } +} + +extension SwiftUI.Color.Resolved { + /// The hex code for this resolved color. + /// + /// Components are clamped to `0...1` and quantized to 8 bits. + /// The result is a 6-digit `#RRGGBB` code, or an 8-digit `#RRGGBBAA` code + /// when the color is not fully opaque. + var hex: HEX { + let r = Int((min(max(Double(red), 0), 1) * 255).rounded()) + let g = Int((min(max(Double(green), 0), 1) * 255).rounded()) + let b = Int((min(max(Double(blue), 0), 1) * 255).rounded()) + + let code: String + if opacity < 1 { + let a = Int((min(max(Double(opacity), 0), 1) * 255).rounded()) + code = String(format: "#%02X%02X%02X%02X", r, g, b, a) + } else { + code = String(format: "#%02X%02X%02X", r, g, b) + } + + return HEX(code: code) + } +} diff --git a/Sources/bar/Models/Notifier.swift b/Sources/bar/Models/Notifier.swift new file mode 100644 index 0000000..cdfe19e --- /dev/null +++ b/Sources/bar/Models/Notifier.swift @@ -0,0 +1,90 @@ +import Foundation + +/// Retains the handler for the lifetime of the registration. +/// +/// The raw pointer returned by ``Notifier/register(_:handler:)`` +/// is a retained reference to this box; it is released when +/// the registration is cancelled. +private final class NotifyBox { + let handler: @Sendable () -> Void + init(_ handler: @escaping @Sendable () -> Void) { self.handler = handler } +} + +/// Delivers refresh triggers via Darwin notifications. +/// +/// Wraps CoreFoundation notifications — the macOS-native, +/// signal-like way to trigger behaviour from the shell with +/// no client binary, using `notifyutil`: +/// +/// ``` +/// notifyutil -p +/// ``` +/// +/// Each category of events uses a different `prefix` +/// to avoid collisions. For example, block refresh +/// notifications use ``touchPrefix``: +/// +/// ``` +/// notifyutil -p bar.touch. +/// ``` +/// +/// > Note: `notify.h` isn't part of Swift's Darwin module, +/// > so we go through `CFNotificationCenter`, which talks +/// > to the same subsystem. +/// > +/// > The callback fires on an arbitrary thread; handlers +/// > must hop to the main actor themselves (see ``AppDelegate``). +enum Notifier { + /// Prefix for block refresh notifications. + /// + /// Use `notifyutil -p bar.touch.` to trigger + /// a block update from the shell. + static let touchPrefix = "bar.touch." + + /// Register `handler` to fire whenever the notification is posted. + /// + /// - Parameters: + /// - name: The notification name suffix. + /// - prefix: The full notification name will be `prefix + name`. + /// Defaults to ``touchPrefix``. + /// - handler: Closure to invoke when the notification fires. + /// + /// The returned token is a retained pointer used to keep the handler + /// alive and to cancel the registration later. The handler runs on an + /// arbitrary thread, so hop to the main actor inside it if you touch + /// UI or published state. + @discardableResult + static func register( + _ name: String, + prefix: String = Notifier.touchPrefix, + handler: @escaping @Sendable () -> Void + ) -> UnsafeMutableRawPointer { + let fullName = prefix + name + let ctx = Unmanaged.passRetained(NotifyBox(handler)).toOpaque() + + CFNotificationCenterAddObserver( + CFNotificationCenterGetDarwinNotifyCenter(), + ctx, + { _, observer, _, _, _ in + guard let observer else { return } + let box = Unmanaged.fromOpaque(observer).takeUnretainedValue() + box.handler() + }, + fullName as CFString, + nil, + .deliverImmediately + ) + + return ctx + } + + /// Remove and release a registration. + /// + /// Pass the token returned by ``Notifier/register(_:prefix:handler:)``. + /// Optional: process exit clears all pending registrations anyway. + static func cancel(_ token: UnsafeMutableRawPointer) { + let center = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterRemoveEveryObserver(center, token) + Unmanaged.fromOpaque(token).release() + } +} diff --git a/Sources/bar/Models/Runner.swift b/Sources/bar/Models/Runner.swift new file mode 100644 index 0000000..be4dbff --- /dev/null +++ b/Sources/bar/Models/Runner.swift @@ -0,0 +1,72 @@ +import Foundation + +/// Runs a shell command and returns its stdout as separate lines. +/// +/// Blocking — always call from a background context (e.g. `Task.detached`). +enum Runner { + /// Run a command and returns stdout as an array of separated lines. + /// - Parameters: + /// - command: The command to run. + /// - env: An optional dictionary of strings to merge into + /// the process environment. + /// + /// - Returns: An array of the text lines printed to stdout. + static func run(_ command: String, env: [String: String] = [:]) -> [String] { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + + var environment = ProcessInfo.processInfo.environment + environment.merge(env) { _, new in new } + process.environment = environment + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + guard (try? process.run()) != nil else { + return ["ERROR"] + } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + write(errData, to: .standardError) + return ["ERROR"] + } + + guard let string = String(data: data, encoding: .utf8) else { + return [""] + } + + return parse(string) + } + + /// Split output into lines. A single trailing newline + /// (from a final `\n`) is dropped; blank lines elsewhere + /// are kept so a script can emit intentional gaps. + static func parse(_ text: String) -> [String] { + var lines = text.components(separatedBy: "\n") + if lines.last == "" { lines.removeLast() } + return lines + } + + /// Write data to the provided handle. + /// - Parameters: + /// - data: The data to write. + /// - handle: The handle to which data should be written. + static func write(_ data: Data, to handle: FileHandle) { + handle.write(data) + } + + /// Write string to the provided handle. + /// - Parameters: + /// - string: The string to write. + /// - handle: The handle to which data should be written. + static func write(_ string: String, to handle: FileHandle) { + write(Data(string.utf8), to: handle) + } +} diff --git a/Sources/bar/Models/Screen.swift b/Sources/bar/Models/Screen.swift new file mode 100644 index 0000000..8029db4 --- /dev/null +++ b/Sources/bar/Models/Screen.swift @@ -0,0 +1,34 @@ +import AppKit +import Foundation + +enum Screen { + /// Frame of the bar within a screen, in global display coordinates. + /// + /// - Parameters: + /// - screen: The screen to calculate the bar frame for. + /// - position: Whether the bar sits at the top or bottom + /// of the screen. + /// - height: The bar height in points (see ``Config/height``). + /// + /// - Returns: An NSRect in screen coordinates. The bar spans + /// the full width of the screen and is anchored at the top + /// or bottom edge. + static func barFrame(_ screen: NSScreen, position: Config.Position, height: CGFloat) -> NSRect { + switch position { + case .top: + return NSRect( + x: screen.frame.minX, + y: screen.frame.maxY - height, + width: screen.frame.width, + height: height + ) + case .bottom: + return NSRect( + x: screen.frame.minX, + y: screen.frame.minY, + width: screen.frame.width, + height: height + ) + } + } +} diff --git a/Sources/bar/Views/BarView.swift b/Sources/bar/Views/BarView.swift new file mode 100644 index 0000000..7ad7c4c --- /dev/null +++ b/Sources/bar/Views/BarView.swift @@ -0,0 +1,29 @@ +import SwiftUI + +struct BarView: View { + private let left: [Block] + private let right: [Block] + + init(blocks: [Block]) { + self.left = blocks.filter { $0.placement == .left } + self.right = blocks.filter { $0.placement == .right } + } + + var body: some View { + HStack(spacing: 0) { + ForEach(left) { block in + BlockView(block: block) + } + + Spacer() + + ForEach(right) { block in + BlockView(block: block) + } + } + .font(Config.font) + .foregroundStyle(Config.foreground) + .frame(maxWidth: .infinity, maxHeight: Config.height) + .background(Config.background) + } +} diff --git a/Sources/bar/Views/BarWindow.swift b/Sources/bar/Views/BarWindow.swift new file mode 100644 index 0000000..ce65432 --- /dev/null +++ b/Sources/bar/Views/BarWindow.swift @@ -0,0 +1,32 @@ +import AppKit + +final class BarWindow: NSPanel { + init(contentRect: NSRect) { + super.init( + contentRect: contentRect, + styleMask: [.nonactivatingPanel, .borderless], + backing: .buffered, + defer: false + ) + + animationBehavior = .none + backgroundColor = .clear + becomesKeyOnlyIfNeeded = true + collectionBehavior = [ + .canJoinAllSpaces, + .fullScreenAuxiliary, + .fullScreenNone, + .stationary, + ] + hasShadow = false + hidesOnDeactivate = false + isFloatingPanel = true + isMovable = false + isOpaque = false + isReleasedWhenClosed = false + level = .floating + } + + override var canBecomeMain: Bool { false } + override var canBecomeKey: Bool { true } +} diff --git a/Sources/bar/Views/BlockView.swift b/Sources/bar/Views/BlockView.swift new file mode 100644 index 0000000..85d6e6e --- /dev/null +++ b/Sources/bar/Views/BlockView.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct BlockView: View { + @ObservedObject var block: Block + + @State private var hovering = false + + var body: some View { + HStack(spacing: 0) { + ForEach(block.cells) { cell in + CellView(cell: cell) + } + } + .onHover { hovering = $0 } + .onAppear { + NSEvent.addLocalMonitorForEvents(matching: [ + .leftMouseDown, + .rightMouseDown, + .otherMouseDown, + .scrollWheel, + ]) { event in + handle(event: event) + } + } + } + + private func handle(event: NSEvent) -> NSEvent { + guard hovering else { return event } + + switch event.type { + case .leftMouseDown, .rightMouseDown, .otherMouseDown: + block.runScript(env: ["BUTTON": String(event.buttonNumber)]) + break + case .scrollWheel: + if event.scrollingDeltaY > 0 { + block.runScript(env: ["SCROLL": "UP"]) + break + } + + if event.scrollingDeltaY < 0 { + block.runScript(env: ["SCROLL": "DOWN"]) + break + } + + if event.scrollingDeltaX > 0 { + block.runScript(env: ["SCROLL": "RIGHT"]) + break + } + + if event.scrollingDeltaX < 0 { + block.runScript(env: ["SCROLL": "LEFT"]) + break + } + default: + break + } + + return event + } +} diff --git a/Sources/bar/Views/CellView.swift b/Sources/bar/Views/CellView.swift new file mode 100644 index 0000000..6b39eb1 --- /dev/null +++ b/Sources/bar/Views/CellView.swift @@ -0,0 +1,11 @@ +import SwiftUI + +struct CellView: View { + var cell: Cell + + var body: some View { + Text(cell.rawValue) + .frame(height: Config.height) + .background(cell.background) + } +} diff --git a/Sources/bar/bar.docc/bar.md b/Sources/bar/bar.docc/bar.md new file mode 100644 index 0000000..3d8b2ee --- /dev/null +++ b/Sources/bar/bar.docc/bar.md @@ -0,0 +1,44 @@ +# ``bar`` + +Modular status bar for macOS written in Swift. + +## Overview + +*bar* draws a status bar on each screen connected at runtime. +The bar can be configured to have a specific ``Config-swift.enum/height``, ``Config-swift.enum/position``, and ``Config-swift.enum/background`` color. +You can also customize the ``Config-swift.enum/font`` and ``Config-swift.enum/foreground`` color used by all the texts. + +Inside the bar, you can display one or more ``Block``, each defined by a name, placement, shell command, and optional refresh interval. +At runtime each block runs the command, reads from stdout, and builds one or more ``Cell`` from the output. +If you specify the interval, the block will re-run the command with such frequency. + +## Topics + +### Getting started + +- +- +- ``Config`` +- ``HEX`` + +### Blocks + +- +- +- ``Block`` +- ``Cell`` +- ``Runner`` +- ``Notifier`` + +### Design + +- + +### Views + +- ``AppDelegate`` +- ``Screen`` +- ``BarWindow`` +- ``BarView`` +- ``BlockView`` +- ``CellView`` diff --git a/Sources/bar/bar.docc/commands.md b/Sources/bar/bar.docc/commands.md new file mode 100644 index 0000000..4c00bfe --- /dev/null +++ b/Sources/bar/bar.docc/commands.md @@ -0,0 +1,50 @@ +# Commands + +How a block runs its command and what you can do with it. + +Each block runs its ``Config-swift.enum/Block/command`` as a shell script via `/bin/sh -c`. +The standard output is captured and **each line becomes one ``Cell``** in the bar. +Cells are laid out horizontally in the order they appear. + +```shell +# A single-line command produces one cell +echo "Hello, world" + +# Multi-line output produces multiple cells +echo "first cell" +echo "second cell" +``` + +## Highlighting cells + +Prefix a line with `&!` to apply a highlight background color. +The highlight level is a positive integer that indexes into ``Config-swift.enum/highlights`` (1-based). + +```shell +echo "&!1 workspace 1" # highlighted with Config.highlights[0] +echo "&!2 workspace 2" # highlighted with Config.highlights[1] +echo "workspace 3" # no highlight +``` + +Highlight colors were imagined specifically for distinguishing cells within a group. E.g: showing window manager workspaces and which one is active. + +## Environment variables + +Each command inherits the `bar` process environment, plus these extras: + +| Variable | What holds | When set | Example value | +|---|---|---|---| +| `BLOCK` | The name of the block | Always | `"clock"` | +| `BUTTON` | The mouse button number | On mouse click inside the block | `"0"` (left), `"1"` (right), `"2"` (middle) | +| `SCROLL` | The scroll direction | On scroll over the block | `"UP"`, `"DOWN"`, `"LEFT"`, `"RIGHT"` | + +This lets a single script adapt its behaviour based on which block called it or what interaction just happened: + +```shell +#!/bin/sh +if [ "$BLOCK" = "clock" ]; then + date '+%a %d, %H:%M:%S' +elif [ -n "$BUTTON" ]; then + echo "clicked button $BUTTON" +fi +``` diff --git a/Sources/bar/bar.docc/customisation.md b/Sources/bar/bar.docc/customisation.md new file mode 100644 index 0000000..cc692ce --- /dev/null +++ b/Sources/bar/bar.docc/customisation.md @@ -0,0 +1,45 @@ +# Customisation + +How to customise the program. + +The program can be customised by editing the static properties inside the ``Config`` enum. + +```swift +// Sources/bar/Config.swift + +enum Config { + static let height: Double = 30 + static let position: Position = .top + + static let font: Font = .custom("My Awesome Font", fixedSize: 14) + + static let foreground: Color = .hex("#FFFFFF") + static let background: Color = .hex("#000000").opacity(0.75) + static let highlights: [Color] = [.blue, .purple] + + static let blocks: [Block] = [ + Block(name: "workspaces", placement: .left, command: "~/scripts/workspaces"), + Block(name: "clock", placement: .right, command: "date '+%a %d, %H:%M:%S'", interval: 1), + ] +} +``` + +> Tip: While testing the configuration you might want to use the `make run` target. +> It will build and execute the program right away, without copying the binaries. + +## HEX Colors + +You can use any standard SwiftUI color. For convenience, we also provide the ``HEX`` type for defining colors by hex code directly in source: + +```swift +static let foreground: Color = .hex("#CDD6F4") +``` + +Supported formats (the leading `#` is optional): `#RGB`, `#RGBA`, `#RRGGBB`, `#RRGGBBAA`. + +## Spacing + +*bar* does not offer an option to customise the spacing between blocks. +We personally think that adding a space character at the end or beginning of a block is plenty enough and eliminates a bunch of possible edge cases. + +> Tip: If you don't like the default space of your font you can try one of the [alternatives from the Unicode specification](https://spaces.mau.fi/). diff --git a/Sources/bar/bar.docc/installation.md b/Sources/bar/bar.docc/installation.md new file mode 100644 index 0000000..a96f79f --- /dev/null +++ b/Sources/bar/bar.docc/installation.md @@ -0,0 +1,95 @@ +# Installation + +How to install and build the program. + +**Building the program from source is an integral part of the philosophy and design of the software**. +For this reason we do not ship pre-built binaries nor you'll be able to find it on Homebrew or any other package manager. + +## Requirements + +- **macOS 26** or later +- **Swift 6.3** (bundled with Xcode or installed via [swift.org](https://swift.org)) + +## Download + +Clone the official repository somewhere on you system: + +```shell +git clone https://github.com/paninihouse/bar.git +``` + +## Build + +From within the *bar* directory, run the install target: + +```shell +make install +``` + +> Tip: By default, the install target builds a release binary and copies it to `/usr/local/bin/bar`. +> You can override the install location by setting `PREFIX`: +> +> ```shell +> make install PREFIX=~/.local/bin +> ``` + +### Without make + +If you don't have `make`, build directly with the Swift Package Manager: + +```shell +swift build -c release +cp .build/release/bar /usr/local/bin/ +``` + +## Run at login + +It is fine to run `bar` manually while you're editing and customising the source code. +Although, as soon as you're happy with your build is a good idea to create a launch agent that can start the program for you. + +Copy the example down below in a `plist` file inside the `~/Library/LaunchAgents/` directory. + +> Tip: It is best practice to use [reverse domain name notation](https://en.wikipedia.org/wiki/Reverse_domain_name_notation) for the filename. + +Adjust the example as you see fit. +For the full file specification run: + +```shell +man launchd.plist +``` + +You can than register the launch agent like so: + +```shell +launchctl load ~/Library/LaunchAgents/house.panini.bar.plist +``` + +### Example launch agent + +```plist + + + + + Label + house.panini.bar + + ProgramArguments + + /usr/local/bin/bar + + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /tmp/bar/bar.log + + StandardErrorPath + /tmp/bar/bar.err + + +``` diff --git a/Sources/bar/bar.docc/manual-updates.md b/Sources/bar/bar.docc/manual-updates.md new file mode 100644 index 0000000..60520ca --- /dev/null +++ b/Sources/bar/bar.docc/manual-updates.md @@ -0,0 +1,28 @@ +# Manual refreshes + +How to trigger a block update on-demand. + +*bar* implements a very basic way to manually signal a block to refresh its content. +This is done by posting a [Darwin notification](https://developer.apple.com/documentation/notifications) from the shell. + +## Triggering a refresh + +Use `notifyutil` (bundled with macOS) to post a notification: + +```shell +notifyutil -p bar.touch. +``` + +Replace `` with the block's name (e.g. `clock`, `battery`). +If multiple blocks share the same name, a single notification refreshes them all. + +## Use cases + +Manual updates are useful for blocks that react to external events: + +- **Window managers** - refresh when the workspace or focused app changes. +- **Volume indicator** — refresh when the sound output changes. +- **Network monitor** — poll only when the interface state flips. +- **Now playing** — update when the current track changes. + +Combine with a launch agent or a shell hook that posts the notification whenever the relevant system state changes. diff --git a/Sources/bar/bar.docc/philosophy.md b/Sources/bar/bar.docc/philosophy.md new file mode 100644 index 0000000..6c9764b --- /dev/null +++ b/Sources/bar/bar.docc/philosophy.md @@ -0,0 +1,34 @@ +# Design philosophy + +*bar* design is heavily inspired by the suite of [suckless tools](https://suckless.org/) for Linux. +These tools are very different from what you typically have on macOS. +They do not have configuration files, extraneous features, or a plugin/extension system. +Instead, they are focused on simplicity, frugality, and a DIY approach. + +We deliberately designed *bar* to be extremely limited, specific, and easy to understand, like a suckless tool. +This is not software you install, run, and forget about. +You're supposed to fully understand how it works, make it your own, and ultimately give back your enhancements to the community. + +## How customization works + +*bar* does not read a configuration file somewhere on your system. +Instead, configuration happens directly in the source code by editing ``Config`` and rebuilding the software. +This approach allows us to keep the software extremely minimal and reliable, since all the possible errors are caught at build time. + +## No pre-built binaries + +Since the configuration requires you to modify the source code, it doesn't make sense to ship pre-built binaries of *bar*. +Therefore, you won't find it in Homebrew or in any other package manager. +The way to install *bar* is simply to download or clone the repository on your system and run . + +## How to extend the functionality + +*bar* already does a lot out of the box, but it is deliberately more limited compared to other solutions like [SketchyBar](https://felixkratz.github.io/SketchyBar/). +What if you need a specific feature that is not available? +Well, you can build it or patch it yourself. + +*bar* aims to stay focused and minimal, so it can be read through and understood in roughly half an hour. +You're more than encouraged to apply patches to extend the software, write your own features, and ultimately publish them for the entire community. + +This approach is far more powerful than a plugin/extension ecosystem because you're not limited to what we, as the main developers, decide you can customize or change. +You own the source code, therefore you are free to do whatever you want. diff --git a/Sources/bar/main.swift b/Sources/bar/main.swift new file mode 100644 index 0000000..e965fa5 --- /dev/null +++ b/Sources/bar/main.swift @@ -0,0 +1,8 @@ +import AppKit + +// Entry point. Top-level code runs on the main actor. +private let app = NSApplication.shared +private let delegate = AppDelegate() +app.delegate = delegate +app.setActivationPolicy(.accessory) // no Dock icon, no menu +app.run() diff --git a/Sources/phbar/Config.swift b/Sources/phbar/Config.swift deleted file mode 100644 index e948b30..0000000 --- a/Sources/phbar/Config.swift +++ /dev/null @@ -1,143 +0,0 @@ -import SwiftUI - -/// The top-level configuration for the bar. -/// -/// Set appearance, define blocks, and choose the bar position here. -/// This is the primary file a user edits to customise phbar. -enum Config { - /// The bar height in points (pt). - /// - /// Tip: Set this value to 30 to match the default macOS - /// menu bar height. - static let height: CGFloat = 30 - - /// The bar position on the screen. - static let position: Position = .top - - // NOTE: Customizing font - // - // You can use system default fonts thanks to the standard SwiftUI - // methods and access properties. However, you can also use your - // preferred custom font. - // - // E.g: `.custom("Comic Code", fixedSize: 14)` - // - // You may want to use a patched Nerd Font (https://www.nerdfonts.com) - // so you can have icons in your bar. Alternatively, you can display - // SF Symbols by copy and pasting them as glyphs. - - /// The font used for all text and icons in the bar. - static let font: Font = .system(size: 14, weight: .regular, design: .monospaced) - - // NOTE: Customizing colors - // - // You can use system default colors and the standard SwiftUI - // methods to define them. However, for convenience we added - // support for hex colors too (see ``HEX``). - // - // E.g: `.hex("#000000")` - - /// The color used for texts and icons. - static let foreground: Color = .hex("#FFFFFF") - - /// The color used for the bar background. - static let background: Color = .hex("#000000").opacity(0.75) - - /// The colors used to highlight a cell at different levels (see ``Cell``). - static let highlights: [Color] = [.blue, .purple] - - /// The blocks that make up the bar, listed in display order. - /// - /// Each block is positioned on the left or right side of the bar - /// and runs a shell command, optionally on a repeating interval. - /// See ``Config-swift.enum/Block`` for the full configuration options. - static let blocks: [Block] = [ - Block(name: "version", placement: .left, command: "echo '&!1 phbar v1.0.0 '"), - Block(name: "ph", placement: .left, command: "echo ' by Panini House'"), - Block(name: "memory", placement: .right, command: "echo \"􀟱 Hi $USER, welcome to phbar! | \""), - Block(name: "clock", placement: .right, command: "echo \"􀐬 $(date '+%a %d, %H:%M:%S') \"", interval: 1), - ] -} - -// MARK: Types reference - -extension Config { - /// The bar position on the screen. - enum Position: Sendable { - /// Anchor the bar at the top of the screen. - case top - - /// Anchor the bar at the bottom of the screen. - case bottom - } - - /// A block defined in the configuration. - /// - /// Represents one unit of the bar. Each block is a named command that - /// produces output on an optional timer. See ``phbar/Block`` for the - /// runtime counterpart that manages execution and cell publishing. - struct Block: Sendable { - /// The block identifier used for manual updates. - /// - /// Some blocks do not need to be updated on an defined - /// interval. You might want instead to react to specific - /// events or "manually" update them. - /// In such cases, you can send a standard system - /// notification that tells phbar to trigger an update - /// for the block identified by the name. - /// - /// E.g: `notifyutil -p house.panini.phbar.touch.` - /// - /// Be aware that nothing stops you from having multiple - /// blocks with the same name. In such case, triggering - /// a notification will cause all blocks with that name - /// to be re-evaluated. - let name: String - - /// Whether the block anchors to the left or right side of the bar. - let placement: Placement - - /// The shell command used for updating the content. - /// - /// You can write an expression directly in the block - /// definition or you can point to a proper executable - /// script somewhere on the system. - /// - /// The command process will inherit the environment - /// of the process that executed phbar in the first place. - /// This means that running phbar from your regular shell - /// or from launchctl might result in different behaviours. - /// Make sure to test both scenarios properly. - let command: String - - /// The interval, in seconds, at which the command is executed. - /// - /// If set to `nil`, the block will be evaluated just once - /// at launch. You can of course trigger a manual update via - /// a notification later on. - let interval: Double? - - /// Define a block of the bar. - /// - /// - Parameters: - /// - name: The block identifier used for manual updates. - /// - placement: Left or right side of the bar. - /// - command: The shell command used for updating the content. - /// - interval: The interval, in seconds, at which the command is executed. - /// - init(name: String, placement: Placement, command: String, interval: Double? = nil) { - self.name = name - self.placement = placement - self.command = command - self.interval = interval - } - - /// Where the block is anchored inside the bar. - enum Placement: Sendable { - /// Anchored to the left side of the bar. - case left - /// Anchored to the right side of the bar. - case right - } - } -} diff --git a/Sources/phbar/Models/AppDelegate.swift b/Sources/phbar/Models/AppDelegate.swift deleted file mode 100644 index a7ecb5a..0000000 --- a/Sources/phbar/Models/AppDelegate.swift +++ /dev/null @@ -1,47 +0,0 @@ -import AppKit -import SwiftUI - -@MainActor -final class AppDelegate: NSObject, NSApplicationDelegate { - private var windows: [BarWindow] = [] - private var blocks: [Block] = Config.blocks.map(\.resolved) - private var tokens: [UnsafeMutableRawPointer] = [] - - func applicationDidFinishLaunching(_ notification: Notification) { - for screen in NSScreen.screens { - let frame = Screen.barFrame(screen, position: Config.position, height: Config.height) - let window = BarWindow(contentRect: frame) - window.contentView = NSHostingView(rootView: BarView(blocks: blocks)) - window.orderFrontRegardless() - windows.append(window) - } - - // Register one listener per unique name; a single post fans out to every - // block with that name (see Notifier). - for name in Set(blocks.map(\.name)) { - let token = Notifier.register(name) { [weak self] in - self?.handleRefresh(name) - } - tokens.append(token) - } - - for block in blocks { block.start() } - } - - func applicationWillTerminate(_ notification: Notification) { - for block in blocks { block.stop() } - for token in tokens { Notifier.cancel(token) } - } - - /// Called from a Darwin notification handler. Marked `nonisolated` because - /// libnotify's delivery contract isn't statically visible to Swift, so we - /// hop back to the main actor explicitly. - nonisolated func handleRefresh(_ name: String) { - Task { @MainActor [weak self] in - guard let self else { return } - for block in self.blocks where block.name == name { - block.runScript() - } - } - } -} diff --git a/Sources/phbar/Models/Block.swift b/Sources/phbar/Models/Block.swift deleted file mode 100644 index fe575e4..0000000 --- a/Sources/phbar/Models/Block.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation - -/// A named, self-updating unit of the bar. -/// -/// Each block runs a shell command on an optional timer, -/// splits its stdout into ``Cell`` values, and publishes -/// them for rendering. Blocks are identified by ``name`` -/// so they can be refreshed on demand via Darwin -/// notifications (see ``Notifier``). -@MainActor -final class Block: ObservableObject, Identifiable { - /// Stable identity for SwiftUI diffing. - let id = UUID() - - /// Identifier used for manual updates via Darwin - /// notifications. - /// - /// Multiple blocks can share the same name. - /// In such case, a single notification will refresh - /// all of them. - let name: String - - /// Whether the block is anchored to the left or right - /// side of the bar. - let placement: Config.Block.Placement - - /// The shell command to execute for producing the cells. - /// - /// Can be an inline expression or a path to an executable - /// script. Inherits the environment of the phbar process. - let command: String - - /// Refresh interval in seconds, or `nil` for a one-shot block. - /// - /// A one-shot block can still be manually refreshed via - /// a Darwin notification (see ``Notifier``). - let interval: Double? - - /// The current cells produced by the last command execution. - /// - /// SwiftUI observes this `@Published` property and re-renders - /// the view tree automatically. - @Published var cells: [Cell] = [] - - private var tick: Task? - - init(name: String, placement: Config.Block.Placement, command: String, interval: Double? = nil) { - self.name = name - self.placement = placement - self.command = command - self.interval = interval - } - - /// Initial paint + interval timer. - func start() { - runScript() - startTick() - } - - /// Re-run the command and update the cells on the main actor. - func runScript(env: [String: String] = [:]) { - let command = self.command - - var env = env - env.updateValue(name, forKey: "BLOCK") - - Task.detached(priority: .utility) { [weak self] in - let lines = Runner.run(command, env: env) - await self?.apply(lines) - } - } - - /// Stop the interval timer. - func stop() { - tick?.cancel() - } - - /// Replace the current cells with freshly parsed lines. - /// - /// Each line becomes a ``Cell``. This is always called - /// on the main actor so SwiftUI sees the update immediately. - @MainActor - private func apply(_ lines: [String]) { - self.cells = lines.map { Cell(rawValue: $0) } - } - - /// Start the interval timer that re-runs the command periodically. - /// - /// Does nothing when ``interval`` is `nil` or ≤ 0. - private func startTick() { - guard let interval, interval > 0 else { return } - tick = Task { [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(interval)) - if Task.isCancelled { break } - self?.runScript() - } - } - } -} - -extension Config.Block { - @MainActor - var resolved: phbar.Block { - .init(name: name, placement: placement, command: command, interval: interval) - } -} diff --git a/Sources/phbar/Models/Cell.swift b/Sources/phbar/Models/Cell.swift deleted file mode 100644 index 0ad8e51..0000000 --- a/Sources/phbar/Models/Cell.swift +++ /dev/null @@ -1,70 +0,0 @@ -import Foundation -import RegexBuilder -import SwiftUI - -/// A thin wrapper around a single text line captured -/// by a block. -/// -/// When a block executes the command it captures stdout -/// and splits each line into a cell. Each cell is then -/// laid out horizontally respecting the order. -/// -/// Besides the underlying text value, a cell holds an -/// highlight level (as Int) that, if true, applies the -/// special background color defined in `Config.swift`. -/// You can set the highlight at runtime by prefixing the -/// text line with the special notation `&!`, where -/// `` is a positive integer that can be used as an -/// index to pick an highlight (count starts from 1). -/// -/// E.g: `echo "&!1 $(date "+%a %d, %H:%M:%S") "` -/// -/// Highlight colors can have multiple use cases, but they -/// were imagined specifically for distinguishing cells -/// within a group. For example when you want to show -/// window manager workspaces and which one is active. -struct Cell: RawRepresentable, Identifiable { - /// Stable identity for SwiftUI diffing. - let id = UUID() - - /// The represented text content. - let rawValue: String - - /// The highlight level of the cell. - let highlight: Int - - /// The background color derived from the highlight level. - /// - /// Returns the `Config.highlights` color at the same index - /// if present, otherwise `.clear` so the underlying bar - /// background shows through. - var background: Color { - guard highlight != 0 && highlight <= Config.highlights.count else { return .clear } - - return Config.highlights[highlight - 1] - } - - init(rawValue: String) { - let pattern = Regex { - Optionally { - "&!" - Capture { OneOrMore(.digit) } - } - Capture { OneOrMore(.any) } - } - - guard let match = rawValue.firstMatch(of: pattern) else { - self.rawValue = rawValue - self.highlight = 0 - return - } - - if let rawHighlight = match.output.1, let highlight = Int(String(rawHighlight)) { - self.highlight = highlight - } else { - self.highlight = 0 - } - - self.rawValue = String(match.output.2) - } -} diff --git a/Sources/phbar/Models/HEX.swift b/Sources/phbar/Models/HEX.swift deleted file mode 100644 index a262630..0000000 --- a/Sources/phbar/Models/HEX.swift +++ /dev/null @@ -1,114 +0,0 @@ -import SwiftUI - -/// A color described by a hexadecimal color code. -/// -/// `HEX` is a lightweight, string-backed representation: the code is stored -/// verbatim and parsed only when converted to a SwiftUI color via `Color(hex:)` -/// or `Color.hex(_:)`, which resolve it in the sRGB color space. -/// -/// Supported formats (the leading `#` is optional): -/// - `#RGB` -/// - `#RGBA` -/// - `#RRGGBB` -/// - `#RRGGBBAA` -/// -/// Single-digit components are expanded by repeating the digit; for example, -/// `#3aF` is read as `#33AAFF`. When present, the trailing digits encode alpha -/// (opacity); otherwise opacity defaults to `1`. -struct HEX { - /// The raw hex code, exactly as supplied. It is not validated or normalized; - /// parsing is performed lazily by `Color.hex(_:)`. - var code: String - - /// Creates a hex color from the given code. - /// - /// - Parameter code: A hex color code. Stored verbatim; see ``HEX`` for the - /// accepted formats. - init(code: String) { - self.code = code - } -} - -extension SwiftUI.Color { - /// Creates a color from a ``HEX`` value. - init(hex: HEX) { - self = .hex(hex.code) - } - - /// Creates a color from a hex code, resolving it in the sRGB color space. - /// - /// Supported formats (the leading `#` is optional): `#RGB`, `#RGBA`, - /// `#RRGGBB`, and `#RRGGBBAA`. Single-digit components are expanded by - /// repeating the digit. When present, the trailing digits encode alpha - /// (opacity); otherwise opacity defaults to `1`. Malformed input resolves - /// to opaque black. - /// - /// - Parameter code: A hex color code. - static func hex(_ code: String) -> SwiftUI.Color { - var sanitized = code.trimmingCharacters(in: .whitespacesAndNewlines) - if sanitized.hasPrefix("#") { - sanitized.removeFirst() - } - - var rgba: UInt64 = 0 - Scanner(string: sanitized).scanHexInt64(&rgba) - - let red: Double - let green: Double - let blue: Double - let opacity: Double - - switch sanitized.count { - case 8: // RRGGBBAA - red = Double((rgba >> 24) & 0xFF) / 255.0 - green = Double((rgba >> 16) & 0xFF) / 255.0 - blue = Double((rgba >> 8) & 0xFF) / 255.0 - opacity = Double(rgba & 0xFF) / 255.0 - case 6: // RRGGBB - red = Double((rgba >> 16) & 0xFF) / 255.0 - green = Double((rgba >> 8) & 0xFF) / 255.0 - blue = Double(rgba & 0xFF) / 255.0 - opacity = 1.0 - case 4: // RGBA - red = Double(((rgba >> 12) & 0xF) * 0x11) / 255.0 - green = Double(((rgba >> 8) & 0xF) * 0x11) / 255.0 - blue = Double(((rgba >> 4) & 0xF) * 0x11) / 255.0 - opacity = Double((rgba & 0xF) * 0x11) / 255.0 - case 3: // RGB - red = Double(((rgba >> 8) & 0xF) * 0x11) / 255.0 - green = Double(((rgba >> 4) & 0xF) * 0x11) / 255.0 - blue = Double((rgba & 0xF) * 0x11) / 255.0 - opacity = 1.0 - default: - red = 0.0 - green = 0.0 - blue = 0.0 - opacity = 1.0 - } - - return Color(.sRGB, red: red, green: green, blue: blue, opacity: opacity) - } -} - -extension SwiftUI.Color.Resolved { - /// The hex code for this resolved color. - /// - /// Components are clamped to `0...1` and quantized to 8 bits. - /// The result is a 6-digit `#RRGGBB` code, or an 8-digit `#RRGGBBAA` code - /// when the color is not fully opaque. - var hex: HEX { - let r = Int((min(max(Double(red), 0), 1) * 255).rounded()) - let g = Int((min(max(Double(green), 0), 1) * 255).rounded()) - let b = Int((min(max(Double(blue), 0), 1) * 255).rounded()) - - let code: String - if opacity < 1 { - let a = Int((min(max(Double(opacity), 0), 1) * 255).rounded()) - code = String(format: "#%02X%02X%02X%02X", r, g, b, a) - } else { - code = String(format: "#%02X%02X%02X", r, g, b) - } - - return HEX(code: code) - } -} diff --git a/Sources/phbar/Models/Notifier.swift b/Sources/phbar/Models/Notifier.swift deleted file mode 100644 index a428c49..0000000 --- a/Sources/phbar/Models/Notifier.swift +++ /dev/null @@ -1,90 +0,0 @@ -import Foundation - -/// Retains the handler for the lifetime of the registration. -/// -/// The raw pointer returned by ``Notifier/register(_:handler:)`` -/// is a retained reference to this box; it is released when -/// the registration is cancelled. -private final class NotifyBox { - let handler: @Sendable () -> Void - init(_ handler: @escaping @Sendable () -> Void) { self.handler = handler } -} - -/// Delivers refresh triggers via Darwin notifications. -/// -/// Wraps CoreFoundation notifications — the macOS-native, -/// signal-like way to trigger behaviour from the shell with -/// no client binary, using `notifyutil`: -/// -/// ``` -/// notifyutil -p -/// ``` -/// -/// Each category of events uses a different ``prefix`` -/// to avoid collisions. For example, block refresh -/// notifications use ``touchPrefix``: -/// -/// ``` -/// notifyutil -p house.panini.phbar.touch. -/// ``` -/// -/// > Note: `notify.h` isn't part of Swift's Darwin module, -/// > so we go through `CFNotificationCenter`, which talks -/// > to the same subsystem. -/// > -/// > The callback fires on an arbitrary thread; handlers -/// > must hop to the main actor themselves (see ``AppDelegate``). -enum Notifier { - /// Prefix for block refresh notifications. - /// - /// Use `notifyutil -p house.panini.phbar.touch.` to trigger - /// a block update from the shell. - static let touchPrefix = "house.panini.phbar.touch." - - /// Register `handler` to fire whenever the notification is posted. - /// - /// - Parameters: - /// - name: The notification name suffix. - /// - prefix: The full notification name will be `prefix + name`. - /// Defaults to ``touchPrefix``. - /// - handler: Closure to invoke when the notification fires. - /// - /// The returned token is a retained pointer used to keep the handler - /// alive and to cancel the registration later. The handler runs on an - /// arbitrary thread, so hop to the main actor inside it if you touch - /// UI or published state. - @discardableResult - static func register( - _ name: String, - prefix: String = Notifier.touchPrefix, - handler: @escaping @Sendable () -> Void - ) -> UnsafeMutableRawPointer { - let fullName = prefix + name - let ctx = Unmanaged.passRetained(NotifyBox(handler)).toOpaque() - - CFNotificationCenterAddObserver( - CFNotificationCenterGetDarwinNotifyCenter(), - ctx, - { _, observer, _, _, _ in - guard let observer else { return } - let box = Unmanaged.fromOpaque(observer).takeUnretainedValue() - box.handler() - }, - fullName as CFString, - nil, - .deliverImmediately - ) - - return ctx - } - - /// Remove and release a registration. - /// - /// Pass the token returned by ``Notifier/register(_:handler:)``. - /// Optional: process exit clears all pending registrations anyway. - static func cancel(_ token: UnsafeMutableRawPointer) { - let center = CFNotificationCenterGetDarwinNotifyCenter() - CFNotificationCenterRemoveEveryObserver(center, token) - Unmanaged.fromOpaque(token).release() - } -} diff --git a/Sources/phbar/Models/Runner.swift b/Sources/phbar/Models/Runner.swift deleted file mode 100644 index be4dbff..0000000 --- a/Sources/phbar/Models/Runner.swift +++ /dev/null @@ -1,72 +0,0 @@ -import Foundation - -/// Runs a shell command and returns its stdout as separate lines. -/// -/// Blocking — always call from a background context (e.g. `Task.detached`). -enum Runner { - /// Run a command and returns stdout as an array of separated lines. - /// - Parameters: - /// - command: The command to run. - /// - env: An optional dictionary of strings to merge into - /// the process environment. - /// - /// - Returns: An array of the text lines printed to stdout. - static func run(_ command: String, env: [String: String] = [:]) -> [String] { - let process = Process() - process.executableURL = URL(fileURLWithPath: "/bin/sh") - process.arguments = ["-c", command] - - var environment = ProcessInfo.processInfo.environment - environment.merge(env) { _, new in new } - process.environment = environment - - let stdout = Pipe() - let stderr = Pipe() - process.standardOutput = stdout - process.standardError = stderr - - guard (try? process.run()) != nil else { - return ["ERROR"] - } - - let data = stdout.fileHandleForReading.readDataToEndOfFile() - let errData = stderr.fileHandleForReading.readDataToEndOfFile() - process.waitUntilExit() - - guard process.terminationStatus == 0 else { - write(errData, to: .standardError) - return ["ERROR"] - } - - guard let string = String(data: data, encoding: .utf8) else { - return [""] - } - - return parse(string) - } - - /// Split output into lines. A single trailing newline - /// (from a final `\n`) is dropped; blank lines elsewhere - /// are kept so a script can emit intentional gaps. - static func parse(_ text: String) -> [String] { - var lines = text.components(separatedBy: "\n") - if lines.last == "" { lines.removeLast() } - return lines - } - - /// Write data to the provided handle. - /// - Parameters: - /// - data: The data to write. - /// - handle: The handle to which data should be written. - static func write(_ data: Data, to handle: FileHandle) { - handle.write(data) - } - - /// Write string to the provided handle. - /// - Parameters: - /// - string: The string to write. - /// - handle: The handle to which data should be written. - static func write(_ string: String, to handle: FileHandle) { - write(Data(string.utf8), to: handle) - } -} diff --git a/Sources/phbar/Models/Screen.swift b/Sources/phbar/Models/Screen.swift deleted file mode 100644 index 870d3b6..0000000 --- a/Sources/phbar/Models/Screen.swift +++ /dev/null @@ -1,38 +0,0 @@ -import AppKit - -/// Helpers for multi-screen geometry. -/// -/// Maps a bar configuration (position, height) into concrete -/// screen coordinates so the window can be placed correctly -/// on each display. -enum Screen { - /// Frame of the bar within a screen, in global display coordinates. - /// - /// - Parameters: - /// - screen: The screen to calculate the bar frame for. - /// - position: Whether the bar sits at the top or bottom - /// of the screen. - /// - height: The bar height in points (see ``Config/height``). - /// - /// - Returns: An ``NSRect`` in screen coordinates. The bar spans - /// the full width of the screen and is anchored at the top - /// or bottom edge. - static func barFrame(_ screen: NSScreen, position: Config.Position, height: CGFloat) -> NSRect { - switch position { - case .top: - return NSRect( - x: screen.frame.minX, - y: screen.frame.maxY - height, - width: screen.frame.width, - height: height - ) - case .bottom: - return NSRect( - x: screen.frame.minX, - y: screen.frame.minY, - width: screen.frame.width, - height: height - ) - } - } -} diff --git a/Sources/phbar/Views/BarView.swift b/Sources/phbar/Views/BarView.swift deleted file mode 100644 index 7ad7c4c..0000000 --- a/Sources/phbar/Views/BarView.swift +++ /dev/null @@ -1,29 +0,0 @@ -import SwiftUI - -struct BarView: View { - private let left: [Block] - private let right: [Block] - - init(blocks: [Block]) { - self.left = blocks.filter { $0.placement == .left } - self.right = blocks.filter { $0.placement == .right } - } - - var body: some View { - HStack(spacing: 0) { - ForEach(left) { block in - BlockView(block: block) - } - - Spacer() - - ForEach(right) { block in - BlockView(block: block) - } - } - .font(Config.font) - .foregroundStyle(Config.foreground) - .frame(maxWidth: .infinity, maxHeight: Config.height) - .background(Config.background) - } -} diff --git a/Sources/phbar/Views/BarWindow.swift b/Sources/phbar/Views/BarWindow.swift deleted file mode 100644 index ce65432..0000000 --- a/Sources/phbar/Views/BarWindow.swift +++ /dev/null @@ -1,32 +0,0 @@ -import AppKit - -final class BarWindow: NSPanel { - init(contentRect: NSRect) { - super.init( - contentRect: contentRect, - styleMask: [.nonactivatingPanel, .borderless], - backing: .buffered, - defer: false - ) - - animationBehavior = .none - backgroundColor = .clear - becomesKeyOnlyIfNeeded = true - collectionBehavior = [ - .canJoinAllSpaces, - .fullScreenAuxiliary, - .fullScreenNone, - .stationary, - ] - hasShadow = false - hidesOnDeactivate = false - isFloatingPanel = true - isMovable = false - isOpaque = false - isReleasedWhenClosed = false - level = .floating - } - - override var canBecomeMain: Bool { false } - override var canBecomeKey: Bool { true } -} diff --git a/Sources/phbar/Views/BlockView.swift b/Sources/phbar/Views/BlockView.swift deleted file mode 100644 index 85d6e6e..0000000 --- a/Sources/phbar/Views/BlockView.swift +++ /dev/null @@ -1,60 +0,0 @@ -import SwiftUI - -struct BlockView: View { - @ObservedObject var block: Block - - @State private var hovering = false - - var body: some View { - HStack(spacing: 0) { - ForEach(block.cells) { cell in - CellView(cell: cell) - } - } - .onHover { hovering = $0 } - .onAppear { - NSEvent.addLocalMonitorForEvents(matching: [ - .leftMouseDown, - .rightMouseDown, - .otherMouseDown, - .scrollWheel, - ]) { event in - handle(event: event) - } - } - } - - private func handle(event: NSEvent) -> NSEvent { - guard hovering else { return event } - - switch event.type { - case .leftMouseDown, .rightMouseDown, .otherMouseDown: - block.runScript(env: ["BUTTON": String(event.buttonNumber)]) - break - case .scrollWheel: - if event.scrollingDeltaY > 0 { - block.runScript(env: ["SCROLL": "UP"]) - break - } - - if event.scrollingDeltaY < 0 { - block.runScript(env: ["SCROLL": "DOWN"]) - break - } - - if event.scrollingDeltaX > 0 { - block.runScript(env: ["SCROLL": "RIGHT"]) - break - } - - if event.scrollingDeltaX < 0 { - block.runScript(env: ["SCROLL": "LEFT"]) - break - } - default: - break - } - - return event - } -} diff --git a/Sources/phbar/Views/CellView.swift b/Sources/phbar/Views/CellView.swift deleted file mode 100644 index 6b39eb1..0000000 --- a/Sources/phbar/Views/CellView.swift +++ /dev/null @@ -1,11 +0,0 @@ -import SwiftUI - -struct CellView: View { - var cell: Cell - - var body: some View { - Text(cell.rawValue) - .frame(height: Config.height) - .background(cell.background) - } -} diff --git a/Sources/phbar/main.swift b/Sources/phbar/main.swift deleted file mode 100644 index 6322d69..0000000 --- a/Sources/phbar/main.swift +++ /dev/null @@ -1,8 +0,0 @@ -import AppKit - -// Entry point. Top-level code runs on the main actor. -let app = NSApplication.shared -let delegate = AppDelegate() -app.delegate = delegate -app.setActivationPolicy(.accessory) // no Dock icon, no menu -app.run() diff --git a/Tests/barTests/phbarTests.swift b/Tests/barTests/phbarTests.swift new file mode 100644 index 0000000..6a2c4d5 --- /dev/null +++ b/Tests/barTests/phbarTests.swift @@ -0,0 +1,8 @@ +import Testing +@testable import bar + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. + // Swift Testing Documentation + // https://developer.apple.com/documentation/testing +} diff --git a/Tests/phbarTests/phbarTests.swift b/Tests/phbarTests/phbarTests.swift deleted file mode 100644 index e4ee1a9..0000000 --- a/Tests/phbarTests/phbarTests.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Testing -@testable import phbar - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. - // Swift Testing Documentation - // https://developer.apple.com/documentation/testing -} -- cgit v1.2.3