Skip to main content

skey_engine/
vseq.rs

1//! Vowel sequence table — models Vietnamese vowel clusters for hook/breve
2//! transformations (inspired by fcitx5-unikey's VSeqList).
3//!
4//! Instead of tracking per-vowel flags (`from_w`, `horn_shared`), we look at
5//! the current vowel cluster as a whole and decide which positions get hook.
6
7use crate::engine::Letter;
8
9/// Result of attempting to apply hook (horn/breve) via the 'w' key.
10#[derive(PartialEq, Eq)]
11pub enum HookResult {
12    /// Hook was applied to vowel(s); w key is consumed.
13    Applied,
14    /// Hook was removed (toggled off); w key falls through as literal.
15    ToggledOff,
16    /// No hookable vowel found; try phase 2 (insert standalone ư).
17    NotApplicable,
18}
19
20/// Vowel sequence entry defining hook behavior.
21struct VSeq {
22    /// Base vowel characters (lowercase ASCII) in order: e.g. ['u','o'] for "uo"
23    base: [char; 3],
24    /// Number of vowels (1-3)
25    len: u8,
26    /// Bitmask of positions that receive the hook mark (breve/horn).
27    /// Bit 0 = first vowel, bit 1 = second, bit 2 = third.
28    hook_mask: u8,
29}
30
31/// Lookup table: base vowel sequences and which positions get hook.
32/// Ordered by priority — longer sequences are checked first.
33static VSEQ_TABLE: &[VSeq] = &[
34    // ── Triphthongs (check before diphthongs) ──
35    VSeq { base: ['u', 'o', 'i'], len: 3, hook_mask: 0b011 },  // uoi → ươi (hook both u+o)
36    VSeq { base: ['u', 'o', 'u'], len: 3, hook_mask: 0b011 },  // uou → ươu
37    // ── Diphthongs staring with u ──
38    VSeq { base: ['u', 'o', '\0'], len: 2, hook_mask: 0b11 },   // uo → ươ (hook both)
39    VSeq { base: ['u', 'a', '\0'], len: 2, hook_mask: 0b01 },   // ua → ưa (hook u only)
40    VSeq { base: ['u', 'i', '\0'], len: 2, hook_mask: 0b01 },   // ui → ưi
41    VSeq { base: ['u', 'y', '\0'], len: 2, hook_mask: 0b01 },   // uy → ưy (hook u)
42    VSeq { base: ['u', 'e', '\0'], len: 2, hook_mask: 0b01 },   // ue → ưe (hook u)
43    VSeq { base: ['o', 'a', '\0'], len: 2, hook_mask: 0b10 },   // oa → oă (breve on a)
44    VSeq { base: ['o', 'e', '\0'], len: 2, hook_mask: 0b01 },   // oe → oe (hook o only? no, oe doesn't take hook)
45    // ── Single vowels (checked last — longest match wins) ──
46    VSeq { base: ['a', '\0', '\0'], len: 1, hook_mask: 0b01 },  // a → ă (breve)
47    VSeq { base: ['o', '\0', '\0'], len: 1, hook_mask: 0b01 },  // o → ơ (horn)
48    VSeq { base: ['u', '\0', '\0'], len: 1, hook_mask: 0b01 },  // u → ư (horn)
49];
50
51/// Find the contiguous vowel cluster ending at the last vowel.
52/// Returns (start_index, count).
53fn vowel_cluster(ls: &[Letter]) -> Option<(usize, usize)> {
54    let end = ls.iter().rposition(|lt| lt.is_vowel)?;
55    let mut start = end;
56    while start > 0 && ls[start - 1].is_vowel {
57        start -= 1;
58    }
59    // Skip gi/qu digraph vowels — they're consonants, not vowels.
60    // Handle both single-vowel (start==end) and multi-vowel (start<end).
61    if ls[start].c == 'u' && start > 0 && ls[start - 1].c == 'q' {
62        start += 1;
63    } else if ls[start].c == 'i' && start > 0 && ls[start - 1].c == 'g' {
64        start += 1;
65    }
66    if start > end {
67        return None;
68    }
69    Some((start, end - start + 1))
70}
71
72/// Find the VSeq entry matching the given base vowel characters.
73fn lookup_vseq(base: &[char]) -> Option<&'static VSeq> {
74    VSEQ_TABLE.iter().find(|vs| {
75        vs.len as usize == base.len()
76            && (0..base.len()).all(|i| vs.base[i] == base[i])
77    })
78}
79
80/// Check whether the 'uo' cluster has a prefix consonant that triggers
81/// o-only hook (h, kh, th) — ported from fcitx5-unikey's "thuong rule".
82#[allow(dead_code)]
83fn uo_o_only_prefix(ls: &[Letter], vstart: usize) -> bool {
84    if vstart < 2 {
85        return false; // need at least a 2-char consonant prefix
86    }
87    // The consonant directly before uo must be 'h' (part of h, th, or kh)
88    let prev = vstart - 1;
89    if !ls[prev].is_vowel && ls[prev].c == 'h' {
90        // Check if 'h' forms th or kh with a preceding consonant
91        if prev > 0 && !ls[prev - 1].is_vowel {
92            match ls[prev - 1].c {
93                't' | 'k' => return true,  // th or kh prefix
94                _ => {}
95            }
96        }
97        // Standalone 'h' prefix also triggers the rule
98        return true;
99    }
100    false
101}
102
103/// Try to apply hook (horn/breve) to the current vowel cluster.
104///
105/// Returns:
106/// - `Applied` — hook was set on vowel(s); w consumed, caller should `continue`
107/// - `ToggledOff` — hook was removed; w falls through as literal
108/// - `NotApplicable` — no hookable vowel; caller should try phase 2
109pub(crate) fn try_apply_hook(ls: &mut Vec<Letter>) -> HookResult {
110    let (vstart, vcount) = match vowel_cluster(ls) {
111        Some(vc) => vc,
112        None => return HookResult::NotApplicable,
113    };
114
115    // Extract base chars (ignoring current variants)
116    let base: Vec<char> = (vstart..vstart + vcount)
117        .map(|i| ls[i].c)
118        .collect();
119
120    let vs = match lookup_vseq(&base) {
121        Some(vs) => vs,
122        None => {
123            // No VSeq entry for this cluster — scan right-to-left for the
124            // first vowel that can take hook (a, o, u). This handles clusters
125            // like "oi" where 'o' should be the hook target, not 'i'.
126            for i in (vstart..vstart + vcount).rev() {
127                if matches!(ls[i].c, 'a' | 'o' | 'u') {
128                    return apply_single_hook(ls, i);
129                }
130            }
131            return HookResult::NotApplicable;
132        }
133    };
134
135    // Check current hook state: are ALL hook positions already hooked?
136    let all_hooked = (0..vs.len as usize)
137        .filter(|&i| (vs.hook_mask >> i) & 1 != 0)
138        .all(|i| ls[vstart + i].variant == 2);
139
140    if all_hooked {
141        // Check if any hook was propagated (not from explicit w).
142        // If so, this w confirms the propagated hooks rather than toggling off.
143        let any_propagated = (0..vs.len as usize)
144            .filter(|&i| (vs.hook_mask >> i) & 1 != 0)
145            .any(|i| ls[vstart + i].horn_propagated);
146        if any_propagated {
147            // Confirm propagated hooks: clear the flag, consume w.
148            // Explicit hooks stay in place.
149            for i in 0..vs.len as usize {
150                if (vs.hook_mask >> i) & 1 != 0 {
151                    ls[vstart + i].horn_propagated = false;
152                }
153            }
154            return HookResult::Applied; // w consumed, hook confirmed
155        }
156        // Toggle OFF: remove hook from all hook positions.
157        // Special case: standalone ư (from_w on single u) → rewrite to literal w.
158        if vs.len == 1 && vs.base[0] == 'u'
159            && ls[vstart].variant == 2 && ls[vstart].from_w
160        {
161            ls[vstart].c = 'w';
162            ls[vstart].is_vowel = false;
163            ls[vstart].from_w = false;
164            return HookResult::Applied; // w consumed, no literal
165        }
166        for i in 0..vs.len as usize {
167            if (vs.hook_mask >> i) & 1 != 0 {
168                ls[vstart + i].variant = 0;
169                ls[vstart + i].from_w = true;
170                ls[vstart + i].horn_propagated = false;
171                ls[vstart + i].horn_toggled = true; // toggled once — no reapply
172            }
173        }
174        HookResult::ToggledOff
175    } else {
176        // Toggle ON: apply hook to unhooked positions.
177        // Skip if this vowel was already toggled off (one-shot after toggle).
178        let any_toggled = (0..vs.len as usize)
179            .filter(|&i| (vs.hook_mask >> i) & 1 != 0)
180            .any(|i| ls[vstart + i].horn_toggled);
181        if any_toggled {
182            return HookResult::ToggledOff; // w falls through as literal
183        }
184
185        // Note: The fcitx5-unikey "thuong rule" (uo with h/kh/th prefix →
186        // o-only hook) is available via uo_o_only_prefix() but currently
187        // disabled to match existing test expectations.
188        let effective_mask = vs.hook_mask;
189
190        for i in 0..vs.len as usize {
191            if (effective_mask >> i) & 1 != 0 && ls[vstart + i].variant != 2 {
192                // Hook replaces circumflex too (matches unikey):
193                // ô→ơ (uô→ươ, "luộc"+w→"lược"), â→ă.
194                ls[vstart + i].variant = 2;
195                ls[vstart + i].from_w = false;
196            }
197        }
198        HookResult::Applied
199    }
200}
201
202/// Apply hook to a single vowel at the given index.
203/// Handles the special standalone w→ư toggle-off case.
204fn apply_single_hook(ls: &mut Vec<Letter>, vi: usize) -> HookResult {
205    let c = ls[vi].c;
206    match c {
207        'a' => {
208            if ls[vi].variant == 2 {
209                ls[vi].variant = 0;
210                ls[vi].from_w = true;
211                HookResult::ToggledOff
212            } else if ls[vi].from_w && ls[vi].variant == 0 {
213                HookResult::ToggledOff // already toggled
214            } else {
215                ls[vi].variant = 2;
216                ls[vi].from_w = false;
217                HookResult::Applied
218            }
219        }
220        'o' => {
221            if ls[vi].variant == 2 {
222                ls[vi].variant = 0;
223                ls[vi].from_w = true;
224                // Also toggle off preceding u's horn (ươ → uo)
225                if vi > 0 && ls[vi - 1].c == 'u' && ls[vi - 1].variant == 2 {
226                    ls[vi - 1].variant = 0;
227                    ls[vi - 1].from_w = true;
228                }
229                HookResult::ToggledOff
230            } else if ls[vi].from_w && ls[vi].variant == 0 {
231                HookResult::ToggledOff
232            } else {
233                // Hook replaces circumflex too: ô→ơ (matches unikey).
234                ls[vi].variant = 2;
235                ls[vi].from_w = false;
236                // Also horn the preceding u (uo → ươ)
237                if vi > 0 && ls[vi - 1].c == 'u' && ls[vi - 1].variant == 0 {
238                    ls[vi - 1].variant = 2;
239                    ls[vi - 1].from_w = false;
240                }
241                HookResult::Applied
242            }
243        }
244        'u' => {
245            // Find the first 'u' in a run of consecutive u's
246            let mut first = vi;
247            while first > 0 && ls[first - 1].c == 'u' && ls[first - 1].is_vowel {
248                first -= 1;
249            }
250            if ls[first].variant == 2 {
251                if ls[first].from_w {
252                    // Standalone w→ư toggle-off: ư → literal w
253                    ls[first].c = 'w';
254                    ls[first].is_vowel = false;
255                    ls[first].from_w = false;
256                    HookResult::Applied // w consumed, no literal
257                } else {
258                    ls[first].variant = 0;
259                    ls[first].from_w = true;
260                    HookResult::ToggledOff
261                }
262            } else if ls[first].from_w && ls[first].variant == 0 {
263                HookResult::ToggledOff
264            } else {
265                ls[first].variant = 2;
266                ls[first].from_w = false;
267                HookResult::Applied
268            }
269        }
270        _ => HookResult::NotApplicable,
271    }
272}
273
274/// Phase 2: insert standalone ư when hook is not applicable (or short_w ư).
275/// Returns true if a character was inserted (w consumed).
276pub(crate) fn try_insert_horn(ls: &mut Vec<Letter>, short_w: bool, upper: bool) -> bool {
277    // Only insert standalone ư when no "real" vowel exists and short_w is enabled.
278    // Skip vowels that are part of gi/qu digraphs — they're consonants, not vowels.
279    let has_real_vowel = ls.iter().enumerate().any(|(idx, lt)| {
280        if !lt.is_vowel {
281            return false;
282        }
283        // 'i' after 'g' → gi digraph (consonant, not vowel)
284        if lt.c == 'i' && idx > 0 && ls[idx - 1].c == 'g' && !ls[idx - 1].is_vowel {
285            return false;
286        }
287        // 'u' after 'q' → qu digraph (consonant, not vowel)
288        if lt.c == 'u' && idx > 0 && ls[idx - 1].c == 'q' && !ls[idx - 1].is_vowel {
289            return false;
290        }
291        true
292    });
293
294    if short_w
295        && !has_real_vowel
296        && !ls.last().map_or(false, |lt| lt.c == 'w' && !lt.is_vowel)
297    {
298        ls.push(Letter::new_standalone_u(upper));
299        return true;
300    }
301    false
302}
303
304impl Letter {
305    /// Create a standalone ư letter (short_w mode: bare w → ư).
306    pub fn new_standalone_u(upper: bool) -> Self {
307        Letter {
308            c: 'u',
309            is_vowel: true,
310            variant: 2,
311            tone: 0,
312            is_dstroke: false,
313            upper,
314            from_w: true,
315            horn_propagated: false,
316            circ_toggled: false,
317            d_toggled: false,
318            horn_toggled: false,
319        }
320    }
321}