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