diff --git a/.github/workflows/android-smoke.yml b/.github/workflows/android-smoke.yml
index d359543..2237956 100644
--- a/.github/workflows/android-smoke.yml
+++ b/.github/workflows/android-smoke.yml
@@ -34,4 +34,6 @@ jobs:
api-level: 34
target: google_apis
arch: x86_64
- script: pwsh -NoProfile -File platforms/android/smoke.ps1 -Serial emulator-5554
+ script: |
+ pwsh -NoProfile -File platforms/android/smoke.ps1 -Serial emulator-5554
+ pwsh -NoProfile -File platforms/android/ui-smoke.ps1 -Serial emulator-5554
diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md
index 2fe8ed4..1f7072d 100644
--- a/CHANGELOG.en.md
+++ b/CHANGELOG.en.md
@@ -9,21 +9,23 @@ Korean version: [CHANGELOG.md](CHANGELOG.md).
## [Unreleased]
-### Fixed
+## [0.10.9] - 2026-10-23
-- Windows release updates and removal no longer fail before the administrator prompt because the encoded installer exceeds the Windows command-line limit. The elevated script is encoded once, preserving release verification and the existing local installation path.
+### Added
+
+- Android keyboard tools for pasting the current clipboard, moving the cursor, choosing an input mode, and controlling haptics and key previews. Clipboard history is not stored, and sensitive content is hidden from previews.
### Changed
+- Refined Android keyboard light/dark surfaces and press feedback with the Tacit logo and Lucide icons. The selected reading appears first with a clear highlight; double-tapping Shift locks capitals, and Enter displays its current confirmation, search, send, or other action.
- Plain confirmation now keeps unfinished readings in the language already shown, including Enter, shortcuts and session flushes. Standalone letters are classified before display, so English suffixes no longer depend on confirmation to fix a Korean preview. Japanese confirmation keeps the displayed segmentation and live candidate, completing only a pending Japanese `n` to `ん`; explicit language changes, Space conversion, Tab completion and newly typed digit context remain separate actions.
-## [0.10.9] - 2026-10-23
-
### Fixed
+- Windows release updates and removal no longer fail before the administrator prompt because the encoded installer exceeds the Windows command-line limit. The elevated script is encoded once, preserving release verification and the existing local installation path.
+- Overlapping Shift input such as `akfgkfRK` now stays Korean (`말할까`) when supported by existing vocabulary or inflections; otherwise, case-sensitive Latin identifiers are preserved. The Korean exception no longer admits Japanese candidates. Unlisted Korean spellings with an overlapping Shift key now keep their Latin keys until explicitly corrected; normal Korean key spelling remains available.
- Generalized correction preferences now apply only to the actual runner-up language. This prevents a Korean preference from turning English into Japanese or broken Hangul when Japanese is enabled, while preserving explicitly learned words.
- Confirmation with Space or Enter keeps the language of a completed composition. This fixes `연습햇` becoming `연습got` and Japanese previews becoming English or Korean, while preserving explicit language cycling and Japanese candidate conversion.
-- A vowel typed while Shift overlaps a preceding Korean doubled consonant is scored normally instead of being categorically classified as English. This fixes `말할까` becoming `akfgkfRK`, while preserving the original keys and English capitalization.
## [0.10.8] - 2026-10-22
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9d0fdf7..a1907a7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,21 +9,23 @@ GitHub 릴리스 노트가 되고 대시보드의 업데이트 탭에도 그대
## [Unreleased]
-### 수정
+## [0.10.9] - 2026-10-23
-- Windows 릴리스 업데이트·제거 시 인코딩된 설치 명령이 Windows 명령줄 길이 제한을 넘어 관리자 권한 창도 뜨지 않던 문제를 고쳤습니다. 관리자 스크립트는 한 번만 인코딩하며, 릴리스 검증과 기존 로컬 설치 경로는 유지합니다.
+### 추가
+
+- Android 키보드에 현재 클립보드 붙여넣기, 커서 이동, 입력 모드 메뉴와 진동·키 미리보기 설정을 추가했습니다. 클립보드 기록은 저장하지 않으며 민감한 내용은 미리 표시하지 않습니다.
### 변경
+- Android 키보드에 Tacit 로고와 Lucide 아이콘을 적용하고 밝은·어두운 키 배경과 눌림 표시를 다듬었습니다. 선택된 읽기는 앞쪽에서 강조하며, Shift 두 번으로 대문자를 고정하고 Enter는 확정·검색·전송 등 현재 동작을 표시합니다.
- Enter·단축키·세션 종료 등 단순 확정은 미완성 입력도 화면에 보인 언어로 유지합니다. 한 글자는 표시 전에 독립된 입력으로 판별해 영어 접미사의 한글 미리보기를 확정 때 고치는 동작을 없앴습니다. 일본어는 표시된 분절과 실시간 후보를 유지하고, 미완성 일본어 `n`만 `ん`으로 마무리합니다. 명시적인 언어 변경·Space 변환·Tab 자동완성과 새로 입력한 숫자 문맥은 별도 동작으로 유지합니다.
-## [0.10.9] - 2026-10-23
-
### 수정
+- Windows 릴리스 업데이트·제거 시 인코딩된 설치 명령이 Windows 명령줄 길이 제한을 넘어 관리자 권한 창도 뜨지 않던 문제를 고쳤습니다. 관리자 스크립트는 한 번만 인코딩하며, 릴리스 검증과 기존 로컬 설치 경로는 유지합니다.
+- `akfgkfRK` (`말할까`)처럼 Shift가 겹친 입력은 기존 한국어 어휘·활용형 근거가 있을 때만 대문자 보호를 완화해, 그 외에는 라틴 식별자의 대소문자를 보존합니다. 한국어 예외로 일본어 후보까지 허용하던 경로도 제한합니다. 사전에 없는 한국어에 Shift가 겹치면 라틴 키를 유지하며, 명시적 언어 교정이나 정상 한국어 키 입력으로 입력할 수 있습니다.
- 일반 선호 학습은 실제 2순위 언어에만 적용합니다. 일본어가 켜져 있을 때 한국어 선호 때문에 영어가 일본어나 깨진 한글로 바뀌는 문제를 고쳤으며, 명시적으로 학습한 단어는 유지합니다.
- Space·Enter로 확정할 때 완성된 조합의 언어를 유지합니다. `연습햇`이 `연습got`으로, 일본어 미리보기가 영어나 한국어로 바뀌던 문제를 고쳤으며, 명시적인 언어 전환과 일본어 후보 변환은 유지합니다.
-- 한국어 된소리를 입력한 뒤 모음까지 Shift가 겹쳐도 자동으로 영문으로 단정하지 않습니다. `말할까`가 `akfgkfRK`로 바뀌던 문제를 고쳤으며, 원래 키 입력과 영문 대소문자는 보존합니다.
## [0.10.8] - 2026-10-22
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 19cf34f..8c179dc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -31,6 +31,11 @@ Android APK를 빌드한 뒤 실행 중인 에뮬레이터·기기에서 JNI를
`./platforms/android/smoke.ps1 -Serial emulator-5554`를 실행합니다. 생성·활성화,
영·한 입력, 일시 정지·재개와 해제를 검사하며 시스템 입력기 선택은 바꾸지 않습니다.
+키보드 UI는 격리된 에뮬레이터에서 `./platforms/android/ui-smoke.ps1 -Serial emulator-5560`으로
+검사합니다. 테스트 APK를 설치하고 Tacit을 잠시 선택한 뒤 기존 입력기를 복원하며, 실제
+조합·클립보드·선택 영역·대문자 고정·Enter·커서 전달을 검증합니다. 테스트용 편집 화면은
+`io.github.iamallyouneed.tacit.tests/io.github.iamallyouneed.tacit.KeyboardTestActivity`입니다.
+
```sh
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
@@ -137,6 +142,11 @@ After building the Android APK, run `./platforms/android/smoke.ps1 -Serial emula
against a running emulator/device to check JNI creation, activation, English/Korean input,
pause/resume, reset and destruction. This does not change the system's selected input method.
+Run `./platforms/android/ui-smoke.ps1 -Serial emulator-5560` on an isolated emulator for keyboard UI
+checks. It installs the test APKs, temporarily selects Tacit, restores the previous IME, and checks
+real composition, clipboard/selection behavior, caps lock, Enter and cursor events. The separate
+test editor is `io.github.iamallyouneed.tacit.tests/io.github.iamallyouneed.tacit.KeyboardTestActivity`.
+
```sh
cargo fmt --all -- --check
cargo clippy --workspace --all-targets -- -D warnings
diff --git a/crates/core/src/decide/engine.rs b/crates/core/src/decide/engine.rs
index badb6ca..d0189b7 100644
--- a/crates/core/src/decide/engine.rs
+++ b/crates/core/src/decide/engine.rs
@@ -374,27 +374,46 @@ impl DecisionEngine for HeuristicEngine {
fn decide(&self, keys: &str, context: &DecisionContext) -> Decision {
let mut previous = '\0';
let mut has_lowercase = false;
+ let mut shift_overlap = false;
let english_capital = self.english_on
// Keep ordinary lowercase typing on the existing fast path.
&& keys
.chars()
.any(|c| c.is_uppercase() && !DubeolsikLayout::is_korean_shift_key(c))
&& keys.chars().any(|c| {
- // Shift may overlap the vowel after a doubled consonant; let the models judge it.
+ // Shift may overlap the vowel after a doubled consonant; verify its Korean evidence below.
let overlap = self.korean_on
&& has_lowercase
&& matches!(previous, 'Q' | 'W' | 'E' | 'R' | 'T')
&& DubeolsikLayout::jamo(c).is_some_and(is_vowel);
+ shift_overlap |= c.is_uppercase()
+ && !DubeolsikLayout::is_korean_shift_key(c)
+ && overlap;
previous = c;
has_lowercase |= c.is_ascii_lowercase();
c.is_uppercase() && !DubeolsikLayout::is_korean_shift_key(c) && !overlap
});
- if english_capital {
+ // Syllable likelihood alone also accepts identifiers; require existing Korean lexical evidence.
+ let korean_overlap = !shift_overlap
+ || HangulComposer::compose(keys).is_some_and(|composed| {
+ if composed.is_broken() {
+ return false;
+ }
+ let text = composed.text();
+ self.korean.is_known_word_or_inflected(&text)
+ || self.korean.is_vocabulary_word(&text)
+ });
+ if english_capital || !korean_overlap {
return Decision {
language: Language::English,
confidence: CAPITALISED_ENGLISH_CONFIDENCE,
output: keys.to_string(),
- explanation: "uppercase outside QWERTOP".to_string(),
+ explanation: if english_capital {
+ "uppercase outside QWERTOP"
+ } else {
+ "Shift overlap without Korean lexical evidence"
+ }
+ .to_string(),
runner_up: None,
};
}
@@ -438,6 +457,8 @@ impl DecisionEngine for HeuristicEngine {
#[cfg(feature = "japanese")]
let j = self
.japanese
+ // Korean Shift evidence only admits a Korean reading, not unrelated Japanese candidates.
+ .filter(|_| !shift_overlap)
.and_then(|model| model.score(keys, context.partial && !known));
// English words inside a Japanese phrase (googlede…) score as segmented Japanese,
// not as kana with stray letters.
diff --git a/crates/core/tests/engine.rs b/crates/core/tests/engine.rs
index 230ee35..a39ae30 100644
--- a/crates/core/tests/engine.rs
+++ b/crates/core/tests/engine.rs
@@ -87,7 +87,7 @@ fn shift_overlap_uses_korean_scores_without_losing_english_case() {
let e = heuristic();
for partial in [true, false] {
let context = DecisionContext { partial, ..ctx() };
- for keys in ["akfgkfRk", "akfgkfRK", "gkfRK", "dlfRK"] {
+ for keys in ["akfgkfRk", "akfgkfRK", "gkfRK", "dlfRk"] {
let d = e.decide(keys, &context);
assert_eq!(d.language, Language::Korean, "{keys}: {d:?}");
assert_eq!(
@@ -108,6 +108,81 @@ fn shift_overlap_uses_korean_scores_without_losing_english_case() {
}
}
+#[test]
+fn shift_overlap_preserves_matching_latin_identifiers() {
+ let mut failures = Vec::new();
+ for japanese in [false, true] {
+ let languages = Languages::legacy(japanese);
+ let e = HeuristicEngine::for_languages(&languages);
+ for partial in [true, false] {
+ let context = DecisionContext { partial, ..ctx() };
+ // These intended identifiers enter the same Shift-overlap gate as Korean input.
+ for keys in [
+ "cmdWL", "rmWL", "dmlWL", "turnsRI", "cmdWLr", "rmWLr", "dmlWLr", "cmdWLfoo",
+ ] {
+ let d = e.decide(keys, &context);
+ if d.language != Language::English || d.output != keys {
+ failures.push(format!(
+ "languages={:?}, partial={partial}, {keys}: {d:?}",
+ languages.ids()
+ ));
+ }
+ }
+ }
+ }
+ assert!(failures.is_empty(), "{}", failures.join("\n"));
+}
+
+#[test]
+fn shift_overlap_retains_korean_positive_cases() {
+ for japanese in [false, true] {
+ let e = HeuristicEngine::for_languages(&Languages::legacy(japanese));
+ for partial in [true, false] {
+ let context = DecisionContext { partial, ..ctx() };
+ for (keys, text) in [("akfgkfRK", "말할까"), ("smfWlr", "늘찍")] {
+ let d = e.decide(keys, &context);
+ assert_eq!(d.language, Language::Korean, "{keys}: {d:?}");
+ assert_eq!(d.output, text, "{keys}: {d:?}");
+ assert_eq!(e.render(keys, Language::English), keys);
+ }
+ }
+ }
+}
+
+#[test]
+fn shift_overlap_without_lexical_evidence_preserves_case() {
+ for japanese in [false, true] {
+ let e = HeuristicEngine::for_languages(&Languages::legacy(japanese));
+ for partial in [true, false] {
+ // An unlisted Korean spelling is ambiguous too; it needs canonical keys or explicit correction.
+ for keys in ["smfWLr", "dlfRK", "yukiRI"] {
+ let d = e.decide(keys, &DecisionContext { partial, ..ctx() });
+ assert_eq!(d.language, Language::English, "{keys}: {d:?}");
+ assert_eq!(d.output, keys);
+ }
+ }
+ }
+}
+
+#[cfg(feature = "japanese")]
+#[test]
+fn korean_shift_evidence_does_not_admit_japanese() {
+ let mut e = HeuristicEngine::for_languages(&Languages::legacy(true));
+ // Even an overwhelming Japanese score cannot use a Korean-specific exception.
+ e.japanese_offset = 100.0;
+ for partial in [true, false] {
+ let d = e.decide("akfgkfRK", &DecisionContext { partial, ..ctx() });
+ assert_eq!(d.language, Language::Korean, "{d:?}");
+ assert_eq!(d.output, "말할까");
+ }
+ let e = HeuristicEngine::for_languages(&Languages::from_ids(&["en", "ja"]).unwrap());
+ for keys in ["cmdWL", "rmWL", "dmlWL", "turnsRI", "yukiRI"] {
+ let d = e.decide(keys, &ctx());
+ assert_eq!(d.language, Language::English, "{keys}: {d:?}");
+ assert_eq!(d.output, keys);
+ }
+}
+
#[test]
fn mixed_sentence() {
let e = heuristic();
diff --git a/crates/core/tests/session.rs b/crates/core/tests/session.rs
index 124dc90..3716f40 100644
--- a/crates/core/tests/session.rs
+++ b/crates/core/tests/session.rs
@@ -319,6 +319,35 @@ fn confirmation_keeps_the_visible_korean_reading() {
}
}
+#[test]
+fn shift_overlap_identifiers_keep_case_and_allow_explicit_korean() {
+ for keys in ["cmdWL", "rmWL", "dmlWL", "turnsRI", "smfWLr", "dlfRK"] {
+ for correct in [false, true] {
+ let mut r = Rig::new("shift-overlap-identifiers");
+ r.field.insert_text("앞😀 뒤", None);
+ r.field.cursor = "앞😀 ".encode_utf16().count();
+ r.type_(keys);
+ assert_eq!(r.text(), format!("앞😀 {keys} 뒤"));
+ let expected = if correct {
+ r.key(Key::Escape);
+ tacit_core::lang::hangul::HangulComposer::compose(keys)
+ .unwrap()
+ .text()
+ } else {
+ keys.to_string()
+ };
+ assert_eq!(r.text(), format!("앞😀 {expected} 뒤"));
+ assert!(r.key(Key::Enter));
+ assert_eq!(r.text(), format!("앞😀 {expected} 뒤"));
+ assert!(!r.composing());
+ assert_eq!(
+ r.field.selected_range().location,
+ format!("앞😀 {expected}").encode_utf16().count()
+ );
+ }
+ }
+}
+
#[test]
fn confirmation_keeps_incomplete_previews() {
let mut changes = Vec::new();
diff --git a/docs/report/performance-enkoja.md b/docs/report/performance-enkoja.md
index 399c60e..7ecf34e 100644
--- a/docs/report/performance-enkoja.md
+++ b/docs/report/performance-enkoja.md
@@ -24,17 +24,17 @@ cargo run --release --manifest-path lab/Cargo.toml -- --profile enkoja --save
직전 버전과 비교합니다. 기준선은 `lab/baseline-enkoja.tsv`이며, 영·한 기준선을 대신
사용하지 않습니다. 아래 수치는 동일 프로필 5회 실행의 중앙값입니다.
-## 한눈에: 0.10.2 → 0.10.3
+## 한눈에: 0.10.3 → 0.10.9
▲ 좋아짐 · ▼ 나빠짐 · = 동점/잡음(정확도 0.05%p, 지연 25% 안은 잡음으로 봄). 정확도는 높을수록, 크기·지연·메모리는 낮을수록 좋음.
-이전 열의 정확도·지연·메모리는 0.10.2 태그를 같은 lab·데이터로 0.10.3과 번갈아 5바퀴 돌린 중앙값입니다. 크기는 각 버전의 빌드 기록이며, 0.10.2는 일본어를 항상 포함한 단일 패키지(`tacit-windows.zip`), 0.10.3은 일본어 포함판(`tacit-windows-ja.zip`)입니다. 지연은 이 컴퓨터의 상대 비교만 의미가 있습니다.
+이전 열은 같은 프로필의 과거 보고서 기록입니다. 같은 lab·데이터로 교차 재측정하지 않은 지연 차이는 코드 개선의 증거로 해석하지 않습니다.
### 정확도 핵심 (%)
막대 범위: 영·한 80~100%, 일본어(ja.*) 0~100%.
-| 지표 | 0.10.2 | 0.10.3 | 변화 |
+| 지표 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
| `acc.ALL` | 99.52 `████████████` | 99.52 `████████████` | +0.00 = |
| `acc.en.freq/none` | 98.85 `███████████░` | 98.85 `███████████░` | +0.00 = |
@@ -63,7 +63,7 @@ cargo run --release --manifest-path lab/Cargo.toml -- --profile enkoja --save
### 유형별 시나리오 문장 정답률 (%, 높을수록 좋음, 막대 80~100 구간)
-| 유형 | 0.10.2 | 0.10.3 | 변화 |
+| 유형 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
| chat | 96.51 `██████████░░` | 96.51 `██████████░░` | +0.00 = |
| dev | 98.98 `███████████░` | 98.98 `███████████░` | +0.00 = |
@@ -72,43 +72,150 @@ cargo run --release --manifest-path lab/Cargo.toml -- --profile enkoja --save
### 크기 비교 (KiB, 낮을수록 좋음, 막대 표 안 최대 기준)
-| 항목 | 0.10.2 | 0.10.3 | 변화 |
+| 항목 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
-| `tacit_tip.dll` (x64) | 957 `█░░░░░░░░░░░` | 962 `█░░░░░░░░░░░` | +5 (+0.5%) ▼ |
-| `tacit_tip.dll` (x86) | 771 `█░░░░░░░░░░░` | 773 `█░░░░░░░░░░░` | +2 (+0.3%) ▼ |
-| `tacit-config.exe` | 3277 `███░░░░░░░░░` | 3285 `███░░░░░░░░░` | +8 (+0.2%) ▼ |
-| `tacit-windows-ja.zip` | - | 2389 `██░░░░░░░░░░` | 신규 |
-| `tacit-pack-en-0.10.3.zip` | 446 `█░░░░░░░░░░░` | 446 `█░░░░░░░░░░░` | 0 = |
-| `tacit-pack-ko-0.10.3.zip` | 450 `█░░░░░░░░░░░` | 450 `█░░░░░░░░░░░` | 0 = |
-| `tacit-pack-ja-0.10.3.zip` | 14628 `████████████` | 14628 `████████████` | 0 = |
+| `tacit_tip.dll` (x64) | 962 `█░░░░░░░░░░░` | 968 `█░░░░░░░░░░░` | +6 (+0.6%) ▼ |
+| `tacit_tip.dll` (x86) | 773 `█░░░░░░░░░░░` | 778 `█░░░░░░░░░░░` | +5 (+0.6%) ▼ |
+| `tacit-config.exe` | 3285 `███░░░░░░░░░` | 3339 `███░░░░░░░░░` | +54 (+1.6%) ▼ |
+| `tacit-windows-ja.zip` | 2389 `██░░░░░░░░░░` | 2418 `██░░░░░░░░░░` | +29 (+1.2%) ▼ |
+| `tacit-pack-en-0.10.9.zip` | 446 `█░░░░░░░░░░░` | 446 `█░░░░░░░░░░░` | 0 = |
+| `tacit-pack-ko-0.10.9.zip` | 450 `█░░░░░░░░░░░` | 451 `█░░░░░░░░░░░` | +1 (+0.2%) ▼ |
+| `tacit-pack-ja-0.10.9.zip` | 14628 `████████████` | 14628 `████████████` | 0 = |
### 지연 비교 (µs 중앙값, 낮을수록 좋음, 막대 표 안 최대 기준)
-| 항목 | 0.10.2 | 0.10.3 | 변화 |
+| 항목 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
-| `commit.avg_us` | 84.15 `██░░░░░░░░░░` | 46.94 `█░░░░░░░░░░░` | -37.21 (-44.2%) ▲ |
-| `commit.p50_us` | 54.30 `█░░░░░░░░░░░` | 42.50 `█░░░░░░░░░░░` | -11.80 (-21.7%) = |
-| `commit.p95_us` | 257.30 `██████░░░░░░` | 99.90 `██░░░░░░░░░░` | -157.40 (-61.2%) ▲ |
-| `commit.p99_us` | 387.30 `█████████░░░` | 143.50 `███░░░░░░░░░` | -243.80 (-62.9%) ▲ |
-| `keystroke.avg_us` | 37.78 `█░░░░░░░░░░░` | 22.07 `█░░░░░░░░░░░` | -15.71 (-41.6%) ▲ |
-| `keystroke.p50_us` | 22.10 `█░░░░░░░░░░░` | 14.00 `█░░░░░░░░░░░` | -8.10 (-36.7%) ▲ |
-| `keystroke.p95_us` | 136.00 `███░░░░░░░░░` | 64.30 `██░░░░░░░░░░` | -71.70 (-52.7%) ▲ |
-| `keystroke.p99_us` | 245.90 `██████░░░░░░` | 101.30 `██░░░░░░░░░░` | -144.60 (-58.8%) ▲ |
-| `ja.convert.avg_us` | 246.87 `██████░░░░░░` | 191.27 `█████░░░░░░░` | -55.60 (-22.5%) = |
-| `ja.convert.p50_us` | 222.70 `█████░░░░░░░` | 173.10 `████░░░░░░░░` | -49.60 (-22.3%) = |
-| `ja.convert.p95_us` | 438.70 `██████████░░` | 320.90 `████████░░░░` | -117.80 (-26.9%) ▲ |
-| `ja.convert.p99_us` | 509.50 `████████████` | 386.00 `█████████░░░` | -123.50 (-24.2%) = |
+| `commit.avg_us` | 46.94 `█░░░░░░░░░░░` | 78.49 `█░░░░░░░░░░░` | +31.55 (+67.2%) ▼ |
+| `commit.p50_us` | 42.50 `█░░░░░░░░░░░` | 68.40 `█░░░░░░░░░░░` | +25.90 (+60.9%) ▼ |
+| `commit.p95_us` | 99.90 `█░░░░░░░░░░░` | 172.30 `██░░░░░░░░░░` | +72.40 (+72.5%) ▼ |
+| `commit.p99_us` | 143.50 `██░░░░░░░░░░` | 257.60 `████░░░░░░░░` | +114.10 (+79.5%) ▼ |
+| `keystroke.avg_us` | 22.07 `█░░░░░░░░░░░` | 37.14 `█░░░░░░░░░░░` | +15.07 (+68.3%) ▼ |
+| `keystroke.p50_us` | 14.00 `█░░░░░░░░░░░` | 22.70 `█░░░░░░░░░░░` | +8.70 (+62.1%) ▼ |
+| `keystroke.p95_us` | 64.30 `█░░░░░░░░░░░` | 111.00 `██░░░░░░░░░░` | +46.70 (+72.6%) ▼ |
+| `keystroke.p99_us` | 101.30 `█░░░░░░░░░░░` | 181.80 `███░░░░░░░░░` | +80.50 (+79.5%) ▼ |
+| `ja.convert.avg_us` | 191.27 `███░░░░░░░░░` | 360.45 `█████░░░░░░░` | +169.18 (+88.5%) ▼ |
+| `ja.convert.p50_us` | 173.10 `██░░░░░░░░░░` | 317.00 `████░░░░░░░░` | +143.90 (+83.1%) ▼ |
+| `ja.convert.p95_us` | 320.90 `████░░░░░░░░` | 592.00 `████████░░░░` | +271.10 (+84.5%) ▼ |
+| `ja.convert.p99_us` | 386.00 `█████░░░░░░░` | 860.00 `████████████` | +474.00 (+122.8%) ▼ |
### 시작·메모리 비교 (낮을수록 좋음, 막대 행별 비교)
-| 항목 | 0.10.2 | 0.10.3 | 변화 |
+| 항목 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
-| `load.ms` | 16.89 `███████████░` | 18.74 `████████████` | +1.85 (+11.0%) = |
-| `heap.loaded_kb` | 2468.13 `████████████` | 2468.14 `████████████` | +0.01 (+0.0%) = |
-| `heap.peak_kb` | 14047.55 `████████████` | 14047.56 `████████████` | +0.01 (+0.0%) = |
-| `ja.load.ms` | 3.22 `████████████` | 2.91 `███████████░` | -0.31 (-9.6%) = |
+| `load.ms` | 18.74 `███████░░░░░` | 30.86 `████████████` | +12.12 (+64.7%) ▼ |
+| `heap.loaded_kb` | 2468.14 `████████████` | 2468.14 `████████████` | +0.00 (+0.0%) = |
+| `heap.peak_kb` | 14047.56 `████████████` | 14047.65 `████████████` | +0.09 (+0.0%) = |
+| `ja.load.ms` | 2.91 `██████░░░░░░` | 5.60 `████████████` | +2.69 (+92.4%) ▼ |
| `ja.heap.loaded_kb` | 2998.60 `████████████` | 2998.60 `████████████` | +0.00 (+0.0%) = |
+## 0.10.9
+
+commit b58cc6d, 2026-10-23, rustc 1.93.1 (01f6ddf75 2026-02-11)
+
+프로필: `enkoja` (영·한·일), lab 0.2.12.
+
+공통 정확도·시나리오는 해당 언어가 켜진 판별기로 측정합니다. acc.ALL은 영·한 공통 세트이며 일본어 정답률과 합산하지 않습니다.
+
+load.ms·heap.*는 판별기 시작/단어 평가 기준입니다. enkoja의 ja.load.ms·ja.heap.loaded_kb는 별도 가나→한자 변환기 초기화/힙 증가입니다. 영·한판(tacit-windows.zip)과 일본어 포함판(tacit-windows-ja.zip)은 프로그램과 설치 zip이 다르고, 팩 목록도 활성 언어에 따라 다릅니다.
+
+### 크기 (KiB)
+
+| 항목 | KiB |
+| --- | ---: |
+| `tacit_tip.dll` (x64) | 968 |
+| `tacit_tip.dll` (x86) | 778 |
+| `tacit-config.exe` | 3339 |
+| `tacit-windows-ja.zip` | 2418 |
+| `tacit-pack-en-0.10.9.zip` | 446 |
+| `tacit-pack-ko-0.10.9.zip` | 451 |
+| `tacit-pack-ja-0.10.9.zip` | 14628 |
+
+### 정확도 (%)
+
+| 세트 | 정확도 |
+| --- | ---: |
+| `acc.ALL` | 99.52 |
+| `acc.en.freq/none` | 98.85 |
+| `acc.en.freq/after-ko` | 98.80 |
+| `acc.ko.words/none` | 100.00 |
+| `acc.ko.vocab/none` | 100.00 |
+| `big.ko.sent.sentence` | 99.03 |
+| `big.ko.sent.token` | 99.81 |
+| `big.ko.phrase.sentence` | 99.37 |
+| `big.ko.long.sentence` | 97.70 |
+| `big.en.sent.sentence` | 99.90 |
+| `big.cs.particle.sentence` | 99.06 |
+| `big.cs.particle.token` | 99.85 |
+| `big.cs_heldout.particle.sentence` | 98.95 |
+| `big.cs.bare.sentence` | 96.94 |
+| `big.cs.number.sentence` | 99.05 |
+| `big.ko.title.none` | 99.87 |
+| `big.ko.title.after-en` | 99.37 |
+| `big.learn.other_forms_before` | 100.00 |
+| `big.learn.other_forms_after` | 100.00 |
+| `ja.convert.ALL.top1` | 85.94 |
+| `ja.convert.ALL.top5` | 96.88 |
+| `ja.detect.none.top1` | 100.00 |
+| `ja.detect.after-ja.top1` | 100.00 |
+| `ja.guard.english.top1` | 100.00 |
+
+### 유형별 시나리오 (문장 정답률 %, token은 단어 단위, chain은 4문장 이어 입력)
+
+| 유형 | 문장 | token | chain.token | µs/글자 |
+| --- | ---: | ---: | ---: | ---: |
+| chat | 96.51 | 98.78 | 99.17 | 8.42 |
+| dev | 98.98 | 99.69 | 99.37 | 9.05 |
+| writing | 98.78 | 99.77 | 99.75 | 10.60 |
+| 전체 | 98.12 | 99.50 | | |
+
+| 시나리오 | 문장 정답률 |
+| --- | ---: |
+| `chat.brand` | 100.00 |
+| `chat.casual` | 100.00 |
+| `chat.english` | 100.00 |
+| `chat.loan` | 92.31 |
+| `chat.number` | 100.00 |
+| `chat.punct` | 100.00 |
+| `chat.slang` | 88.24 |
+| `dev.commit` | 100.00 |
+| `dev.error` | 100.00 |
+| `dev.identifier` | 100.00 |
+| `dev.issue` | 100.00 |
+| `dev.review` | 100.00 |
+| `dev.terminal` | 96.00 |
+| `dev.tool` | 100.00 |
+| `writing.academic` | 92.86 |
+| `writing.email` | 100.00 |
+| `writing.english` | 100.00 |
+| `writing.essay` | 100.00 |
+| `writing.formal` | 100.00 |
+| `writing.long` | 100.00 |
+| `writing.numbers` | 100.00 |
+| `writing.quote` | 100.00 |
+
+### 속도·메모리 (중앙값, 5회)
+
+| 항목 | 값 |
+| --- | ---: |
+| `commit.avg_us` | 78.49 |
+| `commit.p50_us` | 68.40 |
+| `commit.p95_us` | 172.30 |
+| `commit.p99_us` | 257.60 |
+| `keystroke.avg_us` | 37.14 |
+| `keystroke.p50_us` | 22.70 |
+| `keystroke.p95_us` | 111.00 |
+| `keystroke.p99_us` | 181.80 |
+| `ja.convert.avg_us` | 360.45 |
+| `ja.convert.p50_us` | 317.00 |
+| `ja.convert.p95_us` | 592.00 |
+| `ja.convert.p99_us` | 860.00 |
+| `load.ms` | 30.86 |
+| `heap.loaded_kb` | 2468.14 |
+| `heap.peak_kb` | 14047.65 |
+| `ja.load.ms` | 5.60 |
+| `ja.heap.loaded_kb` | 2998.60 |
+
## 0.10.3
commit 5816b5d (작업 트리 변경 포함), 2026-10-19, rustc 1.93.1 (01f6ddf75 2026-02-11)
diff --git a/docs/report/performance.md b/docs/report/performance.md
index a765de7..04ffbb8 100644
--- a/docs/report/performance.md
+++ b/docs/report/performance.md
@@ -86,17 +86,17 @@ sh lab/report.sh --profile enkoja > section-enkoja.md # 별도 영·한·일 문
- 기준선 저장도 프로필별입니다: `cargo run --release --manifest-path lab/Cargo.toml -- --profile enko --save`는 `lab/baseline-enko.tsv`, `--profile enkoja --save`는 `lab/baseline-enkoja.tsv`만 갱신합니다. 기본값은 `enko`입니다.
- **이전 버전과 비교할 때는 같은 lab으로 잽니다.** 세트와 데이터가 달라지면 같은 코드도 수치가 달라집니다. 새 worktree 둘을 만들어 한쪽에만 `git restore --source=<이전 태그> --worktree --staged crates/core`를 하고(코어에 없는 API를 쓰는 `lab/src/tune.rs` 같은 파일은 그 worktree에서만 비활성으로 바꿉니다), 각각 `lab/data`를 **복사**해 두고 5회씩 돌려 중앙값을 냅니다. `lab/data`를 junction이나 링크로 연결한 worktree를 `git worktree remove --force` 하면 링크 너머의 실제 데이터까지 지워집니다.
-## 한눈에: 0.10.2 → 0.10.3
+## 한눈에: 0.10.3 → 0.10.9
▲ 좋아짐 · ▼ 나빠짐 · = 동점/잡음(정확도 0.05%p, 지연 25% 안은 잡음으로 봄). 정확도는 높을수록, 크기·지연·메모리는 낮을수록 좋음.
-0.10.3은 릴리즈 전(미출시) 작업 트리입니다. 이전 열의 정확도·지연·메모리는 0.10.2 태그를 같은 lab·데이터로 0.10.3과 번갈아 9바퀴 돌린 중앙값이고, 크기는 같은 컴퓨터에서 같은 빌드 설정으로 만든 0.10.2 로컬 빌드입니다. 그래서 앞 보고서의 0.10.2 값(지연이 약 1.8배 빠름)과 직접 이어 읽지 않습니다. 지연은 이 컴퓨터의 상대 비교만 의미가 있습니다.
+이전 열은 같은 프로필의 과거 보고서 기록입니다. 같은 lab·데이터로 교차 재측정하지 않은 지연 차이는 코드 개선의 증거로 해석하지 않습니다.
### 정확도 핵심 (%)
막대 범위: 영·한 80~100%, 일본어(ja.*) 0~100%.
-| 지표 | 0.10.2 | 0.10.3 | 변화 |
+| 지표 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
| `acc.ALL` | 99.69 `████████████` | 99.69 `████████████` | +0.00 = |
| `acc.en.freq/none` | 99.50 `████████████` | 99.50 `████████████` | +0.00 = |
@@ -120,7 +120,7 @@ sh lab/report.sh --profile enkoja > section-enkoja.md # 별도 영·한·일 문
### 유형별 시나리오 문장 정답률 (%, 높을수록 좋음, 막대 80~100 구간)
-| 유형 | 0.10.2 | 0.10.3 | 변화 |
+| 유형 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
| chat | 96.51 `██████████░░` | 96.51 `██████████░░` | +0.00 = |
| dev | 98.98 `███████████░` | 98.98 `███████████░` | +0.00 = |
@@ -129,35 +129,130 @@ sh lab/report.sh --profile enkoja > section-enkoja.md # 별도 영·한·일 문
### 크기 비교 (KiB, 낮을수록 좋음, 막대 표 안 최대 기준)
-| 항목 | 0.10.2 | 0.10.3 | 변화 |
+| 항목 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
-| `tacit_tip.dll` (x64) | 951 `███░░░░░░░░░` | 747 `███░░░░░░░░░` | -204 (-21.5%) ▲ |
-| `tacit_tip.dll` (x86) | 766 `███░░░░░░░░░` | 613 `██░░░░░░░░░░` | -153 (-20.0%) ▲ |
-| `tacit-config.exe` | 3276 `████████████` | 3100 `███████████░` | -176 (-5.4%) ▲ |
-| `tacit-windows.zip` | 2379 `█████████░░░` | 2150 `████████░░░░` | -229 (-9.6%) ▲ |
-| `tacit-pack-en-0.10.3.zip` | 446 `██░░░░░░░░░░` | 446 `██░░░░░░░░░░` | 0 = |
-| `tacit-pack-ko-0.10.3.zip` | 451 `██░░░░░░░░░░` | 451 `██░░░░░░░░░░` | 0 = |
+| `tacit_tip.dll` (x64) | 747 `███░░░░░░░░░` | 756 `███░░░░░░░░░` | +9 (+1.2%) ▼ |
+| `tacit_tip.dll` (x86) | 613 `██░░░░░░░░░░` | 620 `██░░░░░░░░░░` | +7 (+1.1%) ▼ |
+| `tacit-config.exe` | 3100 `████████████` | 3158 `████████████` | +58 (+1.9%) ▼ |
+| `tacit-windows.zip` | 2150 `████████░░░░` | 2182 `████████░░░░` | +32 (+1.5%) ▼ |
+| `tacit-pack-en-0.10.9.zip` | 446 `██░░░░░░░░░░` | 446 `██░░░░░░░░░░` | 0 = |
+| `tacit-pack-ko-0.10.9.zip` | 451 `██░░░░░░░░░░` | 451 `██░░░░░░░░░░` | 0 = |
### 지연 비교 (µs 중앙값, 낮을수록 좋음, 막대 표 안 최대 기준)
-| 항목 | 0.10.2 | 0.10.3 | 변화 |
+| 항목 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
-| `commit.avg_us` | 10.32 `██░░░░░░░░░░` | 10.48 `███░░░░░░░░░` | +0.16 (+1.6%) = |
-| `commit.p50_us` | 9.20 `██░░░░░░░░░░` | 9.00 `██░░░░░░░░░░` | -0.20 (-2.2%) = |
-| `commit.p95_us` | 18.20 `████░░░░░░░░` | 19.80 `█████░░░░░░░` | +1.60 (+8.8%) = |
-| `commit.p99_us` | 31.30 `███████░░░░░` | 37.00 `█████████░░░` | +5.70 (+18.2%) = |
-| `keystroke.avg_us` | 9.48 `██░░░░░░░░░░` | 9.70 `██░░░░░░░░░░` | +0.22 (+2.3%) = |
-| `keystroke.p50_us` | 7.80 `██░░░░░░░░░░` | 7.60 `██░░░░░░░░░░` | -0.20 (-2.6%) = |
-| `keystroke.p95_us` | 20.60 `█████░░░░░░░` | 22.80 `█████░░░░░░░` | +2.20 (+10.7%) = |
-| `keystroke.p99_us` | 44.90 `███████████░` | 50.30 `████████████` | +5.40 (+12.0%) = |
+| `commit.avg_us` | 10.48 `██░░░░░░░░░░` | 15.84 `██░░░░░░░░░░` | +5.36 (+51.1%) ▼ |
+| `commit.p50_us` | 9.00 `█░░░░░░░░░░░` | 12.80 `██░░░░░░░░░░` | +3.80 (+42.2%) ▼ |
+| `commit.p95_us` | 19.80 `███░░░░░░░░░` | 26.30 `████░░░░░░░░` | +6.50 (+32.8%) ▼ |
+| `commit.p99_us` | 37.00 `█████░░░░░░░` | 56.50 `████████░░░░` | +19.50 (+52.7%) ▼ |
+| `keystroke.avg_us` | 9.70 `█░░░░░░░░░░░` | 15.18 `██░░░░░░░░░░` | +5.48 (+56.5%) ▼ |
+| `keystroke.p50_us` | 7.60 `█░░░░░░░░░░░` | 10.70 `██░░░░░░░░░░` | +3.10 (+40.8%) ▼ |
+| `keystroke.p95_us` | 22.80 `███░░░░░░░░░` | 33.70 `█████░░░░░░░` | +10.90 (+47.8%) ▼ |
+| `keystroke.p99_us` | 50.30 `███████░░░░░` | 83.60 `████████████` | +33.30 (+66.2%) ▼ |
### 시작·메모리 비교 (낮을수록 좋음, 막대 행별 비교)
-| 항목 | 0.10.2 | 0.10.3 | 변화 |
+| 항목 | 0.10.3 | 0.10.9 | 변화 |
| --- | ---: | ---: | ---: |
-| `load.ms` | 1.78 `████████████` | 1.57 `███████████░` | -0.21 (-11.8%) = |
-| `heap.loaded_kb` | 52.66 `████████████` | 52.67 `████████████` | +0.01 (+0.0%) = |
-| `heap.peak_kb` | 11628.71 `████████████` | 11628.73 `████████████` | +0.02 (+0.0%) = |
+| `load.ms` | 1.57 `██████░░░░░░` | 3.19 `████████████` | +1.62 (+103.2%) ▼ |
+| `heap.loaded_kb` | 52.67 `████████████` | 52.67 `████████████` | +0.00 (+0.0%) = |
+| `heap.peak_kb` | 11628.73 `████████████` | 11628.82 `████████████` | +0.09 (+0.0%) = |
+
+## 0.10.9
+
+commit b58cc6d, 2026-10-23, rustc 1.93.1 (01f6ddf75 2026-02-11)
+
+프로필: `enko` (영·한), lab 0.2.12.
+
+공통 정확도·시나리오는 해당 언어가 켜진 판별기로 측정합니다. acc.ALL은 영·한 공통 세트이며 일본어 정답률과 합산하지 않습니다.
+
+load.ms·heap.*는 판별기 시작/단어 평가 기준입니다. enkoja의 ja.load.ms·ja.heap.loaded_kb는 별도 가나→한자 변환기 초기화/힙 증가입니다. 영·한판(tacit-windows.zip)과 일본어 포함판(tacit-windows-ja.zip)은 프로그램과 설치 zip이 다르고, 팩 목록도 활성 언어에 따라 다릅니다.
+
+### 크기 (KiB)
+
+| 항목 | KiB |
+| --- | ---: |
+| `tacit_tip.dll` (x64) | 756 |
+| `tacit_tip.dll` (x86) | 620 |
+| `tacit-config.exe` | 3158 |
+| `tacit-windows.zip` | 2182 |
+| `tacit-pack-en-0.10.9.zip` | 446 |
+| `tacit-pack-ko-0.10.9.zip` | 451 |
+
+### 정확도 (%)
+
+| 세트 | 정확도 |
+| --- | ---: |
+| `acc.ALL` | 99.69 |
+| `acc.en.freq/none` | 99.50 |
+| `acc.en.freq/after-ko` | 99.45 |
+| `acc.ko.words/none` | 100.00 |
+| `acc.ko.vocab/none` | 100.00 |
+| `big.ko.sent.sentence` | 99.13 |
+| `big.ko.sent.token` | 99.83 |
+| `big.ko.phrase.sentence` | 99.37 |
+| `big.ko.long.sentence` | 98.40 |
+| `big.en.sent.sentence` | 99.93 |
+| `big.cs.particle.sentence` | 99.06 |
+| `big.cs.particle.token` | 99.85 |
+| `big.cs_heldout.particle.sentence` | 98.95 |
+| `big.cs.bare.sentence` | 97.77 |
+| `big.cs.number.sentence` | 99.29 |
+| `big.ko.title.none` | 99.87 |
+| `big.ko.title.after-en` | 99.37 |
+| `big.learn.other_forms_before` | 100.00 |
+| `big.learn.other_forms_after` | 100.00 |
+
+### 유형별 시나리오 (문장 정답률 %, token은 단어 단위, chain은 4문장 이어 입력)
+
+| 유형 | 문장 | token | chain.token | µs/글자 |
+| --- | ---: | ---: | ---: | ---: |
+| chat | 96.51 | 98.78 | 99.17 | 3.04 |
+| dev | 98.98 | 99.69 | 99.37 | 3.50 |
+| writing | 98.78 | 99.77 | 99.75 | 2.23 |
+| 전체 | 98.12 | 99.50 | | |
+
+| 시나리오 | 문장 정답률 |
+| --- | ---: |
+| `chat.brand` | 100.00 |
+| `chat.casual` | 100.00 |
+| `chat.english` | 100.00 |
+| `chat.loan` | 92.31 |
+| `chat.number` | 100.00 |
+| `chat.punct` | 100.00 |
+| `chat.slang` | 88.24 |
+| `dev.commit` | 100.00 |
+| `dev.error` | 100.00 |
+| `dev.identifier` | 100.00 |
+| `dev.issue` | 100.00 |
+| `dev.review` | 100.00 |
+| `dev.terminal` | 96.00 |
+| `dev.tool` | 100.00 |
+| `writing.academic` | 92.86 |
+| `writing.email` | 100.00 |
+| `writing.english` | 100.00 |
+| `writing.essay` | 100.00 |
+| `writing.formal` | 100.00 |
+| `writing.long` | 100.00 |
+| `writing.numbers` | 100.00 |
+| `writing.quote` | 100.00 |
+
+### 속도·메모리 (중앙값, 5회)
+
+| 항목 | 값 |
+| --- | ---: |
+| `commit.avg_us` | 15.84 |
+| `commit.p50_us` | 12.80 |
+| `commit.p95_us` | 26.30 |
+| `commit.p99_us` | 56.50 |
+| `keystroke.avg_us` | 15.18 |
+| `keystroke.p50_us` | 10.70 |
+| `keystroke.p95_us` | 33.70 |
+| `keystroke.p99_us` | 83.60 |
+| `load.ms` | 3.19 |
+| `heap.loaded_kb` | 52.67 |
+| `heap.peak_kb` | 11628.82 |
## 0.10.3
diff --git a/platforms/android/THIRD_PARTY_NOTICES.md b/platforms/android/THIRD_PARTY_NOTICES.md
index 32ac453..31b1015 100644
--- a/platforms/android/THIRD_PARTY_NOTICES.md
+++ b/platforms/android/THIRD_PARTY_NOTICES.md
@@ -14,3 +14,13 @@ Tacit's remaining code is covered by the repository's AGPL-3.0-only license.
HeliBoard's input engine, dictionaries, settings, and gesture libraries are not included.
The rounded key backgrounds and light/dark palettes are original Tacit resources.
+
+## Lucide icons
+
+The `app/res/drawable/ic_*.xml` vectors are adapted from
+[Lucide](https://github.com/lucide-icons/lucide/tree/20aef31db93fe4d9f398dbb15b120d99c48eef95/icons)
+at commit `20aef31db93fe4d9f398dbb15b120d99c48eef95`.
+SVG geometry is preserved as Android vector paths with a 1.8-unit stroke.
+Lucide uses ISC, with MIT attribution for icons derived from Feather.
+The complete upstream [license](app/res/raw/lucide_license.txt) is bundled in the APK.
+No Lucide runtime dependency is used. The toolbar logo is Tacit's existing `assets/icon.png`.
diff --git a/platforms/android/app/res/drawable-nodpi/tacit_logo.png b/platforms/android/app/res/drawable-nodpi/tacit_logo.png
new file mode 100644
index 0000000..5312ed0
Binary files /dev/null and b/platforms/android/app/res/drawable-nodpi/tacit_logo.png differ
diff --git a/platforms/android/app/res/drawable/clipboard_action.xml b/platforms/android/app/res/drawable/clipboard_action.xml
new file mode 100644
index 0000000..fc4ac50
--- /dev/null
+++ b/platforms/android/app/res/drawable/clipboard_action.xml
@@ -0,0 +1,15 @@
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_arrow_big_up.xml b/platforms/android/app/res/drawable/ic_arrow_big_up.xml
new file mode 100644
index 0000000..486c19d
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_arrow_big_up.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_arrow_big_up_dash.xml b/platforms/android/app/res/drawable/ic_arrow_big_up_dash.xml
new file mode 100644
index 0000000..cf6951c
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_arrow_big_up_dash.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_arrow_left.xml b/platforms/android/app/res/drawable/ic_arrow_left.xml
new file mode 100644
index 0000000..c9c9093
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_arrow_left.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_arrow_right.xml b/platforms/android/app/res/drawable/ic_arrow_right.xml
new file mode 100644
index 0000000..526a4ff
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_arrow_right.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_chevron_down.xml b/platforms/android/app/res/drawable/ic_chevron_down.xml
new file mode 100644
index 0000000..a127e44
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_chevron_down.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_chevron_left.xml b/platforms/android/app/res/drawable/ic_chevron_left.xml
new file mode 100644
index 0000000..34f8600
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_chevron_left.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_clipboard.xml b/platforms/android/app/res/drawable/ic_clipboard.xml
new file mode 100644
index 0000000..384d5db
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_clipboard.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_clipboard_paste.xml b/platforms/android/app/res/drawable/ic_clipboard_paste.xml
new file mode 100644
index 0000000..b2b6dbb
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_clipboard_paste.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_corner_down_left.xml b/platforms/android/app/res/drawable/ic_corner_down_left.xml
new file mode 100644
index 0000000..12e0500
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_corner_down_left.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_delete.xml b/platforms/android/app/res/drawable/ic_delete.xml
new file mode 100644
index 0000000..d9aaba1
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_delete.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_ellipsis.xml b/platforms/android/app/res/drawable/ic_ellipsis.xml
new file mode 100644
index 0000000..319249e
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_ellipsis.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_keyboard.xml b/platforms/android/app/res/drawable/ic_keyboard.xml
new file mode 100644
index 0000000..5863ce2
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_keyboard.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_move_horizontal.xml b/platforms/android/app/res/drawable/ic_move_horizontal.xml
new file mode 100644
index 0000000..61e1465
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_move_horizontal.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/ic_settings_2.xml b/platforms/android/app/res/drawable/ic_settings_2.xml
new file mode 100644
index 0000000..6509055
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_settings_2.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
diff --git a/platforms/android/app/res/drawable/keyboard_key.xml b/platforms/android/app/res/drawable/keyboard_key.xml
index b091fb6..86aa623 100644
--- a/platforms/android/app/res/drawable/keyboard_key.xml
+++ b/platforms/android/app/res/drawable/keyboard_key.xml
@@ -6,19 +6,20 @@
-
-
+
-
-
+
-
-
+
+
diff --git a/platforms/android/app/res/drawable/reading_chip.xml b/platforms/android/app/res/drawable/reading_chip.xml
index dd2401f..87b0dca 100644
--- a/platforms/android/app/res/drawable/reading_chip.xml
+++ b/platforms/android/app/res/drawable/reading_chip.xml
@@ -4,21 +4,28 @@
android:insetTop="4dp" android:insetBottom="4dp">
-
-
-
-
-
+
+
-
+
+
+
+
+
+ -
+
+
+
-
-
+
-
-
-
+
+
diff --git a/platforms/android/app/res/layout/input.xml b/platforms/android/app/res/layout/input.xml
index 70d0119..15a3d86 100644
--- a/platforms/android/app/res/layout/input.xml
+++ b/platforms/android/app/res/layout/input.xml
@@ -5,15 +5,125 @@
android:background="@color/keyboard_background"
android:orientation="vertical">
-
+ android:paddingRight="4dp">
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/platforms/android/app/res/layout/keyboard.xml b/platforms/android/app/res/layout/keyboard.xml
index 7bcc79a..63c5679 100644
--- a/platforms/android/app/res/layout/keyboard.xml
+++ b/platforms/android/app/res/layout/keyboard.xml
@@ -9,6 +9,6 @@
android:paddingBottom="6dp"
android:keyBackground="@drawable/keyboard_key"
android:keyTextColor="@color/keyboard_text"
- android:keyTextSize="22sp"
+ android:keyTextSize="21sp"
android:labelTextSize="13sp"
android:shadowRadius="0" />
diff --git a/platforms/android/app/res/raw/lucide_license.txt b/platforms/android/app/res/raw/lucide_license.txt
new file mode 100644
index 0000000..718bb3f
--- /dev/null
+++ b/platforms/android/app/res/raw/lucide_license.txt
@@ -0,0 +1,43 @@
+ISC License
+
+Copyright (c) 2026 Lucide Icons and Contributors
+
+Permission to use, copy, modify, and/or distribute this software for any
+purpose with or without fee is hereby granted, provided that the above
+copyright notice and this permission notice appear in all copies.
+
+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+
+---
+
+The following Lucide icons are derived from the Feather project:
+
+airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out
+
+The MIT License (MIT) (for the icons listed above)
+
+Copyright (c) 2013-present Cole Bemis
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/platforms/android/app/res/values-night/colors.xml b/platforms/android/app/res/values-night/colors.xml
index dc7ba5f..ea0c1be 100644
--- a/platforms/android/app/res/values-night/colors.xml
+++ b/platforms/android/app/res/values-night/colors.xml
@@ -1,8 +1,10 @@
- #1C2025
- #30373F
- #EDF1F5
- #AEBFD2
- #425E7B
+ #151F2C
+ #29384B
+ #EDF3FB
+ #A5B8D0
+ #3B5C82
+ #B1D2FF
+ #3B4B60
diff --git a/platforms/android/app/res/values/colors.xml b/platforms/android/app/res/values/colors.xml
index 1c2f8c6..0f977c0 100644
--- a/platforms/android/app/res/values/colors.xml
+++ b/platforms/android/app/res/values/colors.xml
@@ -1,8 +1,10 @@
- #E5E8EB
- #FAFBFC
- #20272D
- #526478
- #C5DCF3
+ #E9EFF5
+ #FCFDFF
+ #1B2C40
+ #5B7088
+ #CADFF5
+ #245FA8
+ #CFDAE6
diff --git a/platforms/android/app/res/values/strings.xml b/platforms/android/app/res/values/strings.xml
index 1009941..eef558b 100644
--- a/platforms/android/app/res/values/strings.xml
+++ b/platforms/android/app/res/values/strings.xml
@@ -1,4 +1,32 @@
Tacit
+ 입력 도구
+ 읽기 후보
+ 클립보드
+ 키보드로
+ 붙여넣기
+ 복사한 텍스트가 여기에 표시됩니다
+ 민감한 내용은 미리 표시하지 않습니다
+ 클립보드를 읽을 수 없습니다
+ 이 입력란에 붙여넣을 수 없습니다
+ 커서 왼쪽으로
+ 커서 오른쪽으로
+ 자동
+ 한국어
+ 확정
+ 검색
+ 전송
+ 다음
+ 완료
+ 이동
+ 이전
+ 입력 모드
+ 커서 이동
+ 키보드 설정
+ 키보드 닫기
+ 키 누름 진동
+ 키 미리보기
+ 다른 키보드 선택
+ 기록은 따로 저장하지 않아요
diff --git a/platforms/android/app/res/xml/qwerty.xml b/platforms/android/app/res/xml/qwerty.xml
index afc7237..fa25ed0 100644
--- a/platforms/android/app/res/xml/qwerty.xml
+++ b/platforms/android/app/res/xml/qwerty.xml
@@ -26,7 +26,7 @@
-
+
@@ -34,7 +34,7 @@
-
+
diff --git a/platforms/android/app/res/xml/symbols.xml b/platforms/android/app/res/xml/symbols.xml
index a3d13b6..4c0ec16 100644
--- a/platforms/android/app/res/xml/symbols.xml
+++ b/platforms/android/app/res/xml/symbols.xml
@@ -35,7 +35,7 @@
-
+
diff --git a/platforms/android/app/src/io/github/iamallyouneed/tacit/TacitIme.java b/platforms/android/app/src/io/github/iamallyouneed/tacit/TacitIme.java
index a91aa3f..55c1a69 100644
--- a/platforms/android/app/src/io/github/iamallyouneed/tacit/TacitIme.java
+++ b/platforms/android/app/src/io/github/iamallyouneed/tacit/TacitIme.java
@@ -1,17 +1,27 @@
package io.github.iamallyouneed.tacit;
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ClipboardManager;
import android.inputmethodservice.InputMethodService;
import android.inputmethodservice.Keyboard;
import android.inputmethodservice.KeyboardView;
import android.text.InputType;
+import android.text.TextUtils;
+import android.os.SystemClock;
import android.util.TypedValue;
import android.view.Gravity;
+import android.view.HapticFeedbackConstants;
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.View;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
+import android.view.inputmethod.InputMethodManager;
import android.widget.LinearLayout;
+import android.widget.Button;
+import android.widget.ImageButton;
+import android.widget.PopupMenu;
import android.widget.TextView;
import java.util.HashSet;
@@ -41,6 +51,20 @@ public class TacitIme extends InputMethodService implements KeyboardView.OnKeybo
private String shownReadings;
private Keyboard letters;
private Keyboard symbols;
+ private View clipboardPanel;
+ private TextView clipboardPreview;
+ private Button clipboardPaste;
+ private ImageButton toolsToggle;
+ private boolean toolsOpen;
+ private boolean cursorTools;
+ private boolean hapticsEnabled = true;
+ private boolean keyPreviewEnabled = true;
+ private boolean capsLocked;
+ private long lastShiftTap;
+ private ClipboardManager clipboardManager;
+ private final ClipboardManager.OnPrimaryClipChangedListener clipboardChanged = () -> {
+ if (clipboardPanel != null && clipboardPanel.getVisibility() == View.VISIBLE) openClipboard();
+ };
@Override
public void onCreate() {
@@ -50,6 +74,7 @@ public void onCreate() {
@Override
public void onDestroy() {
+ closeClipboard();
Native.nativeDestroy(ime);
ime = 0;
super.onDestroy();
@@ -61,10 +86,38 @@ public View onCreateInputView() {
symbols = new Keyboard(this, R.xml.symbols);
View root = getLayoutInflater().inflate(R.layout.input, null);
readingsView = root.findViewById(R.id.readings);
+ toolsToggle = root.findViewById(R.id.tools_toggle);
+ clipboardPanel = root.findViewById(R.id.clipboard_panel);
+ clipboardPreview = root.findViewById(R.id.clipboard_preview);
+ clipboardPaste = root.findViewById(R.id.clipboard_paste);
+ clipboardManager = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
+ hapticsEnabled = getSharedPreferences("keyboard", MODE_PRIVATE).getBoolean("haptics", true);
+ keyPreviewEnabled = getSharedPreferences("keyboard", MODE_PRIVATE).getBoolean("preview", true);
+ shownReadings = null;
keyboardView = root.findViewById(R.id.keyboard);
keyboardView.setOnKeyboardActionListener(this);
- keyboardView.setPreviewEnabled(false);
+ keyboardView.setPreviewEnabled(keyPreviewEnabled);
keyboardView.setKeyboard(letters);
+ toolsToggle.setOnClickListener(v -> {
+ feedback();
+ if (clipboardPanel.getVisibility() == View.VISIBLE) {
+ closeClipboard();
+ refresh();
+ } else if (!active || Native.nativeReadings(ime).isEmpty()) {
+ requestHideSelf(0);
+ } else {
+ toolsOpen = !toolsOpen;
+ cursorTools = false;
+ showReadings();
+ }
+ });
+ root.findViewById(R.id.keyboard_menu).setOnClickListener(v -> showModeMenu(v));
+ root.findViewById(R.id.clipboard_close).setOnClickListener(v -> {
+ feedback();
+ closeClipboard();
+ refresh();
+ });
+ clipboardPaste.setOnClickListener(v -> pasteClipboard());
return root;
}
@@ -75,14 +128,20 @@ public void onStartInput(EditorInfo info, boolean restarting) {
active = ime != 0 && isTextField(info);
composing = false;
swallowed.clear();
+ toolsOpen = false;
+ closeClipboard();
if (active) Native.nativeActivate(ime, info.packageName);
}
@Override
public void onStartInputView(EditorInfo info, boolean restarting) {
super.onStartInputView(info, restarting);
+ closeClipboard();
if (keyboardView != null) {
+ capsLocked = false;
+ lastShiftTap = 0;
letters.setShifted(false);
+ updateShiftLabel();
keyboardView.setKeyboard(letters);
}
refresh();
@@ -90,6 +149,7 @@ public void onStartInputView(EditorInfo info, boolean restarting) {
@Override
public void onFinishInput() {
+ closeClipboard();
composing = false;
InputConnection ic = getCurrentInputConnection();
if (ic != null) ic.finishComposingText();
@@ -97,6 +157,12 @@ public void onFinishInput() {
super.onFinishInput();
}
+ @Override
+ public void onFinishInputView(boolean finishingInput) {
+ closeClipboard();
+ super.onFinishInputView(finishingInput);
+ }
+
@Override
public void onUpdateSelection(int oldSelStart, int oldSelEnd, int newSelStart, int newSelEnd,
int candidatesStart, int candidatesEnd) {
@@ -209,7 +275,17 @@ private static int charOf(int keyCode, KeyEvent event, int kind) {
public void onKey(int code, int[] keyCodes) {
switch (code) {
case Keyboard.KEYCODE_SHIFT:
- letters.setShifted(!letters.isShifted());
+ long now = SystemClock.uptimeMillis();
+ if (capsLocked) {
+ capsLocked = false;
+ letters.setShifted(false);
+ } else if (letters.isShifted() && now - lastShiftTap < 350) {
+ capsLocked = true;
+ } else {
+ letters.setShifted(!letters.isShifted());
+ }
+ lastShiftTap = now;
+ updateShiftLabel();
keyboardView.invalidateAllKeys();
return;
case CODE_SYMBOLS:
@@ -238,8 +314,9 @@ public void onKey(int code, int[] keyCodes) {
if (Character.isLetter(c)) {
boolean shifted = keyboardView.getKeyboard() == letters && letters.isShifted();
soft(Native.LETTER, shifted ? Character.toUpperCase(c) : c, 0);
- if (shifted) {
+ if (shifted && !capsLocked) {
letters.setShifted(false); // one capital at a time
+ updateShiftLabel();
keyboardView.invalidateAllKeys();
}
} else {
@@ -259,7 +336,9 @@ private void soft(int kind, int ch, int keyCode) {
}
@Override
- public void onPress(int primaryCode) {}
+ public void onPress(int primaryCode) {
+ feedback();
+ }
@Override
public void onRelease(int primaryCode) {}
@@ -283,48 +362,261 @@ public void swipeUp() {}
private void refresh() {
if (keyboardView == null || ime == 0) return;
int mode = active ? Native.nativeMode(ime) : Native.MODE_AUTO;
- String label = modeLabel(mode);
+ String label = active ? "한/영" : "ABC";
+ boolean changed = false;
for (Keyboard board : new Keyboard[] {letters, symbols}) {
for (Keyboard.Key key : board.getKeys()) {
- if (key.codes[0] == CODE_MODE) key.label = label;
+ String next = null;
+ if (key.codes[0] == CODE_MODE) next = label;
+ if (key.codes[0] == 32) next = "tacit · " + (active ? modeLabel(mode) : "English");
+ if (key.codes[0] == 10) {
+ String action = enterLabel();
+ CharSequence text = "⏎".equals(action) ? null : action;
+ if (!TextUtils.equals(key.label, text) || (text == null && key.icon == null)) {
+ key.label = text;
+ key.icon = text == null ? getDrawable(R.drawable.ic_corner_down_left) : null;
+ changed = true;
+ }
+ key.on = true;
+ continue;
+ }
+ if (next != null && !next.contentEquals(key.label)) {
+ key.label = next;
+ changed = true;
+ }
}
}
- keyboardView.setMode(mode);
+ keyboardView.setMode(active ? mode : Native.MODE_ENGLISH);
+ if (changed) keyboardView.invalidateAllKeys();
showReadings();
}
- private static String modeLabel(int mode) {
+ private String modeLabel(int mode) {
switch (mode) {
case Native.MODE_KOREAN:
- return "한";
+ return getString(R.string.mode_korean);
case Native.MODE_ENGLISH:
- return "영";
+ return "English";
default:
- return "tacit";
+ return getString(R.string.mode_auto);
+ }
+ }
+
+ private String enterLabel() {
+ if (composing) return getString(R.string.confirm);
+ EditorInfo info = getCurrentInputEditorInfo();
+ if (info == null || (info.imeOptions & EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) return "⏎";
+ switch (info.imeOptions & EditorInfo.IME_MASK_ACTION) {
+ case EditorInfo.IME_ACTION_SEARCH: return getString(R.string.action_search);
+ case EditorInfo.IME_ACTION_SEND: return getString(R.string.action_send);
+ case EditorInfo.IME_ACTION_NEXT: return getString(R.string.action_next);
+ case EditorInfo.IME_ACTION_DONE: return getString(R.string.action_done);
+ case EditorInfo.IME_ACTION_GO: return getString(R.string.action_go);
+ case EditorInfo.IME_ACTION_PREVIOUS: return getString(R.string.action_previous);
+ default: return "⏎";
+ }
+ }
+
+ private void updateShiftLabel() {
+ for (Keyboard.Key key : letters.getKeys()) {
+ if (key.codes[0] == Keyboard.KEYCODE_SHIFT) {
+ key.label = null;
+ key.icon = getDrawable(capsLocked ? R.drawable.ic_arrow_big_up_dash : R.drawable.ic_arrow_big_up);
+ key.on = letters.isShifted();
+ }
+ }
+ }
+
+ private void feedback() {
+ if (hapticsEnabled && keyboardView != null) {
+ keyboardView.performHapticFeedback(HapticFeedbackConstants.KEYBOARD_TAP);
}
}
private void showReadings() {
String readings = active ? Native.nativeReadings(ime) : "";
- if (readings.equals(shownReadings)) return; // same chips: no rebuild per key
- shownReadings = readings;
+ boolean clipboard = clipboardPanel.getVisibility() == View.VISIBLE;
+ boolean tools = clipboard || toolsOpen || readings.isEmpty();
+ String state = tools ? "tools:" + cursorTools + ":" + clipboard : readings;
+ toolsToggle.setImageResource(clipboard ? R.drawable.ic_keyboard : readings.isEmpty()
+ ? R.drawable.ic_chevron_down : toolsOpen ? R.drawable.ic_chevron_left : R.drawable.ic_ellipsis);
+ toolsToggle.setContentDescription(getString(clipboard ? R.string.back_to_keyboard : readings.isEmpty()
+ ? R.string.hide_keyboard : toolsOpen ? R.string.readings : R.string.tools));
+ if (state.equals(shownReadings)) return; // same controls: no rebuild per key
+ shownReadings = state;
readingsView.removeAllViews();
- if (!active) return;
+ if (tools) {
+ if (cursorTools) {
+ addStripIcon(R.drawable.ic_chevron_left, R.string.tools, v -> { cursorTools = false; showReadings(); });
+ addStripIcon(R.drawable.ic_arrow_left, R.string.cursor_left, v -> moveCursor(KeyEvent.KEYCODE_DPAD_LEFT));
+ addStripIcon(R.drawable.ic_arrow_right, R.string.cursor_right, v -> moveCursor(KeyEvent.KEYCODE_DPAD_RIGHT));
+ } else {
+ ImageButton clip = addStripIcon(R.drawable.ic_clipboard, R.string.clipboard, v -> openClipboard());
+ clip.setSelected(clipboard);
+ addStripIcon(R.drawable.ic_move_horizontal, R.string.cursor_tools, v -> { cursorTools = true; showReadings(); });
+ addStripIcon(R.drawable.ic_settings_2, R.string.keyboard_settings, v -> showSettings(v));
+ }
+ return;
+ }
String[] parts = readings.split("\u0001");
+ for (boolean selected : new boolean[] {true, false}) {
+ for (int i = 1; i < parts.length; i++) {
+ if ((parts[i].charAt(0) == '1') != selected) continue;
+ addReading(parts[i].substring(1), selected, i - 1);
+ }
+ }
+ }
+
+ private ImageButton addStripIcon(int icon, int description, View.OnClickListener action) {
+ ImageButton button = new ImageButton(this);
+ button.setImageResource(icon);
+ button.setContentDescription(getString(description));
+ int padding = (int) (12 * getResources().getDisplayMetrics().density);
+ button.setPadding(padding, padding, padding, padding);
+ button.setScaleType(android.widget.ImageView.ScaleType.CENTER);
+ button.setMinimumWidth(padding * 4);
+ button.setBackgroundResource(R.drawable.reading_chip);
+ button.setOnClickListener(v -> { feedback(); action.onClick(v); });
+ readingsView.addView(button, new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1));
+ return button;
+ }
+
+ private void showModeMenu(View anchor) {
+ feedback();
+ PopupMenu menu = new PopupMenu(this, anchor);
+ for (int mode = Native.MODE_AUTO; mode <= Native.MODE_ENGLISH; mode++) {
+ menu.getMenu().add(0, mode, mode, modeLabel(mode)).setCheckable(true)
+ .setChecked(active && Native.nativeMode(ime) == mode).setEnabled(active);
+ }
+ menu.getMenu().setGroupCheckable(0, true, true);
+ menu.setOnMenuItemClickListener(item -> {
+ InputConnection ic = getCurrentInputConnection();
+ if (ic == null || !active) return false;
+ for (int i = 0; i < 3 && Native.nativeMode(ime) != item.getItemId(); i++) {
+ apply(ic, Native.nativeCycleMode(ime, before(ic)));
+ }
+ refresh();
+ return true;
+ });
+ menu.show();
+ }
+
+ private void showSettings(View anchor) {
+ PopupMenu menu = new PopupMenu(this, anchor);
+ menu.getMenu().add(0, 1, 0, R.string.haptics).setCheckable(true).setChecked(hapticsEnabled);
+ menu.getMenu().add(0, 2, 1, R.string.key_preview).setCheckable(true).setChecked(keyPreviewEnabled);
+ menu.getMenu().add(0, 3, 2, R.string.choose_keyboard);
+ menu.setOnMenuItemClickListener(item -> {
+ if (item.getItemId() == 1) {
+ hapticsEnabled = !hapticsEnabled;
+ getSharedPreferences("keyboard", MODE_PRIVATE).edit().putBoolean("haptics", hapticsEnabled).apply();
+ } else if (item.getItemId() == 2) {
+ keyPreviewEnabled = !keyPreviewEnabled;
+ keyboardView.setPreviewEnabled(keyPreviewEnabled);
+ getSharedPreferences("keyboard", MODE_PRIVATE).edit().putBoolean("preview", keyPreviewEnabled).apply();
+ } else {
+ InputMethodManager manager = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
+ if (manager != null) manager.showInputMethodPicker();
+ }
+ return true;
+ });
+ menu.show();
+ }
+
+ private void addReading(String label, boolean selected, int index) {
float density = getResources().getDisplayMetrics().density;
- for (int i = 1; i < parts.length; i++) {
- final int index = i - 1;
- TextView chip = new TextView(this);
- chip.setText(parts[i].substring(1));
- chip.setSelected(parts[i].charAt(0) == '1');
- chip.setTextColor(getColor(R.color.keyboard_text));
- chip.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
- chip.setGravity(Gravity.CENTER);
- chip.setBackgroundResource(R.drawable.reading_chip);
- chip.setPadding((int) (14 * density), 0, (int) (14 * density), 0);
- chip.setOnClickListener(v -> chooseReading(index));
- readingsView.addView(chip, new LinearLayout.LayoutParams(
- LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.MATCH_PARENT));
+ Button button = new Button(this);
+ button.setText(label);
+ button.setAllCaps(false);
+ button.setSelected(selected);
+ button.setTextColor(getColor(selected ? R.color.keyboard_accent : R.color.keyboard_text));
+ button.setTextSize(TypedValue.COMPLEX_UNIT_SP, 18);
+ button.setMinWidth((int) (48 * density));
+ button.setPadding((int) (12 * density), 0, (int) (12 * density), 0);
+ button.setGravity(Gravity.CENTER);
+ button.setBackgroundResource(R.drawable.reading_chip);
+ button.setOnClickListener(v -> { feedback(); chooseReading(index); });
+ readingsView.addView(button, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.WRAP_CONTENT,
+ LinearLayout.LayoutParams.MATCH_PARENT));
+ }
+
+ private void moveCursor(int code) {
+ InputConnection ic = getCurrentInputConnection();
+ if (ic == null) return;
+ if (active) apply(ic, Native.nativeKey(ime, Native.OTHER, 0, before(ic)));
+ sendDownUpKeyEvents(code);
+ refresh();
+ }
+
+ private void closeClipboard() {
+ if (clipboardPanel == null) return;
+ if (clipboardManager != null) clipboardManager.removePrimaryClipChangedListener(clipboardChanged);
+ clipboardPanel.setVisibility(View.GONE);
+ clipboardPreview.setText("");
+ keyboardView.setVisibility(View.VISIBLE);
+ toolsOpen = false;
+ cursorTools = false;
+ }
+
+ private ClipData clipboardText() {
+ return clipboardManager == null ? null : clipboardManager.getPrimaryClip();
+ }
+
+ private void openClipboard() {
+ clipboardPanel.getLayoutParams().height = Math.max(keyboardView.getHeight(),
+ (int) (236 * getResources().getDisplayMetrics().density));
+ keyboardView.setVisibility(View.INVISIBLE); // keep the panel and keyboard at the same height
+ clipboardPanel.setVisibility(View.VISIBLE);
+ showReadings();
+ if (clipboardManager != null) {
+ clipboardManager.removePrimaryClipChangedListener(clipboardChanged);
+ clipboardManager.addPrimaryClipChangedListener(clipboardChanged);
+ }
+ try {
+ ClipData clip = clipboardText();
+ CharSequence text = clip == null || clip.getItemCount() == 0 ? null : clip.getItemAt(0).getText();
+ clipboardPaste.setEnabled(text != null && text.length() > 0);
+ if (!clipboardPaste.isEnabled()) {
+ clipboardPreview.setText(R.string.clipboard_empty);
+ return;
+ }
+ boolean sensitive = !active || (clip.getDescription().getExtras() != null
+ && clip.getDescription().getExtras().getBoolean(ClipDescription.EXTRA_IS_SENSITIVE));
+ if (sensitive) {
+ clipboardPreview.setText(R.string.clipboard_private);
+ } else {
+ String preview = text.toString();
+ if (preview.length() > 500) {
+ int end = Character.isHighSurrogate(preview.charAt(499)) ? 499 : 500;
+ preview = preview.substring(0, end) + "…";
+ }
+ clipboardPreview.setText(preview);
+ }
+ } catch (SecurityException denied) {
+ clipboardPreview.setText(R.string.clipboard_unavailable);
+ clipboardPaste.setEnabled(false);
+ }
+ }
+
+ private void pasteClipboard() {
+ InputConnection ic = getCurrentInputConnection();
+ if (ic == null) return;
+ try {
+ ClipData clip = clipboardText(); // paste the current clipboard, not a stale preview
+ CharSequence text = clip == null || clip.getItemCount() == 0 ? null : clip.getItemAt(0).getText();
+ if (text == null || text.length() == 0) { openClipboard(); return; }
+ feedback();
+ if (active) apply(ic, Native.nativeKey(ime, Native.OTHER, 0, before(ic)));
+ if (!ic.commitText(text.toString(), 1)) {
+ clipboardPreview.setText(R.string.paste_unavailable);
+ return;
+ }
+ closeClipboard();
+ refresh();
+ } catch (SecurityException denied) {
+ clipboardPreview.setText(R.string.clipboard_unavailable);
+ clipboardPaste.setEnabled(false);
}
}
diff --git a/platforms/android/tests/AndroidManifest.xml b/platforms/android/tests/AndroidManifest.xml
new file mode 100644
index 0000000..655247d
--- /dev/null
+++ b/platforms/android/tests/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/platforms/android/tests/KeyboardTestActivity.java b/platforms/android/tests/KeyboardTestActivity.java
new file mode 100644
index 0000000..d37ad9c
--- /dev/null
+++ b/platforms/android/tests/KeyboardTestActivity.java
@@ -0,0 +1,47 @@
+package io.github.iamallyouneed.tacit;
+
+import android.app.Activity;
+import android.content.ClipData;
+import android.content.ClipboardManager;
+import android.os.Bundle;
+import android.text.InputType;
+import android.view.inputmethod.EditorInfo;
+import android.widget.EditText;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+/** Isolated editor for emulator UI checks; never packaged in the shipping APK. */
+public final class KeyboardTestActivity extends Activity {
+ @Override public void onCreate(Bundle state) {
+ super.onCreate(state);
+ float density = getResources().getDisplayMetrics().density;
+ LinearLayout root = new LinearLayout(this);
+ root.setOrientation(LinearLayout.VERTICAL);
+ int padding = (int) (24 * density);
+ root.setPadding(padding, padding, padding, padding);
+ TextView title = new TextView(this);
+ title.setText("메모");
+ title.setTextSize(24);
+ root.addView(title);
+ EditText editor = new EditText(this);
+ editor.setHint("생각을 자연스럽게 적어보세요.");
+ editor.setTextSize(18);
+ editor.setGravity(android.view.Gravity.TOP | android.view.Gravity.START);
+ editor.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_FLAG_MULTI_LINE);
+ editor.setImeOptions(EditorInfo.IME_FLAG_NO_EXTRACT_UI | EditorInfo.IME_FLAG_NO_ENTER_ACTION);
+ root.addView(editor, new LinearLayout.LayoutParams(-1, (int) (180 * density)));
+ EditText search = new EditText(this);
+ search.setSingleLine(true);
+ search.setHint("검색");
+ search.setImeOptions(EditorInfo.IME_ACTION_SEARCH | EditorInfo.IME_FLAG_NO_EXTRACT_UI);
+ root.addView(search, new LinearLayout.LayoutParams(-1, -2));
+ setContentView(root);
+ editor.requestFocus();
+ getWindow().setSoftInputMode(android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_VISIBLE
+ | android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE);
+ if (getIntent().getBooleanExtra("clipboard", false)) {
+ ClipboardManager clipboard = (ClipboardManager) getSystemService(CLIPBOARD_SERVICE);
+ clipboard.setPrimaryClip(ClipData.newPlainText("UX test", "안녕하세요.\n내일 오후 2시에 만나요."));
+ }
+ }
+}
diff --git a/platforms/android/tests/KeyboardUxSmoke.java b/platforms/android/tests/KeyboardUxSmoke.java
new file mode 100644
index 0000000..202bb06
--- /dev/null
+++ b/platforms/android/tests/KeyboardUxSmoke.java
@@ -0,0 +1,184 @@
+package io.github.iamallyouneed.tacit;
+
+import android.app.Activity;
+import android.app.Instrumentation;
+import android.content.ClipData;
+import android.content.ClipDescription;
+import android.content.ClipboardManager;
+import android.content.Context;
+import android.inputmethodservice.Keyboard;
+import android.os.Bundle;
+import android.os.PersistableBundle;
+import android.text.Editable;
+import android.text.InputType;
+import android.text.Selection;
+import android.text.SpannableStringBuilder;
+import android.view.ContextThemeWrapper;
+import android.view.KeyEvent;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.inputmethod.BaseInputConnection;
+import android.view.inputmethod.EditorInfo;
+import android.view.inputmethod.InputConnection;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+/** Real Android views, composing spans and clipboard; no external test framework. */
+@SuppressWarnings("deprecation")
+public final class KeyboardUxSmoke extends Instrumentation {
+ private static final class Service extends TacitIme {
+ InputConnection connection;
+ EditorInfo info = new EditorInfo();
+ Service(Context context) { attachBaseContext(context); }
+ @Override public LayoutInflater getLayoutInflater() { return LayoutInflater.from(this); }
+ @Override public InputConnection getCurrentInputConnection() { return connection; }
+ @Override public EditorInfo getCurrentInputEditorInfo() { return info; }
+ }
+
+ private static void check(boolean condition, String message) {
+ if (!condition) throw new AssertionError(message);
+ }
+
+ private static Object field(Service service, String name) throws Exception {
+ java.lang.reflect.Field field = TacitIme.class.getDeclaredField(name);
+ field.setAccessible(true);
+ return field.get(service);
+ }
+
+ private static void type(Service service, String keys) {
+ for (char ch : keys.toCharArray()) service.onKey(ch, new int[] {ch});
+ }
+
+ private void verify() throws Exception {
+ Context context = new ContextThemeWrapper(getTargetContext(), android.R.style.Theme_Material_Light_NoActionBar);
+ Service service = new Service(context);
+ long handle = Native.nativeCreate(context.getCacheDir().getAbsolutePath() + "/keyboard-ux-smoke");
+ check(handle != 0, "native create");
+ java.lang.reflect.Field ime = TacitIme.class.getDeclaredField("ime");
+ ime.setAccessible(true);
+ ime.set(service, handle);
+ Editable text = new SpannableStringBuilder("앞😀 뒤");
+ Selection.setSelection(text, "앞😀 ".length());
+ int[] action = {0};
+ int[] cursorKey = {0};
+ boolean[] rejectCommit = {false};
+ service.connection = new BaseInputConnection(new View(context), true) {
+ @Override public Editable getEditable() { return text; }
+ @Override public boolean commitText(CharSequence value, int cursor) {
+ return !rejectCommit[0] && super.commitText(value, cursor);
+ }
+ @Override public boolean performEditorAction(int value) { action[0] = value; return true; }
+ @Override public boolean sendKeyEvent(KeyEvent event) {
+ if (event.getAction() == KeyEvent.ACTION_DOWN) cursorKey[0] = event.getKeyCode();
+ return true;
+ }
+ };
+ service.info.inputType = InputType.TYPE_CLASS_TEXT;
+ service.info.imeOptions = EditorInfo.IME_ACTION_SEARCH;
+ service.info.packageName = "keyboard-ux-smoke";
+ ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ try {
+ service.onStartInput(service.info, false);
+ View root = service.onCreateInputView();
+ service.onStartInputView(service.info, false);
+ int width = context.getResources().getDisplayMetrics().widthPixels;
+ root.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(2000, View.MeasureSpec.AT_MOST));
+ root.layout(0, 0, width, root.getMeasuredHeight());
+ LinearLayout strip = root.findViewById(R.id.readings);
+ check(strip.getChildCount() == 3, "idle tools");
+ type(service, "dkssud");
+ check(text.toString().equals("앞😀 안녕뒤"), "Korean composition: " + text);
+ check(strip.getChildAt(0).isSelected(), "selected reading");
+ strip.getChildAt(1).performClick();
+ check(text.toString().equals("앞😀 dkssud뒤"), "alternative reading uses original native index");
+ strip.getChildAt(1).performClick();
+ check(text.toString().equals("앞😀 안녕뒤"), "reordered candidates switch back to Korean");
+ clipboard.setPrimaryClip(ClipData.newPlainText("test", "붙여넣기😀"));
+ root.findViewById(R.id.tools_toggle).performClick();
+ strip.getChildAt(0).performClick();
+ check(root.findViewById(R.id.clipboard_panel).getVisibility() == View.VISIBLE, "clipboard panel opens");
+ TextView preview = root.findViewById(R.id.clipboard_preview);
+ check(preview.getText().toString().equals("붙여넣기😀"), "clipboard preview");
+ root.findViewById(R.id.clipboard_paste).performClick();
+ check(text.toString().equals("앞😀 안녕붙여넣기😀뒤"), "paste preserves composition and suffix: " + text);
+ check(BaseInputConnection.getComposingSpanStart(text) == -1, "paste ends composition");
+ check(Native.nativeReadings(handle).isEmpty(), "paste clears engine word");
+ check(preview.getText().length() == 0, "close clears preview");
+ check(root.findViewById(R.id.keyboard).getVisibility() == View.VISIBLE, "keyboard restored");
+
+ Selection.setSelection(text, 0, 1);
+ clipboard.setPrimaryClip(ClipData.newPlainText("test", "X"));
+ strip.getChildAt(0).performClick();
+ root.findViewById(R.id.clipboard_paste).performClick();
+ check(text.toString().startsWith("X😀 "), "paste replaces only selection: " + text);
+
+ ClipData secret = ClipData.newPlainText("test", "secret-not-for-preview");
+ PersistableBundle extras = new PersistableBundle();
+ extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true);
+ secret.getDescription().setExtras(extras);
+ clipboard.setPrimaryClip(secret);
+ strip.getChildAt(0).performClick();
+ check(preview.getText().toString().equals(context.getString(R.string.clipboard_private)), "sensitive clipboard hidden");
+ root.findViewById(R.id.clipboard_close).performClick();
+ clipboard.setPrimaryClip(ClipData.newPlainText("test", ""));
+ strip.getChildAt(0).performClick();
+ check(!root.findViewById(R.id.clipboard_paste).isEnabled(), "empty clipboard cannot paste");
+ service.onFinishInputView(false);
+ check(preview.getText().length() == 0, "hidden keyboard clears clipboard preview");
+ clipboard.setPrimaryClip(ClipData.newPlainText("test", "blocked"));
+ strip.getChildAt(0).performClick();
+ String unchanged = text.toString();
+ rejectCommit[0] = true;
+ root.findViewById(R.id.clipboard_paste).performClick();
+ check(text.toString().equals(unchanged), "rejected paste leaves editor text intact");
+ check(preview.getText().toString().equals(context.getString(R.string.paste_unavailable)), "rejected paste feedback");
+ check(root.findViewById(R.id.clipboard_panel).getVisibility() == View.VISIBLE, "rejected paste keeps panel open");
+ rejectCommit[0] = false;
+ root.findViewById(R.id.clipboard_close).performClick();
+
+ text.clear();
+ Selection.setSelection(text, 0);
+ service.onStartInput(service.info, false);
+ service.onStartInputView(service.info, false);
+ service.onKey(-101, null); // auto -> Korean -> English
+ service.onKey(-101, null);
+ service.onKey(Keyboard.KEYCODE_SHIFT, null);
+ service.onKey(Keyboard.KEYCODE_SHIFT, null);
+ type(service, "ab");
+ check(text.toString().equals("AB"), "double Shift locks capitals: " + text);
+ check((Boolean) field(service, "capsLocked"), "caps lock state");
+ service.onKey(Keyboard.KEYCODE_SHIFT, null);
+ type(service, "c");
+ check(text.toString().equals("ABc"), "Shift unlocks capitals: " + text);
+ service.onKey(10, null);
+ Keyboard board = (Keyboard) field(service, "letters");
+ check(board.getKeys().stream().anyMatch(k -> k.codes[0] == 10 && "검색".contentEquals(k.label)), "search action label");
+ service.onKey(10, null);
+ check(action[0] == EditorInfo.IME_ACTION_SEARCH, "search editor action");
+ strip.getChildAt(1).performClick();
+ strip.getChildAt(1).performClick();
+ check(cursorKey[0] == KeyEvent.KEYCODE_DPAD_LEFT, "cursor left reaches editor");
+ } finally {
+ service.onFinishInputView(false);
+ Native.nativeDestroy(handle);
+ }
+ }
+
+ @Override public void onCreate(Bundle arguments) { super.onCreate(arguments); start(); }
+
+ @Override public void onStart() {
+ Bundle result = new Bundle();
+ Throwable[] error = {null};
+ runOnMainSync(() -> {
+ try { verify(); } catch (Throwable failure) { error[0] = failure; }
+ });
+ if (error[0] == null) {
+ result.putString("stream", "PASS: toolbar/readings, clipboard/selection/privacy, caps lock, Enter, cursor\n");
+ finish(Activity.RESULT_OK, result);
+ } else {
+ result.putString("stream", android.util.Log.getStackTraceString(error[0]));
+ finish(Activity.RESULT_CANCELED, result);
+ }
+ }
+}
diff --git a/platforms/android/ui-smoke.ps1 b/platforms/android/ui-smoke.ps1
new file mode 100644
index 0000000..b7c05c9
--- /dev/null
+++ b/platforms/android/ui-smoke.ps1
@@ -0,0 +1,67 @@
+# Build first. This installs the test APKs, temporarily selects Tacit and restores the previous IME.
+# Use an isolated emulator: ./platforms/android/ui-smoke.ps1 -Serial emulator-5560
+param([Parameter(Mandatory = $true)][string] $Serial)
+$ErrorActionPreference = 'Stop'
+$sdk = if ($env:ANDROID_HOME) { $env:ANDROID_HOME } else { Join-Path $env:LOCALAPPDATA 'Android/Sdk' }
+$exe = if ($env:OS -eq 'Windows_NT') { '.exe' } else { '' }
+$bat = if ($env:OS -eq 'Windows_NT') { '.bat' } else { '' }
+$tools = Get-ChildItem (Join-Path $sdk 'build-tools') | Sort-Object { [version] $_.Name } | Select-Object -Last 1
+$androidJar = Join-Path $sdk 'platforms/android-34/android.jar'
+$adb = Join-Path $sdk "platform-tools/adb$exe"
+$device = @('-s', $Serial)
+$build = Join-Path $PSScriptRoot 'build/ui-smoke'
+$classes = Join-Path $build 'classes'
+$dex = Join-Path $build 'dex'
+New-Item -ItemType Directory -Force $classes, $dex | Out-Null
+function Run($what, [scriptblock] $block) {
+ & $block
+ if ($LASTEXITCODE -ne 0) { throw "$what failed ($LASTEXITCODE)" }
+}
+$classpath = @($androidJar, (Join-Path $PSScriptRoot 'build/classes')) -join [IO.Path]::PathSeparator
+$sources = @('KeyboardUxSmoke.java', 'KeyboardTestActivity.java') | ForEach-Object { Join-Path $PSScriptRoot "tests/$_" }
+Run 'javac' { javac --release 8 -encoding UTF-8 -classpath $classpath -d $classes @sources }
+$targetClasses = Join-Path $build 'target.jar'
+Run 'target classpath' { jar cf $targetClasses -C (Join-Path $PSScriptRoot 'build/classes') . }
+$classFiles = Get-ChildItem $classes -Recurse -Filter *.class | ForEach-Object FullName
+Run 'd8' { & "$($tools.FullName)/d8$bat" --min-api 26 --lib $androidJar --classpath $targetClasses --output $dex @classFiles }
+$base = Join-Path $build 'base.apk'
+Run 'aapt2' {
+ & "$($tools.FullName)/aapt2$exe" link -o $base -I $androidJar --manifest (Join-Path $PSScriptRoot 'tests/AndroidManifest.xml') --min-sdk-version 26 --target-sdk-version 34
+}
+Add-Type -AssemblyName System.IO.Compression.FileSystem
+$zip = [IO.Compression.ZipFile]::Open($base, 'Update')
+try { [void][IO.Compression.ZipFileExtensions]::CreateEntryFromFile($zip, (Join-Path $dex 'classes.dex'), 'classes.dex') } finally { $zip.Dispose() }
+$aligned = Join-Path $build 'aligned.apk'
+$apk = Join-Path $build 'tests.apk'
+Run 'zipalign' { & "$($tools.FullName)/zipalign$exe" -f -p 4 $base $aligned }
+$keystore = if ($env:TS_KEYSTORE) { $env:TS_KEYSTORE } else { Join-Path $PSScriptRoot 'build/debug.keystore' }
+$env:TACIT_TEST_SIGN_PASS = if ($env:TS_KEYSTORE) { $env:TS_KEYSTORE_PASS } else { 'android' }
+Run 'sign tests' { & "$($tools.FullName)/apksigner$bat" sign --ks $keystore --ks-pass env:TACIT_TEST_SIGN_PASS --out $apk $aligned }
+Run 'install keyboard' { & $adb @device install --no-incremental --no-streaming -r (Join-Path $PSScriptRoot 'build/tacit.apk') }
+Run 'install UI tests' { & $adb @device install --no-incremental --no-streaming -r $apk }
+Run 'wake emulator' { & $adb @device shell input keyevent KEYCODE_WAKEUP }
+Run 'unlock emulator' { & $adb @device shell wm dismiss-keyguard }
+$testIme = 'io.github.iamallyouneed.tacit/.TacitIme'
+# A fresh emulator must unlock its user and process package broadcasts before listing this IME.
+for ($attempt = 0; $attempt -lt 30; $attempt++) {
+ $methods = Run 'query installed IMEs' { & $adb @device shell ime list -a -s }
+ if ($methods -contains $testIme) { break }
+ Start-Sleep -Seconds 1
+}
+if ($methods -notcontains $testIme) {
+ & $adb @device shell dumpsys input_method
+ & $adb @device logcat -d -s InputMethodManagerService:W PackageManager:W
+ throw 'Installed Tacit IME was not registered within 30 seconds'
+}
+$oldIme = (Run 'current IME' { & $adb @device shell settings get secure default_input_method }).Trim()
+try {
+ Run 'enable test IME' { & $adb @device shell ime enable $testIme }
+ Run 'select test IME' { & $adb @device shell ime set $testIme }
+ $result = Run 'UI smoke' { & $adb @device shell am instrument -w io.github.iamallyouneed.tacit.tests/io.github.iamallyouneed.tacit.KeyboardUxSmoke }
+ $result | Write-Output
+ if (($result -join "`n") -notmatch 'PASS: toolbar/readings') { throw 'Android UI smoke did not pass' }
+} finally {
+ if ($oldIme -and $oldIme -ne 'null') {
+ Run 'restore previous IME' { & $adb @device shell ime set $oldIme }
+ }
+}
diff --git a/platforms/ffi/src/lib.rs b/platforms/ffi/src/lib.rs
index 395db94..c62ac49 100644
--- a/platforms/ffi/src/lib.rs
+++ b/platforms/ffi/src/lib.rs
@@ -502,6 +502,12 @@ mod tests {
for (keys, shown) in [
("dustmqgot", "연습햇"),
("akfgkfRK", "말할까"),
+ ("cmdWL", "cmdWL"),
+ ("rmWL", "rmWL"),
+ ("dmlWL", "dmlWL"),
+ ("turnsRI", "turnsRI"),
+ ("cmdWLr", "cmdWLr"),
+ ("smfWLr", "smfWLr"),
("d", "d"),
("Tom's", "Tom's"),
("dkssud d", "안녕 ㅇ"),