Skip to main content

geulbus_core/
engine.rs

1//! 한글 조합 엔진: 컴파일된 `Layout` 위에서 키 입력을 받아 음절을 조합한다.
2//!
3//! 세벌식(3벌식) 모델을 따른다: 초성/중성/종성이 서로 다른 글쇠라 역할이 분명하므로
4//! 이어치기가 자연스럽다. 완성된 음절에 새 **초성**이 오면 그 음절을 확정(commit)하고
5//! 새 음절을 시작한다. 중성/종성/갈마들이 토글은 현재 음절에 붙는다. 겹낱자(겹받침,
6//! 겹모음, 된소리)는 설정의 `UnitMixTable` 로 결합한다. 출력은 현대 음절이면 완성형
7//! (U+AC00), 아니면 첫가끝(조합용 자모) 시퀀스, 홑낱자면 `FinalConvTable`(호환 자모).
8//!
9//! 참고: `research/02-config-decode.md` §C, `research/04-hangul-unicode.md`.
10
11use crate::config::{BkspUnit, Layout};
12use crate::expr::{Ctx, Value};
13use crate::ngs_seq::ngs_seq;
14use crate::unit::{self, Category, Jamo, Unit};
15
16/// 조합 중인 한 음절. 각 칸은 조합용 자모 코드포인트(겹낱자는 결합된 단일 코드포인트).
17#[derive(Default, Clone, Copy, Debug, PartialEq, Eq)]
18struct Syllable {
19    cho: Option<u32>,
20    jung: Option<u32>,
21    jong: Option<u32>,
22}
23
24impl Syllable {
25    fn is_empty(&self) -> bool {
26        self.cho.is_none() && self.jung.is_none() && self.jong.is_none()
27    }
28}
29
30/// 키 한 번 처리 결과.
31#[derive(Debug, Clone, PartialEq, Eq, Default)]
32pub struct KeyOutcome {
33    /// 응용프로그램에 확정 입력할 문자열(없으면 빈 문자열).
34    pub commit: String,
35    /// 현재 조합 중 표시(preedit). 없으면 빈 문자열.
36    pub preedit: String,
37    /// 엔진이 이 키를 소비했는지. false 면 프런트엔드가 원래 키를 응용에 넘긴다.
38    pub consumed: bool,
39    /// BkspAttach: 커서 앞의 이미 확정된 글자 N개를 응용에서 지워달라는 요청.
40    /// 프런트엔드가 surrounding-text(DeleteSurroundingText)를 지원하면 그만큼 지우고,
41    /// 못 지우면 무시한다(그 경우 preedit 가 비고 consumed=false 로 폴백됨).
42    pub delete_before: u32,
43}
44
45/// 한글 조합 엔진.
46#[derive(Debug, Clone)]
47pub struct Engine {
48    layout: Layout,
49    cur: Syllable,
50    /// 마지막 확정 이후 현재 음절에 투입된 단위들(낱자 단위 백스페이스용 재생 이력).
51    history: Vec<Unit>,
52    /// 오토마타 현재 상태 id. layout.automata 가 비어 있으면 미사용(기본 휴리스틱).
53    auto_state: i64,
54    /// Bksp 연타 지속성: 백스페이스를 연달아 누르는 동안 최초로 결정된 삭제 단위를
55    /// 유지한다(날개셋 "연타 시 한 번 정해진 동작 계속"). 비-Bksp 입력이 들어오면 None 으로.
56    bksp_streak: Option<BkspUnit>,
57    /// BkspAttach 용: 직전에 확정(commit)한 음절들의 단위 이력 스택. 조합이 빈 상태에서
58    /// Backspace + attach 면 맨 위(가장 최근) 음절을 되살려 재조합한다(앞의 확정 글자에
59    /// "달라붙기"). 가나가… 처럼 연속 확정한 글자들을 차례로 되살릴 수 있도록 누적하되,
60    /// `MAX_PREV_SYLLABLES` 상한을 둬 무한 증가를 막는다.
61    prev_syllables: Vec<Vec<Unit>>,
62    /// 무한 낱자 수정으로 *직전에* 덮어쓴 낱자: (갈래, 옛 코드포인트, 새 코드포인트).
63    /// 엔진은 덮어쓴 옛 낱자를 버리므로, 분리(0x13)·복원(0x14) 특수글쇠를 위해 마지막
64    /// 한 번의 겹쳐쓰기만 따로 기억한다(그 뒤 다른 입력·편집·확정이 오면 무효화).
65    last_overwrite: Option<(Category, u32, u32)>,
66    /// 이력 재생(replay) 중인가. 재생 중에는 put_modify 의 겹쳐쓰기 기록을 멈춘다(실제
67    /// 입력이 아니라 재구성이므로). C0 편집 명령들이 cur 을 안전하게 다시 만들 때 쓴다.
68    replaying: bool,
69    /// 프런트엔드가 surrounding-text(앞 확정 글자 삭제·읽기)를 실제로 지원하는가.
70    /// 앞 글자에 결합하는 특수글쇠(낱자 재결합·앞으로 이동 등)는 이게 켜졌을 때만
71    /// 동작한다(끄여 있으면 무동작 — 앱 화면과 내부 버퍼가 어긋나지 않도록).
72    surrounding_ok: bool,
73}
74
75impl Engine {
76    pub fn new(layout: Layout) -> Self {
77        let auto_state = layout.automata_start;
78        Self {
79            layout,
80            cur: Syllable::default(),
81            history: Vec::new(),
82            auto_state,
83            bksp_streak: None,
84            prev_syllables: Vec::new(),
85            last_overwrite: None,
86            replaying: false,
87            surrounding_ok: false,
88        }
89    }
90
91    pub fn layout(&self) -> &Layout {
92        &self.layout
93    }
94
95    /// 프런트엔드의 surrounding-text 지원 여부를 알린다(앞 글자 결합 특수글쇠 게이트).
96    pub fn set_surrounding_ok(&mut self, ok: bool) {
97        self.surrounding_ok = ok;
98    }
99
100    /// 조합 중인 내용이 없는가.
101    pub fn is_empty(&self) -> bool {
102        self.cur.is_empty()
103    }
104
105    /// 오토마타 상태 id (값-식의 `T`): 0=비어있음, 1=미완성(초성/홑낱자), 2=중성 있음.
106    fn t_state(&self) -> i64 {
107        if self.cur.is_empty() {
108            0
109        } else if self.cur.jung.is_some() {
110            2
111        } else {
112            1
113        }
114    }
115
116    /// 현재 음절을 문자열로 렌더링(완성형/첫가끝/호환 자모).
117    fn render(&self, syl: &Syllable) -> String {
118        if syl.is_empty() {
119            return String::new();
120        }
121        // 초성+중성이 모두 있으면 음절 블록.
122        if let (Some(cho), Some(jung)) = (syl.cho, syl.jung) {
123            if let Some(ch) = hanmo::compose(cho, jung, syl.jong) {
124                return ch.to_string(); // 현대 완성형
125            }
126            // 옛한글: 첫가끝 조합용 자모 시퀀스
127            let mut s = String::new();
128            for cp in [Some(cho), Some(jung), syl.jong].into_iter().flatten() {
129                if let Some(c) = char::from_u32(cp) {
130                    s.push(c);
131                }
132            }
133            return s;
134        }
135        // 초성 또는 중성 한쪽만 빈 부분 음절(낱자 2개 이상)은 채움 문자로 모아 그린다.
136        // 빈 초성 자리에 U+115F(초성 채움), 빈 중성 자리에 U+1160(중성 채움)을 넣어
137        // 첫가끝 L·V·T 시퀀스를 만들면, 폰트/셰이퍼가 빈 자리를 둔 음절 블록으로 합친다.
138        // (예: 초성 삭제 후 ㅏㄴ, 중성 삭제 후 ㄱㄴ.) 호환 자모는 절대 안 모아지므로.
139        let count = [syl.cho, syl.jung, syl.jong]
140            .iter()
141            .filter(|x| x.is_some())
142            .count();
143        if count >= 2 {
144            const CHOSEONG_FILLER: u32 = 0x115F;
145            const JUNGSEONG_FILLER: u32 = 0x1160;
146            let mut s = String::new();
147            for cp in [
148                Some(syl.cho.unwrap_or(CHOSEONG_FILLER)),
149                Some(syl.jung.unwrap_or(JUNGSEONG_FILLER)),
150                syl.jong,
151            ]
152            .into_iter()
153            .flatten()
154            {
155                if let Some(c) = char::from_u32(cp) {
156                    s.push(c);
157                }
158            }
159            return s;
160        }
161        // 단독 낱자(초성/중성/종성 중 하나): 호환 자모.
162        let mut s = String::new();
163        for (cat, cp) in [
164            (Category::Cho, syl.cho),
165            (Category::Jung, syl.jung),
166            (Category::Jong, syl.jong),
167        ] {
168            if let Some(cp) = cp {
169                if let Some(ch) = self.layout.standalone(Jamo::new(cat, cp)) {
170                    s.push(ch);
171                } else if let Some(ch) = char::from_u32(cp) {
172                    s.push(ch);
173                }
174            }
175        }
176        s
177    }
178
179    /// 현재 조합 중 표시 문자열.
180    pub fn preedit(&self) -> String {
181        self.render(&self.cur)
182    }
183
184    /// BkspAttach 되살리기 이력으로 보관할 최대 음절 수(메모리 상한).
185    const MAX_PREV_SYLLABLES: usize = 32;
186
187    /// 현재 음절을 확정 문자열로 만들고 버퍼를 비운다(이력은 건드리지 않음).
188    /// 비지 않은 음절을 확정할 때는 그 음절의 단위 이력을 BkspAttach 용으로 누적 보존한다.
189    fn commit_current(&mut self) -> String {
190        let s = self.render(&self.cur);
191        if !self.cur.is_empty() && !self.history.is_empty() {
192            // 확정되는 음절의 자모 이력 스냅샷(되살리기용). 연속 확정(가나다…)을 차례로
193            // 되살릴 수 있도록 스택처럼 누적하되, 상한을 둬 무한 증가를 막는다.
194            self.prev_syllables.push(self.history.clone());
195            if self.prev_syllables.len() > Self::MAX_PREV_SYLLABLES {
196                self.prev_syllables.remove(0);
197            }
198        }
199        self.cur = Syllable::default();
200        self.last_overwrite = None; // 음절이 확정되면 겹쳐쓰기 기록도 무효.
201        s
202    }
203
204    /// 현재 음절을 확정하고 버퍼·이력을 모두 비운다.
205    /// 한글 조합 흐름을 끊는 확정(공백·기호·미배열 글쇠 통과 등)이므로 BkspAttach
206    /// 되살리기 스택을 비운다. 확정 글자 뒤에 비-한글 문자가 끼면 그 글자에 다시
207    /// "달라붙을" 수 없기 때문(그랬다간 백스페이스가 사이의 공백 대신 글자를 지움).
208    fn commit_and_clear(&mut self) -> String {
209        let s = self.commit_current();
210        self.history.clear();
211        self.prev_syllables.clear();
212        s
213    }
214
215    /// 포커스 아웃/리셋 시: 현재 음절을 확정해 돌려주고 버퍼를 비운다.
216    pub fn flush(&mut self) -> String {
217        let s = self.commit_current();
218        self.history.clear();
219        self.prev_syllables.clear();
220        self.auto_state = self.layout.automata_start;
221        self.bksp_streak = None;
222        s
223    }
224
225    /// 조합 버퍼를 확정 없이 비운다.
226    pub fn reset(&mut self) {
227        self.cur = Syllable::default();
228        self.history.clear();
229        self.prev_syllables.clear();
230        self.auto_state = self.layout.automata_start;
231        self.bksp_streak = None;
232        self.last_overwrite = None;
233    }
234
235    // ── 낱자 투입 ────────────────────────────────────────────────────────────
236
237    fn feed_cho(&mut self, cp: u32) -> String {
238        if self.cur.is_empty() {
239            self.cur.cho = Some(cp);
240            return String::new();
241        }
242        // 홑초성만 있는 상태: 된소리 결합 시도
243        if self.cur.cho.is_some() && self.cur.jung.is_none() && self.cur.jong.is_none() {
244            if let Some(r) = self
245                .layout
246                .combine(Category::Cho, self.cur.cho.unwrap(), cp)
247            {
248                self.cur.cho = Some(r);
249                return String::new();
250            }
251        }
252        // 그 외: 새 음절 시작
253        let out = self.commit_current();
254        self.cur.cho = Some(cp);
255        out
256    }
257
258    fn feed_jung(&mut self, cp: u32) -> String {
259        // 중성 칸이 비어 있으면(받침도 없으면) 그대로 채움(초성 유무 무관: 홀소리 음절 가능)
260        if self.cur.jung.is_none() && self.cur.jong.is_none() {
261            self.cur.jung = Some(cp);
262            return String::new();
263        }
264        // 중성이 있고 받침이 없으면 겹모음 결합 시도
265        if self.cur.jung.is_some() && self.cur.jong.is_none() {
266            if let Some(r) = self
267                .layout
268                .combine(Category::Jung, self.cur.jung.unwrap(), cp)
269            {
270                self.cur.jung = Some(r);
271                return String::new();
272            }
273        }
274        // 그 외(CVC 뒤 모음 등): 새 음절(홀소리)로 (3벌식 → 도깨비불 없음)
275        let out = self.commit_current();
276        self.cur.jung = Some(cp);
277        out
278    }
279
280    fn feed_jong(&mut self, cp: u32) -> String {
281        // 초성+중성이 있고 받침이 비면 받침으로 붙임
282        if self.cur.cho.is_some() && self.cur.jung.is_some() && self.cur.jong.is_none() {
283            self.cur.jong = Some(cp);
284            return String::new();
285        }
286        // 받침이 이미 있으면 겹받침 결합 시도
287        if self.cur.jong.is_some() {
288            if let Some(r) = self
289                .layout
290                .combine(Category::Jong, self.cur.jong.unwrap(), cp)
291            {
292                self.cur.jong = Some(r);
293                return String::new();
294            }
295        }
296        // 붙일 곳이 없으면 현재 음절 확정 후 홑받침(홑낱자)로 시작
297        let out = self.commit_current();
298        self.cur = Syllable {
299            jong: Some(cp),
300            ..Syllable::default()
301        };
302        out
303    }
304
305    fn feed_toggle(&mut self) -> String {
306        // 갈마들이 토글: 현재 초성의 된소리/예사소리 전환(설정 UnitMix 에 (cho,500)→ 규칙)
307        if let Some(cho) = self.cur.cho {
308            if let Some(r) = self.layout.combine(Category::Cho, cho, unit::TOGGLE) {
309                self.cur.cho = Some(r);
310            }
311        }
312        String::new()
313    }
314
315    fn feed_jamo(&mut self, j: Jamo) -> String {
316        match j.category {
317            Category::Cho => self.feed_cho(j.cp),
318            Category::Jung => self.feed_jung(j.cp),
319            Category::Jong => self.feed_jong(j.cp),
320        }
321    }
322
323    fn feed_unit(&mut self, u: Unit) -> String {
324        // 오토마타가 정의돼 있으면 낱자(가상단위 포함)는 오토마타 경로로 처리한다.
325        // 토글은 양쪽 모두 feed_toggle 로(현재 초성 된소리 전환), 이력만 갱신.
326        if !self.layout.automata.is_empty() {
327            let jamo = match u {
328                Unit::Jamo(j) => Some(j),
329                Unit::Virtual(id) => self.layout.virtual_units.get(&id).copied(),
330                Unit::Toggle => None,
331            };
332            if let Some(j) = jamo {
333                // 서열을 모르는 낱자(표 밖 옛한글 등)는 안전하게 휴리스틱으로.
334                if ngs_seq(j.category, j.cp).is_some() {
335                    return self.automaton_feed(j);
336                }
337            }
338        }
339        // 레거시(휴리스틱) 경로.
340        let out = match u {
341            Unit::Jamo(j) => self.feed_jamo(j),
342            Unit::Toggle => self.feed_toggle(),
343            Unit::Virtual(id) => match self.layout.virtual_units.get(&id).copied() {
344                Some(j) => self.feed_jamo(j),
345                None => String::new(),
346            },
347        };
348        // 이력 갱신: 확정이 없었으면 현재 음절에 덧붙은 것 → push.
349        // 확정이 있었으면 새 음절이 이 단위로 시작된 것 → 이력을 이 단위만으로 리셋.
350        if out.is_empty() {
351            self.history.push(u);
352        } else if self.cur.is_empty() {
353            self.history.clear();
354        } else {
355            self.history = vec![u];
356        }
357        out
358    }
359
360    // ── 오토마타 실행 (날개셋 AutomataTable) ─────────────────────────────────────
361
362    /// 조합 중 음절의 한 칸(초/중/종) 서열번호. 비었으면 0.
363    fn slot_seq(&self, cat: Category) -> i64 {
364        let cp = match cat {
365            Category::Cho => self.cur.cho,
366            Category::Jung => self.cur.jung,
367            Category::Jong => self.cur.jong,
368        };
369        cp.and_then(|c| ngs_seq(cat, c))
370            .map(|s| s as i64)
371            .unwrap_or(0)
372    }
373
374    /// 낱자를 현재 음절의 해당 칸에 넣는다(확정 없이). 칸이 차 있으면 UnitMix 결합을
375    /// 시도하고, 결합 규칙이 없으면 교체한다(= 무한 낱자 수정). 빈 칸이면 그대로 채운다.
376    /// 교체가 일어났으면 `true`(이력 정리용).
377    fn put_modify(&mut self, j: Jamo) -> bool {
378        let existing = match j.category {
379            Category::Cho => self.cur.cho,
380            Category::Jung => self.cur.jung,
381            Category::Jong => self.cur.jong,
382        };
383        // 빈 칸=채움, 결합 규칙 있으면 결합, 없으면 교체(무한 낱자 수정).
384        let (newcp, replaced) = match existing {
385            None => (j.cp, false),
386            Some(e) => match self.layout.combine(j.category, e, j.cp) {
387                Some(r) => (r, false),
388                None => (j.cp, true),
389            },
390        };
391        match j.category {
392            Category::Cho => self.cur.cho = Some(newcp),
393            Category::Jung => self.cur.jung = Some(newcp),
394            Category::Jong => self.cur.jong = Some(newcp),
395        }
396        // 무한 낱자 수정 분리/복원(0x13/0x14)용: 직전 겹쳐쓰기만 기억(재생 중엔 제외).
397        // 일반 배치(겹쳐쓰기 아님)는 직전 기록을 무효화한다("직전" 의미 유지).
398        if !self.replaying {
399            self.last_overwrite = match existing {
400                Some(e) if replaced => Some((j.category, e, j.cp)),
401                _ => None,
402            };
403        }
404        replaced
405    }
406
407    /// 단위의 낱자 갈래(백스페이스 이력 정리용). 가상단위는 풀어서, 토글은 초성으로 본다.
408    fn unit_cat(layout: &Layout, u: &Unit) -> Option<Category> {
409        match u {
410            Unit::Jamo(j) => Some(j.category),
411            Unit::Virtual(id) => layout.virtual_units.get(id).map(|j| j.category),
412            Unit::Toggle => Some(Category::Cho),
413        }
414    }
415
416    /// 낱자를 이력에 기록한다. 무한 낱자 수정으로 같은 칸을 *교체*한 경우엔, 그 칸의
417    /// 직전 단위를 이력에서 빼고 새 단위를 넣어, 낱자 단위 백스페이스가 정확히 현재
418    /// 낱자만 되돌리도록 한다. (결합이면 둘 다 남겨 한 단계씩 분해.)
419    fn record_unit(&mut self, u: Unit, replaced: bool, cat: Category) {
420        if replaced {
421            let pos = {
422                let layout = &self.layout;
423                self.history
424                    .iter()
425                    .rposition(|h| Self::unit_cat(layout, h) == Some(cat))
426            };
427            if let Some(p) = pos {
428                self.history.remove(p);
429            }
430        }
431        self.history.push(u);
432    }
433
434    /// 빈 음절에 낱자 하나를 넣었을 때의 오토마타 상태(시작 상태에서 평가).
435    fn fresh_state(&self, j: Jamo) -> i64 {
436        let seq = ngs_seq(j.category, j.cp).map(|s| s as i64).unwrap_or(0);
437        let (a, b, c) = match j.category {
438            Category::Cho => (seq, 0, 0),
439            Category::Jung => (0, seq, 0),
440            Category::Jong => (0, 0, seq),
441        };
442        let ctx = Ctx {
443            a,
444            b,
445            c,
446            ..Default::default()
447        };
448        match self.layout.automata.get(&self.layout.automata_start) {
449            Some(st) => match st.value.eval(&ctx) {
450                Ok(Value::Int(n)) if n > 0 => n,
451                _ => self.layout.automata_start,
452            },
453            None => self.layout.automata_start,
454        }
455    }
456
457    /// 한 낱자를 오토마타로 처리한다. 확정 문자열을 돌려준다.
458    fn automaton_feed(&mut self, j: Jamo) -> String {
459        let seq = ngs_seq(j.category, j.cp).map(|s| s as i64).unwrap_or(0);
460        let (a, b, c) = match j.category {
461            Category::Cho => (seq, 0, 0),
462            Category::Jung => (0, seq, 0),
463            Category::Jong => (0, 0, seq),
464        };
465        let ctx = Ctx {
466            a,
467            b,
468            c,
469            d: self.slot_seq(Category::Cho),
470            e: self.slot_seq(Category::Jung),
471            f: self.slot_seq(Category::Jong),
472            ..Default::default() // o=0(세벌식), t=0(일반 상황)
473        };
474        // 현재 상태의 전이식 평가(실패 시 default 식, 그래도 없으면 휴리스틱).
475        let r = match self.layout.automata.get(&self.auto_state) {
476            Some(st) => match st.value.eval(&ctx) {
477                Ok(Value::Int(n)) => n,
478                _ => match st.default.eval(&ctx) {
479                    Ok(Value::Int(n)) => n,
480                    _ => return self.feed_jamo_tracked(j),
481                },
482            },
483            None => return self.feed_jamo_tracked(j),
484        };
485        self.apply_result(r, j)
486    }
487
488    /// 오토마타 결과 코드 r 에 따라 낱자를 배치한다(research/ngs-automata-help.txt).
489    /// 양수=그 상태로 조합 계속, 0=다음 글자 시작, -1=무시, -2=무한 낱자 수정,
490    /// 그 외 음수=보수적으로 현재 확정 후 새 음절(점진적으로 정교화 예정).
491    fn apply_result(&mut self, r: i64, j: Jamo) -> String {
492        match r {
493            // 조합 계속: 해당 칸에 배치(차 있으면 결합/교체) 후 상태 갱신.
494            n if n > 0 => {
495                let replaced = self.put_modify(j);
496                self.auto_state = n;
497                self.record_unit(Unit::Jamo(j), replaced, j.category);
498                String::new()
499            }
500            // 무한 낱자 수정: 현재 음절을 확정하지 않고 칸을 결합/교체. 상태 유지.
501            -2 => {
502                let replaced = self.put_modify(j);
503                self.record_unit(Unit::Jamo(j), replaced, j.category);
504                String::new()
505            }
506            // 입력 무시(소비만).
507            -1 => String::new(),
508            // 0 및 그 외 음수: 현재 음절 확정 후 이 낱자로 새 음절 시작.
509            _ => {
510                let commit = self.commit_current();
511                self.history.clear();
512                self.put_modify(j);
513                self.auto_state = self.fresh_state(j);
514                self.history.push(Unit::Jamo(j));
515                commit
516            }
517        }
518    }
519
520    /// 휴리스틱 feed_jamo + 이력 갱신(오토마타 경로의 폴백용).
521    fn feed_jamo_tracked(&mut self, j: Jamo) -> String {
522        let out = self.feed_jamo(j);
523        if out.is_empty() {
524            self.history.push(Unit::Jamo(j));
525        } else if self.cur.is_empty() {
526            self.history.clear();
527        } else {
528            self.history = vec![Unit::Jamo(j)];
529        }
530        out
531    }
532
533    // ── 키 처리 ──────────────────────────────────────────────────────────────
534
535    /// KeyTable 의 ASCII 글쇠(0x21..0x7E)를 처리한다. `caps` 는 Caps Lock 점등 상태로,
536    /// 값-식의 `P` (bit0)에 들어간다(세벌식 항목은 P 미사용).
537    pub fn press(&mut self, ascii: u8, caps: bool) -> KeyOutcome {
538        // 일반 글쇠 입력은 Bksp 연타를 끊는다(연타 지속성 종료).
539        self.bksp_streak = None;
540        let expr = match self.layout.keys.get(&(ascii as u32)) {
541            Some(e) => e.clone(),
542            None => {
543                // 배열에 없는 인쇄 글쇠(예: 공백).
544                let mut commit = self.commit_and_clear();
545                if commit.is_empty() {
546                    // 조합 중이 아니면 우리가 확정할 것이 없으니 원래 키를 응용에 넘긴다.
547                    return KeyOutcome {
548                        commit,
549                        preedit: String::new(),
550                        consumed: false,
551                        delete_before: 0,
552                    };
553                }
554                // 조합 중이었으면 그 음절을 확정한 뒤 이 글쇠 문자까지 우리가 직접 확정하고
555                // 소비한다. commit_text 를 보내고 consumed=false 로 돌려주면 일부 앱이 글쇠를
556                // 또 입력해 공백이 두 번 들어가므로(앱 의존적), 한 이벤트에서 확정과 통과를
557                // 섞지 않는다.
558                if let Some(ch) = char::from_u32(ascii as u32) {
559                    commit.push(ch);
560                }
561                return KeyOutcome {
562                    commit,
563                    preedit: String::new(),
564                    consumed: true,
565                    delete_before: 0,
566                };
567            }
568        };
569        let ctx = Ctx {
570            t: self.t_state(),
571            p: caps as i64,
572            ..Default::default()
573        };
574        let val = match expr.eval(&ctx) {
575            Ok(v) => v,
576            Err(_) => {
577                let commit = self.commit_and_clear();
578                return KeyOutcome {
579                    commit,
580                    preedit: String::new(),
581                    consumed: false,
582                    delete_before: 0,
583                };
584            }
585        };
586        self.dispatch(val)
587    }
588
589    fn dispatch(&mut self, val: Value) -> KeyOutcome {
590        match val {
591            Value::Unit(u) => {
592                let commit = self.feed_unit(u);
593                KeyOutcome {
594                    commit,
595                    preedit: self.preedit(),
596                    consumed: true,
597                    delete_before: 0,
598                }
599            }
600            Value::Int(n) => {
601                // 문자(기호/숫자) 리터럴: 현재 음절 확정 후 그 문자를 확정.
602                let mut commit = self.commit_and_clear();
603                if let Some(ch) = u32::try_from(n).ok().and_then(char::from_u32) {
604                    commit.push(ch);
605                }
606                KeyOutcome {
607                    commit,
608                    preedit: String::new(),
609                    consumed: true,
610                    delete_before: 0,
611                }
612            }
613            Value::Command(cmd) => self.dispatch_command(cmd),
614        }
615    }
616
617    /// 백스페이스: 낱자 단위로 되돌린다. 현재 음절을 만든 단위 이력에서 마지막 하나를
618    /// 빼고 나머지를 다시 재생(replay)하므로, 겹낱자/겹모음/갈마들이 토글이 정확히 한
619    /// 단계씩 풀린다(날개셋 ByUnitStep 에 해당).
620    pub fn backspace(&mut self) -> KeyOutcome {
621        // 백스페이스는 새로운 편집 동작이므로 "직전 겹쳐쓰기" 기록을 무효화한다(그 낱자가
622        // 지워지면 0x13/0x14 가 사라진 낱자를 되살리려 하면 안 된다).
623        self.last_overwrite = None;
624        if self.cur.is_empty() {
625            // 조합 중이 아님. BkspAttach 가 켜져 있고 직전에 확정한 음절이 있으면, 그
626            // 음절을 되살려(앞 글자에 "달라붙기") 거기서 한 단계 지운다. 프런트엔드가
627            // 그 확정 글자를 앱에서 지우도록 delete_before=1 을 함께 돌려준다.
628            if self.layout.bksp.attach {
629                if let Some(hist) = self.prev_syllables.pop() {
630                    if !hist.is_empty() {
631                        // 그 음절을 재구성한 뒤(달라붙기), 이번 Bksp 의 삭제 단위만큼 지운다.
632                        self.history.clear();
633                        self.cur = Syllable::default();
634                        self.auto_state = self.layout.automata_start;
635                        for u in hist {
636                            let _ = self.feed_unit(u);
637                        }
638                        // 되살린 음절에 이어서 통상 조합-중 백스페이스 한 번을 적용.
639                        let unit = self.layout.bksp.composing;
640                        self.bksp_streak = Some(unit);
641                        self.bksp_remove(unit);
642                        let hist2 = std::mem::take(&mut self.history);
643                        self.cur = Syllable::default();
644                        self.auto_state = self.layout.automata_start;
645                        for u in hist2 {
646                            let _ = self.feed_unit(u);
647                        }
648                        return KeyOutcome {
649                            commit: String::new(),
650                            preedit: self.preedit(),
651                            consumed: true,
652                            delete_before: 1, // 앱에서 앞의 확정 글자 1개 제거
653                        };
654                    }
655                }
656            }
657            // 되살릴 게 없으면 응용이 직접 지우도록 넘김.
658            self.bksp_streak = None;
659            return KeyOutcome {
660                commit: String::new(),
661                preedit: String::new(),
662                consumed: false,
663                delete_before: 0,
664            };
665        }
666        // 삭제 단위 결정: 연타 중이면 최초 결정 동작 유지, 아니면 제1동작(composing)으로
667        // 새로 정하고 연타 상태에 기록(연타 지속성).
668        let unit = match self.bksp_streak {
669            Some(u) => u,
670            None => {
671                let u = self.layout.bksp.composing;
672                self.bksp_streak = Some(u);
673                u
674            }
675        };
676        self.bksp_remove(unit);
677        self.replay_history();
678        KeyOutcome {
679            commit: String::new(),
680            preedit: self.preedit(),
681            consumed: true,
682            delete_before: 0,
683        }
684    }
685
686    /// 백스페이스 삭제 단위에 따라 이력에서 제거할 단위를 정한다(현재 음절 기준).
687    /// 제거 후 호출부가 남은 이력을 재생해 음절을 재구성한다.
688    fn bksp_remove(&mut self, mode: BkspUnit) {
689        match mode {
690            // 글자 전체: 이력 비움 → 재생 시 빈 음절.
691            BkspUnit::Syllable => self.history.clear(),
692            // 직전 한 타: 마지막 단위 하나 제거.
693            BkspUnit::LastKey => {
694                self.history.pop();
695            }
696            // 최하위 낱자 관련: 종성→중성→초성 순으로 채워진 첫 칸을 대상으로.
697            BkspUnit::LowestLastKey | BkspUnit::LowestWhole => {
698                let cat = if self.cur.jong.is_some() {
699                    Category::Jong
700                } else if self.cur.jung.is_some() {
701                    Category::Jung
702                } else if self.cur.cho.is_some() {
703                    Category::Cho
704                } else {
705                    self.history.pop();
706                    return;
707                };
708                let layout = &self.layout;
709                if mode == BkspUnit::LowestWhole {
710                    // 그 낱자 전체: 해당 갈래 단위 모두 제거.
711                    self.history
712                        .retain(|u| Self::unit_cat(layout, u) != Some(cat));
713                } else {
714                    // 최하위 낱자의 직전 한 타: 그 갈래의 마지막 단위 하나만 제거.
715                    if let Some(p) = self
716                        .history
717                        .iter()
718                        .rposition(|u| Self::unit_cat(layout, u) == Some(cat))
719                    {
720                        self.history.remove(p);
721                    }
722                }
723            }
724        }
725    }
726
727    // ── C0 특수글쇠(제어 명령) ──────────────────────────────────────────────────
728    //
729    // 날개셋 "특수글쇠"(value 의 `C0|N`)를 실행한다. 코드별 의미는
730    // research/ngs-chm-speckey-reference 및 chm `usa_speckey.htm` 참고. 대부분은
731    // 조합 중 음절의 이력(history)을 편집한 뒤 처음부터 재생(replay)해 cur 과
732    // 오토마타 상태를 다시 맞추는 방식으로 구현한다(슬롯을 직접 만지면 history 와
733    // 어긋나 이후 백스페이스가 깨지므로). cursor 이동·Del·한자 후보 변환처럼 IBus/
734    // Wayland 에서 불가능하거나 별도 서브시스템이 필요한 코드는 기본 동작(현재 음절
735    // 확정)으로 떨어진다.
736
737    /// 남은 이력을 처음부터 재생해 현재 음절(cur)과 오토마타 상태를 재구성한다.
738    /// (한 음절 안의 단위들이므로 재생 중 확정은 발생하지 않는다.) 재생은 실제 입력이
739    /// 아니므로 겹쳐쓰기 기록을 남기지 않는다(replaying 가드).
740    fn replay_history(&mut self) {
741        let hist = std::mem::take(&mut self.history);
742        self.cur = Syllable::default();
743        self.auto_state = self.layout.automata_start;
744        self.replaying = true;
745        for u in hist {
746            let _ = self.feed_unit(u);
747        }
748        self.replaying = false;
749    }
750
751    /// 조합 중 편집 명령의 공통 결과(확정 없음, 현재 preedit, 소비).
752    fn composing_outcome(&self) -> KeyOutcome {
753        KeyOutcome {
754            commit: String::new(),
755            preedit: self.preedit(),
756            consumed: true,
757            delete_before: 0,
758        }
759    }
760
761    /// 이력에서 주어진 낱자 갈래들에 해당하는 단위를 모두 빼고 재생한다.
762    /// (특정 낱자 삭제 0x2~0x4, 특정 낱자만 남기기 0x15~0x17 용)
763    fn delete_cats_and_replay(&mut self, remove: &[Category]) {
764        let layout = &self.layout;
765        self.history.retain(|u| match Self::unit_cat(layout, u) {
766            Some(c) => !remove.contains(&c),
767            None => true,
768        });
769        self.replay_history();
770    }
771
772    /// 주어진 삭제 단위(BkspUnit)로 이력을 줄이고 재생한다(0x19~0x1C: Backspace 부분 동작).
773    fn bksp_cmd(&mut self, unit: BkspUnit) -> KeyOutcome {
774        self.bksp_remove(unit);
775        self.replay_history();
776        self.composing_outcome()
777    }
778
779    /// 조합 중 한 성분(초/중/종성)을 떼어 **다음 글자로** 보낸다(0x8/0x9/0xA: 뒤로 이동).
780    /// 남은 성분으로 현재 음절을 확정하고, 빼낸 성분을 새 음절로 재투입한다(오토마타/
781    /// 휴리스틱이 자리 배치). 모아치기와 연동해 '염'→'여'+조합 중 'ㅁ' 같은 동작을 한다.
782    fn cmd_move_component_back(&mut self, cat: Category) -> KeyOutcome {
783        let has = match cat {
784            Category::Cho => self.cur.cho.is_some(),
785            Category::Jung => self.cur.jung.is_some(),
786            Category::Jong => self.cur.jong.is_some(),
787        };
788        if !has {
789            return self.composing_outcome();
790        }
791        // 이력을 (남길 것, 빼낼 것)으로 가른다(순서 보존).
792        let (keep, moved) = {
793            let layout = &self.layout;
794            let mut keep = Vec::new();
795            let mut moved = Vec::new();
796            for u in &self.history {
797                if Self::unit_cat(layout, u) == Some(cat) {
798                    moved.push(*u);
799                } else {
800                    keep.push(*u);
801                }
802            }
803            (keep, moved)
804        };
805        self.history = keep;
806        self.replay_history();
807        let commit = self.commit_current();
808        // 빼낸 성분은 새 음절의 시작이므로 오토마타 상태를 초기화한다(commit_current 는
809        // 상태를 보존하므로, 안 하면 '완성' 상태가 남아 다음 글쇠가 곧바로 확정돼 버린다).
810        self.commit_then_start_with(commit, moved)
811    }
812
813    /// 떼어낸 단위(들)로 새 음절을 시작한다(공통). 남은 현재 음절을 확정한 commit 을
814    /// 받고, 오토마타를 초기화한 뒤 moved 를 재투입해 결과를 만든다.
815    fn commit_then_start_with(&mut self, commit: String, moved: Vec<Unit>) -> KeyOutcome {
816        self.history.clear();
817        self.auto_state = self.layout.automata_start;
818        for u in moved {
819            let _ = self.feed_unit(u);
820        }
821        KeyOutcome {
822            commit,
823            preedit: self.preedit(),
824            consumed: true,
825            delete_before: 0,
826        }
827    }
828
829    /// 0x18 초·종성 맞바꾸기: 초성↔종성을 서로 옮긴다(대응 낱자가 없으면 무동작).
830    /// 두벌식 자판에서 종성 자음을 넣는 용도지만 오토마타와 무관하게 동작한다.
831    fn cmd_swap_cho_jong(&mut self) -> KeyOutcome {
832        let cho = self.cur.cho;
833        let jong = self.cur.jong;
834        let jung = self.cur.jung;
835        if cho.is_none() && jong.is_none() {
836            return self.composing_outcome();
837        }
838        let new_cho = jong.and_then(hanmo::jong_to_cho);
839        let new_jong = cho.and_then(hanmo::cho_to_jong);
840        // 변환 불가(대응 낱자 없음: ㄸㅃㅉ 초성, 겹받침 등)면 안전하게 무동작.
841        if (jong.is_some() && new_cho.is_none()) || (cho.is_some() && new_jong.is_none()) {
842            return self.composing_outcome();
843        }
844        self.history.clear();
845        self.cur = Syllable::default();
846        self.auto_state = self.layout.automata_start;
847        if let Some(c) = new_cho {
848            let _ = self.feed_unit(Unit::Jamo(Jamo::new(Category::Cho, c)));
849        }
850        if let Some(v) = jung {
851            let _ = self.feed_unit(Unit::Jamo(Jamo::new(Category::Jung, v)));
852        }
853        if let Some(j) = new_jong {
854            let _ = self.feed_unit(Unit::Jamo(Jamo::new(Category::Jong, j)));
855        }
856        self.composing_outcome()
857    }
858
859    /// 0x12 도깨비불: 종성의 마지막 자음만 떼어 다음 글자의 초성으로 넘긴다.
860    /// '강'→'가'+조합 'ㅇ', 'ㄴㅎ'으로 친 '않'→'안'+'ㅎ'.
861    fn cmd_dokkaebi(&mut self) -> KeyOutcome {
862        if self.cur.jong.is_none() {
863            return self.composing_outcome();
864        }
865        let pos = {
866            let layout = &self.layout;
867            self.history
868                .iter()
869                .rposition(|u| Self::unit_cat(layout, u) == Some(Category::Jong))
870        };
871        let Some(pos) = pos else {
872            return self.composing_outcome();
873        };
874        let moved = self.history.remove(pos);
875        self.replay_history();
876        let commit = self.commit_current();
877        // 떼어낸 종성 자음을 초성으로 바꿔 새 음절 시작.
878        let moved = match moved {
879            Unit::Jamo(j) => hanmo::jong_to_cho(j.cp)
880                .map(|c| Unit::Jamo(Jamo::new(Category::Cho, c)))
881                .unwrap_or(Unit::Jamo(j)),
882            other => other,
883        };
884        self.commit_then_start_with(commit, vec![moved])
885    }
886
887    /// 0x21 마지막 한 타를 분리: 마지막으로 입력된 단위를 떼어 다음 글자로 옮긴다.
888    /// '한'→'하'+조합 'ㄴ'. (무한 낱자 수정 복원 연동은 단순화: 분리만 수행.)
889    fn cmd_split_last_key(&mut self) -> KeyOutcome {
890        let Some(moved) = self.history.pop() else {
891            return self.composing_outcome();
892        };
893        self.replay_history();
894        let commit = self.commit_current();
895        self.commit_then_start_with(commit, vec![moved])
896    }
897
898    /// 0x23 직전의 두 글쇠 교환: 이력의 마지막 두 단위 순서를 바꿔 재생한다.
899    /// (조합 중 두 낱자에 대해서만 동작. cursor 앞 확정 문자까지의 교환은 미지원.)
900    fn cmd_swap_last_two_keys(&mut self) -> KeyOutcome {
901        let n = self.history.len();
902        if n < 2 {
903            return self.composing_outcome();
904        }
905        self.history.swap(n - 1, n - 2);
906        self.replay_history();
907        self.composing_outcome()
908    }
909
910    /// last_overwrite 의 new 낱자를 현재 이력에서 old 로 되돌린다(있으면). 되돌린 뒤
911    /// 재생은 호출부가 한다. 되돌린 (갈래, new) 를 반환(0x13 이 다음 글자로 옮길 때 사용).
912    fn restore_overwrite_in_history(&mut self) -> Option<(Category, u32)> {
913        // 먼저 엿보기만 한다. 기록된 새 낱자가 현재 이력에 실제로 있을 때만 되돌리고
914        // 기록을 소비한다. (삭제·백스페이스 등으로 그 낱자가 이미 사라졌으면 무동작 —
915        // 그래야 0x13 이 사라진 낱자를 되살리는 유령 입력을 만들지 않는다.)
916        let (cat, old, new) = *self.last_overwrite.as_ref()?;
917        let pos = self
918            .history
919            .iter()
920            .rposition(|u| matches!(u, Unit::Jamo(j) if j.category == cat && j.cp == new))?;
921        self.history[pos] = Unit::Jamo(Jamo::new(cat, old));
922        self.last_overwrite = None;
923        Some((cat, new))
924    }
925
926    /// 0x14 무한 낱자 수정 복원: 직전 겹쳐쓰기를 취소해 옛 낱자로 되돌린다.
927    /// '가'→'개'(ㅏ→ㅐ) 뒤 누르면 '가'. (직전 한 번의 겹쳐쓰기만 지원.)
928    fn cmd_infinite_restore(&mut self) -> KeyOutcome {
929        if self.restore_overwrite_in_history().is_some() {
930            self.replay_history();
931        }
932        self.composing_outcome()
933    }
934
935    /// 0x13 무한 낱자 수정 분리: 직전 겹쳐쓰기를 취소(옛 낱자 복원)하고, 새로 들어왔던
936    /// 낱자를 다음 글자로 옮겨 그 글자를 조합한다. '개'→ '가' 확정 + 조합 'ㅐ'.
937    fn cmd_infinite_split(&mut self) -> KeyOutcome {
938        let Some((cat, new)) = self.restore_overwrite_in_history() else {
939            return self.composing_outcome();
940        };
941        self.replay_history();
942        let commit = self.commit_current();
943        self.commit_then_start_with(commit, vec![Unit::Jamo(Jamo::new(cat, new))])
944    }
945
946    /// 0x88~0x8B Backspace 특수글쇠: 백스페이스를 임의 글쇠에 배당한 것.
947    /// always_consume(0x8A/0x8B)면 비조합·미달라붙기여도 글쇠를 가로챈다(원시 문자 미입력).
948    fn cmd_backspace(&mut self, always_consume: bool) -> KeyOutcome {
949        let mut out = self.backspace();
950        if always_consume {
951            out.consumed = true;
952        }
953        out
954    }
955
956    /// 단위가 초성뿐인 두벌식 형식이면 그 초성을 종성으로 바꾼 단위 목록을 돌려준다.
957    /// (낱자 재결합 두벌식 적용 0x1E/0x20)
958    fn dubeol_cho_to_jong(units: Vec<Unit>) -> Vec<Unit> {
959        units
960            .into_iter()
961            .map(|u| match u {
962                Unit::Jamo(j) if j.category == Category::Cho => hanmo::cho_to_jong(j.cp)
963                    .map(|c| Unit::Jamo(Jamo::new(Category::Jong, c)))
964                    .unwrap_or(u),
965                other => other,
966            })
967            .collect()
968    }
969
970    /// 앞의 확정 글자(prev_syllables 맨 위)를 되살려 현재 조합 낱자들을 그 위에 결합하고,
971    /// 결합된 글자를 조합 상태로 만든다. delete_before=1 로 앱의 옛 앞 글자를 지우게 한다.
972    /// `first_cat` 가 있으면(0x5~0x7) 그 성분 단위를 앞으로 재배치한다. `dubeol` 이면
973    /// 초성뿐인 현재 글자의 초성을 종성으로 바꿔 결합한다(0x1E). surrounding-text 미지원
974    /// 이거나 앞 글자가 없으면 무동작(앱과 어긋나지 않도록).
975    fn recombine_forward(&mut self, dubeol: bool, first_cat: Option<Category>) -> KeyOutcome {
976        let cho_only = self.cur.cho.is_some() && self.cur.jung.is_none() && self.cur.jong.is_none();
977        if !self.surrounding_ok || self.prev_syllables.is_empty() || self.cur.is_empty() {
978            return self.composing_outcome();
979        }
980        let prev = self.prev_syllables.pop().unwrap();
981        let mut cur_units = std::mem::take(&mut self.history);
982        if let Some(cat) = first_cat {
983            let layout = &self.layout;
984            let (mut first, rest): (Vec<Unit>, Vec<Unit>) = cur_units
985                .into_iter()
986                .partition(|u| Self::unit_cat(layout, u) == Some(cat));
987            first.extend(rest);
988            cur_units = first;
989        }
990        if dubeol && cho_only {
991            cur_units = Self::dubeol_cho_to_jong(cur_units);
992        }
993        self.cur = Syllable::default();
994        self.history.clear();
995        self.auto_state = self.layout.automata_start;
996        self.replaying = true;
997        for u in prev {
998            let _ = self.feed_unit(u);
999        }
1000        for u in cur_units {
1001            let _ = self.feed_unit(u);
1002        }
1003        self.replaying = false;
1004        KeyOutcome {
1005            commit: String::new(),
1006            preedit: self.preedit(),
1007            consumed: true,
1008            delete_before: 1,
1009        }
1010    }
1011
1012    /// C0 특수글쇠 코드 분배.
1013    fn dispatch_command(&mut self, cmd: u32) -> KeyOutcome {
1014        use Category::{Cho, Jong, Jung};
1015        // 무한 낱자 수정 분리/복원(0x13/0x14)을 제외한 모든 특수글쇠는 새 편집 동작이므로
1016        // "직전 겹쳐쓰기" 기록을 무효화한다. (그 명령들이 이력을 바꿔 겹쳐쓴 낱자를 없애도
1017        // 0x13/0x14 가 사라진 낱자를 되살리지 않도록.)
1018        if cmd != 0x13 && cmd != 0x14 {
1019            self.last_overwrite = None;
1020        }
1021        match cmd {
1022            // 0x2~0x4: 특정 낱자(초/중/종성) 삭제 후 그 글자를 계속 조합.
1023            0x2 => {
1024                self.delete_cats_and_replay(&[Cho]);
1025                self.composing_outcome()
1026            }
1027            0x3 => {
1028                self.delete_cats_and_replay(&[Jung]);
1029                self.composing_outcome()
1030            }
1031            0x4 => {
1032                self.delete_cats_and_replay(&[Jong]);
1033                self.composing_outcome()
1034            }
1035            // 0x8~0xA: 초/중/종성을 뒤 글자로 빼기(모아치기 연동).
1036            0x8 => self.cmd_move_component_back(Cho),
1037            0x9 => self.cmd_move_component_back(Jung),
1038            0xA => self.cmd_move_component_back(Jong),
1039            // 0x15~0x17: 특정 낱자만 남기고 나머지 삭제.
1040            0x15 => {
1041                self.delete_cats_and_replay(&[Jung, Jong]);
1042                self.composing_outcome()
1043            }
1044            0x16 => {
1045                self.delete_cats_and_replay(&[Cho, Jong]);
1046                self.composing_outcome()
1047            }
1048            0x17 => {
1049                self.delete_cats_and_replay(&[Cho, Jung]);
1050                self.composing_outcome()
1051            }
1052            // 0x19~0x1C: Backspace 부분 동작(마지막한타/최하위직전/최하위전체/글자전체).
1053            0x19 => self.bksp_cmd(BkspUnit::LastKey),
1054            0x1A => self.bksp_cmd(BkspUnit::LowestLastKey),
1055            0x1B => self.bksp_cmd(BkspUnit::LowestWhole),
1056            0x1C => self.bksp_cmd(BkspUnit::Syllable),
1057            // 0x12 도깨비불, 0x18 초·종성 맞바꾸기, 0x21 마지막 한 타 분리,
1058            // 0x23 직전 두 글쇠 교환, 0x13/0x14 무한 낱자 수정 분리/복원.
1059            0x12 => self.cmd_dokkaebi(),
1060            0x13 => self.cmd_infinite_split(),
1061            0x14 => self.cmd_infinite_restore(),
1062            0x18 => self.cmd_swap_cho_jong(),
1063            0x21 => self.cmd_split_last_key(),
1064            0x23 => self.cmd_swap_last_two_keys(),
1065            // 0x5~0x7: 초/중/종성을 앞 글자로 이동(앞 글자에 결합, 그 성분을 앞에 둠).
1066            0x5 => self.recombine_forward(false, Some(Cho)),
1067            0x6 => self.recombine_forward(false, Some(Jung)),
1068            0x7 => self.recombine_forward(false, Some(Jong)),
1069            // 0x1D/0x1E: 현재 조합 글자를 앞 글자에 결합(0x1E 는 두벌식 초성→종성 변환).
1070            0x1D => self.recombine_forward(false, None),
1071            0x1E => self.recombine_forward(true, None),
1072            // 0x88~0x8B: Backspace 1~4(0x8A/0x8B 는 항상 가로챔).
1073            0x88 | 0x89 => self.cmd_backspace(false),
1074            0x8A | 0x8B => self.cmd_backspace(true),
1075            // 미구현/미지원(cursor 이동·Del·뒤 글자 재결합 0x1F/0x20·한자 후보 변환 등):
1076            // 현재 음절만 확정. 0x1F/0x20 은 cursor 뒤 글자 삭제(forward delete)가 IBus/
1077            // Wayland 에서 불가해 보류.
1078            _ => {
1079                let commit = self.commit_and_clear();
1080                KeyOutcome {
1081                    commit,
1082                    preedit: self.preedit(),
1083                    consumed: true,
1084                    delete_before: 0,
1085                }
1086            }
1087        }
1088    }
1089}
1090
1091#[cfg(test)]
1092mod tests {
1093    use super::*;
1094    use crate::config::Config;
1095
1096    // 합성 설정으로 엔진 동작의 핵심 경로를 검증(외부 파일 불요).
1097    const MINI: &str = r#"<?xml version="1.0" encoding="utf-8"?>
1098<EditContextSetting version="0x500">
1099  <EditorLayer flag="0">
1100    <FinalConvTable>
1101      <FinalConv from="0x1100" to="0x3131"/>
1102      <FinalConv from="0x1102" to="0x3134"/>
1103      <FinalConv from="0x1161" to="0x314F"/>
1104      <FinalConv from="0x11A8" to="0x3131"/>
1105    </FinalConvTable>
1106  </EditorLayer>
1107  <InputLayer default="0" current="0">
1108    <InputEntry>
1109      <InputSchemeSetting object="CBasicInputScheme">
1110        <KeyTable name="mini" flag="0" from="33" to="126">
1111          <Key at="0x6B" value="H3|G_"/>   <!-- k = 초성 ㄱ -->
1112          <Key at="0x68" value="H3|N_"/>   <!-- h = 초성 ㄴ -->
1113          <Key at="0x66" value="H3|A_"/>   <!-- f = 중성 ㅏ -->
1114          <Key at="0x2F" value="H3|O_"/>   <!-- / = 중성 ㅗ -->
1115          <Key at="0x78" value="H3|_G"/>   <!-- x = 종성 ㄱ -->
1116          <Key at="0x73" value="H3|_N"/>   <!-- s = 종성 ㄴ -->
1117          <Key at="0x24" value="T ? H3|0x1F4 : 0x24"/> <!-- $ = 갈마 토글 -->
1118          <Key at="0x21" value="0x21"/>    <!-- ! = 리터럴 '!' -->
1119        </KeyTable>
1120      </InputSchemeSetting>
1121      <GeneratorSetting object="CNgsImeEx">
1122        <UnitMixTable>
1123          <UnitMix unit="CHO" a="G_" b="500" to="GG"/>
1124          <UnitMix unit="CHO" a="GG" b="500" to="G_"/>
1125          <UnitMix unit="JUNG" a="O_" b="A_" to="WA"/>
1126        </UnitMixTable>
1127        <VirtualUnitTable/>
1128        <AutomataTable default="0"/>
1129      </GeneratorSetting>
1130    </InputEntry>
1131  </InputLayer>
1132</EditContextSetting>"#;
1133
1134    fn engine() -> Engine {
1135        let cfg = Config::parse(MINI).unwrap();
1136        Engine::new(cfg.compile(0).unwrap())
1137    }
1138
1139    /// 키 시퀀스를 눌러 (확정 누적, 마지막 preedit) 반환.
1140    fn typ(e: &mut Engine, keys: &str) -> (String, String) {
1141        let mut committed = String::new();
1142        let mut preedit = String::new();
1143        for ch in keys.chars() {
1144            let out = e.press(ch as u8, false);
1145            committed.push_str(&out.commit);
1146            preedit = out.preedit;
1147        }
1148        (committed, preedit)
1149    }
1150
1151    #[test]
1152    fn simple_syllable() {
1153        let mut e = engine();
1154        let (c, p) = typ(&mut e, "kf"); // ㄱ + ㅏ
1155        assert_eq!(c, "");
1156        assert_eq!(p, "가");
1157        assert_eq!(e.flush(), "가");
1158    }
1159
1160    #[test]
1161    fn syllable_with_jong() {
1162        let mut e = engine();
1163        let (_c, p) = typ(&mut e, "kfx"); // 가 + 받침 ㄱ
1164        assert_eq!(p, "각");
1165    }
1166
1167    #[test]
1168    fn new_cho_commits_previous() {
1169        let mut e = engine();
1170        // kf (가) hf (나): 두 번째 초성 ㄴ 이 '가'를 확정
1171        let (c, p) = typ(&mut e, "kfhf");
1172        assert_eq!(c, "가");
1173        assert_eq!(p, "나");
1174    }
1175
1176    #[test]
1177    fn compound_vowel() {
1178        let mut e = engine();
1179        let (_c, p) = typ(&mut e, "k/f"); // ㄱ + ㅗ + ㅏ → 과
1180        assert_eq!(p, "과");
1181    }
1182
1183    #[test]
1184    fn galma_toggle_tense() {
1185        let mut e = engine();
1186        let (_c, p) = typ(&mut e, "k$f"); // ㄱ + 토글(→ㄲ) + ㅏ → 까
1187        assert_eq!(p, "까");
1188        // 토글 두 번 → 다시 예사소리
1189        let mut e2 = engine();
1190        let (_c2, p2) = typ(&mut e2, "k$$f"); // ㄱ→ㄲ→ㄱ + ㅏ → 가
1191        assert_eq!(p2, "가");
1192    }
1193
1194    #[test]
1195    fn lone_jamo_finalconv() {
1196        let mut e = engine();
1197        let (_c, p) = typ(&mut e, "k"); // 홑초성 ㄱ → 호환 자모
1198        assert_eq!(p, "ㄱ");
1199        assert_eq!(e.flush(), "ㄱ");
1200    }
1201
1202    #[test]
1203    fn literal_commits_and_breaks() {
1204        let mut e = engine();
1205        e.press(b'k', false); // ㄱ
1206        let out = e.press(b'!', false); // 리터럴 '!' → ㄱ 확정 + '!'
1207        assert_eq!(out.commit, "ㄱ!");
1208        assert_eq!(out.preedit, "");
1209        assert!(out.consumed);
1210    }
1211
1212    #[test]
1213    fn backspace_unit_step() {
1214        let mut e = engine();
1215        typ(&mut e, "kfx"); // 각
1216        let o1 = e.backspace(); // 받침 ㄱ 제거 → 가
1217        assert_eq!(o1.preedit, "가");
1218        let o2 = e.backspace(); // 중성 ㅏ 제거 → ㄱ
1219        assert_eq!(o2.preedit, "ㄱ");
1220        let o3 = e.backspace(); // 초성 제거 → 빈
1221        assert_eq!(o3.preedit, "");
1222        let o4 = e.backspace(); // 더 없음 → 비소비
1223        assert!(!o4.consumed);
1224    }
1225
1226    #[test]
1227    fn backspace_decomposes_compound() {
1228        let mut e = engine();
1229        typ(&mut e, "k$"); // ㄲ (토글로 된소리)
1230        assert_eq!(e.preedit(), "ㄲ");
1231        let o = e.backspace(); // 겹낱자 한 단계 → ㄱ
1232        assert_eq!(o.preedit, "ㄱ");
1233    }
1234
1235    // 옛한글: 현대 집합 밖 자모는 완성형이 없으므로 첫가끝(조합용 자모) 시퀀스로,
1236    // 홑낱자면 FinalConv 호환 자모로 출력된다.
1237    const OLD: &str = r#"<?xml version="1.0" encoding="utf-8"?>
1238<EditContextSetting version="0x500">
1239  <EditorLayer flag="0">
1240    <FinalConvTable>
1241      <FinalConv from="0x114C" to="0x3181"/>
1242      <FinalConv from="0x1161" to="0x314F"/>
1243    </FinalConvTable>
1244  </EditorLayer>
1245  <InputLayer default="0" current="0">
1246    <InputEntry>
1247      <InputSchemeSetting object="CBasicInputScheme">
1248        <KeyTable name="old" flag="0" from="33" to="126">
1249          <Key at="0x61" value="H3|0x114C"/> <!-- a = 옛이응 초성 (현대 밖) -->
1250          <Key at="0x62" value="H3|A_"/>      <!-- b = 중성 ㅏ -->
1251        </KeyTable>
1252      </InputSchemeSetting>
1253      <GeneratorSetting object="CNgsImeEx">
1254        <UnitMixTable/><VirtualUnitTable/><AutomataTable default="0"/>
1255      </GeneratorSetting>
1256    </InputEntry>
1257  </InputLayer>
1258</EditContextSetting>"#;
1259
1260    fn old_engine() -> Engine {
1261        let cfg = Config::parse(OLD).unwrap();
1262        Engine::new(cfg.compile(0).unwrap())
1263    }
1264
1265    #[test]
1266    fn old_hangul_lone_jamo_via_finalconv() {
1267        let mut e = old_engine();
1268        let (_c, p) = typ(&mut e, "a"); // 홑 옛이응 초성 → 호환 자모 ㆁ(U+3181)
1269        assert_eq!(p, "\u{3181}");
1270    }
1271
1272    #[test]
1273    fn old_hangul_syllable_emits_conjoining() {
1274        let mut e = old_engine();
1275        let (_c, p) = typ(&mut e, "ab"); // 옛이응 초성 + ㅏ → 완성형 없음 → 첫가끝 시퀀스
1276        assert_eq!(p, "\u{114C}\u{1161}");
1277        assert_eq!(p.chars().count(), 2);
1278    }
1279
1280    #[test]
1281    fn space_not_in_table_commits_and_consumes() {
1282        let mut e = engine();
1283        typ(&mut e, "kf"); // 가
1284        let out = e.press(b' ', false); // space(table 밖): 가 + 공백을 함께 확정하고 소비
1285        assert_eq!(out.commit, "가 ");
1286        assert!(out.consumed); // 한 이벤트에서 확정+통과를 섞지 않음(앱 중복 입력 방지)
1287    }
1288
1289    // 실제 AutomataTable(상태 0/1/2)을 가진 세벌식 설정. 무한 낱자 수정 검증용.
1290    // state1 에 ㅋㅋ/ㅎㅎ(서열 176/185) 연타 → 다음 글자 규칙도 포함(사용자 커스텀).
1291    const AUTO: &str = r#"<?xml version="1.0" encoding="utf-8"?>
1292<EditContextSetting version="0x500">
1293  <EditorLayer flag="0"><FinalConvTable/></EditorLayer>
1294  <InputLayer default="0" current="0">
1295    <InputEntry>
1296      <InputSchemeSetting object="CBasicInputScheme">
1297        <KeyTable name="auto" flag="0" from="33" to="126">
1298          <Key at="0x67" value="H3|G_"/>  <!-- g 초 ㄱ -->
1299          <Key at="0x6E" value="H3|N_"/>  <!-- n 초 ㄴ -->
1300          <Key at="0x63" value="H3|S_"/>  <!-- c 초 ㅅ -->
1301          <Key at="0x6B" value="H3|K_"/>  <!-- k 초 ㅋ -->
1302          <Key at="0x68" value="H3|H_"/>  <!-- h 초 ㅎ -->
1303          <Key at="0x61" value="H3|A_"/>  <!-- a 중 ㅏ -->
1304          <Key at="0x65" value="H3|EO"/>  <!-- e 중 ㅓ -->
1305          <Key at="0x6F" value="H3|O_"/>  <!-- o 중 ㅗ -->
1306          <Key at="0x6D" value="H3|_N"/>  <!-- m 종 ㄴ -->
1307          <Key at="0x69" value="H3|AE"/> <!-- i 중 ㅐ -->
1308          <!-- C0 특수글쇠(테스트용): 대문자 자리에 배당 -->
1309          <Key at="0x41" value="C0|0x2"/>  <!-- A 초성 삭제 -->
1310          <Key at="0x42" value="C0|0x3"/>  <!-- B 중성 삭제 -->
1311          <Key at="0x43" value="C0|0x4"/>  <!-- C 종성 삭제 -->
1312          <Key at="0x44" value="C0|0xA"/>  <!-- D 종성 뒤로 빼기 -->
1313          <Key at="0x45" value="C0|0x8"/>  <!-- E 초성 뒤로 빼기 -->
1314          <Key at="0x46" value="C0|0x9"/>  <!-- F 중성 뒤로 빼기 -->
1315          <Key at="0x47" value="C0|0x15"/> <!-- G 초성만 남기기 -->
1316          <Key at="0x48" value="C0|0x16"/> <!-- H 중성만 남기기 -->
1317          <Key at="0x49" value="C0|0x17"/> <!-- I 종성만 남기기 -->
1318          <Key at="0x4A" value="C0|0x19"/> <!-- J 마지막 한 타 -->
1319          <Key at="0x4B" value="C0|0x1A"/> <!-- K 최하위 직전 한 타 -->
1320          <Key at="0x4C" value="C0|0x1B"/> <!-- L 최하위 낱자 전체 -->
1321          <Key at="0x4D" value="C0|0x1C"/> <!-- M 글자 전체 -->
1322          <Key at="0x4E" value="C0|0x18"/> <!-- N 초·종성 맞바꾸기 -->
1323          <Key at="0x4F" value="C0|0x12"/> <!-- O 도깨비불 -->
1324          <Key at="0x50" value="C0|0x13"/> <!-- P 무한낱자수정 분리 -->
1325          <Key at="0x51" value="C0|0x14"/> <!-- Q 무한낱자수정 복원 -->
1326          <Key at="0x52" value="C0|0x21"/> <!-- R 마지막 한 타 분리 -->
1327          <Key at="0x53" value="C0|0x23"/> <!-- S 직전 두 글쇠 교환 -->
1328          <Key at="0x54" value="C0|0x1D"/> <!-- T 낱자 재결합(앞으로) -->
1329          <Key at="0x55" value="C0|0x1E"/> <!-- U 낱자 재결합(앞으로, 두벌식) -->
1330          <Key at="0x56" value="C0|0x5"/>  <!-- V 초성 앞으로 이동 -->
1331          <Key at="0x57" value="C0|0x88"/> <!-- W Backspace 1 -->
1332          <Key at="0x58" value="C0|0x8A"/> <!-- X Backspace 3(항상 가로챔) -->
1333        </KeyTable>
1334      </InputSchemeSetting>
1335      <GeneratorSetting object="CNgsImeEx">
1336        <UnitMixTable>
1337          <UnitMix unit="JUNG" a="O_" b="A_" to="WA"/>
1338        </UnitMixTable>
1339        <VirtualUnitTable/>
1340        <AutomataTable default="0">
1341          <Automata state="0" value="1" default="0" remark="초기"/>
1342          <Automata state="1" value="D==176&amp;&amp;A==176 || D==185&amp;&amp;A==185 ? 0 : A || B || C ? (A || D)&amp;&amp;(B || E) ? 2 : 1 : -2" default="-1" remark="미완성"/>
1343          <Automata state="2" value="A&amp;&amp;A!=500 ? 0 : B||C||A==500 ? 2 : -2" default="0" remark="완성"/>
1344        </AutomataTable>
1345      </GeneratorSetting>
1346    </InputEntry>
1347  </InputLayer>
1348</EditContextSetting>"#;
1349
1350    fn auto_engine() -> Engine {
1351        let cfg = Config::parse(AUTO).unwrap();
1352        Engine::new(cfg.compile(0).unwrap())
1353    }
1354
1355    #[test]
1356    fn automaton_loads() {
1357        let e = auto_engine();
1358        assert_eq!(e.layout.automata.len(), 3);
1359        assert_eq!(e.auto_state, 0);
1360    }
1361
1362    #[test]
1363    fn infinite_jamo_edit_replaces_jung() {
1364        // 핵심: 산(ㅅㅏㄴ) 입력 후 중성 ㅓ → 새 음절이 아니라 현재 중성 교체 → 선.
1365        let mut e = auto_engine();
1366        let (c, p) = typ(&mut e, "cam"); // ㅅ ㅏ ㄴ
1367        assert_eq!(c, "");
1368        assert_eq!(p, "산");
1369        let out = e.press(b'e', false); // 중성 ㅓ
1370        assert_eq!(out.commit, ""); // 확정 없음(현재 음절 수정)
1371        assert_eq!(out.preedit, "선"); // 무한 낱자 수정!
1372    }
1373
1374    #[test]
1375    fn infinite_jamo_edit_jong() {
1376        // 안(ㅇ 없이 ㅏㄴ은 안 됨) 대신 간(ㄱㅏㄴ) 후 종성 교체: ㄱㅏㄴ → 종성 ㄴ 자리에 또?
1377        // 간 입력 후 중성 ㅗ → 곤? 아니라 중성 교체 → 곤.
1378        let mut e = auto_engine();
1379        typ(&mut e, "gam"); // 간
1380        assert_eq!(e.preedit(), "간");
1381        let out = e.press(b'o', false); // 중성 ㅗ → ㅏ 교체 → 곤
1382        assert_eq!(out.preedit, "곤");
1383    }
1384
1385    #[test]
1386    fn kk_breaks_to_next_syllable() {
1387        // 사용자 ㅋㅋ 규칙(state1: D==176&&A==176 → 0): ㅋ 확정 후 새 ㅋ.
1388        let mut e = auto_engine();
1389        let o1 = e.press(b'k', false); // 초 ㅋ
1390        assert_eq!(o1.preedit, "ㅋ");
1391        let o2 = e.press(b'k', false); // 또 ㅋ → 앞 ㅋ 확정 + 새 ㅋ
1392        assert_eq!(o2.commit, "ㅋ");
1393        assert_eq!(o2.preedit, "ㅋ");
1394    }
1395
1396    #[test]
1397    fn automaton_compound_vowel() {
1398        // 겹모음은 결합 유지: ㄱ ㅗ ㅏ → 과.
1399        let mut e = auto_engine();
1400        let (_c, p) = typ(&mut e, "goa");
1401        assert_eq!(p, "과");
1402    }
1403
1404    #[test]
1405    fn automaton_new_cho_commits() {
1406        // 새 초성은 다음 글자: 가(ㄱㅏ) + ㄴ → 가 확정, 나 조합.
1407        let mut e = auto_engine();
1408        typ(&mut e, "ga"); // 가
1409        let out = e.press(b'n', false); // 초 ㄴ
1410        assert_eq!(out.commit, "가");
1411        assert_eq!(out.preedit, "ㄴ");
1412    }
1413
1414    #[test]
1415    fn backspace_after_infinite_edit() {
1416        // ㄱ → 가 → 개(중성 ㅏ→ㅐ 무한 낱자 수정) 후 백스페이스 → ㄱ.
1417        // (교체된 ㅏ 가 이력에 남아 "가" 로 돌아가던 버그 방지.)
1418        let mut e = auto_engine();
1419        typ(&mut e, "ga"); // 가
1420        let o1 = e.press(b'i', false); // ㅐ → 개
1421        assert_eq!(o1.preedit, "개");
1422        let o2 = e.backspace(); // 중성 제거 → ㄱ
1423        assert_eq!(o2.preedit, "ㄱ");
1424    }
1425
1426    #[test]
1427    fn backspace_after_compound_keeps_steps() {
1428        // 결합(겹모음)은 한 단계씩: ㄱㅗㅏ→과, 백스페이스 → 고(ㅘ→ㅗ).
1429        let mut e = auto_engine();
1430        typ(&mut e, "goa"); // 과
1431        assert_eq!(e.preedit(), "과");
1432        let o = e.backspace();
1433        assert_eq!(o.preedit, "고");
1434    }
1435
1436    // Bksp 삭제 단위 모드별 테스트. AutomataTable + Extra/Bksp 를 가진 세벌식 설정.
1437    fn bksp_engine(value1: &str) -> Engine {
1438        let xml = format!(
1439            r#"<?xml version="1.0" encoding="utf-8"?>
1440<EditContextSetting version="0x500">
1441  <EditorLayer flag="0"><FinalConvTable/></EditorLayer>
1442  <InputLayer default="0" current="0">
1443    <InputEntry>
1444      <InputSchemeSetting object="CBasicInputScheme">
1445        <KeyTable name="b" flag="0" from="33" to="126">
1446          <Key at="0x67" value="H3|G_"/><Key at="0x61" value="H3|A_"/>
1447          <Key at="0x6D" value="H3|_N"/><Key at="0x73" value="H3|_S"/>
1448        </KeyTable>
1449      </InputSchemeSetting>
1450      <GeneratorSetting object="CNgsImeEx">
1451        <UnitMixTable><UnitMix unit="JONG" a="_N" b="_S" to="_NJ"/></UnitMixTable>
1452        <VirtualUnitTable/>
1453        <AutomataTable default="0">
1454          <Automata state="0" value="1" default="0"/>
1455          <Automata state="1" value="A||B||C ? (A||D)&amp;&amp;(B||E) ? 2 : 1 : -2" default="-1"/>
1456          <Automata state="2" value="A&amp;&amp;A!=500 ? 0 : B||C||A==500 ? 2 : -2" default="0"/>
1457        </AutomataTable>
1458        <Extra><Bksp key="1" value1="{value1}" value2="BySyllable" condition1="0" condition2="0"/></Extra>
1459      </GeneratorSetting>
1460    </InputEntry>
1461  </InputLayer>
1462</EditContextSetting>"#
1463        );
1464        let cfg = Config::parse(&xml).unwrap();
1465        Engine::new(cfg.compile(0).unwrap())
1466    }
1467
1468    #[test]
1469    fn bksp_mode_lastkey() {
1470        // 직전 한 타: 간(ㄱㅏㄴ) → ㄴ 한 타만 → 가.
1471        let mut e = bksp_engine("ByUnitStep");
1472        typ(&mut e, "gam"); // 간
1473        let o = e.backspace();
1474        assert_eq!(o.preedit, "가");
1475    }
1476
1477    #[test]
1478    fn bksp_mode_syllable() {
1479        // 글자 전체: 간 → 한 타에 통째 → 빈.
1480        let mut e = bksp_engine("BySyllable");
1481        typ(&mut e, "gam"); // 간
1482        let o = e.backspace();
1483        assert_eq!(o.preedit, "");
1484    }
1485
1486    #[test]
1487    fn bksp_mode_lowest_whole() {
1488        // 최하위 낱자 전체: 갅(ㄱㅏ+겹받침ㄵ) → 종성 전체 제거 → 가.
1489        let mut e = bksp_engine("2"); // LowestWhole
1490        typ(&mut e, "gams"); // ㄱㅏ + ㄴ + ㅈ(겹받침 ㄵ)
1491        assert_eq!(e.preedit(), "갅");
1492        let o = e.backspace();
1493        assert_eq!(o.preedit, "가"); // 종성 ㄵ 통째 제거
1494    }
1495
1496    #[test]
1497    fn bksp_mode_lowest_lastkey() {
1498        // 최하위 낱자 직전 한 타: 갅 → 종성 마지막 한 타(ㅈ) → 간.
1499        let mut e = bksp_engine("1"); // LowestLastKey
1500        typ(&mut e, "gams"); // 갅
1501        assert_eq!(e.preedit(), "갅");
1502        let o = e.backspace();
1503        assert_eq!(o.preedit, "간"); // ㄵ → ㄴ (한 단계)
1504    }
1505
1506    #[test]
1507    fn bksp_streak_keeps_initial_unit() {
1508        // 연타 지속성: 글자전체(Syllable) 모드로 시작한 Bksp 연타는 조합 상태가 바뀌어도
1509        // 매번 글자 전체를 지운다. 간 입력 → Bksp(간 통째 제거, 빈) → 다시 가 입력 후
1510        // 같은 streak 이 아님을 확인(중간에 press 로 끊김).
1511        let mut e = bksp_engine("BySyllable");
1512        typ(&mut e, "gam"); // 간
1513        let o1 = e.backspace(); // 글자 전체 → 빈
1514        assert_eq!(o1.preedit, "");
1515        // 연타 상태는 비조합이 되며 streak 해제됨(다음 입력은 새로).
1516    }
1517
1518    #[test]
1519    fn bksp_streak_broken_by_press() {
1520        // press 가 들어오면 연타가 끊긴다(streak=None). 동작 단위는 매번 composing 으로 결정.
1521        let mut e = bksp_engine("ByUnitStep");
1522        typ(&mut e, "ga"); // 가
1523        let _ = e.backspace(); // ㅏ 제거 → ㄱ (streak=LastKey 기록)
1524        assert_eq!(e.preedit(), "ㄱ");
1525        typ(&mut e, "a"); // 다시 ㅏ → 가 (press 가 streak 해제)
1526        assert_eq!(e.preedit(), "가");
1527        assert!(e.bksp_streak.is_none());
1528    }
1529
1530    // BkspAttach: 확정된 앞 글자를 백스페이스로 되살려 재조합. value1 에 BkspAttach 포함.
1531    fn attach_engine() -> Engine {
1532        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
1533<EditContextSetting version="0x500">
1534  <EditorLayer flag="0"><FinalConvTable/></EditorLayer>
1535  <InputLayer default="0" current="0">
1536    <InputEntry>
1537      <InputSchemeSetting object="CBasicInputScheme">
1538        <KeyTable name="b" flag="0" from="33" to="126">
1539          <Key at="0x67" value="H3|G_"/><Key at="0x61" value="H3|A_"/>
1540          <Key at="0x6E" value="H3|N_"/><Key at="0x6D" value="H3|_N"/>
1541        </KeyTable>
1542      </InputSchemeSetting>
1543      <GeneratorSetting object="CNgsImeEx">
1544        <UnitMixTable/><VirtualUnitTable/>
1545        <AutomataTable default="0">
1546          <Automata state="0" value="1" default="0"/>
1547          <Automata state="1" value="A||B||C ? (A||D)&amp;&amp;(B||E) ? 2 : 1 : -2" default="-1"/>
1548          <Automata state="2" value="A&amp;&amp;A!=500 ? 0 : B||C||A==500 ? 2 : -2" default="0"/>
1549        </AutomataTable>
1550        <Extra><Bksp key="1" value1="ByUnitStep|BkspAttach" value2="BySyllable" condition1="0" condition2="0"/></Extra>
1551      </GeneratorSetting>
1552    </InputEntry>
1553  </InputLayer>
1554</EditContextSetting>"#;
1555        let cfg = Config::parse(xml).unwrap();
1556        Engine::new(cfg.compile(0).unwrap())
1557    }
1558
1559    #[test]
1560    fn bksp_attach_revives_prev_syllable() {
1561        // 가(ㄱㅏ) 확정 후 ㄴ(다음 초성) → "가" commit, 초성 ㄴ 조합(중성 없어 preedit "ㄴ").
1562        // backspace 로 ㄴ 제거 → 조합 빔, 한 번 더 backspace 면 앞의 "가"를 되살려 거기서
1563        // 한 단계(ㅏ 제거) → "ㄱ", 그리고 앱의 "가"를 지우라고 delete_before=1.
1564        let mut e = attach_engine();
1565        let mut committed = String::new();
1566        for ch in "gan".chars() {
1567            committed.push_str(&e.press(ch as u8, false).commit);
1568        }
1569        assert_eq!(committed, "가"); // 가 확정, ㄴ 조합 중
1570        assert_eq!(e.preedit(), "ㄴ"); // 초성만 → 호환 자모 ㄴ
1571        let o1 = e.backspace(); // 초성 ㄴ 제거 → 빈
1572        assert_eq!(o1.preedit, "");
1573        assert_eq!(o1.delete_before, 0);
1574        let o2 = e.backspace(); // 빈 + attach → 앞 "가" 되살려 ㅏ 제거 → ㄱ, delete_before=1
1575        assert_eq!(o2.preedit, "ㄱ");
1576        assert_eq!(o2.delete_before, 1);
1577        assert!(o2.consumed);
1578    }
1579
1580    #[test]
1581    fn bksp_no_attach_passes_through() {
1582        // attach 없는 설정(기본)에서 빈 조합 backspace 는 통과(consumed=false, delete_before=0).
1583        let mut e = bksp_engine("ByUnitStep"); // attach 없음
1584        let _ = e.press(b'g', false);
1585        let _ = e.backspace(); // ㄱ 제거 → 빈
1586        let o = e.backspace(); // 빈 + attach 없음 → 통과
1587        assert!(!o.consumed);
1588        assert_eq!(o.delete_before, 0);
1589    }
1590
1591    #[test]
1592    fn bksp_attach_chain_multiple_syllables() {
1593        // 가나가(가·나 확정, 가 조합) 후 백스페이스 연속: 가→ㄱ→빈→(나 되살림)ㄴ→빈→
1594        // (가 되살림)ㄱ→빈. 즉 확정된 글자들을 차례로 되살려 한 단계씩 지운다.
1595        // (키맵에 ㄷ가 없어 가나가로 같은 3음절 되살리기 연쇄를 재현한다.)
1596        let mut e = attach_engine();
1597        let mut committed = String::new();
1598        for ch in "ganaga".chars() {
1599            committed.push_str(&e.press(ch as u8, false).commit);
1600        }
1601        assert_eq!(committed, "가나"); // 가·나 확정, 마지막 가 조합 중
1602        assert_eq!(e.preedit(), "가");
1603        let o0 = e.backspace(); // 가 → ㄱ
1604        assert_eq!(o0.preedit, "ㄱ");
1605        let o1 = e.backspace(); // ㄱ → 빈
1606        assert_eq!(o1.preedit, "");
1607        let o2 = e.backspace(); // attach: 나 되살려 ㄴ, del=1(앱의 "나" 제거)
1608        assert_eq!((o2.preedit.as_str(), o2.delete_before), ("ㄴ", 1));
1609        let o3 = e.backspace(); // ㄴ → 빈
1610        assert_eq!(o3.preedit, "");
1611        let o4 = e.backspace(); // attach: 가 되살려 ㄱ, del=1(앱의 "가" 제거)
1612        assert_eq!((o4.preedit.as_str(), o4.delete_before), ("ㄱ", 1));
1613        let o5 = e.backspace(); // ㄱ → 빈
1614        assert_eq!(o5.preedit, "");
1615        let o6 = e.backspace(); // 더 되살릴 것 없음 → 통과
1616        assert!(!o6.consumed);
1617    }
1618
1619    #[test]
1620    fn space_after_syllable_commits_char_and_consumes() {
1621        // 조합 중 공백: 음절을 확정하고 공백 문자까지 우리가 확정해 **소비**한다(앱이
1622        // 공백을 또 넣어 두 칸이 되는 것을 방지). 그 뒤 빈 백스페이스는 통과해
1623        // 앱이 공백을 지운다(확정 글자로 "달라붙기" 안 함).
1624        let mut e = attach_engine();
1625        let mut committed = String::new();
1626        for ch in "gana".chars() {
1627            committed.push_str(&e.press(ch as u8, false).commit);
1628        }
1629        assert_eq!(committed, "가"); // 가 확정, 나 조합 중
1630        assert_eq!(e.preedit(), "나");
1631        let sp = e.press(b' ', false);
1632        assert_eq!(sp.commit, "나 "); // 나 + 공백 함께 확정
1633        assert!(sp.consumed); // 공백을 소비(앱이 또 넣지 않음)
1634        assert_eq!(sp.preedit, "");
1635        let bk = e.backspace(); // 공백 뒤 빈 백스페이스 → 통과(앱이 공백 삭제)
1636        assert!(!bk.consumed);
1637        assert_eq!(bk.delete_before, 0);
1638        assert_eq!(bk.preedit, "");
1639    }
1640
1641    #[test]
1642    fn space_with_empty_buffer_passes_through() {
1643        // 조합 중이 아닐 때 공백은 확정할 게 없으니 통과(consumed=false, commit 빔).
1644        let mut e = attach_engine();
1645        let sp = e.press(b' ', false);
1646        assert_eq!(sp.commit, "");
1647        assert!(!sp.consumed);
1648    }
1649
1650    #[test]
1651    fn reset_clears_attach_history() {
1652        // 한글 확정으로 되살리기 스택이 찼더라도 컨텍스트 리셋 후 빈 백스페이스는
1653        // 통과해야 한다(예전: 스택이 남아 빈 상태 백스페이스가 글자를 만들어냈다).
1654        let mut e = attach_engine();
1655        for ch in "gan".chars() {
1656            let _ = e.press(ch as u8, false); // 가 확정(스택), ㄴ 조합
1657        }
1658        e.reset();
1659        let bk = e.backspace();
1660        assert!(!bk.consumed);
1661        assert_eq!(bk.preedit, "");
1662        assert_eq!(bk.delete_before, 0);
1663    }
1664
1665    #[test]
1666    fn flush_clears_attach_history() {
1667        // flush(포커스 아웃 등) 후에도 되살리기 스택이 남지 않아야 한다.
1668        let mut e = attach_engine();
1669        for ch in "gan".chars() {
1670            let _ = e.press(ch as u8, false);
1671        }
1672        let _ = e.flush();
1673        let bk = e.backspace();
1674        assert!(!bk.consumed);
1675        assert_eq!(bk.preedit, "");
1676        assert_eq!(bk.delete_before, 0);
1677    }
1678
1679    // ── C0 특수글쇠 ────────────────────────────────────────────────────────────
1680    // auto_engine 의 KeyTable 대문자 자리에 C0 명령이 배당돼 있다:
1681    // A=초성삭제, B=중성삭제, C=종성삭제, D=종성뒤로, E=초성뒤로, F=중성뒤로,
1682    // G=초성만, H=중성만, I=종성만, J=마지막한타, K=최하위직전, L=최하위전체, M=글자전체.
1683
1684    #[test]
1685    fn c0_delete_cho() {
1686        // 간(ㄱㅏㄴ) 조합 중 초성 삭제 → ㅏㄴ(중성+종성, 초성 없이 계속 조합).
1687        let mut e = auto_engine();
1688        typ(&mut e, "gam"); // 간
1689        let o = e.press(b'A', false); // C0|0x2 초성 삭제
1690        assert_eq!(o.commit, "");
1691        assert!(o.consumed);
1692        // 초성 채움(U+115F) + ㅏ + ㄴ: 빈 초성 자리를 둔 음절로 모아 그려진다.
1693        assert_eq!(o.preedit, "\u{115F}\u{1161}\u{11AB}");
1694    }
1695
1696    #[test]
1697    fn c0_delete_jung() {
1698        let mut e = auto_engine();
1699        typ(&mut e, "gam"); // 간
1700        let o = e.press(b'B', false); // C0|0x3 중성 삭제 → ㄱ(중성채움)ㄴ
1701                                      // ㄱ + 중성 채움(U+1160) + ㄴ: 빈 중성 자리를 둔 음절로 모아 그려진다.
1702        assert_eq!(o.preedit, "\u{1100}\u{1160}\u{11AB}");
1703    }
1704
1705    #[test]
1706    fn c0_delete_jong() {
1707        let mut e = auto_engine();
1708        typ(&mut e, "gam"); // 간
1709        let o = e.press(b'C', false); // C0|0x4 종성 삭제 → 가
1710        assert_eq!(o.preedit, "가");
1711    }
1712
1713    #[test]
1714    fn c0_move_jong_back() {
1715        // 종성 뒤로 빼기(사용자 바인딩): 간 → "가" 확정 + 종성 ㄴ 이 다음 글자로.
1716        let mut e = auto_engine();
1717        typ(&mut e, "gam"); // 간
1718        let o = e.press(b'D', false); // C0|0xA
1719        assert_eq!(o.commit, "가");
1720        assert_eq!(o.preedit, "ㄴ");
1721        assert!(o.consumed);
1722    }
1723
1724    #[test]
1725    fn c0_move_jong_back_then_compose() {
1726        // 빼낸 종성은 모아치기에서 다음 글자의 *종성*으로 붙는다('염'→'여'+ㅁ 뒤 '르'→'름'
1727        // 패턴). 간 → 0xA → "가" 확정 + ㄴ 대기, 이어서 ㄱㅏ 입력 → 그 ㄴ이 받침이 되어 "간".
1728        let mut e = auto_engine();
1729        typ(&mut e, "gam"); // 간
1730        let o = e.press(b'D', false); // 가 확정, ㄴ 대기(종성)
1731        assert_eq!(o.commit, "가");
1732        assert_eq!(o.preedit, "ㄴ");
1733        let o2 = e.press(b'g', false); // 초성 ㄱ → ㄱ(중성채움)ㄴ
1734        assert_eq!(o2.preedit, "\u{1100}\u{1160}\u{11AB}");
1735        let o3 = e.press(b'a', false); // 중성 ㅏ → ㄱ+ㅏ+ㄴ = 간
1736        assert_eq!(o3.preedit, "간");
1737    }
1738
1739    #[test]
1740    fn c0_move_cho_back() {
1741        // 초성 뒤로 빼기: 간 → "ㅏㄴ" 확정 + 초성 ㄱ 이 다음 글자로.
1742        let mut e = auto_engine();
1743        typ(&mut e, "gam"); // 간
1744        let o = e.press(b'E', false); // C0|0x8
1745        assert_eq!(o.commit, "\u{115F}\u{1161}\u{11AB}"); // (초성채움)ㅏㄴ
1746        assert_eq!(o.preedit, "ㄱ");
1747    }
1748
1749    #[test]
1750    fn c0_move_jung_back() {
1751        // 중성 뒤로 빼기: 간 → "ㄱㄴ" 확정 + 중성 ㅏ 이 다음 글자로.
1752        let mut e = auto_engine();
1753        typ(&mut e, "gam"); // 간
1754        let o = e.press(b'F', false); // C0|0x9
1755        assert_eq!(o.commit, "\u{1100}\u{1160}\u{11AB}"); // ㄱ(중성채움)ㄴ
1756        assert_eq!(o.preedit, "ㅏ");
1757    }
1758
1759    #[test]
1760    fn c0_keep_only_cho() {
1761        let mut e = auto_engine();
1762        typ(&mut e, "gam"); // 간
1763        let o = e.press(b'G', false); // C0|0x15 초성만 남기기 → ㄱ
1764        assert_eq!(o.preedit, "ㄱ");
1765    }
1766
1767    #[test]
1768    fn c0_keep_only_jung() {
1769        let mut e = auto_engine();
1770        typ(&mut e, "gam"); // 간
1771        let o = e.press(b'H', false); // C0|0x16 중성만 남기기 → ㅏ
1772        assert_eq!(o.preedit, "ㅏ");
1773    }
1774
1775    #[test]
1776    fn c0_keep_only_jong() {
1777        let mut e = auto_engine();
1778        typ(&mut e, "gam"); // 간
1779        let o = e.press(b'I', false); // C0|0x17 종성만 남기기 → ㄴ
1780        assert_eq!(o.preedit, "ㄴ");
1781    }
1782
1783    #[test]
1784    fn c0_bksp_partial_units() {
1785        // 0x19 마지막한타 / 0x1A 최하위직전 / 0x1B 최하위전체 / 0x1C 글자전체.
1786        let mut e = auto_engine();
1787        typ(&mut e, "gam"); // 간
1788        assert_eq!(e.press(b'J', false).preedit, "가"); // 마지막 한 타(종성 ㄴ)
1789        let mut e2 = auto_engine();
1790        typ(&mut e2, "gam");
1791        assert_eq!(e2.press(b'K', false).preedit, "가"); // 최하위 직전 한 타
1792        let mut e3 = auto_engine();
1793        typ(&mut e3, "gam");
1794        assert_eq!(e3.press(b'L', false).preedit, "가"); // 최하위 낱자 전체
1795        let mut e4 = auto_engine();
1796        typ(&mut e4, "gam");
1797        assert_eq!(e4.press(b'M', false).preedit, ""); // 글자 전체
1798    }
1799
1800    #[test]
1801    fn c0_delete_then_backspace_consistent() {
1802        // 명령으로 편집한 뒤에도 이력이 cur 과 일치해 백스페이스가 깨지지 않는다.
1803        // 간 → 종성삭제(가) → 백스페이스(ㅏ 제거) → ㄱ.
1804        let mut e = auto_engine();
1805        typ(&mut e, "gam"); // 간
1806        e.press(b'C', false); // 종성 삭제 → 가
1807        assert_eq!(e.preedit(), "가");
1808        let o = e.backspace(); // 가 → ㄱ
1809        assert_eq!(o.preedit, "ㄱ");
1810    }
1811
1812    #[test]
1813    fn c0_on_empty_is_noop_consumed() {
1814        // 조합 중이 아닐 때 낱자 편집 명령은 무동작이되 소비한다(원시 글쇠 미입력).
1815        let mut e = auto_engine();
1816        let o = e.press(b'A', false); // C0|0x2, 빈 상태
1817        assert_eq!(o.commit, "");
1818        assert_eq!(o.preedit, "");
1819        assert!(o.consumed);
1820    }
1821
1822    #[test]
1823    fn c0_swap_cho_jong() {
1824        // 초·종성 맞바꾸기: 간(ㄱㅏㄴ) → 낙(ㄴㅏㄱ).
1825        let mut e = auto_engine();
1826        typ(&mut e, "gam"); // 간
1827        let o = e.press(b'N', false); // C0|0x18
1828        assert_eq!(o.preedit, "낙");
1829    }
1830
1831    #[test]
1832    fn c0_dokkaebi_moves_jong_as_cho() {
1833        // 도깨비불: 간 → "가" 확정 + 종성 ㄴ이 다음 글자의 *초성*으로 → 이어 ㅏ면 "나".
1834        let mut e = auto_engine();
1835        typ(&mut e, "gam"); // 간
1836        let o = e.press(b'O', false); // C0|0x12
1837        assert_eq!(o.commit, "가");
1838        assert_eq!(o.preedit, "ㄴ");
1839        let o2 = e.press(b'a', false); // 초성 ㄴ + ㅏ → 나 (0xA 와 달리 초성으로 붙음)
1840        assert_eq!(o2.preedit, "나");
1841    }
1842
1843    #[test]
1844    fn c0_split_last_key() {
1845        // 마지막 한 타 분리: 간 → "가" 확정 + 마지막 타(종성 ㄴ)가 다음 글자로(종성 성격).
1846        let mut e = auto_engine();
1847        typ(&mut e, "gam"); // 간
1848        let o = e.press(b'R', false); // C0|0x21
1849        assert_eq!(o.commit, "가");
1850        assert_eq!(o.preedit, "ㄴ");
1851    }
1852
1853    #[test]
1854    fn c0_infinite_restore() {
1855        // 무한 낱자 수정 복원: 가→개(ㅏ→ㅐ 겹침) 뒤 복원 → 가.
1856        let mut e = auto_engine();
1857        typ(&mut e, "ga"); // 가
1858        let o1 = e.press(b'i', false); // ㅐ → 개(겹쳐쓰기)
1859        assert_eq!(o1.preedit, "개");
1860        let o2 = e.press(b'Q', false); // C0|0x14 복원
1861        assert_eq!(o2.commit, "");
1862        assert_eq!(o2.preedit, "가");
1863    }
1864
1865    #[test]
1866    fn c0_infinite_split() {
1867        // 무한 낱자 수정 분리: 개(ㅏ→ㅐ 겹침) → "가" 확정 + 겹쳐졌던 ㅐ가 다음 글자로.
1868        let mut e = auto_engine();
1869        typ(&mut e, "ga"); // 가
1870        e.press(b'i', false); // 개
1871        let o = e.press(b'P', false); // C0|0x13 분리
1872        assert_eq!(o.commit, "가");
1873        assert_eq!(o.preedit, "ㅐ");
1874    }
1875
1876    #[test]
1877    fn c0_infinite_restore_noop_without_overwrite() {
1878        // 겹쳐쓰기가 없었으면 복원은 무동작(가 그대로).
1879        let mut e = auto_engine();
1880        typ(&mut e, "ga"); // 가 (겹쳐쓰기 없음)
1881        let o = e.press(b'Q', false); // C0|0x14
1882        assert_eq!(o.preedit, "가");
1883    }
1884
1885    #[test]
1886    fn c0_infinite_restore_noop_after_backspace() {
1887        // 겹쳐쓴 낱자를 백스페이스로 지운 뒤 복원(0x14)은 무동작이어야 한다(되살릴 것 없음).
1888        let mut e = auto_engine();
1889        typ(&mut e, "ga"); // 가
1890        e.press(b'i', false); // ㅐ → 개(겹쳐쓰기)
1891        let b = e.backspace(); // ㅐ 제거 → ㄱ
1892        assert_eq!(b.preedit, "ㄱ");
1893        let o = e.press(b'Q', false); // 0x14: 사라진 ㅐ를 되살리려 하면 안 됨
1894        assert_eq!(o.commit, "");
1895        assert_eq!(o.preedit, "ㄱ");
1896    }
1897
1898    #[test]
1899    fn c0_infinite_split_no_phantom_after_keep_only() {
1900        // 겹쳐쓴 낱자를 다른 명령(초성만 남기기 0x15)으로 지운 뒤 분리(0x13)는 유령
1901        // 낱자를 만들지 않아야 한다(예전 버그: 사라진 ㅐ가 다음 글자로 되살아남).
1902        let mut e = auto_engine();
1903        typ(&mut e, "ga"); // 가
1904        e.press(b'i', false); // 개(ㅏ→ㅐ 겹침)
1905        let g = e.press(b'G', false); // 0x15 초성만 남기기 → ㄱ (ㅐ 사라짐)
1906        assert_eq!(g.preedit, "ㄱ");
1907        let o = e.press(b'P', false); // 0x13 분리: 유령 ㅐ 없이 무동작
1908        assert_eq!(o.commit, "");
1909        assert_eq!(o.preedit, "ㄱ");
1910    }
1911
1912    #[test]
1913    fn c0_swap_last_two_keys_reorders_compound() {
1914        // 직전 두 글쇠 교환: ㅗ·ㅏ로 만든 ㅘ(겹모음)에서 순서를 바꾸면 결합 규칙(O+A)이
1915        // 깨져 ㅗ만 남는다(A+O 규칙은 없음). 입력 순서를 세밀히 반영함을 보인다.
1916        let mut e = auto_engine();
1917        typ(&mut e, "oa"); // ㅗ+ㅏ → ㅘ
1918        assert_eq!(e.cur.jung, Some(0x116A)); // ㅘ
1919        let o = e.press(b'S', false); // C0|0x23 직전 두 글쇠 교환
1920        assert!(o.consumed);
1921        assert_eq!(e.cur.jung, Some(0x1169)); // ㅗ (재결합 안 됨)
1922    }
1923
1924    // ── Tier B/C: Backspace 변형 · 앞 글자 결합(surrounding-text 필요) ──────────
1925
1926    #[test]
1927    fn c0_backspace1_like_bksp() {
1928        // 0x88 Backspace 1: 간 → 마지막 한 타(ㄴ) 제거 → 가.
1929        let mut e = auto_engine();
1930        typ(&mut e, "gam"); // 간
1931        let o = e.press(b'W', false); // C0|0x88
1932        assert!(o.consumed);
1933        assert_eq!(o.preedit, "가");
1934    }
1935
1936    #[test]
1937    fn c0_backspace3_always_consumes() {
1938        // 0x8A Backspace 3: 빈 상태에서도 글쇠를 가로챈다(0x88 은 통과).
1939        let mut e = auto_engine();
1940        let o3 = e.press(b'X', false); // C0|0x8A, 빈 상태
1941        assert!(o3.consumed);
1942        let mut e2 = auto_engine();
1943        let o1 = e2.press(b'W', false); // C0|0x88, 빈 상태 → 통과
1944        assert!(!o1.consumed);
1945    }
1946
1947    #[test]
1948    fn c0_recombine_forward_jong() {
1949        // 낱자 재결합(앞으로): 간 → 0xA(가+ㄴ) → 0x1D 로 ㄴ을 앞 글자 가에 도로 결합 → 간.
1950        // surrounding-text 지원 가정. delete_before=1 로 앱의 옛 "가"를 지운다.
1951        let mut e = auto_engine();
1952        e.set_surrounding_ok(true);
1953        typ(&mut e, "gam"); // 간
1954        let d = e.press(b'D', false); // 0xA: "가" 확정 + 종성 ㄴ 대기
1955        assert_eq!(d.commit, "가");
1956        let o = e.press(b'T', false); // 0x1D 재결합(앞으로)
1957        assert_eq!(o.preedit, "간");
1958        assert_eq!(o.delete_before, 1);
1959        assert!(o.consumed);
1960    }
1961
1962    #[test]
1963    fn c0_recombine_forward_dubeol() {
1964        // 두벌식 재결합(0x1E): 가 확정 + 다음 초성 ㄴ 조합 중 → ㄴ을 종성으로 바꿔 앞 가에
1965        // 결합 → 간. 0x1D(변환 없음)는 같은 상황에서 가 재확정 + ㄴ 유지(아래 대비).
1966        let mut e = auto_engine();
1967        e.set_surrounding_ok(true);
1968        typ(&mut e, "gan"); // 가 확정, 초성 ㄴ 조합
1969        let o = e.press(b'U', false); // 0x1E
1970        assert_eq!(o.preedit, "간");
1971        assert_eq!(o.delete_before, 1);
1972    }
1973
1974    #[test]
1975    fn c0_recombine_noop_without_surrounding() {
1976        // surrounding-text 미지원이면 앞 글자 결합은 무동작(앱과 어긋나지 않도록).
1977        let mut e = auto_engine();
1978        // set_surrounding_ok(false) 기본값.
1979        typ(&mut e, "gan"); // 가 확정, ㄴ 조합
1980        let o = e.press(b'U', false); // 0x1E, 미지원
1981        assert_eq!(o.preedit, "ㄴ"); // 그대로
1982        assert_eq!(o.delete_before, 0);
1983    }
1984
1985    // 모아치기 받침-우선 백스페이스: 사용자 설정처럼 Bksp condition1="ReverseJLTRN".
1986    // ㅇ·ㅅ(종성)·ㅏ 순으로 쳐서 모아치기로 '앗'이 된 뒤 백스페이스 → 입력 순서와 무관히
1987    // 받침 ㅅ부터 지워져 '아'.
1988    fn reverse_bksp_engine() -> Engine {
1989        let xml = r#"<?xml version="1.0" encoding="utf-8"?>
1990<EditContextSetting version="0x500">
1991  <EditorLayer flag="0"><FinalConvTable/></EditorLayer>
1992  <InputLayer default="0" current="0">
1993    <InputEntry>
1994      <InputSchemeSetting object="CBasicInputScheme">
1995        <KeyTable name="rev" flag="0" from="33" to="126">
1996          <Key at="0x6F" value="H3|Q_"/>  <!-- o 초성 ㅇ -->
1997          <Key at="0x73" value="H3|_S"/>  <!-- s 종성 ㅅ -->
1998          <Key at="0x61" value="H3|A_"/>  <!-- a 중성 ㅏ -->
1999        </KeyTable>
2000      </InputSchemeSetting>
2001      <GeneratorSetting object="CNgsImeEx">
2002        <UnitMixTable/><VirtualUnitTable/>
2003        <AutomataTable default="0">
2004          <Automata state="0" value="1" default="0"/>
2005          <Automata state="1" value="A||B||C ? (A||D)&amp;&amp;(B||E) ? 2 : 1 : -2" default="-1"/>
2006          <Automata state="2" value="A&amp;&amp;A!=500 ? 0 : B||C||A==500 ? 2 : -2" default="0"/>
2007        </AutomataTable>
2008        <Extra><Bksp key="1" value1="BkspAttach" value2="ByUnitStep|BkspAttach" condition1="ReverseJLTRN" condition2="0"/></Extra>
2009      </GeneratorSetting>
2010    </InputEntry>
2011  </InputLayer>
2012</EditContextSetting>"#;
2013        let cfg = Config::parse(xml).unwrap();
2014        Engine::new(cfg.compile(0).unwrap())
2015    }
2016
2017    #[test]
2018    fn moachigi_backspace_deletes_jong_first() {
2019        let mut e = reverse_bksp_engine();
2020        typ(&mut e, "osa"); // ㅇ(초) ㅅ(종) ㅏ(중) → 모아치기 → 앗
2021        assert_eq!(e.preedit(), "앗");
2022        let o = e.backspace(); // 받침 ㅅ 먼저(입력 순서 무관) → 아
2023        assert_eq!(o.preedit, "아");
2024        let o2 = e.backspace(); // 중성 ㅏ → ㅇ
2025        assert_eq!(o2.preedit, "ㅇ");
2026    }
2027
2028    #[test]
2029    fn reverse_jltrn_sets_lowest_lastkey() {
2030        // condition1="ReverseJLTRN" 가 조합 중 삭제 단위를 최하위 낱자 우선으로 만든다.
2031        let e = reverse_bksp_engine();
2032        assert_eq!(
2033            e.layout().bksp.composing,
2034            crate::config::BkspUnit::LowestLastKey
2035        );
2036        assert!(e.layout().bksp.attach); // BkspAttach 도 켜짐
2037    }
2038}