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: 4 additions & 0 deletions CHANGELOG.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ Korean version: [CHANGELOG.md](CHANGELOG.md).

## [Unreleased]

### Changed

- 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
Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ GitHub 릴리스 노트가 되고 대시보드의 업데이트 탭에도 그대

## [Unreleased]

### 변경

- Enter·단축키·세션 종료 등 단순 확정은 미완성 입력도 화면에 보인 언어로 유지합니다. 한 글자는 표시 전에 독립된 입력으로 판별해 영어 접미사의 한글 미리보기를 확정 때 고치는 동작을 없앴습니다. 일본어는 표시된 분절과 실시간 후보를 유지하고, 미완성 일본어 `n`만 `ん`으로 마무리합니다. 명시적인 언어 변경·Space 변환·Tab 자동완성과 새로 입력한 숫자 문맥은 별도 동작으로 유지합니다.

## [0.10.9] - 2026-10-23

### 수정
Expand Down
75 changes: 43 additions & 32 deletions crates/core/src/session/japanese.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,10 +164,17 @@ impl InputSession {
{
return JapaneseSegmenter::text(&segments);
}
return RomajiComposer::compose(&self.word.keys, false).text;
}
self.japanese_partial_reading().0
}

/// The visible reading and only the pending letters belonging to its Japanese tail.
fn japanese_partial_reading(&self) -> (String, String) {
let composed = RomajiComposer::compose(&self.word.keys, true);
// While typing, a consonant at the end is just waiting for its vowel (watashihag → わたしはg),
// not a stray letter to split an English word ("hag") out of.
if partial && RomajiComposer::compose(&self.word.keys, true).stray_letters > 0 {
if composed.stray_letters > 0 {
if let Some(segments) = self
.ja
.segmenter
Expand All @@ -176,15 +183,21 @@ impl InputSession {
{
// While typing, the last segment may still be growing; keep its pending letters visible.
if let Some((last, head)) = segments.split_last() {
let tail = match last {
Segment::Japanese { keys, .. } => RomajiComposer::compose(keys, true).text,
Segment::English(word) => word.clone(),
let (tail, pending) = match last {
Segment::Japanese { keys, .. } => {
let tail = RomajiComposer::compose(keys, true);
(tail.text, tail.pending)
}
Segment::English(word) => (word.clone(), String::new()),
};
return format!("{}{}", JapaneseSegmenter::text(head), tail);
return (
format!("{}{}", JapaneseSegmenter::text(head), tail),
pending,
);
}
}
}
RomajiComposer::compose(&self.word.keys, partial).text
(composed.text, composed.pending)
}

// MARK: - Live conversion
Expand Down Expand Up @@ -236,38 +249,36 @@ impl InputSession {
/// The Japanese phrase as shown while typing: kana, or with live conversion the top
/// candidate followed by letters still waiting for their vowel (今日はいい天気でs).
pub(super) fn japanese_display(&mut self) -> String {
let reading = self.japanese_reading(true);
if !self.is_live() {
return reading;
}
let pending = RomajiComposer::compose(&self.word.keys, true).pending;
if !reading.ends_with(&pending) {
return reading;
}
let keep = char_count(&reading) - char_count(&pending);
let kana: String = reading.chars().take(keep).collect();
if kana.is_empty() {
return reading;
}
match self.live_candidate(&kana) {
Some(candidate) => format!("{}{}", candidate.text, pending),
None => reading,
}
self.render_japanese(false)
}

/// What a Japanese phrase commits as without the candidate window: the kana, or with
/// live conversion the candidate that was on screen (which the converter learns).
/// Plain confirmation keeps the visible segmentation and live candidate. Only a genuinely
/// pending Japanese n is completed; an English segment's final n is already literal text.
pub(super) fn committed_japanese(&mut self) -> String {
let kana = self.japanese_reading(false);
if self.is_live() {
if let Some(candidate) = self.live_candidate(&kana) {
if let Some(converter) = &self.ja.converter {
converter.learn(&candidate, &kana);
self.render_japanese(true)
}

fn render_japanese(&mut self, confirming: bool) -> String {
let (mut reading, pending) = self.japanese_partial_reading();
let complete_n = confirming && pending == "n";
// The composer appends pending letters verbatim; English tails have no pending suffix.
if self.is_live() && reading.len() > pending.len() {
let kana = &reading[..reading.len() - pending.len()];
if let Some(candidate) = self.live_candidate(kana) {
if confirming {
if let Some(converter) = &self.ja.converter {
converter.learn(&candidate, kana);
}
}
return candidate.text;
let tail = if complete_n { "ん" } else { &pending };
return format!("{}{}", candidate.text, tail);
}
}
kana
if complete_n {
reading.pop();
reading.push('ん');
}
reading
}

// MARK: - Conversion
Expand Down
8 changes: 3 additions & 5 deletions crates/core/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,12 +777,10 @@ impl InputSession {
history::suggest(Some(history), language, &prefix)
}

/// The language the current word will be committed in.
/// Route Enter and explicit Space conversion by the reading actually on screen.
#[cfg(feature = "japanese")]
fn current_language(&mut self) -> Option<Language> {
self.word
.override_language()
.or_else(|| Some(self.committed_language()))
fn current_language(&self) -> Option<Language> {
self.word.shown_language()
}

/// Decides the current word, learns an Esc override, and resets the buffer.
Expand Down
55 changes: 34 additions & 21 deletions crates/core/src/session/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ use crate::lang::english::EnglishModel;
use crate::lang::hangul::HangulComposer;
use crate::lang::korean::{KoreanModel, UNKNOWN_SYLLABLE_LOGP};
use crate::lang::pack::PackLazy;
use crate::lang::registry;
#[cfg(feature = "japanese")]
use crate::lang::romaji::RomajiComposer;
use crate::store::policy::AppPolicy;
Expand All @@ -31,7 +30,7 @@ pub struct Trace {
pub keys: String,
pub previous: Option<Language>,
pub decision: Decision,
/// Still being typed (the preview) rather than committed.
/// Partial-input scoring requested; standalone previews can request final scoring.
pub partial: bool,
}

Expand Down Expand Up @@ -89,23 +88,26 @@ impl InputSession {
decision
}

/// Clean, completed readings keep the language on screen. Digits supply new context
/// (p95, k8s); lone jamo and unfinished romaji still use the preview's confidence rule.
/// Confirmation accepts the engine reading already shown, even when unfinished.
/// A newly typed digit is different: it supplies identifier context (p95, k8s),
/// so that boundary retains the ordinary confidence rule. Overrides are applied
/// by the caller. Explicit corrections still compare against the engine's final
/// decision for all the keys, including keys typed after the user forced a language.
pub(super) fn committed_language(&mut self) -> Language {
if self.word.forced_by_user() {
return self.decide(false).language;
}
let Some(shown) = self.word.engine_preview() else {
return self.decide(false).language;
};
if !self.word_before_digit {
return shown;
}
let d = self.decide(false);
let complete = self.word.engine_preview().is_some_and(|shown| {
registry::is_clean(shown, &self.word.keys)
&& !(shown == Language::Korean && char_count(&self.word.keys) == 1)
});
match self.word.engine_preview() {
Some(shown)
if shown != d.language
&& ((complete && !self.word_before_digit)
|| !self.is_weak_flip_allowed(&d, shown)) =>
{
shown
}
_ => d.language,
if shown != d.language && !self.is_weak_flip_allowed(&d, shown) {
shown
} else {
d.language
}
}

Expand Down Expand Up @@ -137,11 +139,22 @@ impl InputSession {
self.split_off_korean_before_other(client);
#[cfg(feature = "japanese")]
self.split_off_japanese_before_korean(client);
let decision = self.decide(true);
let continuation = self.word.take_continuation().filter(|_| {
self.word.keys.len() == 2 && self.word.keys.bytes().all(|c| c.is_ascii_alphabetic())
});
let partial = self.decide(true);
let decision = if char_count(&self.word.keys) == 1 {
// A lone key is a complete letter/jamo on screen, but the next letter
// must continue the original partial hypothesis, not that standalone guess.
self.word.seed_continuation(partial.language);
self.decide(false)
} else {
partial
};
let mut language = decision.language;
// Hysteresis: a weak flip on one keystroke isn't shown, except into a dictionary
// English word, which is what Space/Return will commit.
if let Some(shown) = self.word.preview_language() {
// Hysteresis uses the first-key continuation hypothesis once, then the visible
// reading. A dictionary English word may always replace a weak earlier guess.
if let Some(shown) = continuation.or(self.word.preview_language()) {
if shown != language
&& char_count(&self.word.keys) > 1
&& !self.is_weak_flip_allowed(&decision, shown)
Expand Down
13 changes: 13 additions & 0 deletions crates/core/src/session/word.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ pub(super) struct WordState {
preview_language: Option<Language>,
/// The language the engine itself showed last, before any Esc.
engine_preview: Option<Language>,
/// Partial first-key hypothesis, used only when the next letter starts a longer word.
/// The standalone reading on screen remains authoritative for confirmation and editing.
continuation_language: Option<Language>,
/// Where the Korean syllable being typed starts once Delete was used in the word.
pub syllable_start: Option<usize>,
}
Expand All @@ -47,14 +50,22 @@ impl WordState {
pub fn engine_preview(&self) -> Option<Language> {
self.engine_preview
}
pub fn seed_continuation(&mut self, language: Language) {
self.continuation_language = Some(language);
}
pub fn take_continuation(&mut self) -> Option<Language> {
self.continuation_language.take()
}

/// The reading is fixed to `language` (by the user's Esc, or by the session).
pub fn force(&mut self, language: Language, by_user: bool) {
self.continuation_language = None;
self.forced = Some(language);
self.forced_by_user = by_user;
}
/// Keeps the language shown when Delete was pressed, until the next letter.
pub fn pin(&mut self, shown: Option<Language>) {
self.continuation_language = None;
self.pinned = shown;
}
pub fn unpin(&mut self) {
Expand All @@ -71,12 +82,14 @@ impl WordState {
}
/// Nothing is on screen for this word any more.
pub fn forget_preview(&mut self) {
self.continuation_language = None;
self.preview_language = None;
self.engine_preview = None;
}
/// The word became a Japanese phrase being converted: no steering is left over.
#[cfg(feature = "japanese")]
pub fn release(&mut self) {
self.continuation_language = None;
self.forced = None;
self.forced_by_user = false;
self.pinned = None;
Expand Down
Loading
Loading