diff --git a/CHANGELOG.en.md b/CHANGELOG.en.md
index 1f7072d..1698cb9 100644
--- a/CHANGELOG.en.md
+++ b/CHANGELOG.en.md
@@ -9,6 +9,12 @@ Korean version: [CHANGELOG.md](CHANGELOG.md).
## [Unreleased]
+## [0.10.10] - 2026-10-23
+
+### Changed
+
+- Android now keeps a fixed toolbar and a separate reading strip, with direct paste, clipboard and emoji access. Spacebar dragging moves the cursor without typing spaces. Neutral light/dark colors and clearer function keys simplify the keyboard. An in-keyboard settings panel saves system/light/dark themes, three key heights, haptics and key previews; changing theme or height preserves the current composition.
+
## [0.10.9] - 2026-10-23
### Added
@@ -440,7 +446,8 @@ This version is **0.8.0**, not 0.7.1, because the pack file format changed. Pack
- First release: English/Korean auto-decision input method for Windows (TSF), Tab completion, per-app rules, KakaoTalk compatibility.
-[Unreleased]: https://github.com/iamallyouneed/tacit/compare/v0.10.9...HEAD
+[Unreleased]: https://github.com/iamallyouneed/tacit/compare/v0.10.10...HEAD
+[0.10.10]: https://github.com/iamallyouneed/tacit/compare/v0.10.9...v0.10.10
[0.10.9]: https://github.com/iamallyouneed/tacit/compare/v0.10.8...v0.10.9
[0.10.8]: https://github.com/iamallyouneed/tacit/compare/v0.10.7...v0.10.8
[0.10.7]: https://github.com/iamallyouneed/tacit/compare/v0.10.6...v0.10.7
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a1907a7..7bc1ca3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ GitHub 릴리스 노트가 되고 대시보드의 업데이트 탭에도 그대
## [Unreleased]
+## [0.10.10] - 2026-10-23
+
+### 변경
+
+- Android 키보드의 도구바와 후보 줄을 분리하고 붙여넣기·클립보드·이모지를 바로 열 수 있게 했습니다. 스페이스를 드래그하면 공백 입력 없이 커서를 옮깁니다. 밝은·어두운 모두 무채색으로 정리하고 기능 키 구분과 키 크기를 다듬었습니다. 키보드 안에서 시스템·라이트·다크 테마, 키 높이 3단계, 진동·키 미리보기를 설정하고 저장할 수 있으며, 테마와 높이를 바꿔도 작성 중인 조합을 유지합니다.
+
## [0.10.9] - 2026-10-23
### 추가
@@ -440,7 +446,8 @@ GitHub 릴리스 노트가 되고 대시보드의 업데이트 탭에도 그대
- 첫 공개: Windows용 영어·한국어 자동 판별 입력기(TSF), Tab 자동완성, 앱별 규칙, 카카오톡 호환.
-[Unreleased]: https://github.com/iamallyouneed/tacit/compare/v0.10.9...HEAD
+[Unreleased]: https://github.com/iamallyouneed/tacit/compare/v0.10.10...HEAD
+[0.10.10]: https://github.com/iamallyouneed/tacit/compare/v0.10.9...v0.10.10
[0.10.9]: https://github.com/iamallyouneed/tacit/compare/v0.10.8...v0.10.9
[0.10.8]: https://github.com/iamallyouneed/tacit/compare/v0.10.7...v0.10.8
[0.10.7]: https://github.com/iamallyouneed/tacit/compare/v0.10.6...v0.10.7
diff --git a/Cargo.lock b/Cargo.lock
index 0c1cf88..ab1f231 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -525,7 +525,7 @@ dependencies = [
[[package]]
name = "tacit-android"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"jni",
"tacit-core",
@@ -533,14 +533,14 @@ dependencies = [
[[package]]
name = "tacit-cli"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"tacit-core",
]
[[package]]
name = "tacit-config"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"getrandom 0.3.4",
"serde",
@@ -556,7 +556,7 @@ dependencies = [
[[package]]
name = "tacit-core"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"memmap2",
"serde",
@@ -567,14 +567,14 @@ dependencies = [
[[package]]
name = "tacit-ffi"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"tacit-core",
]
[[package]]
name = "tacit-packgen"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"serde_json",
"sha2",
@@ -583,7 +583,7 @@ dependencies = [
[[package]]
name = "tacit-tip"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"tacit-core",
"windows",
diff --git a/README.en.md b/README.en.md
index ee32e9d..5644e93 100644
--- a/README.en.md
+++ b/README.en.md
@@ -28,6 +28,7 @@ Type English, Korean, and Japanese on one QWERTY keyboard — it works out which
+
@@ -110,7 +111,7 @@ Re-running the command on an existing install is fine (enabled languages are kep
```powershell
$i = [scriptblock]::Create((irm https://github.com/iamallyouneed/tacit/releases/latest/download/install.ps1))
& $i -Uninstall # remove (settings and language packs are kept)
-& $i -Version v0.10.9 # pinned release
+& $i -Version v0.10.10 # pinned release
& $i -Japanese # also enable Japanese (adds to -Languages if given)
```
diff --git a/README.md b/README.md
index b5fd44f..88adeaf 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,7 @@
+
@@ -112,7 +113,7 @@ irm https://github.com/iamallyouneed/tacit/releases/latest/download/install.ps1
```powershell
$i = [scriptblock]::Create((irm https://github.com/iamallyouneed/tacit/releases/latest/download/install.ps1))
& $i -Uninstall # 제거 (설정과 언어 팩은 남는다)
-& $i -Version v0.10.9 # 특정 버전
+& $i -Version v0.10.10 # 특정 버전
& $i -Japanese # 일본어도 켠다 (-Languages 와 같이 쓰면 거기에 더한다)
```
diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml
index 3741a21..d989df1 100644
--- a/crates/cli/Cargo.toml
+++ b/crates/cli/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tacit-cli"
-version = "0.10.9"
+version = "0.10.10"
edition = "2021"
license = "AGPL-3.0-only"
repository = "https://github.com/iamallyouneed/tacit"
diff --git a/crates/config/Cargo.toml b/crates/config/Cargo.toml
index 3c0a260..bdb7258 100644
--- a/crates/config/Cargo.toml
+++ b/crates/config/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tacit-config"
-version = "0.10.9"
+version = "0.10.10"
edition = "2021"
license = "AGPL-3.0-only"
repository = "https://github.com/iamallyouneed/tacit"
diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml
index e20f8bf..0131953 100644
--- a/crates/core/Cargo.toml
+++ b/crates/core/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tacit-core"
-version = "0.10.9"
+version = "0.10.10"
edition = "2021"
rust-version = "1.89"
license = "AGPL-3.0-only"
diff --git a/crates/packgen/Cargo.toml b/crates/packgen/Cargo.toml
index d16723a..e6ec24e 100644
--- a/crates/packgen/Cargo.toml
+++ b/crates/packgen/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tacit-packgen"
-version = "0.10.9"
+version = "0.10.10"
edition = "2021"
license = "AGPL-3.0-only"
repository = "https://github.com/iamallyouneed/tacit"
diff --git a/docs/report/performance-enkoja.md b/docs/report/performance-enkoja.md
index 7ecf34e..bec3eb4 100644
--- a/docs/report/performance-enkoja.md
+++ b/docs/report/performance-enkoja.md
@@ -24,7 +24,7 @@ cargo run --release --manifest-path lab/Cargo.toml -- --profile enkoja --save
직전 버전과 비교합니다. 기준선은 `lab/baseline-enkoja.tsv`이며, 영·한 기준선을 대신
사용하지 않습니다. 아래 수치는 동일 프로필 5회 실행의 중앙값입니다.
-## 한눈에: 0.10.3 → 0.10.9
+## 한눈에: 0.10.9 → 0.10.10
▲ 좋아짐 · ▼ 나빠짐 · = 동점/잡음(정확도 0.05%p, 지연 25% 안은 잡음으로 봄). 정확도는 높을수록, 크기·지연·메모리는 낮을수록 좋음.
@@ -34,7 +34,7 @@ cargo run --release --manifest-path lab/Cargo.toml -- --profile enkoja --save
막대 범위: 영·한 80~100%, 일본어(ja.*) 0~100%.
-| 지표 | 0.10.3 | 0.10.9 | 변화 |
+| 지표 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
| `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.3 | 0.10.9 | 변화 |
+| 유형 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
| 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.3 | 0.10.9 | 변화 |
+| 항목 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
-| `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 = |
+| `tacit_tip.dll` (x64) | 968 `█░░░░░░░░░░░` | 968 `█░░░░░░░░░░░` | 0 = |
+| `tacit_tip.dll` (x86) | 778 `█░░░░░░░░░░░` | 778 `█░░░░░░░░░░░` | 0 = |
+| `tacit-config.exe` | 3339 `███░░░░░░░░░` | 3339 `███░░░░░░░░░` | 0 = |
+| `tacit-windows-ja.zip` | 2418 `██░░░░░░░░░░` | 2418 `██░░░░░░░░░░` | 0 = |
+| `tacit-pack-en-0.10.10.zip` | 446 `█░░░░░░░░░░░` | 446 `█░░░░░░░░░░░` | 0 = |
+| `tacit-pack-ko-0.10.10.zip` | 451 `█░░░░░░░░░░░` | 450 `█░░░░░░░░░░░` | -1 (-0.2%) ▲ |
+| `tacit-pack-ja-0.10.10.zip` | 14628 `████████████` | 14628 `████████████` | 0 = |
### 지연 비교 (µs 중앙값, 낮을수록 좋음, 막대 표 안 최대 기준)
-| 항목 | 0.10.3 | 0.10.9 | 변화 |
+| 항목 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
-| `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%) ▼ |
+| `commit.avg_us` | 78.49 `█░░░░░░░░░░░` | 75.77 `█░░░░░░░░░░░` | -2.72 (-3.5%) = |
+| `commit.p50_us` | 68.40 `█░░░░░░░░░░░` | 63.20 `█░░░░░░░░░░░` | -5.20 (-7.6%) = |
+| `commit.p95_us` | 172.30 `██░░░░░░░░░░` | 170.40 `██░░░░░░░░░░` | -1.90 (-1.1%) = |
+| `commit.p99_us` | 257.60 `████░░░░░░░░` | 238.70 `███░░░░░░░░░` | -18.90 (-7.3%) = |
+| `keystroke.avg_us` | 37.14 `█░░░░░░░░░░░` | 34.50 `█░░░░░░░░░░░` | -2.64 (-7.1%) = |
+| `keystroke.p50_us` | 22.70 `█░░░░░░░░░░░` | 20.80 `█░░░░░░░░░░░` | -1.90 (-8.4%) = |
+| `keystroke.p95_us` | 111.00 `██░░░░░░░░░░` | 104.00 `█░░░░░░░░░░░` | -7.00 (-6.3%) = |
+| `keystroke.p99_us` | 181.80 `███░░░░░░░░░` | 164.00 `██░░░░░░░░░░` | -17.80 (-9.8%) = |
+| `ja.convert.avg_us` | 360.45 `█████░░░░░░░` | 339.06 `█████░░░░░░░` | -21.39 (-5.9%) = |
+| `ja.convert.p50_us` | 317.00 `████░░░░░░░░` | 311.00 `████░░░░░░░░` | -6.00 (-1.9%) = |
+| `ja.convert.p95_us` | 592.00 `████████░░░░` | 571.60 `████████░░░░` | -20.40 (-3.4%) = |
+| `ja.convert.p99_us` | 860.00 `████████████` | 666.60 `█████████░░░` | -193.40 (-22.5%) = |
### 시작·메모리 비교 (낮을수록 좋음, 막대 행별 비교)
-| 항목 | 0.10.3 | 0.10.9 | 변화 |
+| 항목 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
-| `load.ms` | 18.74 `███████░░░░░` | 30.86 `████████████` | +12.12 (+64.7%) ▼ |
+| `load.ms` | 30.86 `████████████` | 19.83 `████████░░░░` | -11.03 (-35.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%) ▼ |
+| `heap.peak_kb` | 14047.65 `████████████` | 14047.65 `████████████` | +0.00 (+0.0%) = |
+| `ja.load.ms` | 5.60 `████████████` | 4.23 `█████████░░░` | -1.37 (-24.5%) = |
| `ja.heap.loaded_kb` | 2998.60 `████████████` | 2998.60 `████████████` | +0.00 (+0.0%) = |
+## 0.10.10
+
+commit cfd3096, 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.10.zip` | 446 |
+| `tacit-pack-ko-0.10.10.zip` | 450 |
+| `tacit-pack-ja-0.10.10.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 | 7.08 |
+| dev | 98.98 | 99.69 | 99.37 | 7.24 |
+| writing | 98.78 | 99.77 | 99.75 | 8.34 |
+| 전체 | 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` | 75.77 |
+| `commit.p50_us` | 63.20 |
+| `commit.p95_us` | 170.40 |
+| `commit.p99_us` | 238.70 |
+| `keystroke.avg_us` | 34.50 |
+| `keystroke.p50_us` | 20.80 |
+| `keystroke.p95_us` | 104.00 |
+| `keystroke.p99_us` | 164.00 |
+| `ja.convert.avg_us` | 339.06 |
+| `ja.convert.p50_us` | 311.00 |
+| `ja.convert.p95_us` | 571.60 |
+| `ja.convert.p99_us` | 666.60 |
+| `load.ms` | 19.83 |
+| `heap.loaded_kb` | 2468.14 |
+| `heap.peak_kb` | 14047.65 |
+| `ja.load.ms` | 4.23 |
+| `ja.heap.loaded_kb` | 2998.60 |
+
## 0.10.9
commit b58cc6d, 2026-10-23, rustc 1.93.1 (01f6ddf75 2026-02-11)
diff --git a/docs/report/performance.md b/docs/report/performance.md
index 04ffbb8..430823e 100644
--- a/docs/report/performance.md
+++ b/docs/report/performance.md
@@ -86,7 +86,7 @@ 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.3 → 0.10.9
+## 한눈에: 0.10.9 → 0.10.10
▲ 좋아짐 · ▼ 나빠짐 · = 동점/잡음(정확도 0.05%p, 지연 25% 안은 잡음으로 봄). 정확도는 높을수록, 크기·지연·메모리는 낮을수록 좋음.
@@ -96,7 +96,7 @@ sh lab/report.sh --profile enkoja > section-enkoja.md # 별도 영·한·일 문
막대 범위: 영·한 80~100%, 일본어(ja.*) 0~100%.
-| 지표 | 0.10.3 | 0.10.9 | 변화 |
+| 지표 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
| `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.3 | 0.10.9 | 변화 |
+| 유형 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
| 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.3 | 0.10.9 | 변화 |
+| 항목 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
-| `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 = |
+| `tacit_tip.dll` (x64) | 756 `███░░░░░░░░░` | 756 `███░░░░░░░░░` | 0 = |
+| `tacit_tip.dll` (x86) | 620 `██░░░░░░░░░░` | 620 `██░░░░░░░░░░` | 0 = |
+| `tacit-config.exe` | 3158 `████████████` | 3158 `████████████` | 0 = |
+| `tacit-windows.zip` | 2182 `████████░░░░` | 2182 `████████░░░░` | 0 = |
+| `tacit-pack-en-0.10.10.zip` | 446 `██░░░░░░░░░░` | 446 `██░░░░░░░░░░` | 0 = |
+| `tacit-pack-ko-0.10.10.zip` | 451 `██░░░░░░░░░░` | 450 `██░░░░░░░░░░` | -1 (-0.2%) ▲ |
### 지연 비교 (µs 중앙값, 낮을수록 좋음, 막대 표 안 최대 기준)
-| 항목 | 0.10.3 | 0.10.9 | 변화 |
+| 항목 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
-| `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%) ▼ |
+| `commit.avg_us` | 15.84 `██░░░░░░░░░░` | 19.04 `██░░░░░░░░░░` | +3.20 (+20.2%) = |
+| `commit.p50_us` | 12.80 `██░░░░░░░░░░` | 13.70 `██░░░░░░░░░░` | +0.90 (+7.0%) = |
+| `commit.p95_us` | 26.30 `███░░░░░░░░░` | 31.40 `████░░░░░░░░` | +5.10 (+19.4%) = |
+| `commit.p99_us` | 56.50 `███████░░░░░` | 75.00 `█████████░░░` | +18.50 (+32.7%) ▼ |
+| `keystroke.avg_us` | 15.18 `██░░░░░░░░░░` | 17.06 `██░░░░░░░░░░` | +1.88 (+12.4%) = |
+| `keystroke.p50_us` | 10.70 `█░░░░░░░░░░░` | 11.50 `█░░░░░░░░░░░` | +0.80 (+7.5%) = |
+| `keystroke.p95_us` | 33.70 `████░░░░░░░░` | 37.10 `█████░░░░░░░` | +3.40 (+10.1%) = |
+| `keystroke.p99_us` | 83.60 `██████████░░` | 97.70 `████████████` | +14.10 (+16.9%) = |
### 시작·메모리 비교 (낮을수록 좋음, 막대 행별 비교)
-| 항목 | 0.10.3 | 0.10.9 | 변화 |
+| 항목 | 0.10.9 | 0.10.10 | 변화 |
| --- | ---: | ---: | ---: |
-| `load.ms` | 1.57 `██████░░░░░░` | 3.19 `████████████` | +1.62 (+103.2%) ▼ |
+| `load.ms` | 3.19 `███████████░` | 3.47 `████████████` | +0.28 (+8.8%) = |
| `heap.loaded_kb` | 52.67 `████████████` | 52.67 `████████████` | +0.00 (+0.0%) = |
-| `heap.peak_kb` | 11628.73 `████████████` | 11628.82 `████████████` | +0.09 (+0.0%) = |
+| `heap.peak_kb` | 11628.82 `████████████` | 11628.82 `████████████` | +0.00 (+0.0%) = |
+
+## 0.10.10
+
+commit cfd3096, 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.10.zip` | 446 |
+| `tacit-pack-ko-0.10.10.zip` | 450 |
+
+### 정확도 (%)
+
+| 세트 | 정확도 |
+| --- | ---: |
+| `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 | 4.33 |
+| dev | 98.98 | 99.69 | 99.37 | 4.68 |
+| writing | 98.78 | 99.77 | 99.75 | 3.37 |
+| 전체 | 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` | 19.04 |
+| `commit.p50_us` | 13.70 |
+| `commit.p95_us` | 31.40 |
+| `commit.p99_us` | 75.00 |
+| `keystroke.avg_us` | 17.06 |
+| `keystroke.p50_us` | 11.50 |
+| `keystroke.p95_us` | 37.10 |
+| `keystroke.p99_us` | 97.70 |
+| `load.ms` | 3.47 |
+| `heap.loaded_kb` | 52.67 |
+| `heap.peak_kb` | 11628.82 |
## 0.10.9
diff --git a/lab/Cargo.lock b/lab/Cargo.lock
index 0df7e2c..392cdd4 100644
--- a/lab/Cargo.lock
+++ b/lab/Cargo.lock
@@ -280,7 +280,7 @@ dependencies = [
[[package]]
name = "tacit-core"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"memmap2",
"serde",
@@ -297,7 +297,7 @@ dependencies = [
[[package]]
name = "tacit-packgen"
-version = "0.10.9"
+version = "0.10.10"
dependencies = [
"serde_json",
"sha2",
diff --git a/platforms/android/app/res/drawable/ic_smile.xml b/platforms/android/app/res/drawable/ic_smile.xml
new file mode 100644
index 0000000..4086e51
--- /dev/null
+++ b/platforms/android/app/res/drawable/ic_smile.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 86aa623..05a62d7 100644
--- a/platforms/android/app/res/drawable/keyboard_key.xml
+++ b/platforms/android/app/res/drawable/keyboard_key.xml
@@ -6,20 +6,20 @@
-
-
+
-
-
-
+
+
-
-
+
diff --git a/platforms/android/app/res/layout/input.xml b/platforms/android/app/res/layout/input.xml
index 15a3d86..8ac6f22 100644
--- a/platforms/android/app/res/layout/input.xml
+++ b/platforms/android/app/res/layout/input.xml
@@ -18,37 +18,45 @@
android:layout_width="48dp"
android:layout_height="48dp"
android:background="@drawable/reading_chip"
- android:padding="12dp"
+ android:padding="14dp"
android:src="@drawable/tacit_logo"
android:scaleType="fitCenter"
android:contentDescription="@string/input_mode" />
-
-
-
-
-
+
+
+
+
+
+
+
+
+
@@ -125,5 +133,16 @@
android:textColor="@color/keyboard_hint"
android:textSize="12sp" />
+
+
+
+
+
diff --git a/platforms/android/app/res/layout/keyboard.xml b/platforms/android/app/res/layout/keyboard.xml
index 63c5679..f8de69d 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="21sp"
- android:labelTextSize="13sp"
+ android:keyTextSize="22sp"
+ android:labelTextSize="14sp"
android:shadowRadius="0" />
diff --git a/platforms/android/app/res/layout/settings.xml b/platforms/android/app/res/layout/settings.xml
new file mode 100644
index 0000000..e72866d
--- /dev/null
+++ b/platforms/android/app/res/layout/settings.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/platforms/android/app/res/values-night/colors.xml b/platforms/android/app/res/values-night/colors.xml
index ea0c1be..59432c6 100644
--- a/platforms/android/app/res/values-night/colors.xml
+++ b/platforms/android/app/res/values-night/colors.xml
@@ -1,10 +1,11 @@
- #151F2C
- #29384B
- #EDF3FB
- #A5B8D0
- #3B5C82
- #B1D2FF
- #3B4B60
+ #080808
+ #262626
+ #F2F2F2
+ #B3B3B3
+ #484848
+ #FFFFFF
+ #333333
+ #171717
diff --git a/platforms/android/app/res/values/colors.xml b/platforms/android/app/res/values/colors.xml
index 0f977c0..362f528 100644
--- a/platforms/android/app/res/values/colors.xml
+++ b/platforms/android/app/res/values/colors.xml
@@ -1,10 +1,11 @@
- #E9EFF5
- #FCFDFF
- #1B2C40
- #5B7088
- #CADFF5
- #245FA8
- #CFDAE6
+ #E3E3E3
+ #FFFFFF
+ #202020
+ #666666
+ #CCCCCC
+ #202020
+ #CDCDCD
+ #CCCCCC
diff --git a/platforms/android/app/res/values/strings.xml b/platforms/android/app/res/values/strings.xml
index eef558b..67572cf 100644
--- a/platforms/android/app/res/values/strings.xml
+++ b/platforms/android/app/res/values/strings.xml
@@ -29,4 +29,13 @@
키 미리보기
다른 키보드 선택
기록은 따로 저장하지 않아요
+ 이모지
+ 테마
+ 시스템
+ 라이트
+ 다크
+ 키 높이
+ 낮게
+ 기본
+ 높게
diff --git a/platforms/android/app/res/values/styles.xml b/platforms/android/app/res/values/styles.xml
new file mode 100644
index 0000000..b6205af
--- /dev/null
+++ b/platforms/android/app/res/values/styles.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/platforms/android/app/res/xml/qwerty.xml b/platforms/android/app/res/xml/qwerty.xml
index fa25ed0..85730dd 100644
--- a/platforms/android/app/res/xml/qwerty.xml
+++ b/platforms/android/app/res/xml/qwerty.xml
@@ -1,6 +1,6 @@
diff --git a/platforms/android/app/res/xml/symbols.xml b/platforms/android/app/res/xml/symbols.xml
index 4c0ec16..7e0d1f0 100644
--- a/platforms/android/app/res/xml/symbols.xml
+++ b/platforms/android/app/res/xml/symbols.xml
@@ -1,6 +1,6 @@
diff --git a/platforms/android/app/src/io/github/iamallyouneed/tacit/DualKeyboardView.java b/platforms/android/app/src/io/github/iamallyouneed/tacit/DualKeyboardView.java
index c61241d..2b243d8 100644
--- a/platforms/android/app/src/io/github/iamallyouneed/tacit/DualKeyboardView.java
+++ b/platforms/android/app/src/io/github/iamallyouneed/tacit/DualKeyboardView.java
@@ -12,6 +12,10 @@
import android.util.AttributeSet;
import android.inputmethodservice.Keyboard;
import android.inputmethodservice.KeyboardView;
+import android.view.MotionEvent;
+import android.view.ViewConfiguration;
+
+import java.util.function.IntConsumer;
/**
* The framework's keyboard view. tacit mode: letters with the 2-set Hangul jamo of each key in
@@ -26,6 +30,61 @@ public class DualKeyboardView extends KeyboardView {
private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
private int mode = Native.MODE_AUTO;
+ private IntConsumer cursorMove;
+ private boolean spaceTouch;
+ private boolean dragging;
+ private float startX, startY, cursorX;
+
+ public void setOnCursorMoveListener(IntConsumer listener) {
+ cursorMove = listener;
+ }
+
+ @Override
+ public boolean onTouchEvent(MotionEvent event) {
+ int action = event.getActionMasked();
+ if (action == MotionEvent.ACTION_DOWN) {
+ spaceTouch = false;
+ dragging = false;
+ startX = cursorX = event.getX();
+ startY = event.getY();
+ if (getKeyboard() != null && cursorMove != null) {
+ for (Keyboard.Key key : getKeyboard().getKeys()) {
+ if (key.codes[0] == 32 && key.isInside((int) startX - getPaddingLeft(),
+ (int) startY - getPaddingTop())) spaceTouch = true;
+ }
+ }
+ }
+ if (event.getPointerCount() > 1) spaceTouch = false;
+ if (spaceTouch && action == MotionEvent.ACTION_MOVE) {
+ float distance = event.getX() - startX;
+ float step = 16 * getResources().getDisplayMetrics().density;
+ if (!dragging && Math.abs(distance) > Math.max(step,
+ ViewConfiguration.get(getContext()).getScaledTouchSlop())
+ && Math.abs(distance) > Math.abs(event.getY() - startY)) {
+ dragging = true;
+ MotionEvent cancel = MotionEvent.obtain(event);
+ cancel.setAction(MotionEvent.ACTION_CANCEL);
+ super.onTouchEvent(cancel); // cancel the pending space and its key preview
+ cancel.recycle();
+ if (getParent() != null) getParent().requestDisallowInterceptTouchEvent(true);
+ }
+ if (dragging) {
+ int steps = (int) ((event.getX() - cursorX) / step);
+ if (steps != 0) {
+ cursorMove.accept(steps);
+ cursorX += steps * step;
+ }
+ }
+ }
+ if (dragging) {
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) {
+ spaceTouch = dragging = false;
+ }
+ return true;
+ }
+ if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_CANCEL) spaceTouch = false;
+ return super.onTouchEvent(event);
+ }
public DualKeyboardView(Context context, AttributeSet attrs) {
super(context, attrs);
diff --git a/platforms/android/app/src/io/github/iamallyouneed/tacit/SizedKeyboard.java b/platforms/android/app/src/io/github/iamallyouneed/tacit/SizedKeyboard.java
new file mode 100644
index 0000000..70dff7d
--- /dev/null
+++ b/platforms/android/app/src/io/github/iamallyouneed/tacit/SizedKeyboard.java
@@ -0,0 +1,32 @@
+package io.github.iamallyouneed.tacit;
+
+import android.content.Context;
+import android.inputmethodservice.Keyboard;
+
+/** Keep the framework's key layout while changing row height and touch geometry together. */
+@SuppressWarnings("deprecation")
+public final class SizedKeyboard extends Keyboard {
+ private final int height;
+ private final int[] indices;
+ private static final int[] NONE = new int[0];
+
+ public SizedKeyboard(Context context, int layout, int heightDp) {
+ super(context, layout);
+ float scale = heightDp * context.getResources().getDisplayMetrics().density / getKeys().get(0).height;
+ height = Math.round(super.getHeight() * scale);
+ indices = new int[getKeys().size()];
+ for (int i = 0; i < indices.length; i++) {
+ indices[i] = i;
+ Key key = getKeys().get(i);
+ key.y = Math.round(key.y * scale);
+ key.height = Math.round(key.height * scale);
+ }
+ }
+
+ @Override public int getHeight() { return height; }
+
+ @Override public int[] getNearestKeys(int x, int y) {
+ // shortcut: scan <50 keys to avoid the old-height grid; index again if layouts grow substantially.
+ return x < 0 || x >= getMinWidth() || y < 0 || y >= height ? NONE : indices;
+ }
+}
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 55c1a69..39f0f43 100644
--- a/platforms/android/app/src/io/github/iamallyouneed/tacit/TacitIme.java
+++ b/platforms/android/app/src/io/github/iamallyouneed/tacit/TacitIme.java
@@ -3,6 +3,8 @@
import android.content.ClipData;
import android.content.ClipDescription;
import android.content.ClipboardManager;
+import android.content.Context;
+import android.content.res.Configuration;
import android.inputmethodservice.InputMethodService;
import android.inputmethodservice.Keyboard;
import android.inputmethodservice.KeyboardView;
@@ -15,6 +17,8 @@
import android.view.KeyCharacterMap;
import android.view.KeyEvent;
import android.view.View;
+import android.view.ContextThemeWrapper;
+import android.view.LayoutInflater;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
import android.view.inputmethod.InputMethodManager;
@@ -23,6 +27,7 @@
import android.widget.ImageButton;
import android.widget.PopupMenu;
import android.widget.TextView;
+import android.widget.Switch;
import java.util.HashSet;
import java.util.Set;
@@ -55,8 +60,12 @@ public class TacitIme extends InputMethodService implements KeyboardView.OnKeybo
private TextView clipboardPreview;
private Button clipboardPaste;
private ImageButton toolsToggle;
- private boolean toolsOpen;
- private boolean cursorTools;
+ private View emojiPanel;
+ private ImageButton clipboardTool;
+ private ImageButton emojiTool;
+ private View settingsPanel;
+ private ImageButton moreTool;
+ private Context keyboardContext;
private boolean hapticsEnabled = true;
private boolean keyPreviewEnabled = true;
private boolean capsLocked;
@@ -74,7 +83,7 @@ public void onCreate() {
@Override
public void onDestroy() {
- closeClipboard();
+ closePanels();
Native.nativeDestroy(ime);
ime = 0;
super.onDestroy();
@@ -82,11 +91,26 @@ public void onDestroy() {
@Override
public View onCreateInputView() {
- letters = new Keyboard(this, R.xml.qwerty);
- symbols = new Keyboard(this, R.xml.symbols);
- View root = getLayoutInflater().inflate(R.layout.input, null);
+ Configuration configuration = new Configuration(getResources().getConfiguration());
+ int theme = getSharedPreferences("keyboard", MODE_PRIVATE).getInt("theme", 0);
+ if (theme != 0) configuration.uiMode = (configuration.uiMode & ~Configuration.UI_MODE_NIGHT_MASK)
+ | (theme == 2 ? Configuration.UI_MODE_NIGHT_YES : Configuration.UI_MODE_NIGHT_NO);
+ boolean dark = (configuration.uiMode & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES;
+ keyboardContext = new ContextThemeWrapper(createConfigurationContext(configuration), dark
+ ? android.R.style.Theme_Material_NoActionBar : android.R.style.Theme_Material_Light_NoActionBar);
+ int height = getSharedPreferences("keyboard", MODE_PRIVATE).getInt("height", 1);
+ keyboardContext.getTheme().applyStyle(R.style.KeyboardAppearance, true);
+ int keyHeight = height == 0 ? 48 : height == 2 ? 60 : 54;
+ letters = new SizedKeyboard(keyboardContext, R.xml.qwerty, keyHeight);
+ symbols = new SizedKeyboard(keyboardContext, R.xml.symbols, keyHeight);
+ View root = LayoutInflater.from(keyboardContext).inflate(R.layout.input, null);
readingsView = root.findViewById(R.id.readings);
toolsToggle = root.findViewById(R.id.tools_toggle);
+ emojiPanel = root.findViewById(R.id.emoji_panel);
+ clipboardTool = root.findViewById(R.id.clipboard_tool);
+ emojiTool = root.findViewById(R.id.emoji_tool);
+ settingsPanel = root.findViewById(R.id.settings_panel);
+ moreTool = root.findViewById(R.id.more_tool);
clipboardPanel = root.findViewById(R.id.clipboard_panel);
clipboardPreview = root.findViewById(R.id.clipboard_preview);
clipboardPaste = root.findViewById(R.id.clipboard_paste);
@@ -98,23 +122,66 @@ public View onCreateInputView() {
keyboardView.setOnKeyboardActionListener(this);
keyboardView.setPreviewEnabled(keyPreviewEnabled);
keyboardView.setKeyboard(letters);
+ keyboardView.setOnCursorMoveListener(this::moveCursor);
toolsToggle.setOnClickListener(v -> {
feedback();
- if (clipboardPanel.getVisibility() == View.VISIBLE) {
- closeClipboard();
+ if (clipboardPanel.getVisibility() == View.VISIBLE || emojiPanel.getVisibility() == View.VISIBLE
+ || settingsPanel.getVisibility() == View.VISIBLE) {
+ closePanels();
refresh();
- } else if (!active || Native.nativeReadings(ime).isEmpty()) {
- requestHideSelf(0);
} else {
- toolsOpen = !toolsOpen;
- cursorTools = false;
- showReadings();
+ requestHideSelf(0);
}
});
+ root.findViewById(R.id.paste_tool).setOnClickListener(v -> pasteClipboard());
+ clipboardTool.setOnClickListener(v -> { feedback(); openClipboard(); });
+ emojiTool.setOnClickListener(v -> { feedback(); openEmoji(); });
+ moreTool.setOnClickListener(v -> { feedback(); openSettings(); });
+ bindChoices(root, new int[] {R.id.theme_system, R.id.theme_light, R.id.theme_dark}, "theme", 0);
+ bindChoices(root, new int[] {R.id.height_compact, R.id.height_regular, R.id.height_tall}, "height", 1);
+ Switch haptics = root.findViewById(R.id.haptics_setting);
+ haptics.setChecked(hapticsEnabled);
+ haptics.setOnCheckedChangeListener((button, checked) -> {
+ hapticsEnabled = checked;
+ getSharedPreferences("keyboard", MODE_PRIVATE).edit().putBoolean("haptics", checked).apply();
+ });
+ Switch preview = root.findViewById(R.id.preview_setting);
+ preview.setChecked(keyPreviewEnabled);
+ preview.setOnCheckedChangeListener((button, checked) -> {
+ keyPreviewEnabled = checked;
+ keyboardView.setPreviewEnabled(checked);
+ getSharedPreferences("keyboard", MODE_PRIVATE).edit().putBoolean("preview", checked).apply();
+ });
+ root.findViewById(R.id.choose_keyboard).setOnClickListener(v -> {
+ InputMethodManager manager = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
+ if (manager != null) manager.showInputMethodPicker();
+ });
+ root.findViewById(R.id.cursor_left).setOnClickListener(v -> moveCursor(-1));
+ root.findViewById(R.id.cursor_right).setOnClickListener(v -> moveCursor(1));
+ LinearLayout emojiRows = root.findViewById(R.id.emoji_rows);
+ for (String row : new String[] {"😀 😄 😂 🥹 😊 😍", "🥰 😎 🤔 😭 🙏 👍", "❤️ 🎉 🔥 ✨ 👀 ✅"}) {
+ LinearLayout line = new LinearLayout(keyboardContext);
+ for (String emoji : row.split(" ")) {
+ Button button = new Button(keyboardContext);
+ button.setText(emoji);
+ button.setTextSize(26);
+ button.setContentDescription(emoji);
+ button.setBackgroundResource(R.drawable.reading_chip);
+ button.setOnClickListener(v -> {
+ InputConnection ic = getCurrentInputConnection();
+ if (ic == null) return;
+ feedback();
+ if (active) apply(ic, Native.nativeKey(ime, Native.OTHER, 0, before(ic)));
+ if (ic.commitText(emoji, 1)) refresh();
+ });
+ line.addView(button, new LinearLayout.LayoutParams(0, -1, 1));
+ }
+ emojiRows.addView(line, new LinearLayout.LayoutParams(-1, 0, 1));
+ }
root.findViewById(R.id.keyboard_menu).setOnClickListener(v -> showModeMenu(v));
root.findViewById(R.id.clipboard_close).setOnClickListener(v -> {
feedback();
- closeClipboard();
+ closePanels();
refresh();
});
clipboardPaste.setOnClickListener(v -> pasteClipboard());
@@ -128,15 +195,14 @@ public void onStartInput(EditorInfo info, boolean restarting) {
active = ime != 0 && isTextField(info);
composing = false;
swallowed.clear();
- toolsOpen = false;
- closeClipboard();
+ closePanels();
if (active) Native.nativeActivate(ime, info.packageName);
}
@Override
public void onStartInputView(EditorInfo info, boolean restarting) {
super.onStartInputView(info, restarting);
- closeClipboard();
+ closePanels();
if (keyboardView != null) {
capsLocked = false;
lastShiftTap = 0;
@@ -149,7 +215,7 @@ public void onStartInputView(EditorInfo info, boolean restarting) {
@Override
public void onFinishInput() {
- closeClipboard();
+ closePanels();
composing = false;
InputConnection ic = getCurrentInputConnection();
if (ic != null) ic.finishComposingText();
@@ -159,7 +225,7 @@ public void onFinishInput() {
@Override
public void onFinishInputView(boolean finishingInput) {
- closeClipboard();
+ closePanels();
super.onFinishInputView(finishingInput);
}
@@ -366,6 +432,7 @@ private void refresh() {
boolean changed = false;
for (Keyboard board : new Keyboard[] {letters, symbols}) {
for (Keyboard.Key key : board.getKeys()) {
+ if (key.codes[0] < 0 && key.codes[0] != Keyboard.KEYCODE_SHIFT) key.on = true;
String next = null;
if (key.codes[0] == CODE_MODE) next = label;
if (key.codes[0] == 32) next = "tacit · " + (active ? modeLabel(mode) : "English");
@@ -374,7 +441,7 @@ private void refresh() {
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;
+ key.icon = text == null ? keyboardContext.getDrawable(R.drawable.ic_corner_down_left) : null;
changed = true;
}
key.on = true;
@@ -421,7 +488,7 @@ 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.icon = keyboardContext.getDrawable(capsLocked ? R.drawable.ic_arrow_big_up_dash : R.drawable.ic_arrow_big_up);
key.on = letters.isShifted();
}
}
@@ -435,27 +502,24 @@ private void feedback() {
private void showReadings() {
String readings = active ? Native.nativeReadings(ime) : "";
- 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));
+ boolean panel = clipboardPanel.getVisibility() == View.VISIBLE || emojiPanel.getVisibility() == View.VISIBLE
+ || settingsPanel.getVisibility() == View.VISIBLE;
+ toolsToggle.setImageResource(panel ? R.drawable.ic_keyboard : R.drawable.ic_chevron_down);
+ toolsToggle.setContentDescription(getString(panel ? R.string.back_to_keyboard : R.string.hide_keyboard));
+ clipboardTool.setSelected(clipboardPanel.getVisibility() == View.VISIBLE);
+ emojiTool.setSelected(emojiPanel.getVisibility() == View.VISIBLE);
+ moreTool.setSelected(settingsPanel.getVisibility() == View.VISIBLE);
+ String state = readings.isEmpty() ? "idle:" + (active ? Native.nativeMode(ime) : Native.MODE_ENGLISH) : readings;
if (state.equals(shownReadings)) return; // same controls: no rebuild per key
shownReadings = state;
readingsView.removeAllViews();
- 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));
- }
+ if (readings.isEmpty()) {
+ TextView mode = new TextView(keyboardContext);
+ mode.setText(modeLabel(active ? Native.nativeMode(ime) : Native.MODE_ENGLISH));
+ mode.setTextColor(keyboardContext.getColor(R.color.keyboard_hint));
+ mode.setTextSize(13);
+ mode.setGravity(Gravity.CENTER);
+ readingsView.addView(mode, new LinearLayout.LayoutParams(-1, -1));
return;
}
String[] parts = readings.split("\u0001");
@@ -467,23 +531,9 @@ private void showReadings() {
}
}
- 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);
+ PopupMenu menu = new PopupMenu(keyboardContext, 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);
@@ -501,36 +551,44 @@ private void showModeMenu(View anchor) {
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 bindChoices(View root, int[] ids, String preference, int fallback) {
+ int selected = getSharedPreferences("keyboard", MODE_PRIVATE).getInt(preference, fallback);
+ for (int i = 0; i < ids.length; i++) {
+ int choice = i;
+ View button = root.findViewById(ids[i]);
+ button.setSelected(i == selected);
+ button.setOnClickListener(v -> {
+ if (choice == selected) return;
+ getSharedPreferences("keyboard", MODE_PRIVATE).edit().putInt(preference, choice).apply();
+ boolean shifted = letters.isShifted();
+ boolean symbolLayout = keyboardView.getKeyboard() == symbols;
+ closePanels();
+ setInputView(onCreateInputView());
+ letters.setShifted(shifted);
+ keyboardView.setKeyboard(symbolLayout ? symbols : letters);
+ updateShiftLabel();
+ refresh();
+ openSettings();
+ });
+ }
+ }
+
+ private void openSettings() {
+ closePanels();
+ settingsPanel.getLayoutParams().height = panelHeight();
+ settingsPanel.setVisibility(View.VISIBLE);
+ keyboardView.setVisibility(View.INVISIBLE);
+ showReadings();
}
private void addReading(String label, boolean selected, int index) {
float density = getResources().getDisplayMetrics().density;
- Button button = new Button(this);
+ Button button = new Button(keyboardContext);
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.setTextColor(keyboardContext.getColor(selected ? R.color.keyboard_accent : R.color.keyboard_text));
+ button.setTextSize(TypedValue.COMPLEX_UNIT_SP, 16);
button.setMinWidth((int) (48 * density));
button.setPadding((int) (12 * density), 0, (int) (12 * density), 0);
button.setGravity(Gravity.CENTER);
@@ -541,22 +599,37 @@ private void addReading(String label, boolean selected, int index) {
LinearLayout.LayoutParams.MATCH_PARENT));
}
- private void moveCursor(int code) {
+ private void moveCursor(int steps) {
InputConnection ic = getCurrentInputConnection();
if (ic == null) return;
if (active) apply(ic, Native.nativeKey(ime, Native.OTHER, 0, before(ic)));
- sendDownUpKeyEvents(code);
+ for (int i = 0; i < Math.abs(steps); i++) {
+ sendDownUpKeyEvents(steps < 0 ? KeyEvent.KEYCODE_DPAD_LEFT : KeyEvent.KEYCODE_DPAD_RIGHT);
+ }
refresh();
}
- private void closeClipboard() {
+ private int panelHeight() {
+ return keyboardView.getHeight() > 0 ? keyboardView.getHeight()
+ : keyboardView.getKeyboard().getHeight() + keyboardView.getPaddingTop() + keyboardView.getPaddingBottom();
+ }
+
+ private void closePanels() {
if (clipboardPanel == null) return;
if (clipboardManager != null) clipboardManager.removePrimaryClipChangedListener(clipboardChanged);
clipboardPanel.setVisibility(View.GONE);
+ emojiPanel.setVisibility(View.GONE);
+ settingsPanel.setVisibility(View.GONE);
clipboardPreview.setText("");
keyboardView.setVisibility(View.VISIBLE);
- toolsOpen = false;
- cursorTools = false;
+ }
+
+ private void openEmoji() {
+ closePanels();
+ emojiPanel.getLayoutParams().height = panelHeight();
+ emojiPanel.setVisibility(View.VISIBLE);
+ keyboardView.setVisibility(View.INVISIBLE);
+ showReadings();
}
private ClipData clipboardText() {
@@ -564,8 +637,9 @@ private ClipData clipboardText() {
}
private void openClipboard() {
- clipboardPanel.getLayoutParams().height = Math.max(keyboardView.getHeight(),
- (int) (236 * getResources().getDisplayMetrics().density));
+ emojiPanel.setVisibility(View.GONE);
+ settingsPanel.setVisibility(View.GONE);
+ clipboardPanel.getLayoutParams().height = panelHeight();
keyboardView.setVisibility(View.INVISIBLE); // keep the panel and keyboard at the same height
clipboardPanel.setVisibility(View.VISIBLE);
showReadings();
@@ -609,12 +683,14 @@ private void pasteClipboard() {
feedback();
if (active) apply(ic, Native.nativeKey(ime, Native.OTHER, 0, before(ic)));
if (!ic.commitText(text.toString(), 1)) {
+ openClipboard();
clipboardPreview.setText(R.string.paste_unavailable);
return;
}
- closeClipboard();
+ closePanels();
refresh();
} catch (SecurityException denied) {
+ openClipboard();
clipboardPreview.setText(R.string.clipboard_unavailable);
clipboardPaste.setEnabled(false);
}
diff --git a/platforms/android/native/Cargo.toml b/platforms/android/native/Cargo.toml
index b2485f3..5a85869 100644
--- a/platforms/android/native/Cargo.toml
+++ b/platforms/android/native/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tacit-android"
-version = "0.10.9"
+version = "0.10.10"
edition = "2021"
license = "AGPL-3.0-only"
repository = "https://github.com/iamallyouneed/tacit"
diff --git a/platforms/android/tests/KeyboardUxSmoke.java b/platforms/android/tests/KeyboardUxSmoke.java
index 202bb06..bbe588f 100644
--- a/platforms/android/tests/KeyboardUxSmoke.java
+++ b/platforms/android/tests/KeyboardUxSmoke.java
@@ -6,9 +6,13 @@
import android.content.ClipDescription;
import android.content.ClipboardManager;
import android.content.Context;
+import android.content.SharedPreferences;
+import android.content.res.Configuration;
import android.inputmethodservice.Keyboard;
+import android.inputmethodservice.KeyboardView;
import android.os.Bundle;
import android.os.PersistableBundle;
+import android.os.SystemClock;
import android.text.Editable;
import android.text.InputType;
import android.text.Selection;
@@ -16,6 +20,7 @@
import android.view.ContextThemeWrapper;
import android.view.KeyEvent;
import android.view.LayoutInflater;
+import android.view.MotionEvent;
import android.view.View;
import android.view.inputmethod.BaseInputConnection;
import android.view.inputmethod.EditorInfo;
@@ -23,16 +28,21 @@
import android.widget.LinearLayout;
import android.widget.TextView;
+import java.util.HashMap;
+import java.util.Map;
+
/** 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();
+ View inputView;
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; }
+ @Override public void setInputView(View view) { inputView = view; }
}
private static void check(boolean condition, String message) {
@@ -49,6 +59,12 @@ private static void type(Service service, String keys) {
for (char ch : keys.toCharArray()) service.onKey(ch, new int[] {ch});
}
+ private static void touch(DualKeyboardView view, long start, int action, float x, float y) {
+ MotionEvent event = MotionEvent.obtain(start, SystemClock.uptimeMillis(), action, x, y, 0);
+ view.onTouchEvent(event);
+ event.recycle();
+ }
+
private void verify() throws Exception {
Context context = new ContextThemeWrapper(getTargetContext(), android.R.style.Theme_Material_Light_NoActionBar);
Service service = new Service(context);
@@ -61,6 +77,7 @@ private void verify() throws Exception {
Selection.setSelection(text, "앞😀 ".length());
int[] action = {0};
int[] cursorKey = {0};
+ int[] cursorSteps = {0};
boolean[] rejectCommit = {false};
service.connection = new BaseInputConnection(new View(context), true) {
@Override public Editable getEditable() { return text; }
@@ -69,7 +86,11 @@ private void verify() throws Exception {
}
@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();
+ if (event.getAction() == KeyEvent.ACTION_DOWN) {
+ cursorKey[0] = event.getKeyCode();
+ if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_LEFT) cursorSteps[0]--;
+ if (event.getKeyCode() == KeyEvent.KEYCODE_DPAD_RIGHT) cursorSteps[0]++;
+ }
return true;
}
};
@@ -77,6 +98,9 @@ private void verify() throws Exception {
service.info.imeOptions = EditorInfo.IME_ACTION_SEARCH;
service.info.packageName = "keyboard-ux-smoke";
ClipboardManager clipboard = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
+ SharedPreferences settings = context.getSharedPreferences("keyboard", Context.MODE_PRIVATE);
+ Map originalSettings = new HashMap<>(settings.getAll());
+ settings.edit().putInt("theme", 0).putInt("height", 1).commit();
try {
service.onStartInput(service.info, false);
View root = service.onCreateInputView();
@@ -86,17 +110,20 @@ private void verify() throws Exception {
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");
+ int candidateTop = root.findViewById(R.id.readings_scroll).getTop();
+ int keyboardHeight = root.getMeasuredHeight();
+ check(root.findViewById(R.id.paste_tool).getVisibility() == View.VISIBLE, "direct paste visible");
type(service, "dkssud");
check(text.toString().equals("앞😀 안녕뒤"), "Korean composition: " + text);
check(strip.getChildAt(0).isSelected(), "selected reading");
+ check(root.findViewById(R.id.clipboard_tool).getVisibility() == View.VISIBLE, "clipboard stays visible while typing");
+ check(root.findViewById(R.id.readings_scroll).getTop() == candidateTop, "candidate position stable");
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();
+ root.findViewById(R.id.clipboard_tool).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");
@@ -109,8 +136,7 @@ private void verify() throws Exception {
Selection.setSelection(text, 0, 1);
clipboard.setPrimaryClip(ClipData.newPlainText("test", "X"));
- strip.getChildAt(0).performClick();
- root.findViewById(R.id.clipboard_paste).performClick();
+ root.findViewById(R.id.paste_tool).performClick();
check(text.toString().startsWith("X😀 "), "paste replaces only selection: " + text);
ClipData secret = ClipData.newPlainText("test", "secret-not-for-preview");
@@ -118,16 +144,16 @@ private void verify() throws Exception {
extras.putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true);
secret.getDescription().setExtras(extras);
clipboard.setPrimaryClip(secret);
- strip.getChildAt(0).performClick();
+ root.findViewById(R.id.clipboard_tool).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();
+ root.findViewById(R.id.clipboard_tool).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();
+ root.findViewById(R.id.clipboard_tool).performClick();
String unchanged = text.toString();
rejectCommit[0] = true;
root.findViewById(R.id.clipboard_paste).performClick();
@@ -137,6 +163,19 @@ private void verify() throws Exception {
rejectCommit[0] = false;
root.findViewById(R.id.clipboard_close).performClick();
+ Selection.setSelection(text, 0, 1);
+ root.findViewById(R.id.emoji_tool).performClick();
+ root.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(2000, View.MeasureSpec.AT_MOST));
+ root.layout(0, 0, width, root.getMeasuredHeight());
+ check(root.getMeasuredHeight() == keyboardHeight, "emoji panel keeps keyboard height");
+ check(preview.getText().length() == 0, "emoji panel clears clipboard preview");
+ LinearLayout rows = root.findViewById(R.id.emoji_rows);
+ ((LinearLayout) rows.getChildAt(0)).getChildAt(0).performClick();
+ check(text.toString().startsWith("😀😀 "), "emoji replaces only selection: " + text);
+ root.findViewById(R.id.tools_toggle).performClick();
+ check(root.findViewById(R.id.emoji_panel).getVisibility() == View.GONE, "back closes emoji panel");
+
text.clear();
Selection.setSelection(text, 0);
service.onStartInput(service.info, false);
@@ -156,12 +195,112 @@ private void verify() throws Exception {
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");
+ DualKeyboardView view = root.findViewById(R.id.keyboard);
+ // Isolated views need the framework's attachment hook to initialize touch handling.
+ java.lang.reflect.Method attach = KeyboardView.class.getDeclaredMethod("onAttachedToWindow");
+ attach.setAccessible(true);
+ attach.invoke(view);
+ Keyboard.Key space = board.getKeys().stream().filter(k -> k.codes[0] == 32).findFirst().get();
+ float density = context.getResources().getDisplayMetrics().density;
+ float x = view.getPaddingLeft() + space.x + space.width / 2f;
+ float y = view.getPaddingTop() + space.y + space.height / 2f;
+ long start = SystemClock.uptimeMillis();
+ unchanged = text.toString();
+ touch(view, start, MotionEvent.ACTION_DOWN, x, y);
+ touch(view, start, MotionEvent.ACTION_MOVE, x + 48 * density, y);
+ touch(view, start, MotionEvent.ACTION_MOVE, x - 16 * density, y);
+ touch(view, start, MotionEvent.ACTION_UP, x - 16 * density, y);
+ check(cursorSteps[0] == -1, "drag advances and reverses in character steps: " + cursorSteps[0]);
+ check(cursorKey[0] == KeyEvent.KEYCODE_DPAD_LEFT, "drag left reaches editor");
+ check(text.toString().equals(unchanged), "space drag inserts no text");
+ start = SystemClock.uptimeMillis();
+ touch(view, start, MotionEvent.ACTION_DOWN, x, y);
+ touch(view, start, MotionEvent.ACTION_MOVE, x + density, y);
+ touch(view, start, MotionEvent.ACTION_UP, x + density, y);
+ check(text.toString().equals(unchanged + " "), "small movement still types one space: " + text);
+ start = SystemClock.uptimeMillis();
+ unchanged = text.toString();
+ touch(view, start, MotionEvent.ACTION_DOWN, x, y);
+ touch(view, start, MotionEvent.ACTION_CANCEL, x, y);
+ check(text.toString().equals(unchanged), "cancelled touch inserts nothing");
+ type(service, "hello");
+ unchanged = text.toString();
+ start = SystemClock.uptimeMillis();
+ touch(view, start, MotionEvent.ACTION_DOWN, x, y);
+ touch(view, start, MotionEvent.ACTION_MOVE, x - 32 * density, y);
+ touch(view, start, MotionEvent.ACTION_CANCEL, x - 32 * density, y);
+ check(text.toString().equals(unchanged), "drag preserves displayed composition");
+ check(Native.nativeReadings(handle).isEmpty(), "drag flushes native word before cursor movement");
+ check(BaseInputConnection.getComposingSpanStart(text) == -1, "drag detaches composing span");
+ service.onKey(-102, null);
+ unchanged = text.toString();
+ int stepsBeforeSymbols = cursorSteps[0];
+ start = SystemClock.uptimeMillis();
+ touch(view, start, MotionEvent.ACTION_DOWN, x, y);
+ touch(view, start, MotionEvent.ACTION_MOVE, x + 32 * density, y);
+ touch(view, start, MotionEvent.ACTION_UP, x + 32 * density, y);
+ check(cursorSteps[0] == stepsBeforeSymbols + 2 && text.toString().equals(unchanged),
+ "symbols keyboard supports space drag without text");
+ service.onKey(-103, null);
+ java.lang.reflect.Method detach = KeyboardView.class.getDeclaredMethod("onDetachedFromWindow");
+ detach.setAccessible(true);
+ detach.invoke(view);
+
+ service.onKey(-101, null); // English -> auto
+ type(service, "dkssud");
+ String pendingReadings = Native.nativeReadings(handle);
+ check(!pendingReadings.isEmpty(), "pending reading before changing settings");
+ unchanged = text.toString();
+ int caret = Selection.getSelectionEnd(text);
+ int regularHeight = ((Keyboard) field(service, "letters")).getHeight();
+ root.findViewById(R.id.more_tool).performClick();
+ root.findViewById(R.id.theme_dark).performClick();
+ root = service.inputView;
+ check(root != null && root.findViewById(R.id.settings_panel).getVisibility() == View.VISIBLE,
+ "settings stays open after changing theme");
+ check((root.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)
+ == Configuration.UI_MODE_NIGHT_YES, "explicit dark theme overrides system");
+ check(root.getContext().getColor(R.color.keyboard_background) == 0xff080808, "dark background is neutral black");
+ check(text.toString().equals(unchanged) && Selection.getSelectionEnd(text) == caret,
+ "theme change preserves text and caret");
+ check(Native.nativeReadings(handle).equals(pendingReadings), "theme change preserves pending word");
+ root.findViewById(R.id.height_tall).performClick();
+ root = service.inputView;
+ check(((Keyboard) field(service, "letters")).getHeight() > regularHeight, "tall setting changes key geometry");
+ root.findViewById(R.id.height_compact).performClick();
+ root = service.inputView;
+ check(((Keyboard) field(service, "letters")).getHeight() < regularHeight, "compact setting changes key geometry");
+ service.onKey(-102, null);
+ root.findViewById(R.id.theme_light).performClick();
+ root = service.inputView;
+ check(((DualKeyboardView) field(service, "keyboardView")).getKeyboard() == field(service, "symbols"),
+ "theme change preserves symbol layout");
+ service.onKey(-103, null);
+ check((root.getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK)
+ == Configuration.UI_MODE_NIGHT_NO, "light theme applies immediately");
+ root.findViewById(R.id.preview_setting).performClick();
+ check(settings.getBoolean("preview", true) == (Boolean) field(service, "keyPreviewEnabled"), "preview setting saved");
+ root.findViewById(R.id.haptics_setting).performClick();
+ check(settings.getBoolean("haptics", true) == (Boolean) field(service, "hapticsEnabled"), "haptics setting saved");
+ check(text.toString().equals(unchanged) && Native.nativeReadings(handle).equals(pendingReadings),
+ "height and feedback settings preserve composition");
+ service.onFinishInputView(false);
+ View restored = service.onCreateInputView();
+ check(restored.findViewById(R.id.theme_light).isSelected()
+ && restored.findViewById(R.id.height_compact).isSelected(), "theme and height survive view recreation");
+ check(((android.widget.Switch) restored.findViewById(R.id.preview_setting)).isChecked()
+ == settings.getBoolean("preview", true), "preview survives view recreation");
} finally {
service.onFinishInputView(false);
Native.nativeDestroy(handle);
+ SharedPreferences.Editor restore = settings.edit();
+ for (String key : new String[] {"theme", "height", "haptics", "preview"}) {
+ Object value = originalSettings.get(key);
+ if (value instanceof Integer) restore.putInt(key, (Integer) value);
+ else if (value instanceof Boolean) restore.putBoolean(key, (Boolean) value);
+ else restore.remove(key);
+ }
+ restore.commit();
}
}
diff --git a/platforms/ffi/Cargo.toml b/platforms/ffi/Cargo.toml
index f727261..1c1a7c4 100644
--- a/platforms/ffi/Cargo.toml
+++ b/platforms/ffi/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tacit-ffi"
-version = "0.10.9"
+version = "0.10.10"
edition = "2021"
license = "AGPL-3.0-only"
repository = "https://github.com/iamallyouneed/tacit"
diff --git a/platforms/windows/Cargo.toml b/platforms/windows/Cargo.toml
index cd73e2d..f3b493d 100644
--- a/platforms/windows/Cargo.toml
+++ b/platforms/windows/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "tacit-tip"
-version = "0.10.9"
+version = "0.10.10"
edition = "2021"
license = "AGPL-3.0-only"
repository = "https://github.com/iamallyouneed/tacit"