Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/android-smoke.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
12 changes: 7 additions & 5 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 7 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 24 additions & 3 deletions crates/core/src/decide/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down Expand Up @@ -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.
Expand Down
77 changes: 76 additions & 1 deletion crates/core/tests/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand All @@ -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();
Expand Down
29 changes: 29 additions & 0 deletions crates/core/tests/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading