feat: Notitie editor in the app, dictation routed to system or editor
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -22,6 +22,8 @@ struct AppCommands: Commands {
|
|||||||
.keyboardShortcut("1", modifiers: .command)
|
.keyboardShortcut("1", modifiers: .command)
|
||||||
Button(Strings.dictionary) { show(.dictionary) }
|
Button(Strings.dictionary) { show(.dictionary) }
|
||||||
.keyboardShortcut("2", modifiers: .command)
|
.keyboardShortcut("2", modifiers: .command)
|
||||||
|
Button(Strings.note) { show(.note) }
|
||||||
|
.keyboardShortcut("3", modifiers: .command)
|
||||||
Button(Strings.search) {
|
Button(Strings.search) {
|
||||||
show(model.section)
|
show(model.section)
|
||||||
model.focusSearch()
|
model.focusSearch()
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ enum AppPaths {
|
|||||||
}
|
}
|
||||||
static var dictionary: URL { support.appending(path: "dictionary.txt") }
|
static var dictionary: URL { support.appending(path: "dictionary.txt") }
|
||||||
static var history: URL { support.appending(path: "history.json") }
|
static var history: URL { support.appending(path: "history.json") }
|
||||||
|
static var note: URL { support.appending(path: "note.txt") }
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-10
@@ -6,20 +6,25 @@ import SstData
|
|||||||
import SstWhisper
|
import SstWhisper
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
/// Decides at insertion time whether dictated text may be pasted.
|
/// Decides at insertion time where dictated text goes.
|
||||||
/// Not when Sst itself is in front (it would land in Sst's own search field) and not for recordings started in the window.
|
/// 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 {
|
final class InsertionPolicy {
|
||||||
var windowSession = false
|
var windowSession = false
|
||||||
|
|
||||||
func allowed() -> Bool {
|
func route() -> InsertionRoute {
|
||||||
!windowSession && NSWorkspace.shared.frontmostApplication?.bundleIdentifier != Bundle.main.bundleIdentifier
|
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
|
@MainActor
|
||||||
final class AppModel: ObservableObject {
|
final class AppModel: ObservableObject {
|
||||||
enum Section: String, CaseIterable, Identifiable {
|
enum Section: String, CaseIterable, Identifiable {
|
||||||
case transcripts, dictionary
|
case transcripts, dictionary, note
|
||||||
var id: String { rawValue }
|
var id: String { rawValue }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +42,7 @@ final class AppModel: ObservableObject {
|
|||||||
let history: HistoryStore
|
let history: HistoryStore
|
||||||
let dictionary: DictionaryStore
|
let dictionary: DictionaryStore
|
||||||
let storage = ModelStorage()
|
let storage = ModelStorage()
|
||||||
|
let note: NoteStore
|
||||||
let historyList: HistoryViewModel
|
let historyList: HistoryViewModel
|
||||||
let dictionaryList: DictionaryViewModel
|
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 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 dictionary = DictionaryStore(fileURL: preview ? folder.appending(path: "dictionary.txt") : AppPaths.dictionary)
|
||||||
let history = HistoryStore(fileURL: preview ? folder.appending(path: "history.json") : AppPaths.history)
|
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 levelSource = self.levelSource
|
||||||
let recorder = AudioRecorder()
|
let recorder = AudioRecorder()
|
||||||
recorder.onLevel = { levelSource.update($0) }
|
recorder.onLevel = { levelSource.update($0) }
|
||||||
@@ -86,15 +93,17 @@ final class AppModel: ObservableObject {
|
|||||||
self.corrector = corrector
|
self.corrector = corrector
|
||||||
self.dictionary = dictionary
|
self.dictionary = dictionary
|
||||||
self.history = history
|
self.history = history
|
||||||
|
self.note = note
|
||||||
self.historyList = HistoryViewModel(store: history)
|
self.historyList = HistoryViewModel(store: history)
|
||||||
self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge)
|
self.dictionaryList = DictionaryViewModel(store: dictionary, knowledge: knowledge)
|
||||||
self.controller = DictationController(
|
self.controller = DictationController(
|
||||||
recorder: recorder,
|
recorder: recorder,
|
||||||
transcriber: transcriber,
|
transcriber: transcriber,
|
||||||
cleaner: PassthroughCleaner(),
|
cleaner: PassthroughCleaner(),
|
||||||
inserter: GatedInserter(
|
inserter: RoutingInserter(
|
||||||
inner: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()),
|
system: TextInserter(pasteboard: SystemPasteboard(), paster: CGEventPaster()),
|
||||||
shouldInsert: { policy.allowed() }),
|
editor: EditorInserter(),
|
||||||
|
route: { policy.route() }),
|
||||||
corrector: corrector,
|
corrector: corrector,
|
||||||
vocabularyProvider: { settings.usePriming ? VocabularyBuilder.build(from: dictionary.entries) : [] })
|
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
|
NotificationCenter.default.addObserver(forName: NSApplication.didBecomeActiveNotification, object: nil, queue: .main) { [weak self] _ in
|
||||||
MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() }
|
MainActor.assumeIsolated { self?.dictionary.reloadIfChanged() }
|
||||||
}
|
}
|
||||||
NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history] _ in
|
NotificationCenter.default.addObserver(forName: NSApplication.willTerminateNotification, object: nil, queue: .main) { [history, note] _ in
|
||||||
MainActor.assumeIsolated { history.flush() }
|
MainActor.assumeIsolated {
|
||||||
|
history.flush()
|
||||||
|
note.flush()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func start() async {
|
private func start() async {
|
||||||
do { try dictionary.load() } catch { lastError = error.localizedDescription }
|
do { try dictionary.load() } catch { lastError = error.localizedDescription }
|
||||||
dictionary.startWatching()
|
dictionary.startWatching()
|
||||||
|
note.load()
|
||||||
switch history.load() {
|
switch history.load() {
|
||||||
case .recoveredCorrupt:
|
case .recoveredCorrupt:
|
||||||
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
|
lastError = "Het geschiedenisbestand was beschadigd en is opzij gezet."
|
||||||
@@ -364,6 +377,7 @@ final class AppModel: ObservableObject {
|
|||||||
// MARK: preview
|
// MARK: preview
|
||||||
|
|
||||||
private func loadPreviewData() {
|
private func loadPreviewData() {
|
||||||
|
note.text = "Notities voor vrijdag: Supabase-migratie plannen en Claude Code uitproberen."
|
||||||
try? dictionary.load()
|
try? dictionary.load()
|
||||||
try? dictionary.apply { file in
|
try? dictionary.apply { file in
|
||||||
file.append(.term("Anthropic"))
|
file.append(.term("Anthropic"))
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ struct MainWindow: View {
|
|||||||
switch model.section {
|
switch model.section {
|
||||||
case .transcripts: HistoryView(list: model.historyList)
|
case .transcripts: HistoryView(list: model.historyList)
|
||||||
case .dictionary: DictionaryView(list: model.dictionaryList)
|
case .dictionary: DictionaryView(list: model.dictionaryList)
|
||||||
|
case .note: NoteView(note: model.note)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(Layout.panelPadding)
|
.padding(Layout.panelPadding)
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ struct Sidebar: View {
|
|||||||
switch section {
|
switch section {
|
||||||
case .transcripts: Strings.transcripts
|
case .transcripts: Strings.transcripts
|
||||||
case .dictionary: Strings.dictionary
|
case .dictionary: Strings.dictionary
|
||||||
|
case .note: Strings.note
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ enum Strings {
|
|||||||
static let entryGone = "Deze invoer bestaat niet meer; je wijziging is niet bewaard."
|
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 +."
|
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
|
// deck
|
||||||
static let record = "Rec"
|
static let record = "Rec"
|
||||||
static let stop = "Stop"
|
static let stop = "Stop"
|
||||||
|
|||||||
@@ -1,18 +1,33 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Inserts text only while `shouldInsert()` says so (not when Sst itself is in front, nor for window recordings).
|
/// Where one dictation's text goes.
|
||||||
public struct GatedInserter: TextInserting {
|
public enum InsertionRoute: Equatable, Sendable {
|
||||||
private let inner: TextInserting
|
/// Pasted into whichever app is in front.
|
||||||
private let shouldInsert: () -> Bool
|
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) {
|
/// Sends each dictation to the system paster or the in-app editor, asked at insertion time rather than at creation.
|
||||||
self.inner = inner
|
public struct RoutingInserter: TextInserting {
|
||||||
self.shouldInsert = shouldInsert
|
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 {
|
public func insert(_ text: String) async throws {
|
||||||
guard shouldInsert() else { return }
|
switch route() {
|
||||||
try await inner.insert(text)
|
case .system: try await system.insert(text)
|
||||||
|
case .editor: try await editor.insert(text)
|
||||||
|
case .none: break
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<Void, Never>?
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,26 +2,44 @@ import Foundation
|
|||||||
import Testing
|
import Testing
|
||||||
@testable import SstCore
|
@testable import SstCore
|
||||||
|
|
||||||
struct GatedInserterTests {
|
struct RoutingInserterTests {
|
||||||
@Test func insertsWhenAllowed() async throws {
|
@Test func sendsToTheSystemRoute() async throws {
|
||||||
let inner = FakeInserter()
|
let system = FakeInserter(), editor = FakeInserter()
|
||||||
try await GatedInserter(inner: inner, shouldInsert: { true }).insert("hallo")
|
try await RoutingInserter(system: system, editor: editor, route: { .system }).insert("hallo")
|
||||||
#expect(inner.inserted == ["hallo"])
|
#expect(system.inserted == ["hallo"])
|
||||||
|
#expect(editor.inserted.isEmpty)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func staysSilentWhenNotAllowed() async throws {
|
@Test func sendsToTheEditorRoute() async throws {
|
||||||
let inner = FakeInserter()
|
let system = FakeInserter(), editor = FakeInserter()
|
||||||
try await GatedInserter(inner: inner, shouldInsert: { false }).insert("hallo")
|
try await RoutingInserter(system: system, editor: editor, route: { .editor }).insert("hallo")
|
||||||
#expect(inner.inserted.isEmpty)
|
#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 {
|
@Test func askedAtInsertionTimeNotAtCreation() async throws {
|
||||||
let inner = FakeInserter()
|
let system = FakeInserter(), editor = FakeInserter()
|
||||||
var allowed = false
|
var current = InsertionRoute.none
|
||||||
let gated = GatedInserter(inner: inner, shouldInsert: { allowed })
|
let router = RoutingInserter(system: system, editor: editor, route: { current })
|
||||||
allowed = true
|
current = .editor
|
||||||
try await gated.insert("nu wel")
|
try await router.insert("nu wel")
|
||||||
#expect(inner.inserted == ["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")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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") == "")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user