diff options
| -rw-r--r-- | Sources/phbar/Config.swift | 84 | ||||
| -rw-r--r-- | Sources/phbar/Models/AppDelegate.swift | 47 | ||||
| -rw-r--r-- | Sources/phbar/Models/Block.swift | 65 | ||||
| -rw-r--r-- | Sources/phbar/Models/HEX.swift | 114 | ||||
| -rw-r--r-- | Sources/phbar/Models/Notifier.swift | 55 | ||||
| -rw-r--r-- | Sources/phbar/Models/Runner.swift | 45 | ||||
| -rw-r--r-- | Sources/phbar/Models/Screen.swift | 24 | ||||
| -rw-r--r-- | Sources/phbar/Views/BarView.swift | 29 | ||||
| -rw-r--r-- | Sources/phbar/Views/BarWindow.swift | 29 | ||||
| -rw-r--r-- | Sources/phbar/Views/BlockView.swift | 13 | ||||
| -rw-r--r-- | Sources/phbar/Views/CellView.swift | 13 |
11 files changed, 518 insertions, 0 deletions
diff --git a/Sources/phbar/Config.swift b/Sources/phbar/Config.swift new file mode 100644 index 0000000..8015f4d --- /dev/null +++ b/Sources/phbar/Config.swift @@ -0,0 +1,84 @@ +import SwiftUI + +enum Config { + // Geometry + static let height: CGFloat = 30 + static let position: Position = .top + + // Appearance + static let font: Font = .custom("Comic Code", fixedSize: 14) + static let foreground: Color = .hex("#aed3f3") + static let background: Color = .hex("#010408").opacity(0.825) + + // Blocks + static let blocks: [Block] = [ + Block(name: "app", placement: .left, command: "echo ' Safari '"), + Block(name: "clock", placement: .right, command: "echo ' TODAY '"), + ] +} + +// MARK: Support types + +extension Config { + 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 + } + + 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.<name>` + /// + /// 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 + + 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? + + init(name: String, placement: Placement, command: String, interval: Double? = nil) { + self.name = name + self.placement = placement + self.command = command + self.interval = interval + } + + enum Placement: Sendable { + case left + case center + case right + } + } +} diff --git a/Sources/phbar/Models/AppDelegate.swift b/Sources/phbar/Models/AppDelegate.swift new file mode 100644 index 0000000..a7ecb5a --- /dev/null +++ b/Sources/phbar/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/phbar/Models/Block.swift b/Sources/phbar/Models/Block.swift new file mode 100644 index 0000000..7eca7c9 --- /dev/null +++ b/Sources/phbar/Models/Block.swift @@ -0,0 +1,65 @@ +import Foundation + +@MainActor +final class Block: ObservableObject, Identifiable { + let id = UUID() + + let name: String + let placement: Config.Block.Placement + let command: String + let interval: Double? + + @Published var cells: [String] = [] + + private var tick: Task<Void, Never>? + + 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() { + let command = self.command + Task.detached(priority: .utility) { [weak self] in + let cells = Runner.run(command) + await self?.apply(cells) + } + } + + /// Stop the interval timer. + func stop() { + tick?.cancel() + } + + @MainActor + private func apply(_ cells: [String]) { + self.cells = cells + } + + 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/HEX.swift b/Sources/phbar/Models/HEX.swift new file mode 100644 index 0000000..fbbdf39 --- /dev/null +++ b/Sources/phbar/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/phbar/Models/Notifier.swift b/Sources/phbar/Models/Notifier.swift new file mode 100644 index 0000000..1124cd4 --- /dev/null +++ b/Sources/phbar/Models/Notifier.swift @@ -0,0 +1,55 @@ +import Foundation + +// Darwin notifications via CoreFoundation — the macOS-native, signal-like way +// to trigger a refresh from the shell with no client binary: +// +// notifyutil -p <prefix><name> +// +// e.g. notifyutil -p house.panini.phbar.touch.workspaces +// +// (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.handleRefresh). + +/// Retains the handler for the lifetime of the registration. +private final class NotifyBox { + let handler: @Sendable () -> Void + init(_ handler: @escaping @Sendable () -> Void) { self.handler = handler } +} + +enum Notifier { + static let prefix = "house.panini.phbar.touch." + + /// Register `handler` to fire whenever the notification is posted. + @discardableResult + static func register(_ name: String, 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<NotifyBox>.fromOpaque(observer).takeUnretainedValue() + box.handler() + }, + fullName as CFString, + nil, + .deliverImmediately + ) + + return ctx + } + + /// Remove and release a registration. (Optional: process exit clears all.) + static func cancel(_ token: UnsafeMutableRawPointer) { + let center = CFNotificationCenterGetDarwinNotifyCenter() + CFNotificationCenterRemoveEveryObserver(center, token) + Unmanaged<NotifyBox>.fromOpaque(token).release() + } +} diff --git a/Sources/phbar/Models/Runner.swift b/Sources/phbar/Models/Runner.swift new file mode 100644 index 0000000..a98d34a --- /dev/null +++ b/Sources/phbar/Models/Runner.swift @@ -0,0 +1,45 @@ +import Foundation + +/// Runs a shell command and returns its stdout as cells (one per line). +/// +/// Blocking — always call from a background context (e.g. `Task.detached`). +enum Runner { + 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 let _ = try? process.run() else { + return ["(error)"] + } + + let data = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + + if process.terminationStatus != 0 { + let reason = String(data: errData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) + FileHandle.standardError.write("phbar: `\(command)` failed: \(reason ?? "exit \(process.terminationStatus)")\n".data(using: .utf8)!) + } + + return parse(String(data: data, encoding: .utf8) ?? "") + } + + /// Split output into cells: one per line. 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 + } +} diff --git a/Sources/phbar/Models/Screen.swift b/Sources/phbar/Models/Screen.swift new file mode 100644 index 0000000..e4b968f --- /dev/null +++ b/Sources/phbar/Models/Screen.swift @@ -0,0 +1,24 @@ +import AppKit + +@MainActor +enum Screen { + /// Frame of the bar within a screen, in global display coordinates. + 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 new file mode 100644 index 0000000..7ad7c4c --- /dev/null +++ b/Sources/phbar/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/phbar/Views/BarWindow.swift b/Sources/phbar/Views/BarWindow.swift new file mode 100644 index 0000000..83bbcc2 --- /dev/null +++ b/Sources/phbar/Views/BarWindow.swift @@ -0,0 +1,29 @@ +import AppKit + +// A non-activating panel: clicking it (e.g. a workspace cell) runs the action +// without stealing focus from the app you're working in. +final class BarWindow: NSPanel { + init(contentRect: NSRect) { + super.init( + contentRect: contentRect, + styleMask: [.nonactivatingPanel], + backing: .buffered, + defer: false + ) + + isFloatingPanel = true + isOpaque = false + backgroundColor = .clear + hasShadow = false + hidesOnDeactivate = false + isReleasedWhenClosed = false + isMovable = false + becomesKeyOnlyIfNeeded = true + level = .floating + collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary] + animationBehavior = .none + } + + override var canBecomeKey: Bool { true } + override var canBecomeMain: Bool { false } +} diff --git a/Sources/phbar/Views/BlockView.swift b/Sources/phbar/Views/BlockView.swift new file mode 100644 index 0000000..b7c0e7f --- /dev/null +++ b/Sources/phbar/Views/BlockView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct BlockView: View { + @ObservedObject var block: Block + + var body: some View { + HStack(spacing: 0) { + ForEach(block.cells.indices, id: \.self) { index in + CellView(text: block.cells[index]) + } + } + } +} diff --git a/Sources/phbar/Views/CellView.swift b/Sources/phbar/Views/CellView.swift new file mode 100644 index 0000000..494667c --- /dev/null +++ b/Sources/phbar/Views/CellView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct CellView: View { + var text: String + + var body: some View { + VStack { + Text(text) + } + .frame(height: Config.height) + .background(Color.cyan) + } +} |
