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}