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:
Rocky
2026-08-10 13:50:50 +08:00
parent 53abad2050
commit 2bc8c1b87d
85 changed files with 5703 additions and 3997 deletions
@@ -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
}
}