diff --git a/Sources/Sst/AppCommands.swift b/Sources/Sst/AppCommands.swift index e427bad..dc801e0 100644 --- a/Sources/Sst/AppCommands.swift +++ b/Sources/Sst/AppCommands.swift @@ -22,6 +22,8 @@ struct AppCommands: Commands { .keyboardShortcut("1", modifiers: .command) Button(Strings.dictionary) { show(.dictionary) } .keyboardShortcut("2", modifiers: .command) + Button(Strings.note) { show(.note) } + .keyboardShortcut("3", modifiers: .command) Button(Strings.search) { show(model.section) model.focusSearch() diff --git a/Sources/Sst/AppIdentity.swift b/Sources/Sst/AppIdentity.swift index f683c88..e3ecb82 100644 --- a/Sources/Sst/AppIdentity.swift +++ b/Sources/Sst/AppIdentity.swift @@ -14,4 +14,5 @@ enum AppPaths { } static var dictionary: URL { support.appending(path: "dictionary.txt") } static var history: URL { support.appending(path: "history.json") } + static var note: URL { support.appending(path: "note.txt") } } diff --git a/Sources/Sst/AppModel.swift b/Sources/Sst/AppModel.swift index fe1be64..ddd406e 100644 --- a/Sources/Sst/AppModel.swift +++ b/Sources/Sst/AppModel.swift @@ -6,20 +6,25 @@ import SstData import SstWhisper import SwiftUI -/// Decides at insertion time whether dictated text may be pasted. -/// Not when Sst itself is in front (it would land in Sst's own search field) and not for recordings started in the window. +/// Decides at insertion time where dictated text goes. +/// Sst in front: into the Notitie editor if (and only if) it has the cursor, otherwise nowhere (it would land in Sst's own +/// search field). Another app in front: pasted there, except for recordings started in the window. final class InsertionPolicy { var windowSession = false - func allowed() -> Bool { - !windowSession && NSWorkspace.shared.frontmostApplication?.bundleIdentifier != Bundle.main.bundleIdentifier + func route() -> InsertionRoute { + MainActor.assumeIsolated { + let sstInFront = NSWorkspace.shared.frontmostApplication?.bundleIdentifier == Bundle.main.bundleIdentifier + if sstInFront { return NoteEditorRegistry.shared.active != nil ? .editor : .none } + return windowSession ? .none : .system + } } } @MainActor final class AppModel: ObservableObject { enum Section: String, CaseIterable, Identifiable { - case transcripts, dictionary + case transcripts, dictionary, note var id: String { rawValue } } @@ -37,6 +42,7 @@ final class AppModel: ObservableObject { let history: HistoryStore let dictionary: DictionaryStore let storage = ModelStorage() + let note: NoteStore let historyList: HistoryViewModel let dictionaryList: DictionaryViewModel @@ -77,6 +83,7 @@ final class AppModel: ObservableObject { let folder = FileManager.default.temporaryDirectory.appending(path: "sst-preview-\(UUID().uuidString)", directoryHint: .isDirectory) let dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary) let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history) + let note = NoteStore(fileURL: preview ? folder.appending(path: "note.txt") : AppPaths.note) let levelSource = self.levelSource let recorder = AudioRecorder() recorder.onLevel = { levelSource.update($0) } @@ -86,15 +93,17 @@ final class AppModel: ObservableObject { self.corrector = corrector self.dictionary = dictionary self.history = history + self.note = note self.historyList = HistoryViewModel(store: history) self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge) self.controller = DictationController( recorder: recorder, transcriber: transcriber, cleaner: PassthroughCleaner(), - inserter: GatedInserter( - inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()), - shouldInsert: { policy.allowed() }), + inserter: RoutingInserter( + system: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()), + editor: EditorInserter(), + route: { policy.route() }), corrector: corrector, vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] }) @@ -221,14 +230,18 @@ final class AppModel: ObservableObject { NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() } } - NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in - MainActor.assumeIsolated { history.flush() } + NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history, note] _ in + MainActor.assumeIsolated { + history.flush() + note.flush() + } } } private func start() async { do { try dictionary.load() } catch { lastError = error.localizedDescription } dictionary.startWatching() + note.load() switch history.load() { case .recoveredCorrupt: lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet." @@ -364,6 +377,7 @@ final class AppModel: ObservableObject { // MARK: preview private func loadPreviewData() { + note.text = "Notities voor vrijdag: Supabase-migratie plannen en Claude Code uitproberen." try? dictionary.load() try? dictionary.apply { file in file.append(.term("Anthropic")) diff --git a/Sources/Sst/MainWindow.swift b/Sources/Sst/MainWindow.swift index 42da340..1b35312 100644 --- a/Sources/Sst/MainWindow.swift +++ b/Sources/Sst/MainWindow.swift @@ -14,6 +14,7 @@ struct MainWindow: View { switch model.section { case .transcripts: HistoryView(list: model.historyList) case .dictionary: DictionaryView(list: model.dictionaryList) + case .note: NoteView(note: model.note) } } .padding(Layout.panelPadding) diff --git a/Sources/Sst/NoteEditor.swift b/Sources/Sst/NoteEditor.swift new file mode 100644 index 0000000..3cbae10 --- /dev/null +++ b/Sources/Sst/NoteEditor.swift @@ -0,0 +1,130 @@ +import AppKit +import SstCore +import SstData +import SstUI +import SwiftUI + +/// Text view of the Notitie editor; registers itself while it has the cursor. +final class NoteTextView: NSTextView { + override func becomeFirstResponder() -> Bool { + let accepted = super.becomeFirstResponder() + if accepted { NoteEditorRegistry.shared.focused = self } + return accepted + } + + override func resignFirstResponder() -> Bool { + let accepted = super.resignFirstResponder() + if accepted, NoteEditorRegistry.shared.focused === self { NoteEditorRegistry.shared.focused = nil } + return accepted + } + + /// Types dictated text at the cursor (replacing a selection) through the normal edit path, so undo works. + func insertDictation(_ text: String) { + let range = selectedRange() + let ns = string as NSString + var before: Character? + if range.location > 0, range.location <= ns.length { + before = ns.substring(with: ns.rangeOfComposedCharacterSequence(at: range.location - 1)).first + } + let prepared = DictationSpacing.prepared(text, characterBefore: before) + if shouldChangeText(in: range, replacementString: prepared) { + insertText(prepared, replacementRange: range) + } + scrollRangeToVisible(selectedRange()) + } +} + +@MainActor +final class NoteEditorRegistry { + static let shared = NoteEditorRegistry() + weak var focused: NoteTextView? + + /// The editor, but only while it really has the cursor in the key window. + var active: NoteTextView? { + guard let view = focused, let window = view.window, window.isKeyWindow, window.firstResponder === view else { return nil } + return view + } +} + +/// Sends dictated text into the Notitie editor. +struct EditorInserter: TextInserting { + func insert(_ text: String) async throws { + await MainActor.run { NoteEditorRegistry.shared.active?.insertDictation(text) } + } +} + +struct NoteEditor: NSViewRepresentable { + @Binding var text: String + var isEditable: Bool + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let scroll = NSScrollView() + scroll.drawsBackground = false + scroll.hasVerticalScroller = true + scroll.autohidesScrollers = true + scroll.borderType = .noBorder + + let view = NoteTextView(frame: .zero) + view.minSize = .zero + view.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) + view.isVerticallyResizable = true + view.isHorizontallyResizable = false + view.autoresizingMask = [.width] + view.textContainer?.widthTracksTextView = true + view.textContainerInset = NSSize(width: Space.s5, height: Space.s5) + view.isRichText = false + view.allowsUndo = true + view.drawsBackground = false + view.isAutomaticQuoteSubstitutionEnabled = false + view.isAutomaticDashSubstitutionEnabled = false + view.delegate = context.coordinator + view.string = text + scroll.documentView = view + context.coordinator.view = view + apply(to: view) + return scroll + } + + func updateNSView(_ scroll: NSScrollView, context: Context) { + context.coordinator.parent = self + guard let view = context.coordinator.view else { return } + if view.string != text, !view.hasMarkedText() { + let selection = view.selectedRange() + view.string = text + let length = (text as NSString).length + view.setSelectedRange(NSRange(location: min(selection.location, length), length: 0)) + } + view.isEditable = isEditable + apply(to: view) + } + + /// Font and colours come from the design tokens; colours follow the current appearance. + private func apply(to view: NoteTextView) { + let style = Typography.reading + let font = NSFont.systemFont(ofSize: style.size, weight: .regular) + view.font = font + view.effectiveAppearance.performAsCurrentDrawingAppearance { + let ink = NSColor(Palette.inkPrimary) + view.textColor = ink + view.insertionPointColor = ink + } + let paragraph = NSMutableParagraphStyle() + paragraph.lineSpacing = max(0, style.line - style.size * 1.2) + view.defaultParagraphStyle = paragraph + view.typingAttributes = [.font: font, .foregroundColor: view.textColor ?? .labelColor, .paragraphStyle: paragraph] + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: NoteEditor + weak var view: NoteTextView? + + init(_ parent: NoteEditor) { self.parent = parent } + + func textDidChange(_ notification: Notification) { + guard let view else { return } + parent.text = view.string + } + } +} diff --git a/Sources/Sst/NoteView.swift b/Sources/Sst/NoteView.swift new file mode 100644 index 0000000..82da6d1 --- /dev/null +++ b/Sources/Sst/NoteView.swift @@ -0,0 +1,59 @@ +import AppKit +import SstData +import SstUI +import SwiftUI + +struct NoteView: View { + @EnvironmentObject private var model: AppModel + @ObservedObject var note: NoteStore + @State private var confirmingClear = false + + var body: some View { + VStack(spacing: Layout.panelGap) { + header + ZStack { + PaperBackground() + if SnapshotMode.isActive { + // ImageRenderer cannot draw an NSView; the snapshot shows the text instead. + Text(note.text) + .textStyle(Typography.reading) + .foregroundStyle(Palette.inkPrimary) + .padding(Space.s5) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } else { + NoteEditor(text: $note.text, isEditable: !note.isReadOnly) + } + } + footer + } + .confirmationDialog(Strings.clearNoteConfirm, isPresented: $confirmingClear) { + Button(Strings.clear, role: .destructive) { note.text = "" } + } + } + + private var header: some View { + HStack(spacing: Space.s4) { + SilkLabel(Strings.note) + Spacer() + Text(Strings.characters(note.text.count)) + .textStyle(Typography.counterSmall) + .foregroundStyle(Palette.inkSecondary) + Button(Strings.copy) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(note.text, forType: .string) + } + .buttonStyle(KeyButtonStyle()) + .disabled(note.text.isEmpty) + Button(Strings.clear) { confirmingClear = true } + .buttonStyle(KeyButtonStyle()) + .disabled(note.text.isEmpty) + } + } + + private var footer: some View { + Text(note.isReadOnly ? Strings.noteUnreadable : Strings.noteHint(model.hotkeyBinding.displayName)) + .textStyle(Typography.label) + .foregroundStyle(Palette.inkTertiary) + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/Sources/Sst/Sidebar.swift b/Sources/Sst/Sidebar.swift index 6cea23d..14f2ab3 100644 --- a/Sources/Sst/Sidebar.swift +++ b/Sources/Sst/Sidebar.swift @@ -26,6 +26,7 @@ struct Sidebar: View { switch section { case .transcripts: Strings.transcripts case .dictionary: Strings.dictionary + case .note: Strings.note } } } diff --git a/Sources/Sst/Strings.swift b/Sources/Sst/Strings.swift index 71593ba..8751a09 100644 --- a/Sources/Sst/Strings.swift +++ b/Sources/Sst/Strings.swift @@ -33,6 +33,14 @@ enum Strings { static let entryGone = "Deze invoer bestaat niet meer; je wijziging is niet bewaard." static let emptyDictionary = "Nog geen invoeren. Voeg een woord of een correctie toe met +." + // note + static let note = "Notitie" + static let clear = "Wis" + static func characters(_ count: Int) -> String { count == 1 ? "1 teken" : "\(count) tekens" } + static func noteHint(_ keyName: String) -> String { "Zet de cursor in het vak en houd \(keyName) ingedrukt om hier te dicteren." } + static let clearNoteConfirm = "Notitie wissen?" + static let noteUnreadable = "De notitie kon niet worden gelezen; wijzigingen worden niet bewaard." + // deck static let record = "Rec" static let stop = "Stop" diff --git a/Sources/SstCore/AppGlue.swift b/Sources/SstCore/AppGlue.swift index 51c3cdb..218afdd 100644 --- a/Sources/SstCore/AppGlue.swift +++ b/Sources/SstCore/AppGlue.swift @@ -1,18 +1,33 @@ import Foundation -/// Inserts text only while `shouldInsert()` says so (not when Sst itself is in front, nor for window recordings). -public struct GatedInserter: TextInserting { - private let inner: TextInserting - private let shouldInsert: () -> Bool +/// Where one dictation's text goes. +public enum InsertionRoute: Equatable, Sendable { + /// Pasted into whichever app is in front. + case system + /// Typed into the Notitie editor inside Sst. + case editor + /// Dropped (Sst is in front but nothing there takes text, or the recording started in the window). + case none +} - public init(inner: TextInserting, shouldInsert: @escaping () -> Bool) { - self.inner = inner - self.shouldInsert = shouldInsert +/// Sends each dictation to the system paster or the in-app editor, asked at insertion time rather than at creation. +public struct RoutingInserter: TextInserting { + private let system: TextInserting + private let editor: TextInserting + private let route: () -> InsertionRoute + + public init(system: TextInserting, editor: TextInserting, route: @escaping () -> InsertionRoute) { + self.system = system + self.editor = editor + self.route = route } public func insert(_ text: String) async throws { - guard shouldInsert() else { return } - try await inner.insert(text) + switch route() { + case .system: try await system.insert(text) + case .editor: try await editor.insert(text) + case .none: break + } } } diff --git a/Sources/SstData/NoteStore.swift b/Sources/SstData/NoteStore.swift new file mode 100644 index 0000000..86f22b5 --- /dev/null +++ b/Sources/SstData/NoteStore.swift @@ -0,0 +1,68 @@ +import Foundation + +/// The text of the Notitie editor, kept in one plain file. Saves are debounced and atomic. +@MainActor +public final class NoteStore: ObservableObject { + @Published public var text: String = "" { + didSet { + guard text != oldValue, !loading else { return } + changed() + } + } + public private(set) var lastError: String? + /// True when the file exists but could not be read: saving would overwrite the user's text, so it is switched off. + public private(set) var isReadOnly = false + + private let fileURL: URL + private var saveTask: Task? + private var loading = false + + public init(fileURL: URL) { + self.fileURL = fileURL + } + + public func load() { + guard FileManager.default.fileExists(atPath: fileURL.path) else { return } + loading = true + defer { loading = false } + do { + text = try String(contentsOf: fileURL, encoding: .utf8) + } catch { + isReadOnly = true + lastError = error.localizedDescription + } + } + + /// Writes immediately (also call this when the app quits). + public func flush() { + saveTask?.cancel() + saveTask = nil + guard !isReadOnly else { return } + do { + try FileManager.default.createDirectory( + at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try text.write(to: fileURL, atomically: true, encoding: .utf8) + lastError = nil + } catch { + lastError = error.localizedDescription + } + } + + private func changed() { + saveTask?.cancel() + saveTask = Task { [weak self] in + try? await Task.sleep(for: .milliseconds(400)) + guard !Task.isCancelled else { return } + self?.flush() + } + } +} + +/// Puts a space between dictations: the text before the cursor decides whether one is needed. +public enum DictationSpacing { + public static func prepared(_ text: String, characterBefore: Character?) -> String { + guard let before = characterBefore, !before.isWhitespace, let first = text.first else { return text } + if first.isWhitespace || ".,;:!?)]}\u{201D}\u{2019}".contains(first) { return text } + return " " + text + } +} diff --git a/Tests/SstCoreTests/AppGlueTests.swift b/Tests/SstCoreTests/AppGlueTests.swift index ca67bc7..202a0c5 100644 --- a/Tests/SstCoreTests/AppGlueTests.swift +++ b/Tests/SstCoreTests/AppGlueTests.swift @@ -2,26 +2,44 @@ import Foundation import Testing @testable import SstCore -struct GatedInserterTests { - @Test func insertsWhenAllowed() async throws { - let inner = FakeInserter() - try await GatedInserter(inner: inner, shouldInsert: { true }).insert("hallo") - #expect(inner.inserted == ["hallo"]) +struct RoutingInserterTests { + @Test func sendsToTheSystemRoute() async throws { + let system = FakeInserter(), editor = FakeInserter() + try await RoutingInserter(system: system, editor: editor, route: { .system }).insert("hallo") + #expect(system.inserted == ["hallo"]) + #expect(editor.inserted.isEmpty) } - @Test func staysSilentWhenNotAllowed() async throws { - let inner = FakeInserter() - try await GatedInserter(inner: inner, shouldInsert: { false }).insert("hallo") - #expect(inner.inserted.isEmpty) + @Test func sendsToTheEditorRoute() async throws { + let system = FakeInserter(), editor = FakeInserter() + try await RoutingInserter(system: system, editor: editor, route: { .editor }).insert("hallo") + #expect(editor.inserted == ["hallo"]) + #expect(system.inserted.isEmpty) + } + + @Test func dropsTheTextOnNone() async throws { + let system = FakeInserter(), editor = FakeInserter() + try await RoutingInserter(system: system, editor: editor, route: { .none }).insert("hallo") + #expect(system.inserted.isEmpty) + #expect(editor.inserted.isEmpty) } @Test func askedAtInsertionTimeNotAtCreation() async throws { - let inner = FakeInserter() - var allowed = false - let gated = GatedInserter(inner: inner, shouldInsert: { allowed }) - allowed = true - try await gated.insert("nu wel") - #expect(inner.inserted == ["nu wel"]) + let system = FakeInserter(), editor = FakeInserter() + var current = InsertionRoute.none + let router = RoutingInserter(system: system, editor: editor, route: { current }) + current = .editor + try await router.insert("nu wel") + #expect(editor.inserted == ["nu wel"]) + } + + @Test func propagatesInserterErrors() async { + struct Boom: Error {} + let system = FakeInserter() + system.error = Boom() + await #expect(throws: Boom.self) { + try await RoutingInserter(system: system, editor: FakeInserter(), route: { .system }).insert("x") + } } } diff --git a/Tests/SstDataTests/NoteTests.swift b/Tests/SstDataTests/NoteTests.swift new file mode 100644 index 0000000..187824b --- /dev/null +++ b/Tests/SstDataTests/NoteTests.swift @@ -0,0 +1,82 @@ +import Foundation +import Testing +@testable import SstData + +@MainActor +struct NoteStoreTests { + private func temp() -> URL { + FileManager.default.temporaryDirectory.appending(path: "note-\(UUID().uuidString)/note.txt") + } + + @Test func missingFileLoadsEmpty() { + let store = NoteStore(fileURL: temp()) + store.load() + #expect(store.text == "") + #expect(!store.isReadOnly) + } + + @Test func flushWritesAndAnotherStoreReadsIt() { + let url = temp() + let store = NoteStore(fileURL: url) + store.text = "eerste regel\nté" + store.flush() + let other = NoteStore(fileURL: url) + other.load() + #expect(other.text == "eerste regel\nté") + } + + @Test func editsAreSavedAfterTheDebounce() async throws { + let url = temp() + let store = NoteStore(fileURL: url) + store.text = "bewaard" + #expect(!FileManager.default.fileExists(atPath: url.path)) + try await Task.sleep(for: .milliseconds(900)) + #expect(try String(contentsOf: url, encoding: .utf8) == "bewaard") + } + + @Test func loadingDoesNotTriggerASave() async throws { + let url = temp() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try "oud".write(to: url, atomically: true, encoding: .utf8) + let store = NoteStore(fileURL: url) + store.load() + #expect(store.text == "oud") + try FileManager.default.removeItem(at: url) + try await Task.sleep(for: .milliseconds(900)) + #expect(!FileManager.default.fileExists(atPath: url.path)) + } + + @Test func unreadableFileIsNeverOverwritten() throws { + let url = temp() + try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data([0xFF, 0xFE, 0xFD]).write(to: url) + let store = NoteStore(fileURL: url) + store.load() + #expect(store.isReadOnly) + store.text = "nieuw" + store.flush() + #expect(try Data(contentsOf: url) == Data([0xFF, 0xFE, 0xFD])) + } +} + +struct DictationSpacingTests { + @Test func noSpaceAtTheStartOrAfterWhitespace() { + #expect(DictationSpacing.prepared("hallo", characterBefore: nil) == "hallo") + #expect(DictationSpacing.prepared("hallo", characterBefore: " ") == "hallo") + #expect(DictationSpacing.prepared("hallo", characterBefore: "\n") == "hallo") + } + + @Test func spaceAfterAWordOrSentenceEnd() { + #expect(DictationSpacing.prepared("hallo", characterBefore: "k") == " hallo") + #expect(DictationSpacing.prepared("Nog een.", characterBefore: ".") == " Nog een.") + } + + @Test func noSpaceBeforeClosingPunctuationOrLeadingWhitespace() { + #expect(DictationSpacing.prepared(", dus", characterBefore: "k") == ", dus") + #expect(DictationSpacing.prepared(" dus", characterBefore: "k") == " dus") + } + + @Test func emptyTextStaysEmpty() { + #expect(DictationSpacing.prepared("", characterBefore: "k") == "") + } +}