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
157 lines
7.3 KiB
Swift
157 lines
7.3 KiB
Swift
// 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
|
||
}
|
||
}
|