aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Sources/phbar/Config.swift84
-rw-r--r--Sources/phbar/Models/AppDelegate.swift47
-rw-r--r--Sources/phbar/Models/Block.swift65
-rw-r--r--Sources/phbar/Models/HEX.swift114
-rw-r--r--Sources/phbar/Models/Notifier.swift55
-rw-r--r--Sources/phbar/Models/Runner.swift45
-rw-r--r--Sources/phbar/Models/Screen.swift24
-rw-r--r--Sources/phbar/Views/BarView.swift29
-rw-r--r--Sources/phbar/Views/BarWindow.swift29
-rw-r--r--Sources/phbar/Views/BlockView.swift13
-rw-r--r--Sources/phbar/Views/CellView.swift13
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)
+ }
+}