feat: cursor navigation, key sounds, dictionary tooling, key security
Batch of in-progress app work from the working tree. - feat(keyboard): CursorNavigation + CursorDragPad for caret movement; KeyboardSoundFeedback for system key click sounds - feat(dictionary): DictionaryAliasGenerator + PersonalDictionaryEntrySheet; TranscriptPostProcessor quality gate; retire DictionaryLearner - feat(ui): TabBarVisibility handling; drop PageHeaderRow / PageHeaderConfirmButton; refresh views and localizable strings - fix(security): move the hardcoded DeepSeek key out of PreconfiguredKeys.swift into a gitignored PreconfiguredKeys.local.swift (seeded from .example by generate-xcodeproj.sh) - docs(agents): add Conventional Commits versioning + bilingual changelog rules - chore(gitignore): ignore PreconfiguredKeys.local.swift, .cache/, pycache Custom language model / lexicon work stays on feature/custom-language-model-asr. Changelog bullets added under [Unreleased]; no version bump.
This commit is contained in:
@@ -0,0 +1,328 @@
|
||||
// CursorNavigation.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Pure helpers for moving the text caret from the keyboard extension.
|
||||
// Horizontal moves are character-accurate. Vertical moves jump between
|
||||
// *visual* lines — hard `\n` breaks and soft wraps.
|
||||
//
|
||||
// First-principles note: a keyboard extension only sees a bounded text
|
||||
// window (`documentContext{Before,After}Input`) and can only actuate via
|
||||
// `adjustTextPosition(byCharacterOffset:)`. It has NO access to the host
|
||||
// field's font, width, or caret rect, so soft-wrap positions are
|
||||
// fundamentally unknowable and must be *estimated*. We reduce the visible
|
||||
// error two ways: (1) exact handling of hard `\n`; (2) an injectable
|
||||
// per-character width so the extension can feed real font metrics (killing
|
||||
// the i-vs-W column drift that a fixed 1/2 table causes). The wrap width
|
||||
// itself stays a calibrated estimate.
|
||||
|
||||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
public enum CursorNavigation {
|
||||
|
||||
/// Advance width of a single character, in an arbitrary but consistent
|
||||
/// unit (points when backed by real font metrics; abstract "units" for
|
||||
/// the built-in default). Must be paired with a `lineWidth` in the same
|
||||
/// unit.
|
||||
public typealias CharacterWidth = @Sendable (Character) -> CGFloat
|
||||
|
||||
// MARK: - Layout config
|
||||
|
||||
/// Describes how text wraps into visual lines. `lineWidth` and the values
|
||||
/// returned by `widthOf` must share the same unit.
|
||||
public struct VisualLineLayoutConfig: Sendable {
|
||||
/// Wrap threshold: max total width of one visual line.
|
||||
public let lineWidth: CGFloat
|
||||
/// Per-character advance width provider.
|
||||
public let widthOf: CharacterWidth
|
||||
|
||||
public init(
|
||||
lineWidth: CGFloat,
|
||||
widthOf: @escaping CharacterWidth = CursorNavigation.defaultDisplayWidth
|
||||
) {
|
||||
self.lineWidth = max(1, lineWidth)
|
||||
self.widthOf = widthOf
|
||||
}
|
||||
|
||||
/// Conservative default when no field width is known.
|
||||
public static let fallback = VisualLineLayoutConfig(lineWidth: 44)
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Legacy logical column (chars since last `\n`). Kept for tests.
|
||||
public static func column(before: String?) -> Int {
|
||||
guard let before, !before.isEmpty else { return 0 }
|
||||
if let lastNewline = before.lastIndex(of: "\n") {
|
||||
return before.distance(from: before.index(after: lastNewline), to: before.endIndex)
|
||||
}
|
||||
return before.count
|
||||
}
|
||||
|
||||
/// Display-column offset (in `widthOf` units) on the current visual line.
|
||||
public static func visualDisplayColumn(
|
||||
before: String?,
|
||||
after: String?,
|
||||
config: VisualLineLayoutConfig
|
||||
) -> CGFloat {
|
||||
let text = mergedContext(before: before, after: after)
|
||||
let cursor = before?.count ?? 0
|
||||
let layout = VisualLineLayout(text: text, config: config)
|
||||
let lineStart = layout.lineStart(containing: cursor)
|
||||
return layout.width(from: lineStart, to: cursor)
|
||||
}
|
||||
|
||||
/// One visual line up. Returns caret offset and the display column to
|
||||
/// keep sticky for the rest of this vertical drag.
|
||||
public static func visualLineUpOffset(
|
||||
before: String?,
|
||||
after: String?,
|
||||
preferredDisplayColumn: CGFloat?,
|
||||
config: VisualLineLayoutConfig
|
||||
) -> (offset: Int, stickyColumn: CGFloat)? {
|
||||
let text = mergedContext(before: before, after: after)
|
||||
let cursor = before?.count ?? 0
|
||||
let layout = VisualLineLayout(text: text, config: config)
|
||||
|
||||
guard let currentLine = layout.lineIndex(containing: cursor), currentLine > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let sticky = preferredDisplayColumn
|
||||
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
|
||||
let previousStart = layout.lineStarts[currentLine - 1]
|
||||
let previousEnd = layout.lineStarts[currentLine]
|
||||
let target = layout.offset(
|
||||
onLineStartingAt: previousStart,
|
||||
lineEndingBefore: previousEnd,
|
||||
displayColumn: sticky
|
||||
)
|
||||
let offset = target - cursor
|
||||
guard offset != 0 else { return nil }
|
||||
return (offset, sticky)
|
||||
}
|
||||
|
||||
/// One visual line down.
|
||||
public static func visualLineDownOffset(
|
||||
before: String?,
|
||||
after: String?,
|
||||
preferredDisplayColumn: CGFloat?,
|
||||
config: VisualLineLayoutConfig
|
||||
) -> (offset: Int, stickyColumn: CGFloat)? {
|
||||
let text = mergedContext(before: before, after: after)
|
||||
let cursor = before?.count ?? 0
|
||||
let layout = VisualLineLayout(text: text, config: config)
|
||||
|
||||
guard let currentLine = layout.lineIndex(containing: cursor) else { return nil }
|
||||
guard currentLine + 1 < layout.lineStarts.count else { return nil }
|
||||
|
||||
let sticky = preferredDisplayColumn
|
||||
?? layout.width(from: layout.lineStarts[currentLine], to: cursor)
|
||||
let nextStart = layout.lineStarts[currentLine + 1]
|
||||
let nextEnd = currentLine + 2 < layout.lineStarts.count
|
||||
? layout.lineStarts[currentLine + 2]
|
||||
: text.count
|
||||
let target = layout.offset(
|
||||
onLineStartingAt: nextStart,
|
||||
lineEndingBefore: nextEnd,
|
||||
displayColumn: sticky
|
||||
)
|
||||
let offset = target - cursor
|
||||
guard offset != 0 else { return nil }
|
||||
return (offset, sticky)
|
||||
}
|
||||
|
||||
// MARK: - Default width table
|
||||
|
||||
/// Crude fallback advance width: wide scripts count double, everything
|
||||
/// else single. Used by tests and when real metrics are unavailable.
|
||||
public static func defaultDisplayWidth(_ character: Character) -> CGFloat {
|
||||
guard let scalar = character.unicodeScalars.first else { return 1 }
|
||||
if character == "\n" { return 0 }
|
||||
if character == "\t" { return 4 }
|
||||
if isWide(scalar) { return 2 }
|
||||
return 1
|
||||
}
|
||||
|
||||
private static func isWide(_ scalar: UnicodeScalar) -> Bool {
|
||||
let value = scalar.value
|
||||
return (0x1100...0x115F).contains(value) // Hangul Jamo
|
||||
|| (0x2E80...0xA4CF).contains(value) // CJK radicals, symbols, bopomofo, yi
|
||||
|| (0xAC00...0xD7A3).contains(value) // Hangul syllables
|
||||
|| (0xF900...0xFAFF).contains(value) // CJK compatibility
|
||||
|| (0xFE10...0xFE1F).contains(value) // vertical forms
|
||||
|| (0xFE30...0xFE6F).contains(value) // CJK compatibility forms
|
||||
|| (0xFF00...0xFF60).contains(value) // fullwidth
|
||||
|| (0xFFE0...0xFFE6).contains(value) // fullwidth symbols
|
||||
|| (0x20000...0x2FFFF).contains(value) // CJK extension planes
|
||||
|| (0x30000...0x3FFFF).contains(value)
|
||||
}
|
||||
|
||||
// MARK: - Internals
|
||||
|
||||
private static func mergedContext(before: String?, after: String?) -> String {
|
||||
(before ?? "") + (after ?? "")
|
||||
}
|
||||
|
||||
// MARK: - Visual line layout
|
||||
|
||||
struct VisualLineLayout {
|
||||
let text: String
|
||||
let widthOf: CharacterWidth
|
||||
let lineStarts: [Int]
|
||||
|
||||
init(text: String, config: VisualLineLayoutConfig) {
|
||||
self.text = text
|
||||
self.widthOf = config.widthOf
|
||||
self.lineStarts = Self.computeLineStarts(
|
||||
in: text,
|
||||
maxWidth: config.lineWidth,
|
||||
widthOf: config.widthOf
|
||||
)
|
||||
}
|
||||
|
||||
func lineIndex(containing offset: Int) -> Int? {
|
||||
guard !lineStarts.isEmpty else { return nil }
|
||||
for index in lineStarts.indices.reversed() where offset >= lineStarts[index] {
|
||||
return index
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lineStart(containing offset: Int) -> Int {
|
||||
lineIndex(containing: offset).map { lineStarts[$0] } ?? 0
|
||||
}
|
||||
|
||||
func width(from start: Int, to end: Int) -> CGFloat {
|
||||
guard start < end, end <= text.count else { return 0 }
|
||||
let startIndex = text.index(text.startIndex, offsetBy: start)
|
||||
let endIndex = text.index(text.startIndex, offsetBy: end)
|
||||
var total: CGFloat = 0
|
||||
var index = startIndex
|
||||
while index < endIndex {
|
||||
total += widthOf(text[index])
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func offset(
|
||||
onLineStartingAt lineStart: Int,
|
||||
lineEndingBefore lineEnd: Int,
|
||||
displayColumn: CGFloat
|
||||
) -> Int {
|
||||
guard lineStart <= lineEnd, lineEnd <= text.count else { return lineStart }
|
||||
let startIndex = text.index(text.startIndex, offsetBy: lineStart)
|
||||
let endIndex = text.index(text.startIndex, offsetBy: lineEnd)
|
||||
var total: CGFloat = 0
|
||||
var index = startIndex
|
||||
while index < endIndex {
|
||||
let advance = widthOf(text[index])
|
||||
if total + advance > displayColumn { break }
|
||||
total += advance
|
||||
index = text.index(after: index)
|
||||
}
|
||||
return text.distance(from: text.startIndex, to: index)
|
||||
}
|
||||
|
||||
private static func computeLineStarts(
|
||||
in text: String,
|
||||
maxWidth: CGFloat,
|
||||
widthOf: CharacterWidth
|
||||
) -> [Int] {
|
||||
guard !text.isEmpty else { return [0] }
|
||||
|
||||
var starts: [Int] = [0]
|
||||
var lineWidth: CGFloat = 0
|
||||
var lineStart = text.startIndex
|
||||
var lastBreak: String.Index?
|
||||
|
||||
var index = text.startIndex
|
||||
while index < text.endIndex {
|
||||
let character = text[index]
|
||||
|
||||
if character == "\n" {
|
||||
let next = text.index(after: index)
|
||||
let nextOffset = text.distance(from: text.startIndex, to: next)
|
||||
if starts.last != nextOffset {
|
||||
starts.append(nextOffset)
|
||||
}
|
||||
lineStart = next
|
||||
lineWidth = 0
|
||||
lastBreak = nil
|
||||
index = next
|
||||
continue
|
||||
}
|
||||
|
||||
let advance = widthOf(character)
|
||||
if character == " " || character == "\t" {
|
||||
lastBreak = index
|
||||
}
|
||||
|
||||
if lineWidth + advance > maxWidth, index > lineStart {
|
||||
let breakIndex: String.Index
|
||||
if let lastBreak, lastBreak > lineStart {
|
||||
breakIndex = text.index(after: lastBreak)
|
||||
} else {
|
||||
breakIndex = index
|
||||
}
|
||||
let breakOffset = text.distance(from: text.startIndex, to: breakIndex)
|
||||
if starts.last != breakOffset {
|
||||
starts.append(breakOffset)
|
||||
}
|
||||
lineStart = breakIndex
|
||||
lineWidth = 0
|
||||
lastBreak = nil
|
||||
if breakIndex == index {
|
||||
lineWidth = advance
|
||||
index = text.index(after: index)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
lineWidth += advance
|
||||
index = text.index(after: index)
|
||||
}
|
||||
|
||||
return starts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
import UIKit
|
||||
|
||||
/// Real-font per-character advance widths (in points) for cursor visual-line
|
||||
/// navigation. Caches measurements so repeated drag samples are cheap.
|
||||
///
|
||||
/// Absolute values assume a ~17 pt body font; only the *ratios* between
|
||||
/// glyphs (and between a glyph and the field width) matter for column
|
||||
/// fidelity, so a reference font is sufficient to eliminate the fixed-width
|
||||
/// column drift.
|
||||
///
|
||||
/// Not actor-isolated on purpose: the width closure is invoked synchronously
|
||||
/// from the nonisolated `CursorNavigation` layout code. A lock guards the
|
||||
/// cache so `@unchecked Sendable` is safe.
|
||||
public final class CursorGlyphMetrics: @unchecked Sendable {
|
||||
public static let shared = CursorGlyphMetrics()
|
||||
|
||||
private let font = UIFont.systemFont(ofSize: 17)
|
||||
private let lock = NSLock()
|
||||
private var cache: [Character: CGFloat] = [:]
|
||||
|
||||
public init() {}
|
||||
|
||||
public func width(of character: Character) -> CGFloat {
|
||||
if character == "\n" { return 0 }
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if let cached = cache[character] { return cached }
|
||||
let measured = (String(character) as NSString)
|
||||
.size(withAttributes: [.font: font])
|
||||
.width
|
||||
let width = measured > 0 ? measured : font.pointSize * 0.5
|
||||
cache[character] = width
|
||||
return width
|
||||
}
|
||||
}
|
||||
#endif
|
||||
Reference in New Issue
Block a user