Created
December 26, 2025 13:38
-
-
Save timi2506/3c3ecf66850c02b346e74fad23d999dd to your computer and use it in GitHub Desktop.
YAY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // | |
| // ContentView.swift | |
| // Test | |
| // | |
| // Created by Tim on 21.12.25. | |
| // | |
| import SwiftUI | |
| import Combine | |
| struct TestApp: App { | |
| var body: some Scene { | |
| WindowGroup { | |
| ContentView() | |
| } | |
| } | |
| } | |
| class ViewModel: ObservableObject { | |
| static let shared = ViewModel() | |
| @Published var elements: [SpeakElement] = [] | |
| @Published var selected: [UUID] = [] | |
| } | |
| struct ContentView: View { | |
| @StateObject var model = ViewModel.shared | |
| var body: some View { | |
| VStack { | |
| let bindings = model.selected.compactMap { id in | |
| if let index = model.elements.firstIndex(where: { $0.id == id }) { | |
| return $model.elements[index] | |
| } else { | |
| return nil | |
| } | |
| } | |
| if bindings.isEmpty { | |
| Text("No Elements Found") | |
| .padding() | |
| } else { | |
| let columns: [GridItem] = Array( | |
| repeating: GridItem(.flexible(minimum: 350, maximum: 350), spacing: 12), | |
| count: 2 | |
| ) | |
| ScrollView([.horizontal, .vertical]) { | |
| LazyVGrid(columns: columns, spacing: 10) { | |
| ForEach(bindings) { binding in | |
| SpeakElementView(element: binding) | |
| } | |
| } | |
| } | |
| } | |
| } | |
| .navigationTitle("SpeakIt") | |
| .toolbar { | |
| Button("Add", systemImage: "plus") { | |
| let e = SpeakElement() | |
| model.elements.append(e) | |
| model.selected.append(e.id) | |
| } | |
| } | |
| .frame(minWidth: 500, minHeight: 350) | |
| .animation(.default, value: model.selected) | |
| } | |
| } | |
| struct SpeakElementView: View { | |
| @Binding var element: SpeakElement | |
| @StateObject var model = ViewModel.shared | |
| @State var output = "" | |
| var body: some View { | |
| VStack { | |
| if element.isRunning { | |
| HStack { | |
| if output.isEmpty { | |
| Text("Waiting for Process to start...") | |
| .foregroundStyle(.secondary) | |
| } | |
| ANSITextView(text: output) | |
| Spacer() | |
| } | |
| } else { | |
| TextField("Text To Say", text: $element.text) | |
| } | |
| HStack { | |
| if element.isRunning { | |
| Button("Stop Speaking", systemImage: "stop.fill") { | |
| element.process.terminate() | |
| } | |
| .foregroundStyle(.red) | |
| } else { | |
| Button("Speak Text", systemImage: "play.fill") { | |
| Task { | |
| element.isRunning = true | |
| element.process = Process() | |
| let pipe = Pipe() | |
| element.process.environment = [ | |
| "TERM": "xterm-256color", | |
| "CLICOLOR": "1", | |
| "CLICOLOR_FORCE": "1" | |
| ] | |
| element.process.standardOutput = pipe | |
| pipe.fileHandleForReading.readabilityHandler = { handle in | |
| let data = handle.availableData | |
| if let line = String(data: data, encoding: .utf8), !line.isEmpty { | |
| DispatchQueue.main.async { | |
| self.output += line | |
| } | |
| } | |
| } | |
| element.process.executableURL = URL(fileURLWithPath: "/usr/bin/say") | |
| element.process.arguments = ["-i", element.text] | |
| try? element.process.run() | |
| await asyncBlocking { | |
| element.process.waitUntilExit() | |
| } | |
| element.isRunning = false | |
| self.output = "" | |
| } | |
| } | |
| .disabled(element.text.isEmpty) | |
| .buttonStyle(.borderedProminent) | |
| } | |
| Spacer() | |
| if element.isRunning { | |
| ProgressView() | |
| .controlSize(.small) | |
| Spacer() | |
| } | |
| Button("Remove", systemImage: "trash") { | |
| model.selected.removeAll { id in | |
| id == element.id | |
| } | |
| model.elements.removeAll { element in | |
| element.id == self.element.id | |
| } | |
| } | |
| } | |
| } | |
| .animation(.default, value: element.isRunning) | |
| .padding() | |
| .background { | |
| RoundedRectangle(cornerRadius: 15) | |
| .foregroundStyle(.ultraThinMaterial) | |
| } | |
| .padding(.vertical, 5) | |
| .padding(.horizontal) | |
| } | |
| func asyncBlocking(_ function: @escaping () -> Void) async { | |
| await withCheckedContinuation { continuation in | |
| DispatchQueue.global(qos: .userInitiated).async { | |
| function() | |
| continuation.resume() | |
| } | |
| } | |
| } | |
| } | |
| struct SpeakElement: Identifiable { | |
| var id = UUID() | |
| var text: String = "" | |
| var isRunning: Bool = false | |
| var process = Process() | |
| } | |
| autoreleasepool { | |
| let app = NSApplication.shared | |
| app.setActivationPolicy(.regular) | |
| TestApp.main() | |
| } | |
| #Preview { | |
| ContentView() | |
| } | |
| struct ANSITextView: View { | |
| let text: String | |
| var body: some View { | |
| Text(parseANSI(text)) | |
| .textSelection(.enabled) | |
| .multilineTextAlignment(.leading) | |
| } | |
| private func parseANSI(_ input: String) -> AttributedString { | |
| let lastLineInput: String = { | |
| if let range = input.range(of: "\r", options: .backwards) { | |
| return String(input[range.upperBound...]) | |
| } | |
| if let range = input.range(of: "\n", options: .backwards) { | |
| return String(input[range.upperBound...]) | |
| } | |
| return input | |
| }() | |
| var result = AttributedString() | |
| var current = AttributedString() | |
| var isHighlighted = false | |
| var i = lastLineInput.startIndex | |
| func flush() { | |
| if !current.characters.isEmpty { | |
| if isHighlighted { | |
| current.backgroundColor = .yellow.opacity(0.5) | |
| } else { | |
| current.backgroundColor = nil | |
| } | |
| result.append(current) | |
| current = AttributedString() | |
| } | |
| } | |
| while i < lastLineInput.endIndex { | |
| if lastLineInput[i] == "\u{1B}" { | |
| flush() | |
| var seqEnd = lastLineInput.index(after: i) | |
| while seqEnd < lastLineInput.endIndex && !lastLineInput[seqEnd].isLetter { | |
| seqEnd = lastLineInput.index(after: seqEnd) | |
| } | |
| if seqEnd < lastLineInput.endIndex { | |
| seqEnd = lastLineInput.index(after: seqEnd) | |
| } | |
| let sequence = String(lastLineInput[i..<seqEnd]) | |
| switch sequence { | |
| case "\u{1B}[7m": | |
| isHighlighted = true | |
| case "\u{1B}[m": | |
| isHighlighted = false | |
| default: | |
| break | |
| } | |
| i = seqEnd | |
| } else { | |
| current.append(AttributedString(String(lastLineInput[i]))) | |
| i = lastLineInput.index(after: i) | |
| } | |
| } | |
| flush() | |
| return result | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment