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
+110 -9
View File
@@ -60,6 +60,12 @@ public enum TypingKeyLayoutBuilder {
public var bottomActionSpacing: CGFloat
/// Gap between the last letter row and the bottom action row.
public var gridToBottomSpacing: CGFloat
/// When true, `secondRowInset` is ignored and the second row is inset
/// so its keys are exactly as wide as the first row's, leaving a half
/// key at each end what the system keyboard does. A fixed inset is a
/// fraction of a 700 pt column and stops reading as a deliberate
/// indent once the grid fills an iPad's width.
public var derivesSecondRowInsetFromKeyWidth: Bool
public init(
keyRowHeight: CGFloat = 50,
@@ -68,7 +74,8 @@ public enum TypingKeyLayoutBuilder {
secondRowInset: CGFloat = 18,
bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight,
bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing,
gridToBottomSpacing: CGFloat = 7
gridToBottomSpacing: CGFloat = 7,
derivesSecondRowInsetFromKeyWidth: Bool = false
) {
self.keyRowHeight = keyRowHeight
self.keyRowSpacing = keyRowSpacing
@@ -77,16 +84,53 @@ public enum TypingKeyLayoutBuilder {
self.bottomRowHeight = bottomRowHeight
self.bottomActionSpacing = bottomActionSpacing
self.gridToBottomSpacing = gridToBottomSpacing
self.derivesSecondRowInsetFromKeyWidth = derivesSecondRowInsetFromKeyWidth
}
}
/// Inset that makes `row` keys as wide as a full `referenceCount` row.
/// Both rows are laid out at the same unit width, so the second row simply
/// gives back the width of the keys it does not have, split evenly.
static func derivedSecondRowInset(
totalWidth: CGFloat,
referenceCount: Int,
rowCount: Int,
spacing: CGFloat,
weightTotal: CGFloat,
referenceWeightTotal: CGFloat
) -> CGFloat {
guard referenceCount > 0, rowCount > 0, referenceWeightTotal > 0 else { return 0 }
let referenceSpacing = spacing * CGFloat(max(0, referenceCount - 1))
let unitWidth = (totalWidth - referenceSpacing) / referenceWeightTotal
let rowWidth = unitWidth * weightTotal + spacing * CGFloat(max(0, rowCount - 1))
return max(0, (totalWidth - rowWidth) / 2)
}
/// Bottom-row semantic labels used by the touch pad (not always the glyph).
/// When present on iPad, the globe key is excluded from the touch pad's hit
/// testing its `SystemGlobeKey` UIButton handles tap (advance) /
/// long-press (system input-mode list) directly.
public enum BottomKeyID: String, Sendable {
case globe = "bottom.globe"
case pageSwitch = "bottom.page"
case comma = "bottom.comma"
case space = "bottom.space"
case period = "bottom.period"
case `return` = "bottom.return"
}
/// Comma / period glyphs for the iPad bottom row. `nil` keeps the phone's
/// four-slot row.
public struct PunctuationKeys: Equatable, Sendable {
public let comma: String
public let period: String
public init(comma: String, period: String) {
self.comma = comma
self.period = period
}
}
public static func build(
size: CGSize,
letterRows: [[String]],
@@ -94,14 +138,37 @@ public enum TypingKeyLayoutBuilder {
spaceLabel: String,
returnLabel: String,
metrics: Metrics = Metrics(),
includeGlobeKey: Bool = true,
punctuationKeys: PunctuationKeys? = nil,
showTopRowNumbers: Bool = false,
topRowNumbers: [String] = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "0"],
keyWeight: (_ label: String, _ index: Int, _ rowIndex: Int) -> CGFloat
) -> TypingKeyLayout {
var keys: [TypingKeyHitTarget] = []
var cursorY: CGFloat = 0
let firstRow = letterRows.first ?? []
let firstRowWeightTotal = firstRow.enumerated()
.map { keyWeight($0.element, $0.offset, 0) }
.reduce(0, +)
for (rowIndex, row) in letterRows.enumerated() {
let inset = rowIndex == 1 ? metrics.secondRowInset : 0
let weights = row.enumerated().map { keyWeight($0.element, $0.offset, rowIndex) }
let inset: CGFloat
if rowIndex == 1 {
inset = metrics.derivesSecondRowInsetFromKeyWidth
? derivedSecondRowInset(
totalWidth: size.width,
referenceCount: firstRow.count,
rowCount: row.count,
spacing: metrics.keyHorizontalSpacing,
weightTotal: weights.reduce(0, +),
referenceWeightTotal: firstRowWeightTotal
)
: metrics.secondRowInset
} else {
inset = 0
}
let spacingTotal = metrics.keyHorizontalSpacing * CGFloat(max(0, row.count - 1))
let availableWidth = size.width - inset * 2 - spacingTotal
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
@@ -115,12 +182,20 @@ public enum TypingKeyLayoutBuilder {
width: width,
height: metrics.keyRowHeight
)
// iPad top letter row carries the small number overlay (10),
// mirroring the iOS system keyboard. Interior rows don't.
let number = (showTopRowNumbers
&& rowIndex == 0
&& keyIndex < topRowNumbers.count)
? topRowNumbers[keyIndex]
: nil
keys.append(
TypingKeyHitTarget(
id: "grid.\(rowIndex).\(keyIndex)",
label: label,
visualFrame: frame,
behavior: TypingKeyBehaviorResolver.behavior(for: label)
behavior: TypingKeyBehaviorResolver.behavior(for: label),
displayNumber: number
)
)
x += width + metrics.keyHorizontalSpacing
@@ -134,12 +209,38 @@ public enum TypingKeyLayoutBuilder {
cursorY += metrics.gridToBottomSpacing
let bottomY = cursorY
let widths = KeyboardChromeLayout.actionKeyWidths(availableWidth: size.width)
let bottomFrames: [(String, String, CGFloat)] = [
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.side),
(BottomKeyID.space.rawValue, spaceLabel, widths.center),
(BottomKeyID.return.rawValue, returnLabel, widths.side)
]
// On iPad the globe lives at the far-left as a UIKit-backed key
// (handled by SystemGlobeKey); registering its frame reserves the slot
// and lets the touch pad skip hit-testing it. iPhone omits the slot.
let bottomFrames: [(String, String, CGFloat)]
if let punctuationKeys {
let widths = KeyboardChromeLayout.iPadActionKeyWidths(availableWidth: size.width)
bottomFrames = [
(BottomKeyID.globe.rawValue, "", widths.globe),
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.pageSwitch),
(BottomKeyID.comma.rawValue, punctuationKeys.comma, widths.comma),
(BottomKeyID.space.rawValue, spaceLabel, widths.space),
(BottomKeyID.period.rawValue, punctuationKeys.period, widths.period),
(BottomKeyID.return.rawValue, returnLabel, widths.return)
]
} else if includeGlobeKey {
let widths = KeyboardChromeLayout.actionKeyWidths(availableWidth: size.width)
bottomFrames = [
(BottomKeyID.globe.rawValue, "", widths.globe),
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.side),
(BottomKeyID.space.rawValue, spaceLabel, widths.center),
(BottomKeyID.return.rawValue, returnLabel, widths.side2)
]
} else {
let widths = KeyboardChromeLayout.actionKeyWidthsWithoutGlobe(
availableWidth: size.width
)
bottomFrames = [
(BottomKeyID.pageSwitch.rawValue, pageSwitchLabel, widths.side),
(BottomKeyID.space.rawValue, spaceLabel, widths.center),
(BottomKeyID.return.rawValue, returnLabel, widths.side2)
]
}
var bottomX: CGFloat = 0
for (index, item) in bottomFrames.enumerated() {