38e5ad570d
Offline English lexicon suggestions with autocapitalization, plus a same-height Chinese more-candidates grid over the key area.
252 lines
8.7 KiB
Swift
252 lines
8.7 KiB
Swift
// KeyboardTopControls.swift
|
|
// OSGKeyboard · Keyboard Extension
|
|
//
|
|
// Shared top-right input switcher used by both voice and typing surfaces.
|
|
// The same footprint is replaced by candidates while Chinese is composing.
|
|
|
|
import SwiftUI
|
|
import OSGKeyboardShared
|
|
|
|
enum KeyboardTopBarMetrics {
|
|
static let height: CGFloat = 44
|
|
static let horizontalInset: CGFloat = 12
|
|
/// TypingRootView already contributes 8 pt around the entire key surface.
|
|
static let nestedHorizontalInset: CGFloat = horizontalInset - KeyboardChromeLayout.horizontalInset
|
|
static let logoHeight: CGFloat = 22
|
|
static let logoWidth: CGFloat = logoHeight * 952 / 291
|
|
}
|
|
|
|
struct KeyboardBrandLogo: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Image("OSGLogoWide")
|
|
.resizable()
|
|
.renderingMode(.template)
|
|
.scaledToFit()
|
|
.foregroundStyle(colorScheme == .dark ? Color.white : Color.black)
|
|
.frame(
|
|
width: KeyboardTopBarMetrics.logoWidth,
|
|
height: KeyboardTopBarMetrics.logoHeight
|
|
)
|
|
.contentShape(Rectangle())
|
|
.accessibilityHidden(true)
|
|
}
|
|
.buttonStyle(BrandLogoPressStyle())
|
|
.accessibilityLabel(ExtL10n.text("keyboard.onboarding.api.openHostApp"))
|
|
}
|
|
}
|
|
|
|
private enum KeyboardInputTab: CaseIterable {
|
|
case voice
|
|
case chinese
|
|
case english
|
|
|
|
var title: String {
|
|
switch self {
|
|
case .voice: return "语音"
|
|
case .chinese: return "中文"
|
|
case .english: return "EN"
|
|
}
|
|
}
|
|
}
|
|
|
|
struct KeyboardTopControls: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@ObservedObject var state: KeyboardState
|
|
@ObservedObject var typing: TypingSessionController
|
|
|
|
let palette: ThemePalette
|
|
let onInsert: (String) -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
HStack(spacing: 2) {
|
|
ForEach(KeyboardInputTab.allCases, id: \.self) { tab in
|
|
Button {
|
|
select(tab)
|
|
} label: {
|
|
Text(tab.title)
|
|
.font(.system(size: 12, weight: isSelected(tab) ? .semibold : .medium))
|
|
.foregroundStyle(
|
|
isSelected(tab) ? palette.textPrimary : palette.textSecondary
|
|
)
|
|
.frame(width: tab == .english ? 34 : 42, height: 30)
|
|
.background {
|
|
if isSelected(tab) {
|
|
Capsule()
|
|
.fill(selectedFill)
|
|
.shadow(
|
|
color: Color.black.opacity(colorScheme == .dark ? 0.22 : 0.10),
|
|
radius: 1.5,
|
|
y: 1
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(TopControlPressStyle(pressedFill: pressedFill))
|
|
.disabled(tab != .voice && !state.canEnterTypingSurface)
|
|
.opacity(tab != .voice && !state.canEnterTypingSurface ? 0.42 : 1)
|
|
.accessibilityLabel(accessibilityLabel(for: tab))
|
|
.accessibilityAddTraits(isSelected(tab) ? .isSelected : [])
|
|
}
|
|
}
|
|
.padding(2)
|
|
.background(trackFill, in: Capsule())
|
|
|
|
KeyboardTranslationMenuButton(
|
|
palette: palette,
|
|
targetLocaleId: state.translationTargetLocaleId,
|
|
onSelect: state.setTranslationTargetLocaleId
|
|
)
|
|
// Decouple the open picker from the keyboard's 1 Hz App Group
|
|
// poll so scrolling does not reset or dismiss the menu.
|
|
.equatable()
|
|
}
|
|
}
|
|
|
|
private var selectedFill: Color {
|
|
colorScheme == .dark ? Color(white: 0.38) : .white
|
|
}
|
|
|
|
private var trackFill: Color {
|
|
colorScheme == .dark ? Color(white: 0.18) : Color.black.opacity(0.08)
|
|
}
|
|
|
|
private var pressedFill: Color {
|
|
colorScheme == .dark ? Color(white: 0.22) : Color(white: 0.84)
|
|
}
|
|
|
|
private func isSelected(_ tab: KeyboardInputTab) -> Bool {
|
|
switch tab {
|
|
case .voice:
|
|
return state.surface == .voice
|
|
case .chinese:
|
|
return state.surface == .typing && typing.language == .chinese
|
|
case .english:
|
|
return state.surface == .typing && typing.language == .english
|
|
}
|
|
}
|
|
|
|
private func select(_ tab: KeyboardInputTab) {
|
|
switch tab {
|
|
case .voice:
|
|
state.setSurface(.voice)
|
|
case .chinese:
|
|
applyTypingOutput(typing.setLanguage(.chinese))
|
|
state.setSurface(.typing)
|
|
case .english:
|
|
applyTypingOutput(typing.setLanguage(.english))
|
|
state.setSurface(.typing)
|
|
}
|
|
}
|
|
|
|
private func applyTypingOutput(_ output: TypingOutput) {
|
|
if output.deleteCount > 0 {
|
|
// Language switch rarely deletes; keep insert-only path for capsule.
|
|
}
|
|
guard !output.text.isEmpty else {
|
|
typing.syncAutocapitalization()
|
|
return
|
|
}
|
|
onInsert(output.text)
|
|
typing.syncAutocapitalization()
|
|
}
|
|
|
|
private func accessibilityLabel(for tab: KeyboardInputTab) -> String {
|
|
switch tab {
|
|
case .voice: return "切换到语音输入"
|
|
case .chinese: return "切换到中文输入"
|
|
case .english: return "切换到英文输入"
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct KeyboardTranslationMenuButton: View, Equatable {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
let palette: ThemePalette
|
|
let targetLocaleId: String
|
|
let onSelect: (String) -> Void
|
|
|
|
nonisolated static func == (
|
|
lhs: KeyboardTranslationMenuButton,
|
|
rhs: KeyboardTranslationMenuButton
|
|
) -> Bool {
|
|
lhs.palette == rhs.palette && lhs.targetLocaleId == rhs.targetLocaleId
|
|
}
|
|
|
|
private var isEnabled: Bool {
|
|
targetLocaleId != TranslationLanguageCatalog.offLocaleId
|
|
}
|
|
|
|
var body: some View {
|
|
Menu {
|
|
ForEach(TranslationLanguageCatalog.all) { language in
|
|
Button {
|
|
onSelect(language.id)
|
|
} label: {
|
|
if language.id == targetLocaleId {
|
|
Label(displayLabel(for: language), systemImage: "checkmark")
|
|
} else {
|
|
Text(displayLabel(for: language))
|
|
}
|
|
}
|
|
}
|
|
} label: {
|
|
Image(systemName: isEnabled ? "character.bubble.fill" : "character.bubble")
|
|
.font(.system(size: 15, weight: .medium))
|
|
.foregroundStyle(isEnabled ? palette.accent : palette.textSecondary)
|
|
.frame(width: 34, height: 34)
|
|
.background(buttonFill, in: Circle())
|
|
.overlay(Circle().stroke(buttonStroke, lineWidth: 0.5))
|
|
}
|
|
.menuStyle(.button)
|
|
.accessibilityLabel(Text(SharedL10n.string("keyboard.translation.a11y")))
|
|
.accessibilityHint(Text(SharedL10n.string("keyboard.translation.a11yHint")))
|
|
}
|
|
|
|
private var buttonFill: Color {
|
|
if isEnabled {
|
|
return palette.accent.opacity(colorScheme == .dark ? 0.28 : 0.16)
|
|
}
|
|
return colorScheme == .dark ? Color(white: 0.30) : .white
|
|
}
|
|
|
|
private var buttonStroke: Color {
|
|
isEnabled ? palette.accent.opacity(0.35) : palette.divider
|
|
}
|
|
|
|
private func displayLabel(for language: TranslationLanguage) -> String {
|
|
if language.id == TranslationLanguageCatalog.offLocaleId {
|
|
return SharedL10n.string("keyboard.translation.offMenu")
|
|
}
|
|
return language.nativeName
|
|
}
|
|
}
|
|
|
|
private struct BrandLogoPressStyle: ButtonStyle {
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.opacity(configuration.isPressed ? 0.62 : 1)
|
|
.scaleEffect(configuration.isPressed ? 0.97 : 1)
|
|
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
|
|
}
|
|
}
|
|
|
|
private struct TopControlPressStyle: ButtonStyle {
|
|
let pressedFill: Color
|
|
|
|
func makeBody(configuration: Configuration) -> some View {
|
|
configuration.label
|
|
.background(configuration.isPressed ? pressedFill.opacity(0.55) : .clear)
|
|
.clipShape(Capsule())
|
|
.scaleEffect(configuration.isPressed ? 0.98 : 1)
|
|
.animation(.easeOut(duration: 0.08), value: configuration.isPressed)
|
|
}
|
|
}
|