chore(typing): snapshot local pinyin WIP before syncing cloud branch
Preserve the in-progress local typing/pinyin implementation so feat/pinyin can safely reset to origin/feat/pinyin (cloud English + Chinese typing).
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
// TypingRootView.swift
|
||||
// OSGKeyboard · Keyboard Extension
|
||||
//
|
||||
// Typing surface (Phase 1): candidate bar + QWERTY / 123 / symbols.
|
||||
// Top-leading control returns to the voice surface.
|
||||
|
||||
import SwiftUI
|
||||
import OSGKeyboardShared
|
||||
|
||||
enum TypingLayoutMetrics {
|
||||
static let outerPaddingTop: CGFloat = 4
|
||||
static let outerPaddingBottom: CGFloat = 4
|
||||
static let topRegionHeight: CGFloat = KeyboardTopBarMetrics.height
|
||||
static let keyRowHeight: CGFloat = 50
|
||||
static let keyRowSpacing: CGFloat = 7
|
||||
static let keyHorizontalSpacing: CGFloat = 6
|
||||
static let bottomRowHeight: CGFloat = KeyboardChromeLayout.actionKeyHeight
|
||||
static let verticalKeySpacing: CGFloat = 8
|
||||
static let secondRowInset: CGFloat = 18
|
||||
static let keyCornerRadius: CGFloat = KeyboardChromeLayout.actionKeyCornerRadius
|
||||
/// Extends the bottom corner keys to the same outer edges as Shift / Delete.
|
||||
static let bottomLeadingKeyWidth: CGFloat = 70
|
||||
static let bottomTrailingKeyWidth: CGFloat = 86
|
||||
/// Shared top row + three 50 pt key rows + native spacing + bottom row.
|
||||
static let totalHeight: CGFloat = KeyboardChromeLayout.totalHeight
|
||||
}
|
||||
|
||||
struct TypingRootView: View {
|
||||
@Environment(\.colorScheme) private var colorScheme
|
||||
@ObservedObject var state: KeyboardState
|
||||
@ObservedObject var typing: TypingSessionController
|
||||
|
||||
var onInsert: (String) -> Void
|
||||
var onDeleteBackward: () -> Void
|
||||
|
||||
static let totalHeight: CGFloat = TypingLayoutMetrics.totalHeight
|
||||
|
||||
private var palette: ThemePalette {
|
||||
colorScheme == .dark ? Palette.dark : Palette.light
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
topRegion
|
||||
.frame(height: TypingLayoutMetrics.topRegionHeight)
|
||||
|
||||
keyGrid
|
||||
.padding(.top, TypingLayoutMetrics.verticalKeySpacing)
|
||||
|
||||
bottomRow
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
.padding(.top, TypingLayoutMetrics.keyRowSpacing)
|
||||
}
|
||||
.padding(.top, TypingLayoutMetrics.outerPaddingTop)
|
||||
.padding(.bottom, TypingLayoutMetrics.outerPaddingBottom)
|
||||
.padding(.horizontal, KeyboardChromeLayout.horizontalInset)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: Self.totalHeight)
|
||||
.background(Color.clear)
|
||||
.environment(\.themePalette, palette)
|
||||
.onAppear { typing.enterTypingMode() }
|
||||
}
|
||||
|
||||
// MARK: - Shared top region
|
||||
|
||||
@ViewBuilder
|
||||
private var topRegion: some View {
|
||||
if hasCandidateContent {
|
||||
candidateBar
|
||||
} else {
|
||||
idleTopBar
|
||||
}
|
||||
}
|
||||
|
||||
private var hasCandidateContent: Bool {
|
||||
!typing.composition.preedit.isEmpty || !typing.composition.candidates.isEmpty
|
||||
}
|
||||
|
||||
private var idleTopBar: some View {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
KeyboardBrandLogo(action: state.openSettings)
|
||||
|
||||
if let err = typing.lastError {
|
||||
Text(err)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(palette.danger)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
|
||||
KeyboardTopControls(
|
||||
state: state,
|
||||
typing: typing,
|
||||
palette: palette,
|
||||
onInsert: onInsert
|
||||
)
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
|
||||
// MARK: - Candidates
|
||||
|
||||
private var candidateBar: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: Spacing.xs) {
|
||||
if typing.composition.candidates.isEmpty {
|
||||
selectedCandidateLabel(text: typing.composition.preedit)
|
||||
} else {
|
||||
ForEach(
|
||||
Array(typing.composition.candidates.enumerated()),
|
||||
id: \.element.id
|
||||
) { index, candidate in
|
||||
Button {
|
||||
let text = typing.selectCandidate(at: index)
|
||||
if !text.isEmpty { onInsert(text) }
|
||||
} label: {
|
||||
if index == 0 {
|
||||
selectedCandidateLabel(text: candidate.text)
|
||||
} else {
|
||||
Text(candidate.text)
|
||||
.font(.system(size: 20, weight: .regular))
|
||||
.foregroundStyle(palette.textPrimary)
|
||||
.padding(.horizontal, 10)
|
||||
.frame(height: 40)
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, KeyboardTopBarMetrics.nestedHorizontalInset)
|
||||
}
|
||||
}
|
||||
|
||||
private func selectedCandidateLabel(text: String) -> some View {
|
||||
VStack(spacing: 0) {
|
||||
Text(text)
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
.lineLimit(1)
|
||||
Text(typing.composition.preedit)
|
||||
.font(.system(size: 9, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(palette.textSecondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
.foregroundStyle(keyTextColor)
|
||||
.padding(.horizontal, 12)
|
||||
.frame(minWidth: 56, minHeight: 40)
|
||||
.background(
|
||||
selectedCandidateFill,
|
||||
in: RoundedRectangle(cornerRadius: 9, style: .continuous)
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Keys
|
||||
|
||||
private var keyGrid: some View {
|
||||
VStack(spacing: TypingLayoutMetrics.keyRowSpacing) {
|
||||
ForEach(Array(typing.keyRows.enumerated()), id: \.offset) { rowIndex, row in
|
||||
GeometryReader { proxy in
|
||||
let inset = rowIndex == 1 ? TypingLayoutMetrics.secondRowInset : 0
|
||||
let weights = row.enumerated().map {
|
||||
keyWeight(label: $0.element, index: $0.offset, rowIndex: rowIndex)
|
||||
}
|
||||
let spacing = TypingLayoutMetrics.keyHorizontalSpacing
|
||||
* CGFloat(max(0, row.count - 1))
|
||||
let availableWidth = proxy.size.width - inset * 2 - spacing
|
||||
let unitWidth = availableWidth / max(1, weights.reduce(0, +))
|
||||
|
||||
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
|
||||
ForEach(Array(row.enumerated()), id: \.offset) { keyIndex, key in
|
||||
keyButton(key)
|
||||
.frame(width: unitWidth * weights[keyIndex])
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, inset)
|
||||
}
|
||||
.frame(height: TypingLayoutMetrics.keyRowHeight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func keyButton(_ label: String) -> some View {
|
||||
let isSpecial = ["⇧", "⌫", "123", "#+=", "ABC"].contains(label)
|
||||
return Button {
|
||||
let result = typing.handleKey(label)
|
||||
if result == "\u{8}" {
|
||||
onDeleteBackward()
|
||||
} else if !result.isEmpty {
|
||||
onInsert(result)
|
||||
}
|
||||
} label: {
|
||||
keyLabel(label, isSpecial: isSpecial)
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func keyLabel(_ label: String, isSpecial: Bool) -> some View {
|
||||
switch label {
|
||||
case "⇧":
|
||||
Image(systemName: typing.shiftActive || typing.capsLock ? "shift.fill" : "shift")
|
||||
.font(.system(size: 19, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
case "⌫":
|
||||
Image(systemName: "delete.left")
|
||||
.font(.system(size: 20, weight: .medium))
|
||||
.foregroundStyle(keyTextColor)
|
||||
default:
|
||||
Text(label)
|
||||
.font(
|
||||
.system(
|
||||
size: isSpecial ? 15 : 22,
|
||||
weight: isSpecial ? .semibold : .regular
|
||||
)
|
||||
)
|
||||
.foregroundStyle(keyTextColor)
|
||||
}
|
||||
}
|
||||
|
||||
private var bottomRow: some View {
|
||||
HStack(spacing: TypingLayoutMetrics.keyHorizontalSpacing) {
|
||||
Button {
|
||||
typing.setPage(typing.page == .letters ? .numbers : .letters)
|
||||
} label: {
|
||||
Text(typing.page == .letters ? "123" : "ABC")
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.frame(
|
||||
width: TypingLayoutMetrics.bottomLeadingKeyWidth,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
|
||||
Button {
|
||||
let text = typing.handleSpace()
|
||||
if !text.isEmpty { onInsert(text) }
|
||||
} label: {
|
||||
Text(typing.language == .chinese ? "空格" : "space")
|
||||
.font(.system(size: 17, weight: .regular))
|
||||
.foregroundStyle(keyTextColor)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: TypingLayoutMetrics.bottomRowHeight)
|
||||
}
|
||||
.buttonStyle(nativeKeyStyle)
|
||||
|
||||
Button {
|
||||
let text = typing.handleReturn()
|
||||
if !text.isEmpty { onInsert(text) }
|
||||
} label: {
|
||||
returnKeyLabel
|
||||
.foregroundStyle(returnKeyTextColor)
|
||||
.frame(
|
||||
width: TypingLayoutMetrics.bottomTrailingKeyWidth,
|
||||
height: TypingLayoutMetrics.bottomRowHeight
|
||||
)
|
||||
}
|
||||
.buttonStyle(returnKeyStyle)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var returnKeyLabel: some View {
|
||||
switch state.returnKeyRole {
|
||||
case .newline:
|
||||
Image(systemName: "arrow.turn.down.left")
|
||||
.font(.system(size: 21, weight: .medium))
|
||||
case .send:
|
||||
ExtL10n.text(state.returnKeyRole.titleKey)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
}
|
||||
}
|
||||
|
||||
private func keyWeight(label: String, index: Int, rowIndex: Int) -> CGFloat {
|
||||
if label == "⌫" || label == "⇧" || label == "#+=" {
|
||||
return 1.35
|
||||
}
|
||||
if rowIndex == 2 && index == 0 {
|
||||
// Double-pinyin semicolon occupies the normal Shift footprint.
|
||||
return 1.35
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
private var keyFill: Color {
|
||||
NativeKeyboardKeyColors.fill(for: colorScheme)
|
||||
}
|
||||
|
||||
private var keyPressedFill: Color {
|
||||
NativeKeyboardKeyColors.pressedFill(for: colorScheme)
|
||||
}
|
||||
|
||||
private var keyTextColor: Color {
|
||||
NativeKeyboardKeyColors.text(for: colorScheme)
|
||||
}
|
||||
|
||||
private var selectedCandidateFill: Color {
|
||||
colorScheme == .dark ? Color(white: 0.36) : .white
|
||||
}
|
||||
|
||||
private var nativeKeyStyle: NativeKeyboardKeyStyle {
|
||||
NativeKeyboardKeyStyle(
|
||||
fill: keyFill,
|
||||
pressedFill: keyPressedFill,
|
||||
border: palette.divider,
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
)
|
||||
}
|
||||
|
||||
private var returnKeyStyle: NativeKeyboardKeyStyle {
|
||||
switch state.returnKeyRole {
|
||||
case .newline:
|
||||
return nativeKeyStyle
|
||||
case .send:
|
||||
return NativeKeyboardKeyStyle(
|
||||
fill: sendKeyFill,
|
||||
pressedFill: sendKeyPressedFill,
|
||||
border: Color.black.opacity(colorScheme == .dark ? 0.10 : 0.08),
|
||||
cornerRadius: TypingLayoutMetrics.keyCornerRadius
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var returnKeyTextColor: Color {
|
||||
switch state.returnKeyRole {
|
||||
case .newline:
|
||||
return keyTextColor
|
||||
case .send:
|
||||
return .white
|
||||
}
|
||||
}
|
||||
|
||||
/// The send key stays recognizable in both appearances without becoming neon.
|
||||
private var sendKeyFill: Color {
|
||||
NativeKeyboardKeyColors.sendFill(for: colorScheme)
|
||||
}
|
||||
|
||||
private var sendKeyPressedFill: Color {
|
||||
NativeKeyboardKeyColors.sendPressedFill(for: colorScheme)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user