Skip to main content

ui/
edit_spell.rs

1// edit_spell -- spell checking in text boxes (edit.rs includes it as `spellbox`)
2//
3//   TB_SPELL (cap_flags of textbox, TB_MULTI too)   the box checks its words: a red wavy line
4//        under misspelled ones; a right click on one (or the menu key / Shift+F10 with the
5//        caret in it) opens a menu: up to 5 suggestions (a click replaces the word, one undo
6//        step), "Zum Wörterbuch hinzufügen" (the personal word list), "Ignorieren" (this
7//        session).  Password, read-only and digit boxes are never checked.  The app's own
8//        context_menu() for the box still opens on words that are not marked.
9//        Suggestions take a while with large dictionaries (a long German compound up to a
10//        few tenths of a second): Hunspell's are made on a worker (plat::Job), the menu opens
11//        at once with "Vorschläge werden gesucht …" and fills when they are there (E_WAKE; a
12//        menu opened with a key then focuses the first one).  The system's checker answers
13//        on the UI thread.
14//   spell_enable(on)            the switch for the whole UI (default on; off: no marks)
15//   spell_language(tag)         the language, a BCP 47 tag; "" (default) = the user's
16//                               languages (i18n::user_languages), the first with a checker
17//   spell_sources(SRC_*)        where checkers come from (spell crate; 0 = the default:
18//                               everything in window mode, only app:// in headless runs)
19//   spell_suggest_async(on)     suggestions on a worker (default: window mode yes, headless
20//                               no, so scripts stay synchronous; a script that switches it on
21//                               sees the menu waiting, and its `sync` waits for them)
22//   spell_set(speller)          the app's own checker (spell::Speller)
23//   speller() -> Option<&mut Speller>   the checker in use (None while loading, or none);
24//                               loads it when no box asked for it yet
25//   spell_state() -> SPELL_*    OFF (switched off / not asked yet), LOADING, READY, NONE
26//   spell_errors(id) -> ranges  the misspelled byte ranges of box id as last checked
27//
28// Checking: a box checks when its text changed (a hash of it) or the checker or its word
29// lists did; word results are cached in the Speller, so typing re-checks only new words.  In
30// window mode at most BUDGET new words and BUDGET_US of checking per frame (one word at
31// least; the rest on the timer UIT_SPELL: a long German compound may take milliseconds),
32// headless all at once.  The word being typed (the caret at its end while typing) is marked
33// only once the caret leaves it.  Loading: window mode the system's checker on the UI thread,
34// Hunspell files on a worker (plat::Job; no marks until it is read), the personal word list
35// in config://spell (spell::personal_path); headless runs read app:// dictionaries at once
36// and keep the personal list in memory, so scripts never depend on the machine.
37
38use alloc::collections::BTreeMap;
39use alloc::string::String;
40use alloc::sync::Arc;
41use alloc::vec::Vec;
42
43use rt::sync::SpinLock;
44use spell::{Dictionary, Source, Speller, Suggester, Token, SRC_ALL, SRC_APP};
45
46use crate::consts::*;
47use crate::i18n;
48use crate::keys::*;
49use crate::Ui;
50
51const BUDGET: u32 = 200; // new words checked per frame (window mode)
52const BUDGET_US: u64 = 6000; // ... and the time for them
53const SUG_SHOWN: usize = 5;
54const BOXES_MAX: usize = 64; // boxes whose results are kept (then the ones not drawn lately go)
55const MOD3: u32 = MOD_SHIFT | MOD_CTRL | MOD_ALT;
56
57/// the results of one box
58#[derive(Default)]
59struct BoxSpell {
60    hash: u64,
61    generation: u32,
62    rev: u32,
63    toks: Vec<Token>,
64    next: usize, // the next token to check
65    bad: Vec<(usize, usize)>,
66    seen: u32,
67}
68
69/// the open suggestion menu
70struct Menu {
71    id: u32,
72    pid: u32, // its popup
73    start: usize,
74    end: usize,
75    word: String,
76    sugs: Vec<String>,
77    wait: u32,                    // the suggestion job it waits for (0: sugs are there)
78    add_row: u32,                 // the id of its row "Zum Wörterbuch hinzufügen"
79    kbd: bool,                    // opened with a key: anchored below the word
80    rect: [i32; 4],               // the word (px)
81    caret: Option<(usize, usize)>, // the box's caret and anchor when it had the focus
82}
83
84enum Act {
85    Use(usize),
86    Add,
87    Ignore,
88}
89
90/// a dictionary read on a worker: (load number, the dictionary, its language, the personal list)
91type Loaded = (u32, Option<Dictionary>, String);
92
93/// suggestions made on a worker: (the job the menu waits for, its result)
94type Sugs = (u32, Option<Vec<String>>);
95
96pub(crate) struct S {
97    off: bool,
98    lang: String,
99    sources: u32,
100    state: u32,
101    speller: Option<Speller>,
102    generation: u32,
103    load: u32, // number of the load running / last started
104    job: Option<plat::Job>,
105    slot: Arc<SpinLock<Option<Loaded>>>,
106    boxes: BTreeMap<u32, BoxSpell>,
107    budget: u32,
108    budget_frame: u32,
109    budget_until: u64, // plat::time_us() when the frame's checking time is up
110    menu: Option<Menu>,
111    sug_async: Option<bool>, // spell_suggest_async (None: the default of the mode)
112    sug_n: u32,              // number of the suggestion job started last
113    sug_jobs: Vec<plat::Job>,
114    sug_slot: Arc<SpinLock<Sugs>>,
115}
116
117impl Default for S {
118    fn default() -> S {
119        S {
120            off: false,
121            lang: String::new(),
122            sources: 0,
123            state: SPELL_OFF,
124            speller: None,
125            generation: 0,
126            load: 0,
127            job: None,
128            slot: Arc::new(SpinLock::new(None)),
129            boxes: BTreeMap::new(),
130            budget: 0,
131            budget_frame: 0,
132            budget_until: 0,
133            menu: None,
134            sug_async: None,
135            sug_n: 0,
136            sug_jobs: Vec::new(),
137            sug_slot: Arc::new(SpinLock::new((0, None))),
138        }
139    }
140}
141
142impl S {
143    // a new checker is due (language, sources); a load still running is let finish and dropped
144    fn reset(&mut self) {
145        self.speller = None;
146        self.state = SPELL_OFF;
147        self.load = self.load.wrapping_add(1);
148        self.generation = self.generation.wrapping_add(1);
149        self.menu = None;
150    }
151
152    // make the suggestions for word on a worker -> the number of the job (plat::wake() when
153    // done); a job still busy with an earlier word is let finish, its result dropped
154    fn sug_start(&mut self, sg: Suggester, word: &str) -> u32 {
155        self.sug_n = self.sug_n.wrapping_add(1).max(1);
156        let n = self.sug_n;
157        *self.sug_slot.lock() = (n, None);
158        let i = match self.sug_jobs.iter().position(|j| j.done()) {
159            Some(i) => i,
160            None => {
161                self.sug_jobs.push(plat::Job::default());
162                self.sug_jobs.len() - 1
163            }
164        };
165        let slot = self.sug_slot.clone();
166        let w = String::from(word);
167        let job = &mut self.sug_jobs[i];
168        job.set(1, move |_, _| {
169            if slot.lock().0 != n {
170                return; // another word was asked for before this one started
171            }
172            let v = sg.suggest(&w, SUG_SHOWN);
173            let mut g = slot.lock();
174            if g.0 == n {
175                g.1 = Some(v);
176            }
177        });
178        job.start();
179        n
180    }
181}
182
183// FNV-1a of a text (its revision)
184fn fnv(b: &[u8]) -> u64 {
185    let mut h: u64 = 0xCBF2_9CE4_8422_2325;
186    for &c in b {
187        h = (h ^ c as u64).wrapping_mul(0x0000_0100_0000_01B3);
188    }
189    h
190}
191
192impl Ui {
193    /// the spell checking switch for every TB_SPELL box (default on)
194    pub fn spell_enable(&mut self, on: bool) {
195        let s = &mut self.m.edit.spell;
196        if s.off == on {
197            s.off = !on;
198            s.menu = None;
199            self.repass();
200        }
201    }
202
203    /// the language of the checker: a BCP 47 tag ("de-DE"), "" = the user's languages
204    pub fn spell_language(&mut self, tag: &str) {
205        let s = &mut self.m.edit.spell;
206        if s.lang != tag {
207            s.lang = String::from(tag);
208            s.reset();
209            self.repass();
210        }
211    }
212
213    /// where checkers come from (spell::SRC_*; 0 = the default, see the module header)
214    pub fn spell_sources(&mut self, sources: u32) {
215        let s = &mut self.m.edit.spell;
216        if s.sources != sources {
217            s.sources = sources;
218            s.reset();
219            self.repass();
220        }
221    }
222
223    /// use the app's own checker
224    pub fn spell_set(&mut self, sp: Speller) {
225        let s = &mut self.m.edit.spell;
226        s.reset();
227        s.speller = Some(sp);
228        s.state = SPELL_READY;
229        self.repass();
230    }
231
232    /// suggestions on a worker: on / off (default: on in window mode, off in headless runs)
233    pub fn spell_suggest_async(&mut self, on: bool) {
234        self.m.edit.spell.sug_async = Some(on);
235    }
236
237    /// SPELL_OFF / SPELL_LOADING / SPELL_READY / SPELL_NONE
238    pub fn spell_state(&self) -> u32 {
239        let s = &self.m.edit.spell;
240        if s.off { SPELL_OFF } else { s.state }
241    }
242
243    /// the checker in use (None while it loads or when there is none)
244    pub fn speller(&mut self) -> Option<&mut Speller> {
245        self.spell_ensure();
246        self.m.edit.spell.speller.as_mut()
247    }
248
249    /// the misspelled byte ranges of text box id, as last checked (none while switched off)
250    pub fn spell_errors(&self, id: u32) -> Vec<(usize, usize)> {
251        let s = &self.m.edit.spell;
252        if s.off || s.speller.is_none() {
253            return Vec::new();
254        }
255        s.boxes.get(&id).filter(|b| b.generation == s.generation).map_or_else(Vec::new, |b| b.bad.clone())
256    }
257
258    // the personal word list of a checker: config:// in window mode, memory headless
259    fn spell_personal(&self, sp: &mut Speller) {
260        if !self.headless {
261            let p = spell::personal_path(sp.language());
262            sp.set_personal_file(Some(&p));
263        }
264    }
265
266    // the checker: found and loaded (see the module header)
267    fn spell_ensure(&mut self) {
268        if self.m.edit.spell.off {
269            return;
270        }
271        // a finished load (one started before the last reset is dropped)
272        let r = self.m.edit.spell.slot.lock().take();
273        if let Some((n, dict, lang)) = r {
274            if n == self.m.edit.spell.load && self.m.edit.spell.state == SPELL_LOADING {
275                match dict {
276                    Some(d) => {
277                        let mut sp = Speller::with_dictionary(d, &lang);
278                        self.spell_personal(&mut sp);
279                        self.spell_ready(sp);
280                    }
281                    None => self.m.edit.spell.state = SPELL_NONE,
282                }
283                self.repass();
284                return;
285            }
286        }
287        if self.m.edit.spell.state != SPELL_OFF {
288            return; // loading (the job wakes the UI), ready or none
289        }
290        let headless = self.headless;
291        let s = &mut self.m.edit.spell;
292        let sources = if s.sources != 0 {
293            s.sources
294        } else if headless {
295            SRC_APP
296        } else {
297            SRC_ALL
298        };
299        let langs: Vec<String> = if s.lang.is_empty() { i18n::user_languages() } else { alloc::vec![s.lang.clone()] };
300        let found = langs.iter().find_map(|l| spell::find(l, sources));
301        match found {
302            None => s.state = SPELL_NONE,
303            Some(Source::System(t)) => match Speller::from_source(&Source::System(t)) {
304                Some(mut sp) => {
305                    self.spell_personal(&mut sp);
306                    self.spell_ready(sp);
307                }
308                None => self.m.edit.spell.state = SPELL_NONE,
309            },
310            Some(Source::Hunspell { aff, dic, lang, .. }) => {
311                if headless || s.job.as_ref().is_some_and(|j| !j.done()) {
312                    if !headless {
313                        self.timer(UIT_SPELL, 50); // an older load is still running: soon again
314                        return;
315                    }
316                    match Dictionary::load(&aff, &dic) {
317                        Some(d) => {
318                            let mut sp = Speller::with_dictionary(d, &lang);
319                            self.spell_personal(&mut sp);
320                            self.spell_ready(sp);
321                        }
322                        None => self.m.edit.spell.state = SPELL_NONE,
323                    }
324                    return;
325                }
326                // window mode: read the files on a worker, plat::wake() when done
327                let n = s.load;
328                let slot = s.slot.clone();
329                let mut job = s.job.take().unwrap_or_default();
330                job.set(1, move |_, _| {
331                    let d = Dictionary::load(&aff, &dic);
332                    *slot.lock() = Some((n, d, lang.clone()));
333                });
334                job.start();
335                s.job = Some(job);
336                s.state = SPELL_LOADING;
337            }
338        }
339    }
340
341    fn spell_ready(&mut self, sp: Speller) {
342        let s = &mut self.m.edit.spell;
343        s.speller = Some(sp);
344        s.state = SPELL_READY;
345        s.generation = s.generation.wrapping_add(1);
346    }
347
348    /// (edit.rs) the text box being built checks its text -> marks can be drawn
349    pub(super) fn spell_scan(&mut self, id: u32) -> bool {
350        let flags = self.m.edit.tb_flags;
351        if flags & TB_SPELL == 0 || flags & (TB_PASSWORD | TB_READONLY | TB_DIGITS) != 0 || self.m.edit.spell.off {
352            return false;
353        }
354        self.spell_ensure();
355        let (headless, frame) = (self.headless, self.frame);
356        let e = &mut self.m.edit;
357        let s = &mut e.spell;
358        let Some(sp) = s.speller.as_mut() else { return false };
359        let Ok(text) = core::str::from_utf8(&e.buf) else { return false };
360        if s.budget_frame != frame {
361            s.budget_frame = frame;
362            s.budget = if headless { u32::MAX } else { BUDGET };
363            s.budget_until = if headless { u64::MAX } else { plat::time_us() + BUDGET_US };
364        }
365        let h = fnv(text.as_bytes());
366        let b = s.boxes.entry(id).or_default();
367        if b.hash != h || b.generation != s.generation || b.rev != sp.revision() || b.seen == 0 {
368            b.hash = h;
369            b.generation = s.generation;
370            b.rev = sp.revision();
371            b.toks = spell::tokens(text, sp.wordchars());
372            b.next = 0;
373            b.bad.clear();
374        }
375        while b.next < b.toks.len() {
376            let t = b.toks[b.next];
377            if sp.cached(&text[t.start..t.end]).is_none() {
378                if s.budget == 0 || (s.budget < BUDGET && plat::time_us() >= s.budget_until) {
379                    break;
380                }
381                s.budget -= 1;
382            }
383            b.bad.extend(sp.token_errors(text, &t));
384            b.next += 1;
385        }
386        b.seen = frame.max(1);
387        let pending = b.next < b.toks.len();
388        if s.boxes.len() > BOXES_MAX {
389            s.boxes.retain(|k, b| *k == id || frame.wrapping_sub(b.seen) < 600);
390        }
391        if pending {
392            self.timer(UIT_SPELL, 16);
393        }
394        true
395    }
396
397    /// (edit.rs) the marks of box id between the text indexes ls..le of a line whose first
398    /// character is at x (baseline tb_base, the box's font)
399    pub(super) fn spell_draw(&mut self, id: u32, x: i32, ls: usize, le: usize, editing: bool) {
400        let Some(b) = self.m.edit.spell.boxes.get(&id) else { return };
401        let spans: Vec<(usize, usize)> = b.bad.iter().filter(|r| r.1 > ls && r.0 < le).copied().collect();
402        if spans.is_empty() {
403            return;
404        }
405        // the word being typed stays unmarked
406        let e = &self.m.edit;
407        let typing = if editing && e.ed_merge && e.ed_caret == e.ed_anchor { Some(e.ed_caret) } else { None };
408        let base = e.tb_base;
409        let c = self.color(T_CRIT);
410        self.draw_color(c);
411        for (a, z) in spans {
412            if typing == Some(z) {
413                continue;
414            }
415            let (a0, z0) = (a.max(ls), z.min(le));
416            let x0 = x.wrapping_add(self.text.caret_x(&self.m.edit.buf[ls..], a0 - ls));
417            let x1 = x.wrapping_add(self.text.caret_x(&self.m.edit.buf[ls..], z0 - ls));
418            self.spell_wave(x0, x1, base);
419            let lh = self.dp(20);
420            if let Some(m) = self.m.edit.spell.menu.as_mut() {
421                if m.id == id && m.start == a {
422                    m.rect = [x0, base - lh * 3 / 4, (x1 - x0).max(1), lh];
423                }
424            }
425        }
426    }
427
428    // a wavy line from x0 to x1 below the baseline y (px)
429    fn spell_wave(&mut self, x0: i32, x1: i32, y: i32) {
430        let hp = self.dp(2).max(2); // half a wave
431        let amp = self.dp(2).max(2);
432        let y0 = y + self.dp(2);
433        let w = self.dp(1).max(1) << 8;
434        let mut x = x0;
435        let mut up = false;
436        while x < x1 {
437            let nx = (x + hp).min(x1);
438            let (ya, yb) = if up { (y0 + amp, y0) } else { (y0, y0 + amp) };
439            self.draw_line(x << 8, (ya << 8) + 128, nx << 8, (yb << 8) + 128, w);
440            x = nx;
441            up = !up;
442        }
443    }
444
445    // the popup id of box id's menu (in the id scope of the box)
446    fn spell_pid(&mut self, id: u32) -> u32 {
447        self.id_push(id);
448        let p = self.id("spell-menu");
449        self.id_pop();
450        p
451    }
452
453    // the misspelled word of box id at text index i
454    fn spell_at(&self, id: u32, i: usize) -> Option<(usize, usize)> {
455        self.m.edit.spell.boxes.get(&id)?.bad.iter().find(|r| r.0 <= i && i <= r.1).copied()
456    }
457
458    // open the menu for the word a..z of box id (its suggestions now or from a worker)
459    fn spell_open(&mut self, id: u32, a: usize, z: usize, kbd: bool) {
460        let word = String::from_utf8_lossy(&self.m.edit.buf[a..z]).into_owned();
461        let headless = self.headless;
462        let s = &mut self.m.edit.spell;
463        let Some(sp) = s.speller.as_mut() else { return };
464        let sg = if s.sug_async.unwrap_or(!headless) { sp.suggester() } else { None };
465        let (sugs, wait) = match sg {
466            Some(sg) => (Vec::new(), s.sug_start(sg, &word)),
467            None => (sp.suggest(&word, SUG_SHOWN), 0),
468        };
469        let pid = self.spell_pid(id);
470        let e = &self.m.edit;
471        let caret = (e.ed_id == id && self.wp.ctx.focus == id).then_some((e.ed_caret, e.ed_anchor));
472        let rect = self.wp.ctx.last_rect;
473        self.m.edit.spell.menu = Some(Menu { id, pid, start: a, end: z, word, sugs, wait, add_row: 0, kbd, rect, caret });
474        self.popup_open(pid);
475        if kbd {
476            self.wp.ctx.focus_visible = true;
477            self.pp_flag(pid, crate::popup::PPF_FOCUS);
478        }
479        self.repass();
480    }
481
482    /// (edit.rs) after the box is drawn: a right click at text index `hit` (or the menu key)
483    /// opens the menu, the open menu is drawn and its choice applied
484    pub(super) fn spell_input(&mut self, id: u32, ev: u32, hit: Option<usize>, editing: bool) {
485        if ev & EV_RCLICK != 0 {
486            if let Some((a, z)) = hit.and_then(|i| self.spell_at(id, i)) {
487                self.wp.ctx.released &= !(1 << BTN_RIGHT); // not the app's context menu as well
488                self.spell_open(id, a, z, false);
489            }
490        } else if editing && self.m.edit.spell.menu.is_none() {
491            let caret = self.m.edit.ed_caret;
492            if let Some((a, z)) = self.spell_at(id, caret) {
493                let k = self.key_first();
494                let open = if k & 0xFFFF == VK_APPS {
495                    self.key(VK_APPS)
496                } else if k & 0xFFFF == VK_F10 && (k >> 16) & MOD3 == MOD_SHIFT {
497                    self.key(VK_F10)
498                } else {
499                    false
500                };
501                if open {
502                    self.spell_open(id, a, z, true);
503                }
504            }
505        }
506        self.spell_menu(id);
507    }
508
509    /// (runtime E_WAKE, auto `sync`) the suggestions of a worker reach the menu waiting for
510    /// them; a menu opened with a key focuses the first one (unless the focus moved on)
511    pub(crate) fn spell_poll(&mut self) {
512        let s = &mut self.m.edit.spell;
513        let Some(m) = s.menu.as_mut().filter(|m| m.wait != 0) else { return };
514        let v = {
515            let mut g = s.sug_slot.lock();
516            if g.0 != m.wait {
517                return;
518            }
519            match g.1.take() {
520                Some(v) => v,
521                None => return,
522            }
523        };
524        m.sugs = v;
525        m.wait = 0;
526        let (kbd, pid, row) = (m.kbd, m.pid, m.add_row);
527        if kbd && row != 0 && self.wp.ctx.focus == row {
528            self.pp_flag(pid, crate::popup::PPF_FOCUS);
529        }
530        self.repass();
531    }
532
533    /// (auto `sync`) a worker of the spell checker is busy (suggestions, a dictionary)
534    pub(crate) fn spell_busy(&self) -> bool {
535        let s = &self.m.edit.spell;
536        s.sug_jobs.iter().any(|j| !j.done()) || s.job.as_ref().is_some_and(|j| !j.done())
537    }
538
539    /// (edit.rs) the menu of box id is open: the box leaves the keys to it
540    pub(super) fn spell_menu_open(&self, id: u32) -> bool {
541        self.m.edit.spell.menu.as_ref().is_some_and(|m| m.id == id && self.popup_is_open(m.pid))
542    }
543
544    // the menu closed: a box that gets the focus back keeps its caret (instead of starting
545    // the edit again with everything selected)
546    fn spell_closed(&mut self, id: u32, caret: Option<(usize, usize)>) {
547        if self.wp.ctx.focus != id || self.m.edit.ed_id == id {
548            return;
549        }
550        let Some((c, a)) = caret else { return };
551        let frame = self.frame;
552        let e = &mut self.m.edit;
553        e.ed_id = id;
554        e.ed_caret = Self::tb_fix_index(&e.buf, c);
555        e.ed_anchor = Self::tb_fix_index(&e.buf, a);
556        e.ed_seen = frame;
557        e.ed_merge = false;
558        e.ed_prefx = -1;
559        self.tb_blink_reset();
560    }
561
562    // the menu of box id when it is open; its choice is applied to the box's text
563    fn spell_menu(&mut self, id: u32) {
564        let Some(m) = self.m.edit.spell.menu.as_ref() else { return };
565        if m.id != id {
566            return;
567        }
568        let (kbd, rect, sugs, pid, wait) = (m.kbd, m.rect, m.sugs.clone(), m.pid, m.wait != 0);
569        if !self.popup_is_open(pid) {
570            let caret = m.caret;
571            self.m.edit.spell.menu = None;
572            self.spell_closed(id, caret);
573            return;
574        }
575        // the box stays the last widget for the app's post-modifiers (tooltip, context_menu)
576        let (lr, ls) = (self.wp.ctx.last_rect, self.wp.ctx.last_state);
577        if kbd {
578            self.wp.ctx.last_rect = rect;
579        }
580        let mut act = None;
581        if self.flyout_begin(pid, if kbd { FL_BELOW } else { FL_POINT }) {
582            self.id_push(id);
583            self.id_push_str("spell");
584            if wait {
585                self.next_flags(ST_DISABLED);
586                let r = self.id("wait");
587                self.menu_item(r, i18n::tr("Vorschläge werden gesucht …"));
588            } else if sugs.is_empty() {
589                self.next_flags(ST_DISABLED);
590                let r = self.id("none");
591                self.menu_item(r, i18n::tr("Keine Vorschläge"));
592            }
593            for (k, s) in sugs.iter().enumerate() {
594                let r = self.id_n(k as u32);
595                if self.menu_item(r, s) & EV_ACT != 0 {
596                    act = Some(Act::Use(k));
597                }
598            }
599            self.menu_sep();
600            let r = self.id("add");
601            if let Some(m) = self.m.edit.spell.menu.as_mut() {
602                m.add_row = r;
603            }
604            if self.menu_item(r, i18n::tr("Zum Wörterbuch hinzufügen")) & EV_ACT != 0 {
605                act = Some(Act::Add);
606            }
607            let r = self.id("ignore");
608            if self.menu_item(r, i18n::tr("Ignorieren")) & EV_ACT != 0 {
609                act = Some(Act::Ignore);
610            }
611            self.id_pop();
612            self.id_pop();
613        }
614        self.flyout_end();
615        self.wp.ctx.last_rect = lr;
616        self.wp.ctx.last_state = ls;
617        let Some(act) = act else { return };
618        let Some(m) = self.m.edit.spell.menu.take() else { return };
619        self.spell_closed(id, m.caret);
620        match act {
621            Act::Use(k) => self.spell_replace(id, &m, &sugs[k]),
622            Act::Add => {
623                if let Some(sp) = self.m.edit.spell.speller.as_mut() {
624                    if !sp.add(&m.word) {
625                        self.warn("spell: the personal word list could not be written");
626                    }
627                }
628            }
629            Act::Ignore => {
630                if let Some(sp) = self.m.edit.spell.speller.as_mut() {
631                    sp.ignore(&m.word);
632                }
633            }
634        }
635        self.repass();
636    }
637
638    // replace the word of the menu by a suggestion (one undo step when the box is focused)
639    fn spell_replace(&mut self, id: u32, m: &Menu, with: &str) {
640        let e = &self.m.edit;
641        if m.end > e.buf.len() || &e.buf[m.start..m.end] != m.word.as_bytes() {
642            return; // the text changed meanwhile
643        }
644        if e.tb_max > 0 && e.buf.len() - (m.end - m.start) + with.len() > e.tb_max {
645            return; // it would not fit
646        }
647        let focused = e.ed_id == id && self.wp.ctx.focus == id;
648        if focused {
649            self.ud_push();
650        }
651        self.tb_delete(m.start, m.end);
652        let n = self.tb_insert(m.start, with.as_bytes());
653        if focused {
654            let e = &mut self.m.edit;
655            e.ed_caret = m.start + n;
656            e.ed_anchor = m.start + n;
657            e.ed_merge = false;
658        }
659        self.m.edit.tb_ev |= EV_ACT;
660    }
661}