Files
OSGKeyboard/OSGKeyboardShared/Models/TypingSurfaceMetrics.swift
T
Rocky 4dce67df84 chore(release): bump version to 2.1.6 (build 131)
Version: CURRENT_PROJECT_VERSION 130 -> 131 (project.yml, AGENTS.md, READMEs,
docs/llms.txt).

fix(flow): host-app identification now compiles in every build configuration
(Debug, TestFlight, Release). The arbiter probe and HostAppIdentity were
DEBUG-only, so TestFlight/App Store builds never knew the host bundle id and
could not auto-return after a startflow handoff. Debug-only logging and the
Home test card are unchanged.

Also includes the pending work in the tree:
- Home: single account/setup card surface (HomeAccountSurfacePolicy/Views)
- Wide screens: voice key row and centred mic placement, iPhone Duo layout
- Keyboard: clipboard skill ranking and AI skill layout updates
- Debug: account session handoff to the Simulator (+ import script)
- Docs: portfolio assets, privacy policy URL, site/README refresh
2026-10-29 11:54:13 +08:00

157 lines
7.3 KiB
Swift
Raw 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.
// 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: - Expanded phone (unfolded iPhone Duo inner display)
/// The system keyboard on the inner display keeps the iPhone key layout at
/// ~38.5 pt rows inside a 263 pt panel, and the system adds ~23 pt of
/// chrome around a custom input view. These rows give a 260 pt shell —
/// still roomy for the voice surface's live transcript — so the panel
/// stays ~20 pt above the system one instead of ~40 pt.
public static let expandedPhoneKeyRowHeight: CGFloat = 41
public static let expandedPhoneKeyRowSpacing: CGFloat = 12
public static let expandedPhoneKeyHorizontalSpacing: CGFloat = 10
// MARK: - Compact-height phone (any iPhone in landscape, incl. Duo outer)
/// A landscape iPhone is only ~400–470 pt tall, so the 281 pt portrait
/// panel would take 60–70% of it. These rows give a 244 pt shell — enough
/// for the voice surface to centre its mic and still fit the 3-line live
/// transcript above it — instead of following the system keyboard down
/// to ~200 pt.
public static let compactHeightPhoneKeyRowHeight: CGFloat = 40
public static let compactHeightPhoneKeyRowSpacing: CGFloat = 8
// 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.
/// `isExpandedPhone` / `isCompactHeightPhone` are only meaningful when
/// `isIPad` is false, and never true together (regular vs compact height).
public static func metrics(
isIPad: Bool,
width: CGFloat,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> TypingKeyLayoutBuilder.Metrics {
guard isIPad else {
if isCompactHeightPhone {
return TypingKeyLayoutBuilder.Metrics(
keyRowHeight: compactHeightPhoneKeyRowHeight,
keyRowSpacing: compactHeightPhoneKeyRowSpacing,
keyHorizontalSpacing: keyHorizontalSpacing,
// Ignored: landscape keys are ~70 pt wide and need the
// system's half-key indent, not the portrait 18 pt.
secondRowInset: 0,
bottomRowHeight: compactHeightPhoneKeyRowHeight,
bottomActionSpacing: KeyboardChromeLayout.actionKeySpacing,
gridToBottomSpacing: compactHeightPhoneKeyRowSpacing,
derivesSecondRowInsetFromKeyWidth: true
)
}
if isExpandedPhone {
return TypingKeyLayoutBuilder.Metrics(
keyRowHeight: expandedPhoneKeyRowHeight,
keyRowSpacing: expandedPhoneKeyRowSpacing,
keyHorizontalSpacing: expandedPhoneKeyHorizontalSpacing,
// Ignored: wide keys need the system's half-key indent.
secondRowInset: 0,
bottomRowHeight: expandedPhoneKeyRowHeight,
// Bottom-row widths are pre-split with this spacing.
bottomActionSpacing: KeyboardChromeLayout.actionKeySpacing,
gridToBottomSpacing: expandedPhoneKeyRowSpacing,
derivesSecondRowInsetFromKeyWidth: true
)
}
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,
isExpandedPhone: Bool = false,
isCompactHeightPhone: Bool = false
) -> CGFloat {
guard isIPad || isExpandedPhone || isCompactHeightPhone else { return KeyboardChromeLayout.totalHeight }
let m = metrics(
isIPad: isIPad,
width: width,
isExpandedPhone: isExpandedPhone,
isCompactHeightPhone: isCompactHeightPhone
)
let inner = topRegionHeight
+ verticalKeySpacing
+ (3 * m.keyRowHeight + 2 * m.keyRowSpacing)
+ m.gridToBottomSpacing
+ m.bottomRowHeight
return inner + outerPaddingTop + outerPaddingBottom
}
}