Files
sst/Sources/SstData/ViewSupport.swift
T

186 lines
6.3 KiB
Swift

import Combine
import Foundation
import SstCore
/// "mm:ss.t" for the counter.
public enum StopwatchFormat {
public static func string(_ seconds: Double) -> String {
let tenths = Int(max(0, seconds) * 10)
return String(format: "%02d:%02d.%d", (tenths / 600) % 100, (tenths / 10) % 60, tenths % 10)
}
}
public struct TextSegment: Equatable, Sendable {
public let text: String
/// Set when this piece was written by the dictionary correction.
public let correction: AppliedCorrection?
public init(text: String, correction: AppliedCorrection? = nil) {
self.text = text
self.correction = correction
}
}
public enum CorrectionMarkup {
/// Splits `finalText` into plain and corrected pieces. Offsets are UTF-16; out-of-range or overlapping ones are skipped.
public static func segments(finalText: String, corrections: [AppliedCorrection]) -> [TextSegment] {
let ns = finalText as NSString
guard ns.length > 0 else { return [] }
var result: [TextSegment] = []
var cursor = 0
for correction in corrections.sorted(by: { $0.start < $1.start }) {
let end = correction.start + correction.length
guard correction.length > 0, correction.start >= cursor, end <= ns.length else { continue }
if correction.start > cursor {
result.append(TextSegment(text: ns.substring(with: NSRange(location: cursor, length: correction.start - cursor))))
}
result.append(TextSegment(text: ns.substring(with: NSRange(location: correction.start, length: correction.length)), correction: correction))
cursor = end
}
if cursor < ns.length {
result.append(TextSegment(text: ns.substring(from: cursor)))
}
return result
}
}
/// The searchable, day-grouped list behind the transcripts view.
@MainActor
public final class HistoryViewModel: ObservableObject {
private let store: HistoryStore
private let calendar: Calendar
@Published public var query = "" {
didSet { refresh() }
}
@Published public var selection: UUID?
@Published public private(set) var days: [HistoryDay] = []
@Published public private(set) var totalCount = 0
public init(store: HistoryStore, calendar: Calendar = .current) {
self.store = store
self.calendar = calendar
refresh()
}
public func refresh() {
let visible = HistorySearch.filter(store.entries, query: query)
days = HistoryGrouping.byDay(visible, calendar: calendar)
totalCount = store.entries.count
if let selection, !visible.contains(where: { $0.id == selection }) { self.selection = nil }
}
public var selectedEntry: HistoryEntry? {
store.entries.first { $0.id == selection }
}
public func delete(id: UUID) {
store.remove(id: id)
refresh()
}
}
/// Dictionary rows with warnings, search, editing and undo.
@MainActor
public final class DictionaryViewModel: ObservableObject {
public struct Row: Identifiable, Equatable {
public let id: UUID
public let entry: DictionaryEntry
public let warnings: [EntryWarning]
public var worst: EntryWarning.Level? { warnings.map(\.level).max() }
}
private let store: DictionaryStore
private let knowledge: WordKnowledge
private var undoStack: [DictionaryFile] = []
@Published public var query = "" {
didSet { applyFilter() }
}
@Published public private(set) var rows: [Row] = []
@Published public private(set) var visibleRows: [Row] = []
@Published public private(set) var canUndo = false
@Published public var errorMessage: String?
public init(store: DictionaryStore, knowledge: WordKnowledge) {
self.store = store
self.knowledge = knowledge
reload()
}
/// Rebuilds the rows from the store (call after the store changed).
public func reload() {
let entries = store.entries
rows = entries.enumerated().map { index, entry in
// Only earlier entries count as "already there", so a duplicate is flagged on the later one.
Row(id: entry.id, entry: entry,
warnings: EntryWarnings.check(entry.kind, among: Array(entries[..<index]), knowledge: knowledge))
}
applyFilter()
}
private func applyFilter() {
let words = HistorySearch.fold(query).split(whereSeparator: \.isWhitespace).map(String.init)
guard !words.isEmpty else {
visibleRows = rows
return
}
visibleRows = rows.filter { row in
let haystack = HistorySearch.fold(row.entry.source + " " + row.entry.wrote)
return words.allSatisfy { haystack.contains($0) }
}
}
/// Live feedback for the editor; `id` is the entry being edited (nil for a new one).
public func warnings(for kind: DictionaryEntry.Kind, editing id: UUID?) -> [EntryWarning] {
let entries = store.entries
let others: [DictionaryEntry]
if let id, let index = entries.firstIndex(where: { $0.id == id }) {
others = Array(entries[..<index])
} else {
others = entries
}
return EntryWarnings.check(kind, among: others, knowledge: knowledge)
}
@discardableResult
public func add(_ kind: DictionaryEntry.Kind) -> UUID? {
var created: UUID?
mutate { created = $0.append(kind) }
return created
}
public func update(id: UUID, to kind: DictionaryEntry.Kind) {
mutate { $0.update(id: id, kind: kind) }
}
public func remove(id: UUID) {
guard store.entries.contains(where: { $0.id == id }) else { return }
mutate { $0.remove(id: id) }
}
public func undo() {
guard let snapshot = undoStack.popLast() else { return }
canUndo = !undoStack.isEmpty
do {
try store.apply { $0 = snapshot }
} catch {
errorMessage = error.localizedDescription
}
reload()
}
private func mutate(_ change: (inout DictionaryFile) -> Void) {
let snapshot = store.file
do {
try store.apply(change)
undoStack.append(snapshot)
canUndo = true
} catch {
errorMessage = error.localizedDescription
}
reload()
}
}