Files
Rocky 2bc8c1b87d 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.
2026-08-10 13:50:50 +08:00

280 lines
11 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// TypingKeyLayout.swift
// OSGKeyboard · Shared
//
// Builds visual frames for the typing grid + bottom action row so hit
// testing and rendering share one geometry source.
import CoreGraphics
import Foundation
public struct TypingKeyLayout: Equatable, Sendable {
public let keys: [TypingKeyHitTarget]
/// Union of all visual key frames (letter grid + bottom row).
public let keyPlaneBounds: CGRect
public let horizontalGap: CGFloat
public let verticalGap: CGFloat
public let bottomRowMinY: CGFloat
/// Phase 4: per-key hit weights for ambiguous nearest resolution.
/// Empty / missing id → neutral (`1.0`).
public let hitWeights: [String: CGFloat]
public init(
keys: [TypingKeyHitTarget],
keyPlaneBounds: CGRect,
horizontalGap: CGFloat,
verticalGap: CGFloat,
bottomRowMinY: CGFloat,
hitWeights: [String: CGFloat] = [:]
) {
self.keys = keys
self.keyPlaneBounds = keyPlaneBounds
self.horizontalGap = horizontalGap
self.verticalGap = verticalGap
self.bottomRowMinY = bottomRowMinY
self.hitWeights = hitWeights
}
public func key(id: String) -> TypingKeyHitTarget? {
keys.first { $0.id == id }
}
public func withHitWeights(_ weights: [String: CGFloat]) -> TypingKeyLayout {
TypingKeyLayout(
keys: keys,
keyPlaneBounds: keyPlaneBounds,
horizontalGap: horizontalGap,
verticalGap: verticalGap,
bottomRowMinY: bottomRowMinY,
hitWeights: weights
)
}
}
public enum TypingKeyLayoutBuilder {
public struct Metrics: Equatable, Sendable {
public var keyRowHeight: CGFloat
public var keyRowSpacing: CGFloat
public var keyHorizontalSpacing: CGFloat
public var secondRowInset: CGFloat
public var bottomRowHeight: CGFloat
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,
keyRowSpacing: CGFloat = 7,
keyHorizontalSpacing: CGFloat = 6,
secondRowInset: CGFloat = 18,
bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight,
bottomActionSpacing: CGFloat = KeyboardChromeLayout.actionKeySpacing,
gridToBottomSpacing: CGFloat = 7,
derivesSecondRowInsetFromKeyWidth: Bool = false
) {
self.keyRowHeight = keyRowHeight
self.keyRowSpacing = keyRowSpacing
self.keyHorizontalSpacing = keyHorizontalSpacing
self.secondRowInset = secondRowInset
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]],
pageSwitchLabel: String,
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 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, +))
var x = inset
for (keyIndex, label) in row.enumerated() {
let width = unitWidth * weights[keyIndex]
let frame = CGRect(
x: x,
y: cursorY,
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),
displayNumber: number
)
)
x += width + metrics.keyHorizontalSpacing
}
cursorY += metrics.keyRowHeight
if rowIndex < letterRows.count - 1 {
cursorY += metrics.keyRowSpacing
}
}
cursorY += metrics.gridToBottomSpacing
let bottomY = cursorY
// 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() {
let frame = CGRect(
x: bottomX,
y: bottomY,
width: item.2,
height: metrics.bottomRowHeight
)
keys.append(
TypingKeyHitTarget(
id: item.0,
label: item.1,
visualFrame: frame,
behavior: .commitOnRelease
)
)
bottomX += item.2
if index < bottomFrames.count - 1 {
bottomX += metrics.bottomActionSpacing
}
}
let plane = keys.reduce(CGRect.null) { $0.union($1.visualFrame) }
return TypingKeyLayout(
keys: keys,
keyPlaneBounds: plane.isNull ? .zero : plane,
horizontalGap: metrics.keyHorizontalSpacing,
// Between letter rows use keyRowSpacing; between grid and bottom
// use gridToBottomSpacing. Hit-test uses the larger gap so the
// mid-gap is always covered.
verticalGap: max(metrics.keyRowSpacing, metrics.gridToBottomSpacing),
bottomRowMinY: bottomY
)
}
}