feat(typing): add English autocomplete and Chinese candidate expand panel

Offline English lexicon suggestions with autocapitalization, plus a same-height Chinese more-candidates grid over the key area.
This commit is contained in:
Rocky
2026-08-03 18:30:20 +08:00
parent d1e5fed964
commit 38e5ad570d
35 changed files with 5086 additions and 179 deletions
@@ -0,0 +1,163 @@
// CandidatePanelExpandTests.swift
// OSGKeyboard · Ext unit tests
import XCTest
@testable import OSGKeyboardShared
@MainActor
final class CandidatePanelExpandTests: XCTestCase {
func testDefaultConfigPageSizeMatchesExpandPool() {
let yaml = RimeSchemaGenerator.defaultConfiguration()
XCTAssertTrue(yaml.contains("page_size: 100"))
}
func testChevronRequiresChineseAndAtLeastTwoCandidates() {
let engine = StubRimeEngine()
let typing = TypingSessionController(engine: engine)
XCTAssertFalse(typing.canExpandCandidatePanel)
engine.composition = TypingComposition(
preedit: "ni",
candidates: [
TypingCandidate(text: ""),
TypingCandidate(text: "")
]
)
// Composition is owned by the session; drive via processCharacter.
_ = typing.handleKey("n")
XCTAssertTrue(typing.canExpandCandidatePanel)
XCTAssertEqual(typing.composition.candidates.count, 2)
_ = typing.setLanguage(.english)
XCTAssertFalse(typing.canExpandCandidatePanel)
XCTAssertFalse(typing.isCandidatePanelExpanded)
}
func testToggleExpandAndSelectCollapses() {
let engine = StubRimeEngine()
let typing = TypingSessionController(engine: engine)
_ = typing.handleKey("n")
XCTAssertTrue(typing.canExpandCandidatePanel)
typing.toggleCandidatePanelExpanded()
XCTAssertTrue(typing.isCandidatePanelExpanded)
typing.toggleCandidatePanelExpanded()
XCTAssertFalse(typing.isCandidatePanelExpanded)
typing.toggleCandidatePanelExpanded()
XCTAssertTrue(typing.isCandidatePanelExpanded)
let output = typing.selectCandidate(at: 0)
XCTAssertEqual(output, .insert(""))
XCTAssertFalse(typing.isCandidatePanelExpanded)
}
func testPanelCollapsesWhenCandidatesDropBelowTwo() {
let engine = StubRimeEngine()
let typing = TypingSessionController(engine: engine)
_ = typing.handleKey("n")
typing.toggleCandidatePanelExpanded()
XCTAssertTrue(typing.isCandidatePanelExpanded)
// Backspace to a single-candidate (or empty) composition.
engine.nextComposition = TypingComposition(
preedit: "n",
candidates: [TypingCandidate(text: "")]
)
_ = typing.handleKey("")
XCTAssertFalse(typing.canExpandCandidatePanel)
XCTAssertFalse(typing.isCandidatePanelExpanded)
}
func testLeaveTypingModeCollapsesPanel() {
let engine = StubRimeEngine()
let typing = TypingSessionController(engine: engine)
_ = typing.handleKey("n")
typing.toggleCandidatePanelExpanded()
XCTAssertTrue(typing.isCandidatePanelExpanded)
typing.leaveTypingMode()
XCTAssertFalse(typing.isCandidatePanelExpanded)
XCTAssertTrue(typing.composition.candidates.isEmpty)
}
}
// Minimal engine that returns a two-candidate snapshot after any letter.
@MainActor
private final class StubRimeEngine: RimeEngineBridging {
var composition: TypingComposition = .empty
var isReady: Bool = true
var schema: TypingInputSchema = .fullPinyin
/// Optional override applied on the next processBackspace.
var nextComposition: TypingComposition?
func prepare() async throws {}
func teardown() {
composition = .empty
}
func setLanguage(_ language: TypingInputLanguage) {
if language == .english {
composition = .empty
}
}
@discardableResult
func setSchema(_ schema: TypingInputSchema) -> Bool {
self.schema = schema
return true
}
func processCharacter(_ character: Character) -> String? {
composition = TypingComposition(
preedit: String(character),
candidates: [
TypingCandidate(text: ""),
TypingCandidate(text: "")
]
)
return nil
}
func processBackspace() -> String? {
if let next = nextComposition {
composition = next
nextComposition = nil
} else {
composition = .empty
}
return nil
}
func processSpace() -> String? {
let text = composition.candidates.first?.text ?? " "
composition = .empty
return text
}
func processReturn() -> String? {
composition = .empty
return "\n"
}
func selectCandidate(at index: Int) -> String {
guard composition.candidates.indices.contains(index) else { return "" }
let text = composition.candidates[index].text
composition = .empty
return text
}
func flushPreedit() -> String {
let raw = composition.preedit
composition = .empty
return raw
}
func clearComposition() {
composition = .empty
}
}