basic block structure and auto-refresh
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import AppKit
|
||||
|
||||
@MainActor
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
var barController: PHController!
|
||||
var window: BarWindow!
|
||||
|
||||
init(screen: NSScreen, blocks: [PHBlock]) {
|
||||
super.init()
|
||||
|
||||
self.barController = PHController(screen: screen, blocks: blocks)
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
window = BarWindow(controller: barController)
|
||||
window.orderFront(nil)
|
||||
barController.startAutoRefresh()
|
||||
}
|
||||
|
||||
func applicationWillTerminate(_ notification: Notification) {
|
||||
barController.stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import AppKit
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
extension phbar {
|
||||
struct start: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "start",
|
||||
abstract: "Start the status bar."
|
||||
)
|
||||
|
||||
mutating func run() throws {
|
||||
let screen = try targetScreen(monitor: 0)
|
||||
|
||||
// NOTE: Make sure NSApp.run() runs in the main thread
|
||||
dispatchPrecondition(condition: .onQueue(.main))
|
||||
|
||||
try MainActor.assumeIsolated {
|
||||
let blocks = try PHBlock.load()
|
||||
|
||||
let delegate = AppDelegate(screen: screen, blocks: blocks)
|
||||
let app = NSApplication.shared
|
||||
app.setActivationPolicy(.accessory)
|
||||
app.delegate = delegate
|
||||
app.run()
|
||||
|
||||
throw ExitCode.success
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor
|
||||
|
||||
extension phbar.start {
|
||||
private func targetScreen(monitor: Int) throws -> NSScreen {
|
||||
let screens = NSScreen.screens
|
||||
guard !screens.isEmpty else { throw CleanExit.message("No monitor founded") }
|
||||
|
||||
if monitor >= 0, monitor < screens.count {
|
||||
return screens[monitor]
|
||||
}
|
||||
|
||||
// Default: screen containing the mouse cursor
|
||||
let mouseLocation = NSEvent.mouseLocation
|
||||
guard
|
||||
let screen = screens.first(where: { NSMouseInRect(mouseLocation, $0.frame, false) })
|
||||
?? screens.first
|
||||
else {
|
||||
throw CleanExit.message("No monitor founded")
|
||||
}
|
||||
|
||||
return screen
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import SwiftUI
|
||||
|
||||
@MainActor
|
||||
final class PHController: ObservableObject {
|
||||
let screen: NSScreen
|
||||
@Published var blocks: [PHBlock]
|
||||
|
||||
init(screen: NSScreen, blocks: [PHBlock]) {
|
||||
self.screen = screen
|
||||
self.blocks = blocks
|
||||
}
|
||||
|
||||
// MARK: - Refresh
|
||||
|
||||
/// Start auto-refresh for every block.
|
||||
func startAutoRefresh() {
|
||||
for block in blocks {
|
||||
block.startAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop auto-refresh for every block.
|
||||
func stopAutoRefresh() {
|
||||
for block in blocks {
|
||||
block.stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
/// Force every block to recompute its label immediately (manual refresh).
|
||||
/// Each block updates concurrently; the view refreshes as results arrive.
|
||||
func refresh() {
|
||||
for block in blocks {
|
||||
Task { await block.update() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import TOML
|
||||
|
||||
@MainActor
|
||||
final class PHBlock: ObservableObject, Decodable, Identifiable {
|
||||
let id = UUID()
|
||||
let command: String
|
||||
let name: String?
|
||||
let style: String?
|
||||
let refresh: Double?
|
||||
|
||||
@Published private(set) var label = ""
|
||||
|
||||
/// The repeating refresh task, if any.
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case command, name, style, refresh
|
||||
}
|
||||
|
||||
deinit {
|
||||
refreshTask?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Refresh
|
||||
|
||||
/// Start keeping the label up to date.
|
||||
///
|
||||
/// The label is always recomputed immediately. When `refresh` is set to a
|
||||
/// positive number of seconds, it is then recomputed on that interval until
|
||||
/// `stopAutoRefresh()` is called. With no interval set, only the initial
|
||||
/// computation runs and later updates must be triggered manually via
|
||||
/// `update()`.
|
||||
func startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
|
||||
guard let interval = refresh, interval > 0 else {
|
||||
// No interval: compute once, rely on manual updates afterwards.
|
||||
Task { await update() }
|
||||
return
|
||||
}
|
||||
|
||||
let milliseconds = Int(interval * 1000)
|
||||
refreshTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
await self?.update()
|
||||
try? await Task.sleep(for: .milliseconds(milliseconds))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel the repeating auto-refresh, if any.
|
||||
func stopAutoRefresh() {
|
||||
refreshTask?.cancel()
|
||||
refreshTask = nil
|
||||
}
|
||||
|
||||
/// Recompute the label from `command`. Used by auto-refresh and manual updates.
|
||||
func update() async {
|
||||
label = await compute()
|
||||
}
|
||||
|
||||
/// Runs the provided command and returns its stdout.
|
||||
///
|
||||
/// Runs off the main actor so it can be awaited safely from SwiftUI views
|
||||
/// without blocking the UI.
|
||||
///
|
||||
/// - Returns: A non-optional String to use as the block label.
|
||||
func compute() async -> String {
|
||||
let command = command
|
||||
return await Task.detached(priority: .userInitiated) {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/bin/bash")
|
||||
process.arguments = ["-c", command]
|
||||
|
||||
// Inherit the parent's environment (includes $PATH, etc.)
|
||||
process.environment = ProcessInfo.processInfo.environment
|
||||
|
||||
// Capture stdout via a pipe (otherwise standardOutput is always nil).
|
||||
let pipe = Pipe()
|
||||
process.standardOutput = pipe
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
let data = pipe.fileHandleForReading.readDataToEndOfFile()
|
||||
return String(data: data, encoding: .utf8) ?? ""
|
||||
} catch {
|
||||
// Silently ignore failures
|
||||
return ""
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
// Loading
|
||||
|
||||
extension PHBlock {
|
||||
private struct Wrapper: Decodable {
|
||||
let blocks: [PHBlock]
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case blocks = "block"
|
||||
}
|
||||
}
|
||||
|
||||
/// Load blocks from the default path (~/.config/phbar/blocks.toml).
|
||||
/// If the file doesn't exist or fails to parse, defaults are used (non-fatal).
|
||||
static func load() throws -> [PHBlock] {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appending(
|
||||
path: ".config/phbar/blocks.toml"
|
||||
)
|
||||
|
||||
if FileManager.default.fileExists(atPath: url.relativePath) {
|
||||
return try load(from: url)
|
||||
} else {
|
||||
throw phbar.Error("Failed to load blocks file")
|
||||
}
|
||||
}
|
||||
|
||||
/// Load theme from a file at URL.
|
||||
/// If the file doesn't exist or fails to parse, the execution is interrupted.
|
||||
static func load(from url: URL) throws -> [PHBlock] {
|
||||
do {
|
||||
let data = try Data(contentsOf: url)
|
||||
guard let contents = String(data: data, encoding: .utf8), !contents.isEmpty else {
|
||||
throw phbar.Error("the file is empty")
|
||||
}
|
||||
return try load(from: contents)
|
||||
} catch {
|
||||
throw phbar.Error("Failed to load blocks file", underlyingError: error)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load theme from a TOML string.
|
||||
/// If the content fails to parse, the execution is interrupted.
|
||||
static func load(from contents: String) throws -> [PHBlock] {
|
||||
do {
|
||||
let decoder = TOMLDecoder()
|
||||
let wrapper = try decoder.decode(PHBlock.Wrapper.self, from: contents)
|
||||
return wrapper.blocks
|
||||
} catch {
|
||||
throw phbar.Error("Failed to parse blocks file", underlyingError: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BarView: View {
|
||||
@StateObject private var bar: PHController
|
||||
|
||||
init(controller: PHController) {
|
||||
self._bar = StateObject(wrappedValue: controller)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .center, spacing: 0) {
|
||||
ForEach(bar.blocks) { block in
|
||||
TextBlock(block: block)
|
||||
}
|
||||
|
||||
SpaceBlock()
|
||||
}
|
||||
.font(.custom("Comic Code", size: 14))
|
||||
.frame(height: 30)
|
||||
.ignoresSafeArea()
|
||||
.environmentObject(bar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
final class BarWindow: NSPanel {
|
||||
let barController: PHController
|
||||
var barView: NSHostingView<BarView>!
|
||||
|
||||
init(controller: PHController) {
|
||||
self.barController = controller
|
||||
|
||||
super.init(
|
||||
contentRect: Self.computeFrame(from: barController),
|
||||
styleMask: [.borderless, .nonactivatingPanel],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
|
||||
// self.delegate = self
|
||||
self.title = "PMenu"
|
||||
self.isFloatingPanel = true
|
||||
self.level = .floating
|
||||
self.animationBehavior = .utilityWindow
|
||||
self.isOpaque = false
|
||||
self.backgroundColor = .clear
|
||||
// self.hasShadow = pmenu.config.window.shadow
|
||||
self.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
||||
self.acceptsMouseMovedEvents = false
|
||||
self.isMovable = false
|
||||
self.ignoresMouseEvents = false
|
||||
self.hidesOnDeactivate = false
|
||||
self.titleVisibility = .hidden
|
||||
self.titlebarAppearsTransparent = true
|
||||
self.isRestorable = false
|
||||
self.displaysWhenScreenProfileChanges = true
|
||||
|
||||
self.barView = NSHostingView(rootView: BarView(controller: barController))
|
||||
barView.autoresizingMask = [.width, .height]
|
||||
barView.frame = self.contentView?.bounds ?? .zero
|
||||
|
||||
self.contentView = barView
|
||||
}
|
||||
}
|
||||
|
||||
extension BarWindow {
|
||||
/// Calculate window frame and position.
|
||||
static func computeFrame(from controller: PHController) -> NSRect {
|
||||
let screenFrame = controller.screen.frame
|
||||
let height: CGFloat = 30
|
||||
let x = screenFrame.minX
|
||||
let y = screenFrame.maxY - height
|
||||
|
||||
return NSRect(x: x, y: y, width: screenFrame.width, height: height)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SpaceBlock: View {
|
||||
var body: some View {
|
||||
Rectangle()
|
||||
.fill(.blue)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import SwiftUI
|
||||
|
||||
struct TextBlock: View {
|
||||
@ObservedObject var block: PHBlock
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.red)
|
||||
|
||||
Text(block.label)
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
//
|
||||
// Swift Argument Parser
|
||||
// https://swiftpackageindex.com/apple/swift-argument-parser/documentation
|
||||
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
|
||||
@main
|
||||
struct phbar: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(
|
||||
commandName: "phbar",
|
||||
abstract: "Modular status bar for macOS.",
|
||||
discussion: """
|
||||
pmenu reads a list of newline-separated items from stdin and presents them to the user.
|
||||
When the user selects an item and presses Return, their choice is printed to stdout and pmenu terminates.
|
||||
Entering text will narrow the items to those matching the tokens in the input.
|
||||
""",
|
||||
version: "1.0.0",
|
||||
subcommands: [start.self]
|
||||
)
|
||||
}
|
||||
|
||||
extension phbar {
|
||||
struct Error: LocalizedError {
|
||||
let message: String
|
||||
let underlyingError: Swift.Error?
|
||||
|
||||
init(_ message: String, underlyingError: Swift.Error? = nil) {
|
||||
self.message = message
|
||||
self.underlyingError = underlyingError
|
||||
}
|
||||
|
||||
var errorDescription: String? {
|
||||
guard let underlyingError else { return message }
|
||||
|
||||
if let decodingError = underlyingError as? DecodingError {
|
||||
return "\(message): \(Self.describe(decodingError))"
|
||||
}
|
||||
return "\(message): \(underlyingError.localizedDescription)"
|
||||
}
|
||||
|
||||
private static func describe(_ error: DecodingError) -> String {
|
||||
switch error {
|
||||
case .keyNotFound(let key, let context):
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return "missing key '\(key.stringValue)' at \(path.isEmpty ? "root" : path)"
|
||||
case .typeMismatch(let type, let context):
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return "type mismatch for \(type) at \(path)"
|
||||
case .valueNotFound(let type, let context):
|
||||
let path = context.codingPath.map(\.stringValue).joined(separator: ".")
|
||||
return "missing value of type \(type) at \(path)"
|
||||
case .dataCorrupted(let context):
|
||||
return "corrupted data → \(context.debugDescription)"
|
||||
@unknown default:
|
||||
return error.localizedDescription
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// The Swift Programming Language
|
||||
// https://docs.swift.org/swift-book
|
||||
//
|
||||
// Swift Argument Parser
|
||||
// https://swiftpackageindex.com/apple/swift-argument-parser/documentation
|
||||
|
||||
import ArgumentParser
|
||||
|
||||
@main
|
||||
struct phbar: ParsableCommand {
|
||||
mutating func run() throws {
|
||||
print("Hello, world!")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user