fix(ipad): ship iPad P0 layout/globe fixes, edit-last-input, drop clipboard commands
Adapt typing/voice surfaces for iPad width and height, add the system globe key and last-input editing flow, harden host-only Rime deployment, and remove clipboard voice commands. Bump build to 61.
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
// EditSessionState.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Closed state machine for long-press editing. Associated values make invalid
|
||||
// combinations (for example "reviewing with no result") unrepresentable.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct EditSessionSource: Equatable, Sendable {
|
||||
public let reference: EditableInputReference
|
||||
public let generation: UUID
|
||||
|
||||
public init(reference: EditableInputReference, generation: UUID = UUID()) {
|
||||
self.reference = reference
|
||||
self.generation = generation
|
||||
}
|
||||
}
|
||||
|
||||
public struct EditReview: Equatable, Sendable {
|
||||
public let source: EditSessionSource
|
||||
public let resultText: String
|
||||
public let utteranceID: UUID
|
||||
|
||||
public init(source: EditSessionSource, resultText: String, utteranceID: UUID) {
|
||||
self.source = source
|
||||
self.resultText = resultText
|
||||
self.utteranceID = utteranceID
|
||||
}
|
||||
}
|
||||
|
||||
public enum EditSessionState: Equatable, Sendable {
|
||||
case inactive
|
||||
case preparing(EditSessionSource)
|
||||
case listening(EditSessionSource)
|
||||
case processing(EditSessionSource)
|
||||
case review(EditReview)
|
||||
case applying(EditReview)
|
||||
case appending(EditReview)
|
||||
case failed(EditSessionSource, message: String)
|
||||
|
||||
public var isActive: Bool {
|
||||
if case .inactive = self { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
public var source: EditSessionSource? {
|
||||
switch self {
|
||||
case .inactive:
|
||||
return nil
|
||||
case .preparing(let source),
|
||||
.listening(let source),
|
||||
.processing(let source),
|
||||
.failed(let source, _):
|
||||
return source
|
||||
case .review(let review),
|
||||
.applying(let review),
|
||||
.appending(let review):
|
||||
return review.source
|
||||
}
|
||||
}
|
||||
|
||||
public var review: EditReview? {
|
||||
switch self {
|
||||
case .review(let review), .applying(let review), .appending(let review):
|
||||
return review
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
// EditableInputReference.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// A short-lived, cross-process-safe reference to the last text the keyboard
|
||||
// actually inserted. It is material for explicit voice editing, not history.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct EditableInputReference: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 1
|
||||
public static let lifetime: TimeInterval = 10 * 60
|
||||
/// Initial safety budget. Device benchmarks may lower this value.
|
||||
public static let maxEditableGraphemes = 1_200
|
||||
|
||||
public let schemaVersion: Int
|
||||
public let targetID: UUID
|
||||
public let historyEntryID: UUID?
|
||||
public let historyEntryRevision: Int64?
|
||||
public let pendingHistoryMutationID: UUID?
|
||||
public let displayText: String
|
||||
/// Exact host-field insertion, including any leading separator.
|
||||
public let insertedText: String
|
||||
public let postInsertionFingerprint: String?
|
||||
public let extensionInstanceID: UUID
|
||||
public let observedDocumentRevision: Int64
|
||||
public let createdAt: TimeInterval
|
||||
public let expiresAt: TimeInterval
|
||||
|
||||
public init(
|
||||
targetID: UUID = UUID(),
|
||||
historyEntryID: UUID? = nil,
|
||||
historyEntryRevision: Int64? = nil,
|
||||
pendingHistoryMutationID: UUID? = nil,
|
||||
displayText: String,
|
||||
insertedText: String,
|
||||
postInsertionFingerprint: String?,
|
||||
extensionInstanceID: UUID,
|
||||
observedDocumentRevision: Int64 = 0,
|
||||
createdAt: TimeInterval = Date().timeIntervalSince1970
|
||||
) {
|
||||
self.schemaVersion = Self.schemaVersion
|
||||
self.targetID = targetID
|
||||
self.historyEntryID = historyEntryID
|
||||
self.historyEntryRevision = historyEntryRevision
|
||||
self.pendingHistoryMutationID = pendingHistoryMutationID
|
||||
self.displayText = displayText
|
||||
self.insertedText = insertedText
|
||||
self.postInsertionFingerprint = postInsertionFingerprint
|
||||
self.extensionInstanceID = extensionInstanceID
|
||||
self.observedDocumentRevision = observedDocumentRevision
|
||||
self.createdAt = createdAt
|
||||
self.expiresAt = createdAt + Self.lifetime
|
||||
}
|
||||
|
||||
public var isWithinLengthBudget: Bool {
|
||||
!displayText.isEmpty && displayText.count <= Self.maxEditableGraphemes
|
||||
}
|
||||
|
||||
public func isExpired(at now: TimeInterval = Date().timeIntervalSince1970) -> Bool {
|
||||
now >= expiresAt
|
||||
}
|
||||
|
||||
/// Rebuilt extensions must prove the entire insertion is still at the caret.
|
||||
public func isFullyVerified(
|
||||
contextBeforeInput: String?,
|
||||
fieldFingerprint: String?
|
||||
) -> Bool {
|
||||
guard !isExpired(), isWithinLengthBudget,
|
||||
let contextBeforeInput,
|
||||
contextBeforeInput.hasSuffix(insertedText) else {
|
||||
return false
|
||||
}
|
||||
guard let expected = postInsertionFingerprint else { return true }
|
||||
return fieldFingerprint == expected
|
||||
}
|
||||
}
|
||||
|
||||
public enum EditableInputReferenceStore {
|
||||
private static let key = "editLastInput.reference.v1"
|
||||
|
||||
public static func load(
|
||||
defaults: UserDefaults? = nil,
|
||||
now: TimeInterval = Date().timeIntervalSince1970
|
||||
) -> EditableInputReference? {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable,
|
||||
let data = store.data(forKey: key),
|
||||
let reference = try? JSONDecoder().decode(EditableInputReference.self, from: data)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
guard !reference.isExpired(at: now) else {
|
||||
clear(defaults: store)
|
||||
return nil
|
||||
}
|
||||
return reference
|
||||
}
|
||||
|
||||
public static func save(
|
||||
_ reference: EditableInputReference,
|
||||
defaults: UserDefaults? = nil
|
||||
) {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable,
|
||||
let data = try? JSONEncoder().encode(reference) else {
|
||||
return
|
||||
}
|
||||
store.set(data, forKey: key)
|
||||
store.synchronize()
|
||||
}
|
||||
|
||||
public static func clear(defaults: UserDefaults? = nil) {
|
||||
guard let store = defaults ?? AppGroup.defaultsIfAvailable else { return }
|
||||
store.removeObject(forKey: key)
|
||||
store.synchronize()
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,36 @@
|
||||
// FlowUtteranceMode.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Distinguishes dictation polish from clipboard-command generation on the
|
||||
// Flow command / result wire (plan §11).
|
||||
// Distinguishes dictation polish from explicit last-input editing on the
|
||||
// Flow command / result wire.
|
||||
|
||||
import Foundation
|
||||
|
||||
public enum FlowUtteranceMode: String, Codable, Equatable, Sendable {
|
||||
/// ASR is draft text to polish and insert (default / legacy).
|
||||
case dictation
|
||||
/// ASR is an instruction over a frozen clipboard snapshot.
|
||||
case clipboardCommand
|
||||
/// ASR is an explicit instruction over the last verified OSG insertion.
|
||||
case editLastInput
|
||||
/// Decoded only from retired or unknown wire modes. Production code must
|
||||
/// reject this value and must never treat it as dictation.
|
||||
case unsupportedLegacy
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
switch try container.decode(String.self) {
|
||||
case Self.dictation.rawValue:
|
||||
self = .dictation
|
||||
case Self.editLastInput.rawValue:
|
||||
self = .editLastInput
|
||||
case "clipboardCommand", Self.unsupportedLegacy.rawValue:
|
||||
self = .unsupportedLegacy
|
||||
default:
|
||||
self = .unsupportedLegacy
|
||||
}
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
try container.encode(rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// FlowUtteranceRequest.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// One keyboard-side start contract for dictation and explicit editing.
|
||||
|
||||
import Foundation
|
||||
|
||||
public struct FlowUtteranceRequest: Equatable, Sendable {
|
||||
public let mode: FlowUtteranceMode
|
||||
public let editSourceText: String?
|
||||
public let sourceHistoryEntryID: UUID?
|
||||
public let sourceHistoryEntryRevision: Int64?
|
||||
|
||||
public static let dictation = FlowUtteranceRequest(mode: .dictation)
|
||||
|
||||
public init(
|
||||
mode: FlowUtteranceMode,
|
||||
editSourceText: String? = nil,
|
||||
sourceHistoryEntryID: UUID? = nil,
|
||||
sourceHistoryEntryRevision: Int64? = nil
|
||||
) {
|
||||
self.mode = mode
|
||||
self.editSourceText = editSourceText
|
||||
self.sourceHistoryEntryID = sourceHistoryEntryID
|
||||
self.sourceHistoryEntryRevision = sourceHistoryEntryRevision
|
||||
}
|
||||
|
||||
public static func editLastInput(
|
||||
_ reference: EditableInputReference
|
||||
) -> FlowUtteranceRequest {
|
||||
FlowUtteranceRequest(
|
||||
mode: .editLastInput,
|
||||
editSourceText: reference.displayText,
|
||||
sourceHistoryEntryID: reference.historyEntryID,
|
||||
sourceHistoryEntryRevision: reference.historyEntryRevision
|
||||
)
|
||||
}
|
||||
|
||||
public var isEdit: Bool { mode == .editLastInput }
|
||||
}
|
||||
|
||||
public enum FlowUtteranceStartRejection: Equatable, Sendable {
|
||||
case pipelineBusy
|
||||
case onboardingIncomplete
|
||||
case missingAPIKey
|
||||
case noFullAccess
|
||||
case appGroupUnavailable
|
||||
case hostUnavailable
|
||||
}
|
||||
|
||||
public enum FlowUtteranceStartDisposition: Equatable, Sendable {
|
||||
case issued(UUID)
|
||||
case waitingForHost(UUID)
|
||||
case alreadyInFlight(UUID)
|
||||
case rejected(FlowUtteranceStartRejection)
|
||||
|
||||
public var utteranceID: UUID? {
|
||||
switch self {
|
||||
case .issued(let id), .waitingForHost(let id), .alreadyInFlight(let id):
|
||||
return id
|
||||
case .rejected:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,20 +9,123 @@ public enum KeyboardChromeLayout {
|
||||
public static let totalHeight: CGFloat = 281
|
||||
public static let actionKeyHeight: CGFloat = 50
|
||||
public static let actionKeyCornerRadius: CGFloat = 10
|
||||
/// Shared geometry for every three-key bottom row.
|
||||
/// Shared spacing for every bottom action row. iPad uses a custom globe
|
||||
/// slot; iPhone relies on the system-provided switch below the keyboard.
|
||||
public static let actionKeySpacing: CGFloat = 8
|
||||
public static let sideActionKeyFraction: CGFloat = 0.2
|
||||
public static let centerActionKeyFraction: CGFloat = 0.6
|
||||
public static let horizontalInset: CGFloat = 8
|
||||
/// Keeps voice and typing controls equally reachable on iPad.
|
||||
public static let contentMaxWidth: CGFloat = 700
|
||||
/// Globe (🌐) key — small, icon-only.
|
||||
public static let globeActionKeyFraction: CGFloat = 0.12
|
||||
/// Outer side key — pageSwitch / delete.
|
||||
public static let sideActionKeyFraction: CGFloat = 0.18
|
||||
/// Center key — space (typing) or return (voice). Widest in the row.
|
||||
public static let centerActionKeyFraction: CGFloat = 0.50
|
||||
/// Other outer side key — return (typing) or space/delete (voice).
|
||||
public static let side2ActionKeyFraction: CGFloat = 0.20
|
||||
/// iPad typing bottom row: `[globe · 123 · , · space · . · return]`.
|
||||
///
|
||||
/// The four-slot phone row gives the centre key 50% of the width, which on
|
||||
/// a full-width landscape iPad turns the space bar into a ~660 pt runway.
|
||||
/// The system keyboard spends that width on more keys instead, so iPad
|
||||
/// gains comma / period and space settles near the system's ~430 pt.
|
||||
public static let iPadGlobeFraction: CGFloat = 0.09
|
||||
public static let iPadPageSwitchFraction: CGFloat = 0.13
|
||||
public static let iPadPunctuationFraction: CGFloat = 0.09
|
||||
public static let iPadSpaceFraction: CGFloat = 0.38
|
||||
public static let iPadReturnFraction: CGFloat = 0.22
|
||||
/// Five gaps separate the six iPad slots.
|
||||
public static let iPadActionKeyGapCount: CGFloat = 5
|
||||
|
||||
/// Splits the width left after spacing into a 20 / 60 / 20 row.
|
||||
public static func actionKeyWidths(availableWidth: CGFloat) -> (side: CGFloat, center: CGFloat) {
|
||||
let keyWidth = max(0, availableWidth - actionKeySpacing * 2)
|
||||
public static let horizontalInset: CGFloat = 8
|
||||
/// Voice-surface content column cap.
|
||||
///
|
||||
/// The voice surface is a sparse cluster — two cursor-drag pads flanking a
|
||||
/// fixed 121 pt mic — over a transparent background, so filling an iPad's
|
||||
/// width buys no visual width; it only parks delete/return at the screen
|
||||
/// edges and turns each drag pad into a ~450 pt runway. The typing surface
|
||||
/// has the opposite need (a key grid must fill the width to match the
|
||||
/// system keyboard), which is why it no longer shares this constant.
|
||||
public static let voiceContentMaxWidth: CGFloat = 700
|
||||
|
||||
/// Width at or above which the typing surface switches to wide-iPad
|
||||
/// metrics (taller rows). Chosen to sit between the widest iPad portrait
|
||||
/// width (1024 pt on 13") and the narrowest landscape width (1133 pt on
|
||||
/// mini), so it tracks real available width rather than device orientation
|
||||
/// — Stage Manager and Split View resize into the right bucket for free.
|
||||
public static let wideIPadWidthThreshold: CGFloat = 1100
|
||||
|
||||
/// Uses iPad-scale metrics only when the host is both an iPad and currently
|
||||
/// exposes regular horizontal space. Compact Split View / Slide Over keeps
|
||||
/// the phone-scale layout, while wide iPhones never opt into iPad metrics.
|
||||
public static func usesIPadMetrics(isPad: Bool, hasRegularWidth: Bool) -> Bool {
|
||||
isPad && hasRegularWidth
|
||||
}
|
||||
|
||||
/// Wide metrics need iPad-scale keys *and* enough width to justify them.
|
||||
public static func usesWideIPadMetrics(isIPad: Bool, width: CGFloat) -> Bool {
|
||||
isIPad && width >= wideIPadWidthThreshold
|
||||
}
|
||||
|
||||
/// Splits the width left after three gaps into a 12 / 18 / 50 / 20 row for
|
||||
/// layouts that include a globe key, with a wide centre and balanced sides.
|
||||
public static func actionKeyWidths(availableWidth: CGFloat) -> (globe: CGFloat, side: CGFloat, center: CGFloat, side2: CGFloat) {
|
||||
let keyWidth = max(0, availableWidth - actionKeySpacing * 3)
|
||||
return (
|
||||
globe: keyWidth * globeActionKeyFraction,
|
||||
side: keyWidth * sideActionKeyFraction,
|
||||
center: keyWidth * centerActionKeyFraction
|
||||
center: keyWidth * centerActionKeyFraction,
|
||||
side2: keyWidth * side2ActionKeyFraction
|
||||
)
|
||||
}
|
||||
|
||||
/// iPhone bottom row `[side · center · side2]`. Preserve the established
|
||||
/// side/centre balance while redistributing the removed globe slot.
|
||||
public static func actionKeyWidthsWithoutGlobe(
|
||||
availableWidth: CGFloat
|
||||
) -> (side: CGFloat, center: CGFloat, side2: CGFloat) {
|
||||
let keyWidth = max(0, availableWidth - actionKeySpacing * 2)
|
||||
let fractionTotal = sideActionKeyFraction
|
||||
+ centerActionKeyFraction
|
||||
+ side2ActionKeyFraction
|
||||
return (
|
||||
side: keyWidth * sideActionKeyFraction / fractionTotal,
|
||||
center: keyWidth * centerActionKeyFraction / fractionTotal,
|
||||
side2: keyWidth * side2ActionKeyFraction / fractionTotal
|
||||
)
|
||||
}
|
||||
|
||||
/// iPad voice bottom row `[globe · delete · return · space]`. The phone's
|
||||
/// 12/18/50/20 split would hand the centre key ~577 pt once the surface
|
||||
/// spans an iPad; these fractions keep every key in a usable range while
|
||||
/// giving the primary return action a little more room.
|
||||
public static let iPadVoiceGlobeFraction: CGFloat = 0.10
|
||||
public static let iPadVoiceSideFraction: CGFloat = 0.24
|
||||
public static let iPadVoiceCenterFraction: CGFloat = 0.40
|
||||
public static let iPadVoiceSide2Fraction: CGFloat = 0.26
|
||||
|
||||
/// iPad variant of `actionKeyWidths` for the voice surface.
|
||||
public static func iPadVoiceActionKeyWidths(
|
||||
availableWidth: CGFloat
|
||||
) -> (globe: CGFloat, side: CGFloat, center: CGFloat, side2: CGFloat) {
|
||||
let keyWidth = max(0, availableWidth - actionKeySpacing * 3)
|
||||
return (
|
||||
globe: keyWidth * iPadVoiceGlobeFraction,
|
||||
side: keyWidth * iPadVoiceSideFraction,
|
||||
center: keyWidth * iPadVoiceCenterFraction,
|
||||
side2: keyWidth * iPadVoiceSide2Fraction
|
||||
)
|
||||
}
|
||||
|
||||
/// Six-slot iPad variant of `actionKeyWidths`.
|
||||
public static func iPadActionKeyWidths(
|
||||
availableWidth: CGFloat
|
||||
) -> (globe: CGFloat, pageSwitch: CGFloat, comma: CGFloat, space: CGFloat, period: CGFloat, return: CGFloat) {
|
||||
let keyWidth = max(0, availableWidth - actionKeySpacing * iPadActionKeyGapCount)
|
||||
return (
|
||||
globe: keyWidth * iPadGlobeFraction,
|
||||
pageSwitch: keyWidth * iPadPageSwitchFraction,
|
||||
comma: keyWidth * iPadPunctuationFraction,
|
||||
space: keyWidth * iPadSpaceFraction,
|
||||
period: keyWidth * iPadPunctuationFraction,
|
||||
return: keyWidth * iPadReturnFraction
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
public let id: UUID
|
||||
public let text: String
|
||||
public let createdAt: Date
|
||||
/// Last content mutation. Legacy rows default to `createdAt`.
|
||||
public let modifiedAt: Date
|
||||
/// Monotonic per-entry revision used to merge edits across devices.
|
||||
public let revision: Int64
|
||||
/// iOS Flow engine mode; nil on macOS captures.
|
||||
public let engineMode: String?
|
||||
|
||||
@@ -16,14 +20,28 @@ public struct SpeechHistoryEntry: Codable, Identifiable, Equatable, Sendable {
|
||||
id: UUID = UUID(),
|
||||
text: String,
|
||||
createdAt: Date = Date(),
|
||||
modifiedAt: Date? = nil,
|
||||
revision: Int64 = 0,
|
||||
engineMode: String? = nil
|
||||
) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.createdAt = createdAt
|
||||
self.modifiedAt = modifiedAt ?? createdAt
|
||||
self.revision = revision
|
||||
self.engineMode = engineMode
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let container = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try container.decode(UUID.self, forKey: .id)
|
||||
text = try container.decode(String.self, forKey: .text)
|
||||
createdAt = try container.decode(Date.self, forKey: .createdAt)
|
||||
modifiedAt = try container.decodeIfPresent(Date.self, forKey: .modifiedAt) ?? createdAt
|
||||
revision = try container.decodeIfPresent(Int64.self, forKey: .revision) ?? 0
|
||||
engineMode = try container.decodeIfPresent(String.self, forKey: .engineMode)
|
||||
}
|
||||
|
||||
/// First-line preview for compact list rows (macOS history sidebar).
|
||||
public var previewTitle: String {
|
||||
let firstLine = text.split(separator: "\n").first.map(String.init) ?? text
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import Foundation
|
||||
|
||||
public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public static let schemaVersion = 2
|
||||
public static let schemaVersion = 3
|
||||
public static let kvsKey = "speechHistory.v2"
|
||||
public static let legacyKVSKey = "speechHistory.v1"
|
||||
public static let maxEntries = 300
|
||||
@@ -28,6 +28,8 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
public var entries: [SpeechHistoryEntry]
|
||||
/// Entry IDs deleted on any device, with deletion timestamps.
|
||||
public var deletedEntryIDs: [UUID: Date]
|
||||
/// Recent idempotency keys for keyboard-originated history mutations.
|
||||
public var appliedMutationIDs: [UUID]
|
||||
/// When set, entries created at or before this instant are excluded.
|
||||
public var clearedAt: Date?
|
||||
|
||||
@@ -36,12 +38,14 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
updatedAt: Date = Date(),
|
||||
entries: [SpeechHistoryEntry] = [],
|
||||
deletedEntryIDs: [UUID: Date] = [:],
|
||||
appliedMutationIDs: [UUID] = [],
|
||||
clearedAt: Date? = nil
|
||||
) {
|
||||
self.schemaVersion = schemaVersion
|
||||
self.updatedAt = updatedAt
|
||||
self.entries = entries
|
||||
self.deletedEntryIDs = deletedEntryIDs
|
||||
self.appliedMutationIDs = appliedMutationIDs
|
||||
self.clearedAt = clearedAt
|
||||
}
|
||||
|
||||
@@ -58,12 +62,16 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
} else {
|
||||
deletedEntryIDs = [:]
|
||||
}
|
||||
appliedMutationIDs = try container.decodeIfPresent(
|
||||
[UUID].self,
|
||||
forKey: .appliedMutationIDs
|
||||
) ?? []
|
||||
clearedAt = try container.decodeIfPresent(Date.self, forKey: .clearedAt)
|
||||
}
|
||||
|
||||
public static let empty = SyncedSpeechHistory(updatedAt: .distantPast)
|
||||
|
||||
/// Union entries by id (newer `createdAt` wins), apply tombstones and clear.
|
||||
/// Union entries by id. Higher revision wins; timestamps break legacy ties.
|
||||
public static func merge(local: SyncedSpeechHistory, remote: SyncedSpeechHistory) -> SyncedSpeechHistory {
|
||||
let clearedAt = later(of: local.clearedAt, and: remote.clearedAt)
|
||||
var deletedIDs = local.deletedEntryIDs
|
||||
@@ -75,13 +83,18 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
}
|
||||
}
|
||||
deletedIDs = pruneTombstones(deletedIDs, clearedAt: clearedAt)
|
||||
let appliedMutationIDs = Array(
|
||||
Set(local.appliedMutationIDs + remote.appliedMutationIDs)
|
||||
.sorted { $0.uuidString < $1.uuidString }
|
||||
.prefix(256)
|
||||
)
|
||||
|
||||
var byID: [UUID: SpeechHistoryEntry] = [:]
|
||||
for entry in local.entries + remote.entries {
|
||||
if deletedIDs[entry.id] != nil { continue }
|
||||
if let clearedAt, entry.createdAt <= clearedAt { continue }
|
||||
if let existing = byID[entry.id] {
|
||||
byID[entry.id] = entry.createdAt >= existing.createdAt ? entry : existing
|
||||
byID[entry.id] = preferred(entry, over: existing)
|
||||
} else {
|
||||
byID[entry.id] = entry
|
||||
}
|
||||
@@ -96,6 +109,7 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
updatedAt: max(local.updatedAt, remote.updatedAt),
|
||||
entries: entries,
|
||||
deletedEntryIDs: deletedIDs,
|
||||
appliedMutationIDs: appliedMutationIDs,
|
||||
clearedAt: clearedAt
|
||||
)
|
||||
}
|
||||
@@ -143,6 +157,19 @@ public struct SyncedSpeechHistory: Codable, Equatable, Sendable {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
private static func preferred(
|
||||
_ candidate: SpeechHistoryEntry,
|
||||
over existing: SpeechHistoryEntry
|
||||
) -> SpeechHistoryEntry {
|
||||
if candidate.revision != existing.revision {
|
||||
return candidate.revision > existing.revision ? candidate : existing
|
||||
}
|
||||
if candidate.modifiedAt != existing.modifiedAt {
|
||||
return candidate.modifiedAt > existing.modifiedAt ? candidate : existing
|
||||
}
|
||||
return candidate.createdAt >= existing.createdAt ? candidate : existing
|
||||
}
|
||||
}
|
||||
|
||||
extension SyncedSpeechHistory {
|
||||
|
||||
@@ -9,9 +9,18 @@ import Foundation
|
||||
public struct TranscriptionDelivery: Sendable, Equatable {
|
||||
public let text: String
|
||||
public let polishWarning: String?
|
||||
public let historyEntryID: UUID?
|
||||
public let historyEntryRevision: Int64?
|
||||
|
||||
public init(text: String, polishWarning: String? = nil) {
|
||||
public init(
|
||||
text: String,
|
||||
polishWarning: String? = nil,
|
||||
historyEntryID: UUID? = nil,
|
||||
historyEntryRevision: Int64? = nil
|
||||
) {
|
||||
self.text = text
|
||||
self.polishWarning = polishWarning
|
||||
self.historyEntryID = historyEntryID
|
||||
self.historyEntryRevision = historyEntryRevision
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
// TypingSurfaceMetrics.swift
|
||||
// OSGKeyboard · Shared
|
||||
//
|
||||
// Size decisions for the typing surface: which key metrics apply, and how tall
|
||||
// the keyboard must be to hold them.
|
||||
//
|
||||
// These live here rather than next to the SwiftUI view because two independent
|
||||
// consumers must agree on them exactly — `KeyboardViewController` sets a UIKit
|
||||
// height constraint, and the SwiftUI grid lays keys out inside it. If they ever
|
||||
// pick different metrics the bottom row is clipped, so there is one source of
|
||||
// truth and it is unit-testable without the extension target.
|
||||
|
||||
import CoreGraphics
|
||||
|
||||
public enum TypingSurfaceMetrics {
|
||||
// MARK: - Structural bands (identical on every device)
|
||||
|
||||
/// Candidate / top control band above the key grid.
|
||||
public static let topRegionHeight: CGFloat = 44
|
||||
/// Gap between the top band and the first key row.
|
||||
public static let verticalKeySpacing: CGFloat = 8
|
||||
public static let outerPaddingTop: CGFloat = 4
|
||||
public static let outerPaddingBottom: CGFloat = 4
|
||||
|
||||
// MARK: - Phone
|
||||
|
||||
public static let keyRowHeight: CGFloat = 50
|
||||
public static let keyRowSpacing: CGFloat = 7
|
||||
public static let keyHorizontalSpacing: CGFloat = 6
|
||||
public static let secondRowInset: CGFloat = 18
|
||||
|
||||
// MARK: - iPad (narrow: portrait, or a compact-ish regular window)
|
||||
|
||||
public static let iPadKeyRowHeight: CGFloat = 54
|
||||
public static let iPadKeyRowSpacing: CGFloat = 8
|
||||
public static let iPadKeyHorizontalSpacing: CGFloat = 8
|
||||
|
||||
// MARK: - iPad (wide: landscape or a large Stage Manager window)
|
||||
|
||||
/// The grid spans the full host width here, so rows must grow with it.
|
||||
/// Holding the narrow 54 pt height at ~1200 pt wide produces 110×54 keys —
|
||||
/// flatter than any system key.
|
||||
public static let wideIPadKeyRowHeight: CGFloat = 76
|
||||
public static let wideIPadKeyRowSpacing: CGFloat = 10
|
||||
public static let wideIPadKeyHorizontalSpacing: CGFloat = 10
|
||||
|
||||
/// Key metrics for a device class and available width. Width — not device
|
||||
/// orientation — picks the wide bucket, so a resized Stage Manager window
|
||||
/// lands on the right metrics without consulting orientation at all.
|
||||
public static func metrics(isIPad: Bool, width: CGFloat) -> TypingKeyLayoutBuilder.Metrics {
|
||||
guard isIPad else {
|
||||
return TypingKeyLayoutBuilder.Metrics(
|
||||
keyRowHeight: keyRowHeight,
|
||||
keyRowSpacing: keyRowSpacing,
|
||||
keyHorizontalSpacing: keyHorizontalSpacing,
|
||||
secondRowInset: secondRowInset,
|
||||
bottomRowHeight: KeyboardChromeLayout.actionKeyHeight,
|
||||
bottomActionSpacing: KeyboardChromeLayout.actionKeySpacing,
|
||||
gridToBottomSpacing: keyRowSpacing
|
||||
)
|
||||
}
|
||||
let isWide = KeyboardChromeLayout.usesWideIPadMetrics(isIPad: true, width: width)
|
||||
let rowHeight = isWide ? wideIPadKeyRowHeight : iPadKeyRowHeight
|
||||
let rowSpacing = isWide ? wideIPadKeyRowSpacing : iPadKeyRowSpacing
|
||||
return TypingKeyLayoutBuilder.Metrics(
|
||||
keyRowHeight: rowHeight,
|
||||
keyRowSpacing: rowSpacing,
|
||||
keyHorizontalSpacing: isWide ? wideIPadKeyHorizontalSpacing : iPadKeyHorizontalSpacing,
|
||||
// Ignored: iPad derives the indent from the first row's key width.
|
||||
secondRowInset: 0,
|
||||
bottomRowHeight: rowHeight,
|
||||
bottomActionSpacing: KeyboardChromeLayout.actionKeySpacing,
|
||||
gridToBottomSpacing: rowSpacing,
|
||||
derivesSecondRowInsetFromKeyWidth: true
|
||||
)
|
||||
}
|
||||
|
||||
/// Content-driven keyboard height. iPad grows its rows, so the shell has to
|
||||
/// grow with them or the bottom row is clipped.
|
||||
public static func contentHeight(isIPad: Bool, width: CGFloat) -> CGFloat {
|
||||
guard isIPad else { return KeyboardChromeLayout.totalHeight }
|
||||
let m = metrics(isIPad: true, width: width)
|
||||
let inner = topRegionHeight
|
||||
+ verticalKeySpacing
|
||||
+ (3 * m.keyRowHeight + 2 * m.keyRowSpacing)
|
||||
+ m.gridToBottomSpacing
|
||||
+ m.bottomRowHeight
|
||||
return inner + outerPaddingTop + outerPaddingBottom
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user