diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e6a4138 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +TODO.md diff --git a/.readme-resources/progressline_output.gif b/.readme-resources/progressline_output.gif new file mode 100644 index 0000000..09cc373 Binary files /dev/null and b/.readme-resources/progressline_output.gif differ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f1c20a7 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Vasilii Ianguzin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4746928 --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +MAKEFLAGS += --no-print-directory +EXECUTABLE_NAME := progressline +SWIFT_VERSION := 5.10 +ROOT_PATH := $(shell dirname $(realpath $(lastword $(MAKEFILE_LIST)))) +DOCKER_RUN := docker run --rm --volume $(ROOT_PATH):/workdir --workdir /workdir +ZIP := zip -j +BUILD_FLAGS = --disable-sandbox --configuration release --triple $(TRIPLE) +ifeq ($(TRIPLE), aarch64-unknown-linux-gnu) + BUILD_FLAGS := $(BUILD_FLAGS) --static-swift-stdlib + SWIFT := $(DOCKER_RUN) --platform linux/arm64 swift:$(SWIFT_VERSION) swift + STRIP := $(DOCKER_RUN) --platform linux/arm64 swift:$(SWIFT_VERSION) strip -s +else ifeq ($(TRIPLE), x86_64-unknown-linux-gnu) + BUILD_FLAGS := $(BUILD_FLAGS) --static-swift-stdlib + SWIFT := $(DOCKER_RUN) --platform linux/amd64 swift:$(SWIFT_VERSION) swift + STRIP := $(DOCKER_RUN) --platform linux/amd64 swift:$(SWIFT_VERSION) strip -s +else + SWIFT := swift + STRIP := strip -rSTx +endif +EXECUTABLE_PATH = $(shell swift build $(BUILD_FLAGS) --show-bin-path)/$(EXECUTABLE_NAME) +EXECUTABLE_ARCHIVE_PATH = .build/artifacts/$(EXECUTABLE_NAME)-$(TRIPLE).zip + +clean: + @rm -rf .build 2> /dev/null || true +.PHONY: clean + +prepare_release_artifacts: \ +prepare_release_artifacts_linux_arm64 \ +prepare_release_artifacts_linux_x86_64 \ +prepare_release_artifacts_macos_arm64 \ +prepare_release_artifacts_macos_x86_64 +.PHONY: prepare_release_artifacts + +prepare_release_artifacts_linux_arm64: + @$(MAKE) prepare_release_artifacts_for_triple TRIPLE=aarch64-unknown-linux-gnu +.PHONY: prepare_release_artifacts_linux_arm64 + +prepare_release_artifacts_linux_x86_64: + @$(MAKE) prepare_release_artifacts_for_triple TRIPLE=x86_64-unknown-linux-gnu +.PHONY: prepare_release_artifacts_linux_x86_64 + +prepare_release_artifacts_macos_arm64: + @$(MAKE) prepare_release_artifacts_for_triple TRIPLE=arm64-apple-macosx +.PHONY: prepare_release_artifacts_macos_arm64 + +prepare_release_artifacts_macos_x86_64: + @$(MAKE) prepare_release_artifacts_for_triple TRIPLE=x86_64-apple-macosx +.PHONY: prepare_release_artifacts_macos_x86_64 + +define relpath +$(shell \ + base="$(1)"; \ + abs="$(2)"; \ + common_part="$$base"; \ + back=""; \ + while [ "$${abs#$$common_part}" = "$$abs" ]; do \ + common_part=$$(dirname "$$common_part"); \ + if [ -z "$$back" ]; then \ + back=".."; \ + else \ + back="../$$back"; \ + fi; \ + done; \ + if [ "$$common_part" = "/" ]; then \ + rel="$$back$${abs#/}"; \ + else \ + forward="$${abs#$$common_part/}"; \ + rel="$$back$$forward"; \ + fi; \ + echo "$$rel" \ +) +endef + +# use relative path for use with docker container +RELATIVE_EXECUTABLE_PATH = $(call relpath,$(ROOT_PATH),$(EXECUTABLE_PATH)) +GREEN := \033[0;32m +NC := \033[0m + +prepare_release_artifacts_for_triple: + $(SWIFT) build $(BUILD_FLAGS) 1> /dev/null + $(STRIP) $(RELATIVE_EXECUTABLE_PATH) + @echo "$(GREEN)Built $(EXECUTABLE_PATH)$(NC)" + zip -j $(EXECUTABLE_ARCHIVE_PATH) $(EXECUTABLE_PATH) + @echo "$(GREEN)Archived $(EXECUTABLE_ARCHIVE_PATH)$(NC)" +.PHONY: prepare_release_artifacts_for_triple diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..f0e739d --- /dev/null +++ b/Package.resolved @@ -0,0 +1,33 @@ +{ + "originHash" : "e759c45271facbb3650829c703702a2ac4817adf75a8116cc3d77eae8e3d3bae", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "0fbc8848e389af3bb55c182bc19ca9d5dc2f255b", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "state" : { + "revision" : "bb5059bde9022d69ac516803f4f227d8ac967f71", + "version" : "1.1.0" + } + }, + { + "identity" : "swift-tagged", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-tagged.git", + "state" : { + "revision" : "3907a9438f5b57d317001dc99f3f11b46882272b", + "version" : "0.10.0" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..62b5195 --- /dev/null +++ b/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.10 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ProgressLine", + platforms: [ + .macOS(.v10_15), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.4.0"), + .package(url: "https://github.com/pointfreeco/swift-tagged.git", from: "0.10.0"), + .package(url: "https://github.com/pointfreeco/swift-concurrency-extras.git", from: "1.1.0"), + ], + targets: [ + .executableTarget( + name: "progressline", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "TaggedTime", package: "swift-tagged"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + ], swiftSettings: [ + .enableExperimentalFeature("StrictConcurrency"), + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..d71d328 --- /dev/null +++ b/README.md @@ -0,0 +1,56 @@ +**ProgressLine** is a command-line tool for tracking the progress of piped commands in a compact one-line format. + +![progressline_output](./.readme-resources/progressline_output.gif) + +## Overview + +ProgressLine enhances your command-line experience by providing a visually appealing way to monitor the progress of long-running tasks. It integrates seamlessly into your existing workflow, allowing you to focus on your work while staying informed about the progress. + +## Usage + +Simply pipe your command output into `progressline` to start tracking: + +```sh +long-running-command | progressline +``` + +If the command you are executing also writes data to `stderr`, then you should probably use ["redirection"](https://www.gnu.org/software/bash/manual/html_node/Redirections.html) and send `stderr` messages to `stdout` so that they also go through the `progressline`: + +``` sh +long-running-command 2>&1 | progressline +``` + +**ProgressLine** offers different styles to represent activity, they can be changed using `--activity-style`/`-s` option: + +``` sh +long-running-command | progressline --activity-style { dots - default | kitt | snake } +# Example +long-running-command | progressline --activity-style snake +long-running-command | progressline -s snake +``` + +## Installation + +### [Homebrew](https://brew.sh) (MacOS / Linux) + +Coming soon + +### [Mint](https://github.com/yonaskolb/Mint) (MacOS) + +``` sh +mint install kattouf/ProgressLine +``` + +### [Mise](Mise) (MacOS) + +``` sh +mise use -g spm:kattouf/ProgressLine +``` + +### Manual Installation (MacOS / Linux) + +Download the binary for your platform from the [releases page](https://github.com/kattouf/ProgressLine/releases), and place it in your executable path. + +## Contributing + +Feel free to open a pull request or a discussion. diff --git a/Sources/ANSI.swift b/Sources/ANSI.swift new file mode 100644 index 0000000..1dcaf9a --- /dev/null +++ b/Sources/ANSI.swift @@ -0,0 +1,21 @@ +enum ANSI { + // Cursor controls + static func cursorUp(_ count: Int) -> String { + "\u{1B}[\(count)A" + } + + static func cursorToColumn(_ column: Int) -> String { + "\u{1B}[\(column)G" + } + + static let eraseLine = "\u{1B}[2K" + + // Colors and styles + static let red = "\u{1B}[31m" + static let green = "\u{1B}[32m" + static let yellow = "\u{1B}[33m" + static let blue = "\u{1B}[34m" + static let magenta = "\u{1B}[35m" + static let bold = "\u{1B}[1m" + static let reset = "\u{1B}[0m" +} diff --git a/Sources/AcitivityIndicatorStyleArgument.swift b/Sources/AcitivityIndicatorStyleArgument.swift new file mode 100644 index 0000000..70633eb --- /dev/null +++ b/Sources/AcitivityIndicatorStyleArgument.swift @@ -0,0 +1,20 @@ +import ArgumentParser + +enum ActivityIndicatorStyle: String, CaseIterable, ExpressibleByArgument { + case dots + case kitt + case snake +} + +extension ActivityIndicator { + static func make(style: ActivityIndicatorStyle) -> ActivityIndicator { + switch style { + case .dots: + .dots + case .kitt: + .kitt + case .snake: + .snake + } + } +} diff --git a/Sources/ActivityIndicator.swift b/Sources/ActivityIndicator.swift new file mode 100644 index 0000000..d5b6184 --- /dev/null +++ b/Sources/ActivityIndicator.swift @@ -0,0 +1,79 @@ +import Foundation +import TaggedTime + +final class ActivityIndicator: Sendable { + struct Configuration { + let refreshRate: Milliseconds + let states: [String] + } + + let configuration: Configuration + + init(configuration: Configuration) { + self.configuration = configuration + } + + func state(forDuration duration: Seconds) -> String { + let iteration = Int(duration.milliseconds.rawValue / TimeInterval(configuration.refreshRate.rawValue)) % configuration.states.count + return configuration.states[iteration] + } +} + +extension ActivityIndicator { + static let dots: ActivityIndicator = { + let configuration = Configuration( + refreshRate: 125, + states: [ + "⠋", + "⠙", + "⠹", + "⠸", + "⠼", + "⠴", + "⠦", + "⠧", + "⠇", + "⠏", + ] + ) + return ActivityIndicator(configuration: configuration) + }() + + static let kitt: ActivityIndicator = { + let configuration = Configuration( + refreshRate: 125, + states: [ + "▰▱▱▱▱", + "▰▰▱▱▱", + "▰▰▰▱▱", + "▱▰▰▰▱", + "▱▱▰▰▰", + "▱▱▱▰▰", + "▱▱▱▱▰", + "▱▱▱▰▰", + "▱▱▰▰▰", + "▱▰▰▰▱", + "▰▰▰▱▱", + "▰▰▱▱▱", + ] + ) + return ActivityIndicator(configuration: configuration) + }() + + static let snake: ActivityIndicator = { + let configuration = Configuration( + refreshRate: 125, + states: [ + "▰▱▱▱▱", + "▰▰▱▱▱", + "▰▰▰▱▱", + "▱▰▰▰▱", + "▱▱▰▰▰", + "▱▱▱▰▰", + "▱▱▱▱▰", + "▱▱▱▱▱", + ] + ) + return ActivityIndicator(configuration: configuration) + }() +} diff --git a/Sources/FileHandler+AsyncStream.swift b/Sources/FileHandler+AsyncStream.swift new file mode 100644 index 0000000..1daef34 --- /dev/null +++ b/Sources/FileHandler+AsyncStream.swift @@ -0,0 +1,27 @@ +#if os(Linux) +// Linux implementation of FileHandle not Sendable +@preconcurrency import Foundation +#else +import Foundation +#endif + +extension FileHandle { + var asyncStream: AsyncStream { + AsyncStream { continuation in + Task { + while let data = try waitAndReadAvailableData() { + continuation.yield(data) + } + continuation.finish() + } + } + } + + private func waitAndReadAvailableData() throws -> Data? { + let data = availableData + guard !data.isEmpty else { + return nil + } + return data + } +} diff --git a/Sources/Printer.swift b/Sources/Printer.swift new file mode 100644 index 0000000..c4572dc --- /dev/null +++ b/Sources/Printer.swift @@ -0,0 +1,54 @@ +import ConcurrencyExtras +#if os(Linux) +// Linux implementation of FileHandle not Sendable +@preconcurrency import Foundation +#else +import Foundation +#endif + +final class Printer: Sendable { + private let fileHandle: LockIsolated + private let buffer = LockIsolated(String()) + + init(fileHandle: FileHandle) { + self.fileHandle = .init(fileHandle) + } + + @discardableResult + func writeln(_ text: String) -> Self { + buffer.withValue { $0 += text + "\n" } + return self + } + + @discardableResult + func write(_ text: String) -> Self { + buffer.withValue { $0 += text } + return self + } + + @discardableResult + func cursorToColumn(_ column: Int) -> Self { + buffer.withValue { $0 += ANSI.cursorToColumn(column) } + return self + } + + @discardableResult + func cursorUp(_ count: Int = 1) -> Self { + buffer.withValue { $0 += ANSI.cursorUp(count) } + return self + } + + @discardableResult + func eraseLine() -> Self { + buffer.withValue { $0 += ANSI.eraseLine } + return self + } + + func flush() { + fileHandle.withValue { + $0.write(buffer.value.data(using: .utf8)!) + try? $0.synchronize() + } + buffer.setValue(String()) + } +} diff --git a/Sources/ProgressLine.swift b/Sources/ProgressLine.swift new file mode 100644 index 0000000..0c3b75a --- /dev/null +++ b/Sources/ProgressLine.swift @@ -0,0 +1,28 @@ +import ArgumentParser +import ConcurrencyExtras +import Foundation +import TaggedTime + +@main +struct ProgressLine: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "progressline", + abstract: "A command-line tool for compactly tracking the progress of piped commands.", + usage: "some-command | progressline" + ) + + @Option(name: [.customLong("activity-style"), .customShort("s")]) + var activityIndicatorStyle: ActivityIndicatorStyle = .dots + + mutating func run() async throws { + let progressLineController = await ProgressLineController.buildAndStart( + activityIndicator: .make(style: activityIndicatorStyle) + ) + + for await data in FileHandle.standardInput.asyncStream { + await progressLineController.didGetStdinDataChunk(data) + } + + await progressLineController.didReachEndOfStdin() + } +} diff --git a/Sources/ProgressLineController.swift b/Sources/ProgressLineController.swift new file mode 100644 index 0000000..4c185fd --- /dev/null +++ b/Sources/ProgressLineController.swift @@ -0,0 +1,117 @@ +import Foundation +import TaggedTime + +final actor ProgressLineController { + // Dependencies + private let printer: Printer + private let errorsPrinter: Printer + private let progressLineFormatter: ProgressLineFormatter + private let progressTracker: ProgressTracker + // State + private var renderLoopTask: Task? + private var lastStdinLine: String? + private var progress: Progress? + + private init( + printer: Printer, + errorsPrinter: Printer, + progressLineFormatter: ProgressLineFormatter, + progressTracker: ProgressTracker + ) { + self.printer = printer + self.errorsPrinter = errorsPrinter + self.progressLineFormatter = progressLineFormatter + self.progressTracker = progressTracker + } + + // MARK: - Public + + static func buildAndStart(activityIndicator: ActivityIndicator) async -> Self { + let progressTracker = ProgressTracker.start() + let printer = Printer(fileHandle: .standardOutput) + let errorsPrinter = Printer(fileHandle: .standardError) + let windowSizeObserver = WindowSizeObserver.startObserving() + let progressLineFormatter = ProgressLineFormatter( + activityIndicator: activityIndicator, + windowSizeObserver: windowSizeObserver + ) + + let controller = Self( + printer: printer, + errorsPrinter: errorsPrinter, + progressLineFormatter: progressLineFormatter, + progressTracker: progressTracker + ) + await controller.startAnimationLoop(refreshRate: activityIndicator.configuration.refreshRate) + + return controller + } + + // MARK: - Input + + func didGetStdinDataChunk(_ data: Data) { + let stdinText = String(data: data, encoding: .utf8) + guard let stdinText else { + printToStderrAboveProgressLine("\(ANSI.yellow)[!] progressline: Failed to decode stdin data as UTF-8\(ANSI.reset)") + return + } + + lastStdinLine = stdinText + .split(whereSeparator: \.isNewline) + .last { !$0.isEmpty } + .map(String.init) + + redrawProgressLine() + } + + func didReachEndOfStdin() { + stopAnimationLoop() + + let progressLine = progressLineFormatter.finished(progress: progress) + if progress != nil { + printer + .cursorUp() + .eraseLine() + } + printer + .writeln(progressLine) + .flush() + } + + // MARK: - Private + + private func startAnimationLoop(refreshRate: Milliseconds) { + renderLoopTask = Task.periodic(interval: refreshRate) { [weak self] in + guard !Task.isCancelled else { + return + } + await self?.redrawProgressLine() + } + } + + private func stopAnimationLoop() { + renderLoopTask?.cancel() + } + + private func redrawProgressLine() { + if self.progress != nil { + printer + .cursorUp() + .eraseLine() + } + let progress = progressTracker.moveForward(lastStdinLine) + let progressLine = progressLineFormatter.inProgress(progress: progress) + self.progress = progress + printer.writeln(progressLine) + printer.flush() + } + + private func printToStderrAboveProgressLine(_ message: String) { + errorsPrinter + .cursorUp() + .eraseLine() + .writeln(message) + .writeln("") + .flush() + } +} diff --git a/Sources/ProgressLineFormatter.swift b/Sources/ProgressLineFormatter.swift new file mode 100644 index 0000000..0b00018 --- /dev/null +++ b/Sources/ProgressLineFormatter.swift @@ -0,0 +1,117 @@ +import Foundation +import TaggedTime + +private enum Symbol { + static let checkmark = "✓" + static let prompt = "❯" +} + +final class ProgressLineFormatter: Sendable { + // Linux doesn't support DateComponentsFormatter + #if os(macOS) + private let durationFormatter: DateComponentsFormatter = { + let durationFormatter = DateComponentsFormatter() + durationFormatter.unitsStyle = .abbreviated + durationFormatter.allowedUnits = [.hour, .minute, .second] + durationFormatter.maximumUnitCount = 2 + return durationFormatter + }() + #endif + + private let activityIndicator: ActivityIndicator + private let windowSizeObserver: WindowSizeObserver + + init( + activityIndicator: ActivityIndicator, + windowSizeObserver: WindowSizeObserver + ) { + self.activityIndicator = activityIndicator + self.windowSizeObserver = windowSizeObserver + } + + func inProgress(progress: Progress) -> String { + let activityIndicator = activityIndicator.state(forDuration: progress.duration) + let formattedDuration = formatDuration(from: progress.duration) + + let styledActivityIndicator = ANSI.blue + activityIndicator + ANSI.reset + let styledDuration = ANSI.bold + formattedDuration + ANSI.reset + let styledPrompt = ANSI.blue + Symbol.prompt + ANSI.reset + + return buildResultString( + styledActivityIndicator: styledActivityIndicator, + styledDuration: styledDuration, + styledPrompt: styledPrompt, + progressLine: progress.line + ) + } + + func finished(progress: Progress?) -> String { + let formattedDuration = progress.map { formatDuration(from: $0.duration) } + + let styledActivityIndicator = ANSI.green + Symbol.checkmark + ANSI.reset + let styledDuration = formattedDuration.map { ANSI.bold + $0 + ANSI.reset } + let styledPrompt = ANSI.green + Symbol.prompt + ANSI.reset + + return buildResultString( + styledActivityIndicator: styledActivityIndicator, + styledDuration: styledDuration, + styledPrompt: styledPrompt, + progressLine: progress?.line + ) + } + + private func buildResultString( + styledActivityIndicator: String, + styledDuration: String?, + styledPrompt: String, + progressLine: String? + ) -> String { + let buildResultWithProgressLine = { (progressLine: String?) -> String in + [styledActivityIndicator, styledDuration, styledPrompt, progressLine] + .compactMap { $0 } + .joined(separator: " ") + } + let result = buildResultWithProgressLine(progressLine) + + let notFittedToWindowLength = calculateStringNotFittedToWindowLength(result) + if let progressLine, notFittedToWindowLength > 0 { + let fittedProgressLine = String(progressLine.prefix(progressLine.count - notFittedToWindowLength)) + return buildResultWithProgressLine(fittedProgressLine) + } else { + return result + } + } + + private func calculateStringNotFittedToWindowLength(_ string: String) -> Int { + let stringWithoutANSI = string.withoutANSI() + let windowWidth = windowSizeObserver.size.width + return max(stringWithoutANSI.count - windowWidth, 0) + } + + private func formatDuration(from duration: Seconds) -> String { + #if os(Linux) + duration.rawValue.formattedDuration() + #else + durationFormatter.string(from: duration.rawValue)! + #endif + } +} + +#if os(Linux) + extension TimeInterval { + func formattedDuration() -> String { + let totalSeconds = Int(self) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours >= 1 { + return "\(hours)h \(minutes)m" + } else if minutes >= 1 { + return "\(minutes)m \(seconds)s" + } else { + return "\(seconds)s" + } + } + } +#endif diff --git a/Sources/ProgressTracker.swift b/Sources/ProgressTracker.swift new file mode 100644 index 0000000..3e82d15 --- /dev/null +++ b/Sources/ProgressTracker.swift @@ -0,0 +1,24 @@ +import Foundation +import TaggedTime + +struct Progress { + let line: String? + let duration: Seconds +} + +final class ProgressTracker: Sendable { + private let startTimestamp: Seconds + + private init(startTimestamp: Seconds) { + self.startTimestamp = startTimestamp + } + + static func start() -> ProgressTracker { + ProgressTracker(startTimestamp: Seconds(Date().timeIntervalSince1970)) + } + + func moveForward(_ line: String?) -> Progress { + let duration = Seconds(Date().timeIntervalSince1970) - startTimestamp + return Progress(line: line, duration: duration) + } +} diff --git a/Sources/String+ANSI.swift b/Sources/String+ANSI.swift new file mode 100644 index 0000000..deb1b43 --- /dev/null +++ b/Sources/String+ANSI.swift @@ -0,0 +1,10 @@ +import Foundation + +extension String { + private nonisolated(unsafe) static let ansiRegex = try! NSRegularExpression(pattern: "\u{1B}(?:[@-Z\\-_]|\\[[0-?]*[ -/]*[@-~])") + + func withoutANSI() -> String { + let range = NSRange(startIndex ..< endIndex, in: self) + return Self.ansiRegex.stringByReplacingMatches(in: self, options: [], range: range, withTemplate: "") + } +} diff --git a/Sources/Task+Periodic.swift b/Sources/Task+Periodic.swift new file mode 100644 index 0000000..e8b5711 --- /dev/null +++ b/Sources/Task+Periodic.swift @@ -0,0 +1,15 @@ +import Foundation +import TaggedTime + +extension Task where Success == Never, Failure == any Error { + @discardableResult + static func periodic(interval: Milliseconds, operation: @Sendable @escaping () async throws -> Void) -> Task { + Task { + while true { + try Task.checkCancellation() + try await operation() + try await Task.sleep(nanoseconds: 1_000_000 * interval.rawValue) + } + } + } +} diff --git a/Sources/TerminalSizeObserver.swift b/Sources/TerminalSizeObserver.swift new file mode 100644 index 0000000..640da0c --- /dev/null +++ b/Sources/TerminalSizeObserver.swift @@ -0,0 +1,52 @@ +import ConcurrencyExtras +import Foundation + +final class WindowSizeObserver: Sendable { + struct Size { + let width: Int + let height: Int + } + + private let signalHandler: LockIsolated?> = .init(nil) + private let _size: LockIsolated = .init(getTerminalSize()) + + var size: Size { + _size.value + } + + static func startObserving() -> WindowSizeObserver { + let observer = WindowSizeObserver() + observer.setupSignalHandler() + return observer + } + + private func setupSignalHandler() { + let sigwinch = SIGWINCH + + let signalHandler = DispatchSource.makeSignalSource(signal: sigwinch) + signal(sigwinch, SIG_IGN) + + signalHandler.setEventHandler { [weak self] in + guard let self = self else { return } + self.syncWindowSize() + } + signalHandler.resume() + + let uncheckedSendable = UncheckedSendable(signalHandler) + self.signalHandler.setValue(uncheckedSendable) + } + + private func syncWindowSize() { + _size.setValue(Self.getTerminalSize()) + } + + private static func getTerminalSize() -> Size { + var w = winsize() + #if os(Linux) + _ = ioctl(STDOUT_FILENO, UInt(TIOCGWINSZ), &w) + #else + _ = ioctl(STDOUT_FILENO, TIOCGWINSZ, &w) + #endif + return Size(width: Int(w.ws_col), height: Int(w.ws_row)) + } +}