Skip to main content

ui/
auto.rs

1// auto -- UI automation for headless tests: scripts and the widget dump  [src/ui/ui_auto.s]
2//
3//   script_run(app, path) -> exit code   0 all expectations met, 1 failures, 2 parse error
4//   dump(path)                           widget tree as JSON lines (None = stdout)
5//
6// Script syntax (one command per line, '#' starts a comment; coordinates in DIP):
7//   size W H                      resize the headless window
8//   theme dark|light|contrast
9//   theme file PATH | none        an app theme from a file (an error is printed and kept for
10//                                 `expect theme error`; the theme stays) / no app theme
11//   style NAME                    another style linked into the app (Ui::set_style)
12//   move S          down S [l|r|m]      up [S] [l|r|m]      click S [l|r|m] [shift|ctrl|alt]...
13//   rclick S        dblclick S [shift|ctrl|alt]...
14//                   drag S1 S2 [steps] [hold MS] [shift|ctrl|alt|right|middle]...
15//                   drag ... hold MS   the button rests MS ms before the first move (like
16//                   `wait`: timers and animations run), the hold a dock tab asks for (its drag
17//                   counts from where the pointer is then, so the moves must leave that place)
18//                   drag ... pen|eraser P0 P1   (PN1) a pen stroke, pressure P0 -> P1 (%)
19//   pointer mouse | pen [P [TX TY]] | eraser [P [TX TY]] | touch   (PN1) pen data of the
20//                   following pointer events (touch: they are a finger, as in the next lines)
21//   tap S           a finger: down and up at S, no time between  (touch.rs: the recogniser sees
22//                   every finger on the virtual clock)
23//   longpress S [MS]   a finger rests MS ms (default 600) on S, then lifts
24//   swipe S1 S2 [MS] [hold]   a finger from S1 to S2 at an even speed in MS ms (default 300, a
25//                   sample every 8 ms), then it lifts: with its speed (a fling starts; `wait`
26//                   runs it), with hold after resting 100 ms (no fling)
27//   flick S DX DY [MS]   a quick swipe from S by DX, DY (DIP) in MS ms (default 60)
28//   finger [N] down S | move S | up | cancel   one raw finger event (`wait` between them): it stays
29//                   down across other commands; N: the finger's id (default 0), for a second one;
30//                   cancel: the system took it
31//   pinch S D0 D1 [MS]   two fingers around S, D0 DIP apart, go to D1 DIP apart in MS ms (default 300),
32//                   then they lift
33//   pan2 S DX DY [MS]   two fingers around S (80 DIP apart) move by DX, DY (DIP) in MS ms (default 300),
34//                   then they lift
35//   wheel S DY [shift|ctrl|alt]...   DY in wheel units (120 = one notch); the words hold
36//                   those modifiers during the wheel event (e.g. Ctrl + wheel zoom)
37//   key CHORD...    e.g. key ctrl+s   key tab   key shift+tab enter esc left f4 apps (menu key)
38//   keydown CHORD [scan N] [left|right|numpad]   (E2) the key goes down and stays down (a
39//                   modifier key -- shift, ctrl, alt -- is held for the following events; once
40//                   more while down: an auto-repeat); keyup CHORD [scan N] [left|right|numpad]
41//                   lets it go; scan N and the location reach ui.key_event_take
42//   preedit "text" [CURSOR]   (E4) an input method's composition (ui.ime_preedit; CURSOR a byte
43//                   offset, default the end; "" ends it); commit "text" commits
44//                   (also [ ] , . - + / and num0..num9 numadd numsub nummul numdiv numdec)
45//   keys [N] CHORD...   the chords N times over, queued for ONE pass of the window with the
46//                   keyboard (more keys than frames: what a tool window hands on); `key` gives
47//                   every key a pass of its own, as the platform does
48//   focus S         give the widget the keyboard focus (focus ring shown)
49//   type "text"     ("\n" presses Enter)
50//   commit "text" [N]   an input method commits the text (N times over: one long commit)
51//   cmd ID [ARG]    run a command as if a menu item was chosen
52//   wait MS         advance the virtual clock (16 ms frames; timers, animations and the
53//                   sleep()s of async tasks); nothing else happens meanwhile: the same run
54//                   every time
55//   wait real MS    MS of real time pass while the app's own threads work: their wake-ups
56//                   (plat::wake) reach App::command (UIC_WAKE) as they come, and the virtual
57//                   clock follows the real one (timers, animations, frames asked for); for apps
58//                   whose engine runs on threads of its own (not deterministic: expect what
59//                   holds however long the threads took)
60//   shot FILE.png   render and save a screenshot
61//   dump [FILE]     print / save the widget tree
62//   expect S exists | missing | hover | nohover | focused | unfocused | checked | unchecked |
63//            disabled | enabled | selected | unselected | open | closed | pressed | unpressed |
64//            value N | value MIN..MAX | max N | text "s" |
65//            rect X Y W H   (rect: '*' takes any value)
66//   a11y ACTION S [ARG]   (a11y_auto.rs, section 19) a screen reader's request for the node of
67//                 record S, as a bridge sends it: click, focus, scroll, expand, collapse,
68//                 increment, decrement, select, add, remove, value N, text "s", selecttext A C
69//   a11y expect S role R | name "s" | desc "s" | url "s" | text "s" | level N | set POS SIZE |
70//                 cell ROW COL [RSPAN CSPAN] | grid ROWS COLS | live polite|assertive|off |
71//                 is STATE | not STATE | can ACTION | cannot ACTION | parent S2 | top |
72//                 children N | missing          (its node in the accessibility tree)
73//   a11y expect announced "s" | none   the last text ui.announce said since the last check
74//   a11y dump [FILE]      the accessibility tree as text
75//   expect theme "NAME" | none    the app theme;  expect theme error "TEXT" | none  the error
76//                                 of the last `theme file` contains TEXT
77//   expect style NAME   expect token NAME #RRGGBB|#AARRGGBB (a colour token now)
78//   expect pixel X Y #RRGGBB      the main window's pixel at (X, Y), rendered now
79//   expect same FILE.png [X Y W H]   the main window (that part of it, DIP) rendered now has
80//                                 the pixels of that shot (one taken with the same crop)
81//   print TEXT      echo
82//   bench N         time N frames (build, full render, unchanged frame) with the real clock
83//   bench N anim [MAX_US]   N frames 16 ms apart on the virtual clock (timers, animations):
84//                   each frame's passes and the render of its dirty cells, timed with the real
85//                   clock (--verify-dirty compares afterwards, untimed); prints mean, median, max
86//   bench N drag S1 S2 [middle|right|shift|ctrl|alt]... [MAX_US]   the same for the N moves of
87//                   a drag (press and release untimed; every other run drags back)
88//   bench N wheel S DY [shift|ctrl|alt]... [MAX_US]   ... for N wheel events at S, DY and -DY
89//                   by turns (with ctrl: zoom in and out)
90//   bench N key CHORD [MAX_US]    ... for N presses of a key (e.g. bench 20 key shift+right)
91//   bench N type "s" [MAX_US]     ... for N times typing the text (one frame each)
92//                   With MAX_US a run passes when its median is at most MAX_US; up to three
93//                   runs (the median and the retries ride out a busy machine), FAIL when none
94//                   does
95//   quit
96//   system focus in|out | suspend | resume | background | foreground | session-end |
97//          settings theme|locale|contrast|fonts|other|N
98//                       the platform's lifecycle events (UIC_ACTIVE, UIC_SUSPEND, ...;
99//                       background / foreground: a hidden tab, Android's onStop / onStart)
100//   system browser default|other|unknown   (E13) what ui.default_browser answers from here (a
101//                       headless run never asks the system; at first other), then the app
102//                       loses the keyboard (when it had it) and gets it back, as after the
103//                       system's settings: UIC_ACTIVE 0 and 1 (only 1 when it was not active)
104//   system fullscreen on|off [ID|main]   (L7) the platform (the user) puts the window into /
105//                       takes it out of full screen by itself: UIC_FULLSCREEN (default main)
106//   system env NAME VALUE|-   set (- removes) a jfgui_* environment variable of the run: the
107//                       overrides of the system settings (follow with `system settings ...`),
108//                       jfgui_CSD (the library title bar and its resize band, next pass),
109//                       jfgui_FONT_FALLBACK 1 / - (the system fonts for missing characters on /
110//                       off from here)
111//   sensor accel|gravity|gyro|magnet X Y Z [ACCURACY]   (sensors.rs) a reading of plat::sensors:
112//                       the sensor exists from now on; a stopped sensor delivers nothing
113//   sensor rot X Y Z W [abs|rel]   the rotation estimate (a quaternion; default abs)
114//   sensor none NAME|all   this sensor does not exist (the start: none does)
115//   sensor expect NAME off|waiting|running|paused|denied|unavailable
116//   location LAT LON [ACC ALT SPEED BEARING]   a fix of plat::sensors ('-': the provider does not
117//                       say); location age MS: how old it is now
118//   location searching|denied|off|none   the host's answer (off: the system's switch; none: no
119//                       location service, as at the start)
120//   location expect off|searching|fix|paused|denied|disabled|unavailable
121//   expect wm none|move|menu|minimize|maximize|restore|close|lower|resize EDGE   what the title
122//                       bar or the resize band asked the window manager for since the last check
123//                       (EDGE n, s, w, e, nw, ne, sw, se: physical edges)
124// Windows (ID = the app's window id, 0 / main = the main window):
125//   window ID|main|any            selectors only look into that window (default any)
126//   expect window ID open|closed|visible|active|modal|keys|fullscreen|windowed
127//                                 expect window ID rect X Y W H
128//                                 (keys: the window keys go to now -- a tool window borrows them
129//                                 from its owner after F6 / Ctrl+F6 or a click on a widget;
130//                                 fullscreen / windowed: window_fullscreen's state, L7)
131//   close window ID   movewin ID X Y   resizewin ID W H   activate ID
132//                                 (movewin: the user's drag, snapped like window_snap says)
133//   scale P [window ID]           dpi of the main window (default) or of one window
134//   shot F.png [window ID|desktop] [X Y W H]   one window, or all visible windows on the
135//                                 virtual desktop; a crop rectangle in DIP
136//   screen X Y W H | off          (§6) the virtual screen (the monitor's work area, DIP of the
137//                                 main window's client area): popups of every window may leave
138//                                 it up to there and flip at its edges; off (the default): the
139//                                 main window's client area holds all popups
140//   sync [MS] [soft]              wait until no background job is pending: the app's sync hook,
141//                                 the async tasks' work (blocking jobs, wake-ups) and the spell
142//                                 checker's workers (suggestions, edit_spell.rs); real time, at
143//                                 most MS (default 10 s): reaching it fails the script, with soft
144//                                 it does not (a note is printed; the expects that follow decide)
145//   expect sound KIND|none        the last sound ui.beep / ui.dialog_sound asked for since the
146//                                 last check (default, information, warning, error, question,
147//                                 notify; headless runs play none)
148//   expect scrollcopy [N]|none    at least N (default 1) frames / no frame moved pixels by a
149//                                 scroll copy since the last check (scrollcopy.rs)
150//   gpu on|off|wait [MS]|lose|print   gpu expect gpu|cpu|same|window|screen|upload N
151//                                 (Windows, else a SKIP) real windows through the swapchain, every
152//                                 presented image read back and compared (gpu_out.rs)
153//   expect_warn / expect_nowarn   a UIWARN was / was not printed since the last check (a
154//                                 script also warns about a widget id used twice in a pass)
155// (K1) Files and clipboard, drag and drop:
156//   drop X Y FILE...              files dropped at (X, Y) DIP of the scoped window
157//   drop X Y [files F...] [text "s"] [html "s"] [url U] [image F.png] [copy|move|link] [sync]
158//            [allows copy|move|link...]
159//                                 other content dropped there (copy|move|link: the effect the
160//                                 platform delivers); drop X Y self: the app's own drag that left
161//                                 the window comes back; sync: the platform answers the source at
162//                                 the drop (no drop_defer, as on macOS); allows: the effects the
163//                                 source allows, given with the drop (as Windows' Drop does;
164//                                 without: those of the dragover before it, else every effect)
165//   drop expect copy|move|link|none|pending   what the last drop's source was answered (the
166//                                 effect, none: not taken), pending: the answer waits (drop_defer)
167//   dragover X Y [files] [text] [url] [html] [image] [copy|move|link]   another program's
168//                                 drag hovers there (default files), allowing those effects
169//                                 (default copy and move: drag_allowed);  dragleave: it left
170//   dragover expect copy|move|link|none [ctrl] [shift]   the platform's answer to that drag
171//                                 now (the target's drop_effect_set; the system's pointer)
172//   dragout expect none | text "s" | html "s" | url "s" | files N | image | kinds N
173//                                 what the app's drag handed over when it left the window
174//   dragout end copy|move|link|none   the platform ended that drag (E_DRAG_END)
175//   (widgets dragged onto widgets: drag S1 S2; key esc while held cancels)
176//   clip text "s" | clip file F | clip dib F | clip clear | clip save F [bmp] |
177//   clip expect none|text|image|png
178// Permissions and agent control (auto_perm.rs):
179//   perm answer once|always|deny|never|dialog|none   perm timeout MS   perm store FILE|none
180//   perm reload   perm wait [MS]   perm reset   perm expect open|none|RULE ANSWER
181//   agent on|off   agent call|write ACTION [JSON]   agent keep   agent id S
182//   agent expect ok|error CODE|reason WORD [ID]|same|moved
183// The live inspector (inspect.rs):
184//   inspect on|off|toggle|arm|disarm|pick|copy|snap   inspect select S|none
185//   inspect option outlines|order|dirty on|off   inspect tab widgets|frame|colors|look
186//   inspect expect open|closed|pick on|off|flash on|off|idle|same|excluded|selected S|
187//                  hover S|selector "s"|copied "s"
188//   ... editing in it (development builds, inspect_edit.rs; KEY = token NAME | accent |
189//   radius | spacing | height | size STYLE | font | mode | style | lang | scale | textscale):
190//   inspect set KEY VALUE   inspect reset [KEY]   inspect undo|redo   inspect save PATH
191//   inspect list (copy the change list)   inspect do click|drag DX DY|value N|type "s"|cmd ID
192//   [ARG] (the selected widget)   inspect expect changes N|list "s"|value KEY "s"|undo on|off|
193//   redo on|off|message "s"|none
194//   ... the sources (development builds, inspect_src.rs): inspect editor "COMMAND"|none (the
195//   editor of this run; a script starts no other)   inspect open [dry] ("Im Editor öffnen";
196//   dry: built and checked, nothing started)   inspect expect source "PREFIX"|none (the
197//   selected widget's "file:line")   inspect expect sources "PREFIX" (every record of the
198//   app's windows)   inspect expect opened "s"|none   inspect expect note "s"|none
199// Selectors S:  #123  (widget id)   "Label"   kind:"Label"   X,Y  (a point in DIP)
200// A label must match exactly one widget (plain text is ignored when a control with the
201// same label exists; matches in several windows: the topmost window, then the topmost
202// layer).  #id selectors must be unique across windows (else a UIWARN, the topmost window
203// wins).  Points are in the scoped window (main for any).  Failed expectations print
204//   FAIL line N: <command> -> <detail>
205// and the script continues.
206//
207// Scripts are bytes (as the asm reads them): lines of at most 1023 bytes, CR dropped; a NUL
208// ends the script.
209
210use alloc::string::String;
211use alloc::vec::Vec;
212use core::fmt::Write;
213
214use super::consts::*;
215use super::keys::*;
216use super::layout::{dp_at, px2dip_at};
217use super::runtime::{parse_hex, parse_int};
218use super::{App, Sem, Ui};
219use gfx::{Surface, FMT_XRGB};
220use plat::{DragData, Event};
221
222// a script line (and so a quoted label of a selector or `expect S text`, which must match a
223// record's whole label: a wrapped paragraph of a web page is one label)
224const LINE_MAX: usize = 16384;
225// the bytes of a record's label `dump` writes (cut at a character): labels are bounded by the
226// pass's text arena already (SEMTEXT_MAX)
227const DUMP_TEXT_MAX: usize = 65536;
228// the bytes of a window title `dump` writes
229const DUMP_TITLE_MAX: usize = 172;
230
231/// script runner state (ui_auto.s .bss / .data); survives between scripts (scope, the
232/// warning mark) and between commands (the pointer)
233pub(crate) struct S {
234    lineno: u32,
235    fails: u32,
236    parse_err: bool,
237    line: Vec<u8>,
238    script_mods: u32,
239    sel_err: u32,
240    sel_kind: i32,
241    sel_wf: i32,
242    sel_win: u32,
243    sel_x: i32,
244    sel_y: i32,
245    sel_label: Vec<u8>,
246    sc_win: u32,
247    sc_x: i32,
248    sc_y: i32,
249    sc_pdata: u32,
250    /// the id of the next raw finger event (`finger N ...`)
251    finger_id: u32,
252    warn_mark: u32,
253    sc_scope: i32,
254    /// the agent session of `agent call` (auto_perm.rs)
255    pub(crate) agent: super::auto_perm::S,
256    /// the error of the last `theme file` (None: it was applied)
257    theme_err: Option<String>,
258}
259
260impl Default for S {
261    fn default() -> S {
262        S {
263            lineno: 0,
264            fails: 0,
265            parse_err: false,
266            line: Vec::new(),
267            script_mods: 0,
268            sel_err: 0,
269            sel_kind: -1,
270            sel_wf: -1,
271            sel_win: 0,
272            sel_x: 0,
273            sel_y: 0,
274            sel_label: Vec::new(),
275            sc_win: 0,
276            sc_x: 0,
277            sc_y: 0,
278            sc_pdata: 0,
279            finger_id: 0,
280            warn_mark: 0,
281            sc_scope: -1, // `window` scope: window id, -1 = any
282            agent: Default::default(),
283            theme_err: None,
284        }
285    }
286}
287
288/// a resolved selector: a semantic record, or a point (a zero record)
289#[derive(Clone, Copy)]
290pub(crate) enum Sel {
291    Rec(usize),
292    Point,
293}
294
295// ------------------------------------------------------------------ parsing helpers
296
297// skip_ws: blanks and tabs
298pub(crate) fn skip_ws(p: &[u8]) -> &[u8] {
299    let i = p.iter().position(|&c| c != b' ' && c != b'\t').unwrap_or(p.len());
300    &p[i..]
301}
302
303// word_eq(p, word) -> the rest after the word when p starts with it followed by a blank /
304// the end
305pub(crate) fn word_eq<'a>(p: &'a [u8], w: &str) -> Option<&'a [u8]> {
306    let w = w.as_bytes();
307    if !p.starts_with(w) {
308        return None;
309    }
310    match p.get(w.len()) {
311        None | Some(b' ') | Some(b'\t') => Some(&p[w.len()..]),
312        _ => None,
313    }
314}
315
316// next_word: after the current word and the following blanks
317pub(crate) fn next_word(p: &[u8]) -> &[u8] {
318    let i = p.iter().position(|&c| c == b' ' || c == b'\t').unwrap_or(p.len());
319    skip_ws(&p[i..])
320}
321
322// parse_qstring(p, cap) -> (string (at most cap - 1 bytes), rest after it); "\n" and "\t"
323// escapes, any other escaped character as itself
324pub(crate) fn parse_qstring(p: &[u8], cap: usize) -> Option<(Vec<u8>, &[u8])> {
325    if p.first() != Some(&b'"') {
326        return None;
327    }
328    let mut out = Vec::new();
329    let mut i = 1;
330    loop {
331        let c = *p.get(i)?;
332        i += 1;
333        let ch = match c {
334            b'"' => return Some((out, &p[i..])),
335            b'\\' => {
336                let e = *p.get(i)?; // "\" at the end of the line: no string
337                i += 1;
338                match e {
339                    b'n' => 10,
340                    b't' => 9,
341                    o => o,
342                }
343            }
344            o => o,
345        };
346        if out.len() < cap - 1 {
347            out.push(ch);
348        }
349    }
350}
351
352// utf8_decode (str.s): codepoint and the next index; invalid bytes, overlong forms,
353// surrogates and values above U+10FFFF decode as U+FFFD and advance one byte
354fn utf8_decode(s: &[u8], i: usize) -> (u32, usize) {
355    let b0 = s.get(i).copied().unwrap_or(0) as u32;
356    if b0 < 0x80 {
357        return (b0, i + 1);
358    }
359    let bad = (0xFFFD, i + 1);
360    let (mut cp, n, min) = if b0 < 0xC2 {
361        return bad;
362    } else if b0 < 0xE0 {
363        (b0 & 0x1F, 1, 0x80)
364    } else if b0 < 0xF0 {
365        (b0 & 0x0F, 2, 0x800)
366    } else if b0 < 0xF5 {
367        (b0 & 7, 3, 0x10000)
368    } else {
369        return bad;
370    };
371    let mut j = i + 1;
372    for _ in 0..n {
373        let c = s.get(j).copied().unwrap_or(0) as u32;
374        if c & 0xC0 != 0x80 {
375            return bad;
376        }
377        cp = cp << 6 | (c & 0x3F);
378        j += 1;
379    }
380    if cp < min || cp > 0x10FFFF || cp & 0xFFFF_F800 == 0xD800 {
381        return bad;
382    }
383    (cp, j)
384}
385
386// parse_button(p) -> BTN_* (default left)
387fn parse_button(p: &[u8]) -> u32 {
388    match p.first().map(|c| c | 32) {
389        Some(b'r') => BTN_RIGHT,
390        Some(b'm') => BTN_MIDDLE,
391        _ => BTN_LEFT,
392    }
393}
394
395// chord(p) -> (modifiers, virtual key, the rest after the chord's word): "ctrl+shift+f3"; None
396// = an unknown key
397fn chord(p: &[u8]) -> Option<(u32, u32, &[u8])> {
398    let mut p = p;
399    let mut mods = 0;
400    loop {
401        if p.starts_with(b"ctrl+") {
402            mods |= MOD_CTRL;
403            p = &p[5..];
404        } else if p.starts_with(b"shift+") {
405            mods |= MOD_SHIFT;
406            p = &p[6..];
407        } else if p.starts_with(b"alt+") {
408            mods |= MOD_ALT;
409            p = &p[4..];
410        } else {
411            break;
412        }
413    }
414    // the key name ends at a blank
415    let vk = key_lookup(p);
416    (vk != 0).then(|| (mods, vk, next_word(p)))
417}
418
419// mod_word(p) -> MOD_* of the word shift / ctrl / alt at p (0 = another word): the modifiers
420// drag and wheel hold during their events
421fn mod_word(p: &[u8]) -> u32 {
422    if p.starts_with(b"shift") {
423        MOD_SHIFT
424    } else if p.starts_with(b"ctrl") {
425        MOD_CTRL
426    } else if p.starts_with(b"alt") {
427        MOD_ALT
428    } else {
429        0
430    }
431}
432
433// parse_pct(p) -> (pressure units: % 0..100 -> 0..PRESSURE_ONE, rest)
434fn parse_pct(p: &[u8]) -> (i32, &[u8]) {
435    let (v, r) = parse_int(p);
436    let v = v.clamp(0, 100) as u32;
437    ((v * 65535 / 100) as i32, r)
438}
439
440// key_lookup(p) -> VK for the word at p (0 = unknown)
441fn key_lookup(p: &[u8]) -> u32 {
442    let c0 = p.first().copied().unwrap_or(0);
443    let c1 = p.get(1).copied().unwrap_or(0);
444    if c1 == b' ' || c1 == 0 {
445        // single letter / digit
446        if c0.is_ascii_lowercase() {
447            return (c0 - 32) as u32;
448        }
449        if c0.is_ascii_uppercase() || c0.is_ascii_digit() {
450            return c0 as u32;
451        }
452    }
453    const NAMES: &[(&str, u32)] = &[
454        ("enter", VK_RETURN), ("esc", VK_ESCAPE), ("tab", VK_TAB), ("space", VK_SPACE),
455        ("backspace", VK_BACK), ("delete", VK_DELETE), ("left", VK_LEFT), ("right", VK_RIGHT),
456        ("up", VK_UP), ("down", VK_DOWN), ("home", VK_HOME), ("end", VK_END),
457        ("pgup", VK_PRIOR), ("pgdn", VK_NEXT), ("insert", VK_INSERT), ("alt", VK_MENU), ("apps", VK_APPS),
458        ("shift", VK_SHIFT), ("ctrl", VK_CONTROL), // (E2) keydown shift / keyup shift
459        ("plus", VK_OEM_PLUS), ("minus", VK_OEM_MINUS),
460        // (U1) punctuation (US layout keys) and the numeric keypad
461        ("[", VK_OEM_4), ("]", VK_OEM_6), (",", VK_OEM_COMMA), (".", VK_OEM_PERIOD),
462        ("-", VK_OEM_MINUS), ("+", VK_OEM_PLUS), ("/", VK_OEM_2),
463        ("numadd", VK_ADD), ("numsub", VK_SUBTRACT), ("nummul", VK_MULTIPLY), ("numdiv", VK_DIVIDE),
464        ("numdec", VK_DECIMAL),
465        ("num0", VK_NUMPAD0), ("num1", VK_NUMPAD0 + 1), ("num2", VK_NUMPAD0 + 2), ("num3", VK_NUMPAD0 + 3),
466        ("num4", VK_NUMPAD0 + 4), ("num5", VK_NUMPAD0 + 5), ("num6", VK_NUMPAD0 + 6), ("num7", VK_NUMPAD0 + 7),
467        ("num8", VK_NUMPAD0 + 8), ("num9", VK_NUMPAD0 + 9),
468    ];
469    for &(n, vk) in NAMES {
470        if word_eq(p, n).is_some() {
471            return vk;
472        }
473    }
474    // f1..f12
475    if c0 | 32 == b'f' {
476        let (n, _) = parse_int(&p[1..]);
477        if (1..=12).contains(&n) {
478            return VK_F1 + n as u32 - 1;
479        }
480    }
481    0
482}
483
484/// json_escape(src, max): src with " \ escaped and control characters as blanks; at most max
485/// bytes of src, cut before a character that would not fit whole (a NUL ends it)
486fn json_escape(out: &mut Vec<u8>, src: &[u8], max: usize) {
487    let mut end = src.len().min(max);
488    while end > 0 && end < src.len() && src[end] & 0xC0 == 0x80 {
489        end -= 1; // inside a character: before it
490    }
491    for &c in &src[..end] {
492        if c == 0 {
493            break;
494        }
495        match c {
496            b'"' | b'\\' => {
497                out.push(b'\\');
498                out.push(c);
499            }
500            c if c < 32 => out.push(b' '), // control characters become spaces
501            c => out.push(c),
502        }
503    }
504}
505
506const WFLAG_NAMES: [&str; 10] = ["tool", "dialog", "modal", "resizable", "noclose", "sysframe", "noactivate", "autoheight", "keys", "touchhold"];
507
508// copy_px(dst, src, x, y): gfx_blit_copy -- src at (x, y) of dst, clipped, no blending
509fn blit_copy(dst: &mut Surface, src: &Surface, x: i32, y: i32) {
510    let x0 = x.max(0);
511    let y0 = y.max(0);
512    let x1 = (x + src.w).min(dst.w);
513    let y1 = (y + src.h).min(dst.h);
514    if x1 <= x0 || y1 <= y0 {
515        return;
516    }
517    let n = (x1 - x0) as usize * 4;
518    for yy in y0..y1 {
519        let d = yy as usize * dst.stride as usize + x0 as usize * 4;
520        let s = (yy - y) as usize * src.stride as usize + (x0 - x) as usize * 4;
521        dst.px[d..d + n].copy_from_slice(&src.px[s..s + n]);
522    }
523}
524
525// crop_surface(sf, dpi, crop) -> a copy of the DIP rectangle crop (x, y, w, h) of sf,
526// clipped to it (None when nothing is left)
527fn crop_surface(sf: &Surface, dpi: i32, c: [i32; 4]) -> Option<Surface> {
528    let x = dp_at(c[0], dpi);
529    let y = dp_at(c[1], dpi);
530    let mut w = dp_at(c[2], dpi);
531    let mut h = dp_at(c[3], dpi);
532    if x < 0 || y < 0 {
533        return None;
534    }
535    let ww = sf.w - x;
536    if ww <= 0 {
537        return None;
538    }
539    w = w.min(ww);
540    let hh = sf.h - y;
541    if hh <= 0 {
542        return None;
543    }
544    h = h.min(hh);
545    if w <= 0 || h <= 0 {
546        return None;
547    }
548    let mut out = Surface::new(w, h, sf.fmt);
549    for r in 0..h as usize {
550        let s = (y as usize + r) * sf.stride as usize + x as usize * 4;
551        let d = r * out.stride as usize;
552        out.px[d..d + w as usize * 4].copy_from_slice(&sf.px[s..s + w as usize * 4]);
553    }
554    Some(out)
555}
556
557impl Ui {
558    // ------------------------------------------------------------------ dump
559
560    /// ui_dump(path): JSON lines -- a header, one line per shown secondary window, then one
561    /// line per semantic record (None = stdout)
562    pub fn dump(&mut self, path: Option<&[u8]>) {
563        let mut out: Vec<u8> = Vec::new();
564        self.dump_header(&mut out);
565        for i in 1..WIN_MAX as u32 {
566            if self.uiw_drawable(i) {
567                self.dump_window(&mut out, i);
568            }
569        }
570        for k in 0..self.sems.len() {
571            let r = self.sems[k].clone();
572            self.dump_record(&mut out, &r);
573        }
574        match path {
575            Some(p) => {
576                super::perm::trusted(|| plat::write_file(&String::from_utf8_lossy(p), &out));
577            }
578            None => plat::write_stdout(&out),
579        }
580    }
581
582    fn dump_header(&self, out: &mut Vec<u8>) {
583        let mut s = String::new();
584        let scale = ((self.wp.rt.dpi as u32).wrapping_mul(100).wrapping_add(48)) / 96;
585        let _ = write!(
586            s,
587            "{{\"frame\":{},\"size\":[{},{}],\"scale\":{},\"theme\":\"{}\",\"focus\":{},\"hot\":{},\"keys\":{}}}\n",
588            self.frame,
589            self.px2dip(self.wp.rt.fb.w),
590            self.px2dip(self.wp.rt.fb.h),
591            scale,
592            if self.theme_dark { "dark" } else { "light" },
593            self.wp.ctx.focus,
594            self.wp.ctx.hot,
595            self.uiw_rec_id(self.uiw_keys_window()) // the window the keys go to (0 = main)
596        );
597        out.extend_from_slice(s.as_bytes());
598    }
599
600    // dump_window(i): {"window":ID,"title":"..","flags":"tool","rect":[x,y,w,h],"size":[w,h],
601    // "scale":S,"active":0}
602    fn dump_window(&self, out: &mut Vec<u8>, i: u32) {
603        let rec = &self.m.win.recs[i as usize]; // ui-win: Rec holds shared values
604        let mut title = Vec::new();
605        json_escape(&mut title, self.uiw_title(i).as_bytes(), DUMP_TITLE_MAX);
606        let mut flags = String::new();
607        for (b, n) in WFLAG_NAMES.iter().enumerate() {
608            if rec.flags & (1 << b) != 0 {
609                if !flags.is_empty() {
610                    flags.push(' ');
611                }
612                flags.push_str(n);
613            }
614        }
615        let dpi = self.win_dpi(i);
616        let fb = self.win_fb(i);
617        out.extend_from_slice(alloc::format!("{{\"window\":{},\"title\":\"", rec.id).as_bytes());
618        out.extend_from_slice(&title);
619        let r = rec.rect.get(); // ui-win: the WINRECT (the app's)
620        let s = alloc::format!(
621            "\",\"flags\":\"{}\",\"rect\":[{},{},{},{}],\"size\":[{},{}],\"scale\":{},\"active\":{},\"focus\":{}}}\n",
622            flags,
623            r.x,
624            r.y,
625            r.w,
626            r.h,
627            px2dip_at(fb.w, dpi),
628            px2dip_at(fb.h, dpi),
629            ((dpi as u32).wrapping_mul(100).wrapping_add(48)) / 96,
630            (i == self.m.win.active) as u32,
631            self.uiw_parts(i).map_or(0, |p| p.ctx.focus)
632        );
633        out.extend_from_slice(s.as_bytes());
634    }
635
636    // dump_record(sem): one record line
637    fn dump_record(&self, out: &mut Vec<u8>, r: &Sem) {
638        let mut st = String::new();
639        for (k, n) in STATE_NAMES.iter().enumerate() {
640            if r.state & (1 << k) != 0 {
641                if !st.is_empty() {
642                    st.push(',');
643                }
644                st.push('"');
645                st.push_str(n);
646                st.push('"');
647            }
648        }
649        let mut lab = Vec::new();
650        json_escape(&mut lab, r.label.as_deref().unwrap_or("").as_bytes(), DUMP_TEXT_MAX);
651        // rect in DIP (at the dpi of the record's window)
652        let dpi = self.win_dpi(r.win as u32);
653        let kind = kind_name(r.kind);
654        out.extend_from_slice(
655            alloc::format!("{{\"l\":{},\"d\":{},\"id\":{},\"k\":\"{}\",\"t\":\"", r.layer, r.depth, r.id, kind).as_bytes(),
656        );
657        out.extend_from_slice(&lab);
658        let mut s = alloc::format!(
659            "\",\"r\":[{},{},{},{}],\"s\":[{}],\"v\":{},\"max\":{}",
660            px2dip_at(r.rect[0], dpi),
661            px2dip_at(r.rect[1], dpi),
662            px2dip_at(r.rect[2], dpi),
663            px2dip_at(r.rect[3], dpi),
664            st,
665            r.value,
666            r.max
667        );
668        if r.win != 0 {
669            // records of other windows: "w":ID
670            let _ = write!(s, ",\"w\":{}", self.uiw_rec_id(r.win as u32));
671        }
672        s.push_str("}\n");
673        out.extend_from_slice(s.as_bytes());
674    }
675
676    // ------------------------------------------------------------------ script runner
677
678    /// ui_script_run(path) -> exit code
679    pub(crate) fn script_run(&mut self, app: &mut dyn App, path: &str) -> i32 {
680        let Some(data) = super::perm::trusted(|| plat::read_file(path)) else {
681            plat::write_stderr(alloc::format!("error: cannot read script {}\n", path).as_bytes());
682            return 2;
683        };
684        self.sc.fails = 0;
685        self.sc.parse_err = false;
686        self.sc.lineno = 0;
687        self.dup_check = true;
688        let mut pos = 0;
689        while pos < data.len() && data[pos] != 0 {
690            self.sc.lineno += 1;
691            // the next line (without CR / LF, at most LINE_MAX - 1 bytes)
692            let mut line = Vec::new();
693            while pos < data.len() && data[pos] != 0 {
694                let c = data[pos];
695                pos += 1;
696                if c == b'\n' {
697                    break;
698                }
699                if c == b'\r' {
700                    continue;
701                }
702                if line.len() < LINE_MAX - 1 {
703                    line.push(c);
704                }
705            }
706            if self.script_exec(app, &line) {
707                break; // quit
708            }
709            if self.sc.parse_err {
710                break;
711            }
712        }
713        if self.sc.parse_err {
714            2
715        } else if self.sc.fails != 0 {
716            1
717        } else {
718            0
719        }
720    }
721
722    // fail(detail): report a failed command
723    pub(crate) fn fail(&mut self, detail: &[u8]) {
724        self.fail_more(detail);
725        plat::write_stdout(b"\n");
726    }
727
728    // fail_more(detail): like fail, but the line is finished by the caller
729    fn fail_more(&mut self, detail: &[u8]) {
730        self.sc.fails += 1;
731        let mut o = alloc::format!("FAIL line {}: ", self.sc.lineno).into_bytes();
732        o.extend_from_slice(&self.sc.line);
733        o.extend_from_slice(b" -> ");
734        o.extend_from_slice(detail);
735        plat::write_stdout(&o);
736    }
737
738    // sel_fail: report a selector that did not resolve
739    pub(crate) fn sel_fail(&mut self) {
740        let m: &[u8] = match self.sc.sel_err {
741            2 => b"ambiguous selector",
742            3 => b"bad selector",
743            _ => b"no such widget",
744        };
745        self.fail(m);
746    }
747
748    // script_exec(line) -> true = quit
749    fn script_exec(&mut self, app: &mut dyn App, line: &[u8]) -> bool {
750        self.sc.line = line.to_vec(); // a copy of the line for messages
751        let l = skip_ws(line);
752        if l.is_empty() || l[0] == b'#' {
753            return false;
754        }
755        const CMDS: [&str; 58] = [
756            "a11y", "quit", "print", "size", "theme", "move", "down", "up", "click", "rclick", "dblclick", "drag", "wheel",
757            "key", "keys", "keydown", "keyup", "type", "commit", "preedit", "cmd", "wait", "shot", "dump", "expect", "bench", "window", "close",
758            "movewin", "resizewin", "activate", "scale", "sync", "expect_warn", "expect_nowarn", "focus", "drop",
759            "clip", "pointer", "system", "dragover", "dragleave", "dragout", "screen", "perm", "agent", "inspect",
760            "style", "sensor", "location", "tap", "longpress", "swipe", "flick", "finger", "pinch", "pan2", "gpu",
761        ];
762        let Some(&c) = CMDS.iter().find(|c| word_eq(l, c).is_some()) else {
763            let mut o = alloc::format!("error: line {}: unknown command: ", self.sc.lineno).into_bytes();
764            o.extend_from_slice(l);
765            o.push(b'\n');
766            plat::write_stderr(&o);
767            self.sc.parse_err = true;
768            return false;
769        };
770        let a = next_word(l);
771        match c {
772            "quit" => return true,
773            "a11y" => self.sc_a11y(app, a), // a11y_auto.rs
774            "system" => self.sc_system(app, a),
775            "sensor" => self.sc_sensor(app, a),
776            "location" => self.sc_location(app, a),
777            "print" => {
778                let mut o = a.to_vec();
779                o.push(b'\n');
780                plat::write_stdout(&o);
781            }
782            "size" => {
783                let (w, r) = parse_int(a);
784                let (h, _) = parse_int(r);
785                let (w, h) = (self.dp(w), self.dp(h));
786                self.resize(app, w, h);
787            }
788            "theme" => {
789                if let Some(r) = word_eq(a, "file") {
790                    // an app theme from a file; an error is remembered (expect theme error)
791                    let path = String::from(String::from_utf8_lossy(skip_ws(r)).trim());
792                    let res = super::perm::trusted(|| self.load_theme(&path));
793                    self.sc.theme_err = res.err().map(|e| {
794                        plat::write_stdout(alloc::format!("theme: {}\n", e).as_bytes());
795                        e.msg
796                    });
797                } else if word_eq(a, "none").is_some() {
798                    self.clear_theme();
799                } else if word_eq(a, "contrast").is_some() {
800                    self.set_contrast(Some(true));
801                } else {
802                    self.set_contrast(Some(false));
803                    let dark = word_eq(a, "dark").is_some();
804                    self.theme_set(dark as u32);
805                }
806                self.cmd_run(UIC_THEME, 0);
807                self.run_passes(app);
808            }
809            "move" => match self.resolve(a).0 {
810                None => self.sel_fail(),
811                Some(_) => {
812                    let (x, y) = (self.sc.sel_x, self.sc.sel_y);
813                    self.mouse_to(app, x, y);
814                }
815            },
816            "down" => self.sc_button(app, a, plat::E_DOWN),
817            "up" => self.sc_button(app, a, plat::E_UP),
818            "click" => {
819                let (s, rest) = self.resolve(a);
820                if s.is_none() {
821                    self.sel_fail();
822                } else {
823                    // optional words: the button (l / r / m), shift ctrl alt held during the click
824                    let saved_mods = self.sc.script_mods;
825                    let mut mods = saved_mods;
826                    let mut b = BTN_LEFT;
827                    let mut q = skip_ws(rest);
828                    while !q.is_empty() {
829                        let m = mod_word(q);
830                        if m != 0 {
831                            mods |= m;
832                        } else {
833                            b = parse_button(q);
834                        }
835                        q = next_word(q);
836                    }
837                    let (x, y) = (self.sc.sel_x, self.sc.sel_y);
838                    self.mouse_to(app, x, y);
839                    self.sc.script_mods = mods;
840                    self.click_here(app, b);
841                    self.sc.script_mods = saved_mods;
842                }
843            }
844            "rclick" => {
845                if self.resolve(a).0.is_none() {
846                    self.sel_fail();
847                } else {
848                    let (x, y) = (self.sc.sel_x, self.sc.sel_y);
849                    self.mouse_to(app, x, y);
850                    self.click_here(app, BTN_RIGHT);
851                }
852            }
853            "dblclick" => {
854                let (s, rest) = self.resolve(a);
855                if s.is_none() {
856                    self.sel_fail();
857                } else {
858                    // optional words: shift ctrl alt, held during both clicks
859                    let saved_mods = self.sc.script_mods;
860                    let mut mods = saved_mods;
861                    let mut q = skip_ws(rest);
862                    while !q.is_empty() {
863                        let m = mod_word(q);
864                        if m == 0 {
865                            let mut d = b"unknown dblclick option ".to_vec();
866                            d.extend_from_slice(q);
867                            self.fail(&d);
868                            return false;
869                        }
870                        mods |= m;
871                        q = next_word(q);
872                    }
873                    let (x, y) = (self.sc.sel_x, self.sc.sel_y);
874                    self.mouse_to(app, x, y);
875                    self.sc.script_mods = mods;
876                    self.click_here(app, BTN_LEFT);
877                    self.advance_time(app, 50);
878                    self.click_here(app, BTN_LEFT);
879                    self.sc.script_mods = saved_mods;
880                }
881            }
882            "drag" => self.sc_drag(app, a),
883            "wheel" => self.sc_wheel(app, a),
884            "tap" => self.sc_tap(app, a, 0),
885            "longpress" => self.sc_tap(app, a, 600),
886            "swipe" => self.sc_swipe(app, a),
887            "flick" => self.sc_flick(app, a),
888            "finger" => self.sc_finger(app, a),
889            "pinch" => self.sc_two(app, a, true),
890            "pan2" => self.sc_two(app, a, false),
891            "key" => self.sc_key(app, a),
892            "keydown" => self.sc_keyraw(app, a, true),
893            "keyup" => self.sc_keyraw(app, a, false),
894            "keys" => self.sc_keys(app, a),
895            "type" => self.sc_type(app, a),
896            "commit" => self.sc_commit(app, a),
897            "preedit" => self.sc_preedit(app, a),
898            "cmd" => {
899                let (id, r) = parse_int(a);
900                let (arg, _) = parse_int(r);
901                self.cmd_run(id as u32, arg as u32);
902                self.run_passes(app);
903            }
904            "wait" => {
905                if let Some(r) = word_eq(a, "real") {
906                    let (ms, _) = parse_int(r);
907                    self.wait_real(app, ms);
908                } else {
909                    let (ms, _) = parse_int(a);
910                    self.advance_time(app, ms);
911                }
912            }
913            "shot" => self.sc_shot(app, a),
914            "dump" => self.dump(if a.is_empty() { None } else { Some(a) }),
915            "expect" => self.sc_expect(a),
916            "bench" => self.sc_bench(app, a),
917            "window" => {
918                self.sc.sc_scope = if word_eq(a, "any").is_some() {
919                    -1
920                } else if word_eq(a, "main").is_some() {
921                    0
922                } else {
923                    parse_int(a).0
924                };
925            }
926            "close" => self.sc_close(app, a),
927            "movewin" => {
928                let (i, r) = self.window_arg(a);
929                if i <= 0 {
930                    self.fail(b"no such window");
931                } else {
932                    let (x, r) = parse_int(r);
933                    let (y, _) = parse_int(r);
934                    // the user's drag ends where the platform's move hook snaps it to
935                    let (x, y) = self.uiw_snap_dip(i as u32, x, y);
936                    self.uiw_user_rect(i as u32, x, y, 0, 0);
937                    self.run_passes(app);
938                }
939            }
940            "resizewin" => {
941                let (i, r) = self.window_arg(a);
942                if i < 0 {
943                    self.fail(b"no such window");
944                } else {
945                    let (w, r) = parse_int(r);
946                    let (h, _) = parse_int(r);
947                    if i == 0 {
948                        // the main window: like `size`
949                        let (w, h) = (self.dp(w), self.dp(h));
950                        self.resize(app, w, h);
951                    } else {
952                        self.uiw_user_rect(i as u32, i32::MIN, 0, w, h);
953                        self.run_passes(app);
954                    }
955                }
956            }
957            "activate" => {
958                let (i, _) = self.window_arg(a);
959                if i < 0 {
960                    self.fail(b"no such window");
961                } else {
962                    let id = self.uiw_rec_id(i as u32);
963                    self.window_activate(id);
964                    self.run_passes(app);
965                }
966            }
967            "scale" => self.sc_scale(app, a),
968            "screen" => {
969                // (§6) the virtual screen: X Y W H (DIP) or off
970                self.m.win.screen = if word_eq(a, "off").is_some() {
971                    [0; 4]
972                } else {
973                    let (x, r) = parse_int(a);
974                    let (y, r) = parse_int(r);
975                    let (w, r) = parse_int(r);
976                    let (h, _) = parse_int(r);
977                    [x, y, w.max(0), h.max(0)]
978                };
979                self.invalidate();
980                self.run_passes(app);
981            }
982            "sync" => self.sc_sync(app, a),
983            "style" => {
984                // another linked style (Ui::set_style)
985                let name = String::from_utf8_lossy(skip_ws(a)).into_owned();
986                if self.set_style(name.trim()) {
987                    self.run_passes(app);
988                } else {
989                    self.fail(b"no such style");
990                }
991            }
992            "gpu" => self.sc_gpu(app, a),     // gpu_out.rs
993            "perm" => self.sc_perm(app, a),   // auto_perm.rs
994            "agent" => self.sc_agent(app, a), // auto_perm.rs
995            "inspect" => self.sc_inspect(app, a), // inspect.rs
996            "expect_warn" => {
997                let (w, m) = (self.warns, self.sc.warn_mark);
998                self.sc.warn_mark = w;
999                if w == m {
1000                    self.fail(b"no warning");
1001                }
1002            }
1003            "expect_nowarn" => {
1004                let d = self.warns.wrapping_sub(self.sc.warn_mark);
1005                self.sc.warn_mark = self.warns;
1006                if d != 0 {
1007                    self.fail(alloc::format!("{} warnings", d as i32).as_bytes());
1008                }
1009            }
1010            "focus" => match self.resolve(a).0 {
1011                None => self.sel_fail(),
1012                Some(s) => {
1013                    let id = self.sel_rec(s).id;
1014                    if id == 0 {
1015                        self.fail(b"the widget has no id");
1016                    } else {
1017                        self.focus(id);
1018                        self.run_passes(app);
1019                    }
1020                }
1021            },
1022            "drop" => self.sc_drop(app, a),
1023            "dragover" => self.sc_dragover(app, a, false),
1024            "dragleave" => self.sc_dragover(app, a, true),
1025            "dragout" => self.sc_dragout(app, a),
1026            "clip" => self.sc_clip(app, a),
1027            "pointer" => self.sc_pointer(app, a),
1028            _ => {}
1029        }
1030        false
1031    }
1032
1033    // the record of a resolved selector (a point: the zero record)
1034    pub(crate) fn sel_rec(&self, s: Sel) -> Sem {
1035        match s {
1036            Sel::Rec(i) => self.sems[i].clone(),
1037            Sel::Point => Sem::default(),
1038        }
1039    }
1040
1041    // ------------------------------------------------------------------ selectors
1042
1043    // resolve(p) -> (the record (or a point), the rest after the selector); None: sel_err
1044    // holds the reason (1 none, 2 ambiguous, 3 syntax).  The point to use is left in
1045    // sel_x / sel_y (px).
1046    pub(crate) fn resolve<'a>(&mut self, p: &'a [u8]) -> (Option<Sel>, &'a [u8]) {
1047        self.sc.sel_err = 3;
1048        self.sc.sel_kind = -1;
1049        self.sc.sel_wf = self.scope_index(); // window filter (-1 = any)
1050        self.sc.sel_win = 0;
1051        let c0 = p.first().copied().unwrap_or(0);
1052        if c0 == b'#' {
1053            return self.resolve_id(p);
1054        }
1055        let mut q = p;
1056        if c0 != b'"' {
1057            if c0.is_ascii_digit() || c0 == b'-' {
1058                return self.resolve_point(p);
1059            }
1060            // kind:"Label"
1061            let mut found = false;
1062            for k in 0..K_KIND_END {
1063                let n = kind_name(k);
1064                if p.starts_with(n.as_bytes()) && p.get(n.len()) == Some(&b':') {
1065                    self.sc.sel_kind = k as i32;
1066                    q = &p[n.len() + 1..];
1067                    found = true;
1068                    break;
1069                }
1070            }
1071            if !found || q.first() != Some(&b'"') {
1072                self.sc.sel_err = 3;
1073                return (None, p);
1074            }
1075        }
1076        self.resolve_label(p, q)
1077    }
1078
1079    // a point of the scoped window (the main window for "any")
1080    fn resolve_point<'a>(&mut self, p: &'a [u8]) -> (Option<Sel>, &'a [u8]) {
1081        let (x, r) = parse_int(p);
1082        if r.first() != Some(&b',') {
1083            self.sc.sel_err = 3;
1084            return (None, p);
1085        }
1086        let (y, r) = parse_int(&r[1..]);
1087        let w = self.sc.sel_wf.max(0) as u32;
1088        self.sc.sel_win = w;
1089        let dpi = self.win_dpi(w);
1090        self.sc.sel_x = dp_at(x, dpi);
1091        self.sc.sel_y = dp_at(y, dpi);
1092        (Some(Sel::Point), r)
1093    }
1094
1095    // sel_win_ok(rec): the record passes the window filter sel_wf
1096    fn sel_win_ok(&self, r: &Sem) -> bool {
1097        self.sc.sel_wf < 0 || self.sc.sel_wf as u8 == r.win
1098    }
1099
1100    fn resolve_id<'a>(&mut self, p: &'a [u8]) -> (Option<Sel>, &'a [u8]) {
1101        let q = &p[1..];
1102        let (id, rest) = if q.len() >= 2 && q[0] == b'0' && q[1] == b'x' {
1103            parse_hex(q)
1104        } else {
1105            let (v, r) = parse_int(q);
1106            (v as u32, r)
1107        };
1108        // windows that have this id: one expected
1109        let mut mask = 0u32;
1110        for r in self.sems.iter().rev() {
1111            if r.id == id && self.sel_win_ok(r) {
1112                mask |= 1 << (r.win as u32 & (WIN_MAX as u32 - 1));
1113            }
1114        }
1115        if mask == 0 {
1116            self.sc.sel_err = 1;
1117            return (None, rest);
1118        }
1119        if mask & (mask - 1) != 0 {
1120            self.warn(&alloc::format!("duplicate id {} in several windows (mask {:x})", id, mask));
1121            self.sc.sel_wf = self.win_topmost(mask) as i32;
1122        }
1123        // last record with this id
1124        let found = (0..self.sems.len()).rev().find(|&i| self.sems[i].id == id && self.sel_win_ok(&self.sems[i]));
1125        match found {
1126            Some(i) => {
1127                self.sel_found(i);
1128                (Some(Sel::Rec(i)), rest)
1129            }
1130            None => {
1131                self.sc.sel_err = 1;
1132                (None, rest)
1133            }
1134        }
1135    }
1136
1137    fn label_match(&self, r: &Sem) -> bool {
1138        let Some(l) = r.label.as_deref() else { return false };
1139        if !self.sel_win_ok(r) {
1140            return false;
1141        }
1142        if self.sc.sel_kind >= 0 && r.kind as i32 != self.sc.sel_kind {
1143            return false;
1144        }
1145        l.as_bytes() == self.sc.sel_label.as_slice()
1146    }
1147
1148    fn resolve_label<'a>(&mut self, p: &'a [u8], q: &'a [u8]) -> (Option<Sel>, &'a [u8]) {
1149        let Some((label, rest)) = parse_qstring(q, LINE_MAX) else {
1150            self.sc.sel_err = 3;
1151            return (None, p);
1152        };
1153        self.sc.sel_label = label;
1154        // matches in several windows: only the topmost window counts
1155        let mask = self.label_windows();
1156        if mask & mask.wrapping_sub(1) != 0 {
1157            self.sc.sel_wf = self.win_topmost(mask) as i32;
1158        }
1159        // count matches (all) and non-text matches
1160        let (mut n, mut nt) = (0, 0);
1161        let (mut last, mut last_nt) = (0, 0);
1162        for i in 0..self.sems.len() {
1163            if !self.label_match(&self.sems[i]) {
1164                continue;
1165            }
1166            n += 1;
1167            last = i;
1168            if self.sems[i].kind != K_TEXT {
1169                nt += 1;
1170                last_nt = i;
1171            }
1172        }
1173        if n == 0 {
1174            self.sc.sel_err = 1;
1175            return (None, rest);
1176        }
1177        let pick = if n == 1 {
1178            Some(last)
1179        } else if nt == 1 {
1180            Some(last_nt)
1181        } else {
1182            // still ambiguous: a single match on the topmost layer wins (dialogs and popups
1183            // cover everything below them)
1184            self.top_layer_match()
1185        };
1186        match pick {
1187            Some(i) => {
1188                self.sel_found(i);
1189                (Some(Sel::Rec(i)), rest)
1190            }
1191            None => {
1192                self.sc.sel_err = 2;
1193                (None, rest)
1194            }
1195        }
1196    }
1197
1198    fn sel_found(&mut self, i: usize) {
1199        let r = &self.sems[i];
1200        self.sc.sel_err = 0;
1201        self.sc.sel_win = r.win as u32 & (WIN_MAX as u32 - 1);
1202        self.sc.sel_x = (r.rect[2] >> 1).wrapping_add(r.rect[0]);
1203        self.sc.sel_y = (r.rect[3] >> 1).wrapping_add(r.rect[1]);
1204    }
1205
1206    // top_layer_match -> the only non-text label match on the highest layer that has
1207    // matches
1208    fn top_layer_match(&self) -> Option<usize> {
1209        let mut top = -1;
1210        let mut n = 0;
1211        let mut m = 0;
1212        for i in 0..self.sems.len() {
1213            let r = &self.sems[i];
1214            if r.kind == K_TEXT || !self.label_match(r) {
1215                continue;
1216            }
1217            let l = r.layer as i32;
1218            if l < top {
1219                continue;
1220            }
1221            if l > top {
1222                top = l;
1223                n = 0;
1224            }
1225            n += 1;
1226            m = i;
1227        }
1228        if n == 1 { Some(m) } else { None }
1229    }
1230
1231    // label_windows -> bit mask of the windows with a match (controls; plain text only
1232    // when there is no control)
1233    fn label_windows(&self) -> u32 {
1234        let (mut ctl, mut any) = (0u32, 0u32);
1235        for r in &self.sems {
1236            if !self.label_match(r) {
1237                continue;
1238            }
1239            let b = 1 << (r.win as u32 & (WIN_MAX as u32 - 1));
1240            any |= b;
1241            if r.kind != K_TEXT {
1242                ctl |= b;
1243            }
1244        }
1245        if ctl != 0 { ctl } else { any }
1246    }
1247
1248    // win_topmost(mask) -> the topmost of the windows: the modal window, else the highest
1249    // stacking order
1250    fn win_topmost(&self, mask: u32) -> u32 {
1251        let modal = self.m.win.modal;
1252        if modal != 0 && mask & (1 << modal) != 0 {
1253            return modal;
1254        }
1255        let mut best = 0;
1256        let mut z: i64 = -1;
1257        for i in 0..WIN_MAX as u32 {
1258            if mask & (1 << i) == 0 {
1259                continue;
1260            }
1261            let wz = self.m.win.recs[i as usize].z as i32 as i64;
1262            if wz > z {
1263                z = wz;
1264                best = i;
1265            }
1266        }
1267        best
1268    }
1269
1270    // scope_index -> window index of the `window` scope, -1 = any, -2 = unknown window
1271    fn scope_index(&self) -> i32 {
1272        if self.sc.sc_scope == -1 {
1273            return -1;
1274        }
1275        let i = self.uiw_index(self.sc.sc_scope as u32);
1276        if i < 0 { -2 } else { i }
1277    }
1278
1279    // window_arg(p) -> (window index for "ID" / "main" (-1 = unknown or closed), rest)
1280    fn window_arg<'a>(&self, p: &'a [u8]) -> (i32, &'a [u8]) {
1281        let p = skip_ws(p);
1282        if let Some(r) = word_eq(p, "main") {
1283            return (0, r);
1284        }
1285        let (id, r) = parse_int(p);
1286        let i = self.uiw_index(id as u32);
1287        if i < 0 {
1288            return (i, r);
1289        }
1290        if self.m.win.recs[i as usize].state == 0 {
1291            return (-1, r);
1292        }
1293        (i, r)
1294    }
1295
1296    // ------------------------------------------------------------------ input injection
1297
1298    // inject(type, btn, x, y, data): queue one event (current mods) and process it; keys
1299    // and text go to the keyboard window, pointer events to the script's window
1300    // system focus in | out, suspend, resume, background, foreground (the browser's hidden tab,
1301    // Android's onStop / onStart), session-end, settings theme | locale | contrast | fonts |
1302    // other | N: the lifecycle events of the platform (UIC_ACTIVE, UIC_SUSPEND, ...);
1303    // fullscreen on | off [ID | main]: the platform changed the window's full screen (L7);
1304    // env NAME VALUE | -: a jfgui_* variable of the run
1305    fn sc_system(&mut self, app: &mut dyn App, a: &[u8]) {
1306        let w = |n: &str| word_eq(a, n).is_some();
1307        if w("env") {
1308            let r = next_word(a);
1309            let name_len = r.iter().position(|&c| c == b' ' || c == b'\t').unwrap_or(r.len());
1310            let name = String::from_utf8_lossy(&r[..name_len]).into_owned();
1311            let value = String::from(String::from_utf8_lossy(skip_ws(&r[name_len..])).trim_end());
1312            if !name.starts_with("jfgui_") || value.is_empty() {
1313                self.fail(b"system env: a jfgui_* NAME and a VALUE (- removes it)");
1314                return;
1315            }
1316            plat::setenv(&name, if value == "-" { None } else { Some(&value) });
1317            if name == "jfgui_FONT_FALLBACK" {
1318                // the system fonts for missing characters from here on (a run reads the variable
1319                // at its start: headless runs start without them, so shots match everywhere)
1320                self.text.set_system_fallback(value.starts_with('1'));
1321                self.dirty_force_full();
1322            }
1323            self.run_passes(app);
1324            return;
1325        }
1326        if w("browser") {
1327            // (E13) what ui.default_browser answers (the user chose in the system's settings)
1328            let r = next_word(a);
1329            let s = if word_eq(r, "default").is_some() {
1330                plat::BrowserStatus::Default
1331            } else if word_eq(r, "other").is_some() {
1332                plat::BrowserStatus::NotDefault
1333            } else if word_eq(r, "unknown").is_some() {
1334                plat::BrowserStatus::Unknown
1335            } else {
1336                self.fail(b"system browser: default, other or unknown");
1337                return;
1338            };
1339            super::assoc::browser_script_set(s);
1340            // the user chose in the system's settings and came back: the app loses the keyboard
1341            // (when it had it) and gets it again, UIC_ACTIVE 0 and 1 -- an app that keeps the
1342            // status (GUIDE section 21) asks again, as after a real change
1343            let had = self.app_focus;
1344            let back = if had != 0 { had.trailing_zeros() } else { self.sc.sc_win };
1345            let mut f = had;
1346            while f != 0 {
1347                let w = f.trailing_zeros();
1348                f &= f - 1;
1349                self.push_window_event(plat::E_FOCUS_OUT, w);
1350            }
1351            if had != 0 {
1352                self.process_events(app);
1353            }
1354            self.push_window_event(plat::E_FOCUS_IN, back);
1355            self.process_events(app);
1356            self.run_passes(app);
1357            return;
1358        }
1359        if w("fullscreen") {
1360            let r = next_word(a);
1361            let on = word_eq(r, "on").is_some();
1362            if !on && word_eq(r, "off").is_none() {
1363                self.fail(b"system fullscreen: on or off");
1364                return;
1365            }
1366            let rest = next_word(r);
1367            let i = if skip_ws(rest).is_empty() { 0 } else { self.window_arg(rest).0 };
1368            if i < 0 {
1369                self.fail(b"no such window");
1370                return;
1371            }
1372            if self.m.win.recs[i as usize].fs != on {
1373                self.uiw_fs_changed(i as u32, on);
1374            }
1375            self.run_passes(app);
1376            return;
1377        }
1378        let (typ, data) = if w("focus") {
1379            let r = next_word(a);
1380            (if word_eq(r, "out").is_some() { plat::E_FOCUS_OUT } else { plat::E_FOCUS_IN }, 0)
1381        } else if w("suspend") {
1382            (plat::E_POWER, 1)
1383        } else if w("resume") {
1384            (plat::E_POWER, 0)
1385        } else if w("background") {
1386            (plat::E_POWER, 2) // the browser's hidden tab, Android's onStop
1387        } else if w("foreground") {
1388            (plat::E_POWER, 3)
1389        } else if w("session-end") {
1390            (plat::E_SESSION_END, 0)
1391        } else if w("settings") {
1392            let r = next_word(a);
1393            let bits = [("theme", plat::SET_THEME), ("locale", plat::SET_LOCALE), ("contrast", plat::SET_CONTRAST), ("fonts", plat::SET_FONTS), ("other", plat::SET_OTHER), ("monitors", plat::SET_MONITORS)]
1394                .iter()
1395                .find(|(n, _)| word_eq(r, n).is_some())
1396                .map_or_else(|| parse_int(r).0 as u32, |(_, b)| *b);
1397            (plat::E_SETTINGS, bits)
1398        } else if w("hotkey") {
1399            // a registered global shortcut pressed (screen.rs: UIC_HOTKEY)
1400            let id = parse_int(next_word(a)).0 as u32;
1401            if !plat::hotkey::hotkey_test_press(id) {
1402                self.fail(b"system hotkey: no global shortcut with this id");
1403                return;
1404            }
1405            self.inject(app, plat::E_WAKE, 0, 0, 0, 0);
1406            self.run_passes(app);
1407            return;
1408        } else if w("pick") {
1409            // the user's answer to a running colour pick: RRGGBB or cancel (screen.rs)
1410            let r = next_word(a);
1411            if !self.m.screen.picking {
1412                self.fail(b"system pick: no colour pick is running");
1413                return;
1414            }
1415            let ans = if word_eq(r, "cancel").is_some() { Err(plat::screen::ScreenError::Cancelled) } else { Ok(super::runtime::parse_hex(r).0 & 0xFF_FFFF) };
1416            self.pick_done(ans);
1417            self.run_passes(app);
1418            return;
1419        } else {
1420            self.fail(b"system: focus in|out, suspend, resume, session-end, settings WHAT, hotkey ID, pick RRGGBB|cancel or fullscreen on|off");
1421            return;
1422        };
1423        self.inject(app, typ, 0, 0, 0, data);
1424    }
1425
1426    // queue an event of window w without a position (focus in / out), processed by the caller
1427    fn push_window_event(&mut self, typ: u8, w: u32) {
1428        let ev = Event { typ, btn: 0, mods: self.sc.script_mods as u8, win: (w & (WIN_MAX as u32 - 1)) as u8, x: 0, y: 0, data: 0 };
1429        plat::evq_push(ev);
1430    }
1431
1432    fn inject(&mut self, app: &mut dyn App, typ: u8, btn: u32, x: i32, y: i32, data: u32) {
1433        let w = if matches!(typ, plat::E_KEY_DOWN | plat::E_KEY_UP | plat::E_TEXT) { self.m.win.active } else { self.sc.sc_win };
1434        // a finger: its id in the button (plat::finger_event), the sample is new
1435        let finger = matches!(typ, plat::E_MOVE | plat::E_DOWN | plat::E_UP) && (data >> plat::PD_TYPE_SHIFT) & 3 == PTR_TOUCH;
1436        let btn = if finger { self.sc.finger_id } else { btn };
1437        let ev = Event {
1438            typ,
1439            btn: btn as u8,
1440            mods: self.sc.script_mods as u8,
1441            win: (w & (WIN_MAX as u32 - 1)) as u8,
1442            x,
1443            y,
1444            data,
1445        };
1446        plat::evq_push(ev);
1447        self.process_events(app);
1448    }
1449
1450    // mouse_to(x, y): move the pointer (px of window sel_win)
1451    fn mouse_to(&mut self, app: &mut dyn App, x: i32, y: i32) {
1452        self.sc.sc_win = self.sc.sel_win;
1453        self.sc.sc_x = x;
1454        self.sc.sc_y = y;
1455        let d = self.sc.sc_pdata;
1456        self.inject(app, plat::E_MOVE, 0, x, y, d);
1457    }
1458
1459    // click_here(btn): press + release at the pointer
1460    fn click_here(&mut self, app: &mut dyn App, btn: u32) {
1461        let (x, y, d) = (self.sc.sc_x, self.sc.sc_y, self.sc.sc_pdata);
1462        self.inject(app, plat::E_DOWN, btn, x, y, d);
1463        let (x, y, d) = (self.sc.sc_x, self.sc.sc_y, self.sc.sc_pdata);
1464        self.inject(app, plat::E_UP, btn, x, y, d);
1465    }
1466
1467    // down S [btn] / up [S] [btn]
1468    fn sc_button(&mut self, app: &mut dyn App, a: &[u8], typ: u8) {
1469        let mut rest = a;
1470        let c = a.first().map(|c| c | 32);
1471        if !a.is_empty() && !matches!(c, Some(b'l' | b'r' | b'm')) {
1472            let (s, r) = self.resolve(a);
1473            if s.is_none() {
1474                self.sel_fail();
1475                return;
1476            }
1477            let (x, y) = (self.sc.sel_x, self.sc.sel_y);
1478            self.mouse_to(app, x, y);
1479            rest = skip_ws(r);
1480        }
1481        let b = parse_button(rest);
1482        let (x, y, d) = (self.sc.sc_x, self.sc.sc_y, self.sc.sc_pdata);
1483        self.inject(app, typ, b, x, y, d);
1484    }
1485
1486    // drag S1 S2 [steps] [words]
1487    fn sc_drag(&mut self, app: &mut dyn App, a: &[u8]) {
1488        let mut pen_type = 0u32;
1489        let (mut p0, mut p1) = (0i32, 0i32);
1490        let saved_pdata = self.sc.sc_pdata;
1491        let (s, r) = self.resolve(a);
1492        if s.is_none() {
1493            self.sel_fail();
1494            return;
1495        }
1496        let (bx, by) = (self.sc.sel_x, self.sc.sel_y);
1497        let press_win = self.sc.sel_win; // the window of the press
1498        let (s, r) = self.resolve(skip_ws(r));
1499        if s.is_none() {
1500            self.sel_fail();
1501            return;
1502        }
1503        // the pointer stays captured by the first window
1504        self.point_to_window(self.sc.sel_win, press_win);
1505        self.sc.sel_win = press_win;
1506        let (ex, ey) = (self.sc.sel_x, self.sc.sel_y);
1507        let mut q = skip_ws(r);
1508        let mut steps = 8;
1509        // optional step count
1510        if q.first().is_some_and(|c| c.is_ascii_digit()) {
1511            let (n, r) = parse_int(q);
1512            if n > 0 {
1513                steps = n;
1514            }
1515            q = r;
1516        }
1517        // optional words: shift ctrl alt (held during the drag), right / middle (button), hold MS
1518        let mut btn = BTN_LEFT;
1519        let mut hold = 0;
1520        let saved_mods = self.sc.script_mods;
1521        let mut mods = saved_mods;
1522        loop {
1523            let w = skip_ws(q);
1524            if w.is_empty() {
1525                break;
1526            }
1527            let mut after = w;
1528            let m = mod_word(w);
1529            if m != 0 {
1530                mods |= m;
1531            } else if w.starts_with(b"right") {
1532                btn = BTN_RIGHT;
1533            } else if w.starts_with(b"middle") {
1534                btn = BTN_MIDDLE;
1535            } else if w.starts_with(b"pen") || w.starts_with(b"eraser") {
1536                // (PN1) pen|eraser P0 P1
1537                pen_type = if w.starts_with(b"pen") { PTR_PEN } else { PTR_ERASER };
1538                let (a0, r) = parse_pct(next_word(w));
1539                let (a1, r) = parse_pct(skip_ws(r));
1540                p0 = a0;
1541                p1 = a1;
1542                after = r;
1543            } else if let Some(r) = word_eq(w, "hold") {
1544                let (ms, r) = parse_int(skip_ws(r));
1545                hold = ms;
1546                after = r;
1547            } else {
1548                let mut d = b"unknown drag option ".to_vec();
1549                d.extend_from_slice(w);
1550                self.fail(&d);
1551                return;
1552            }
1553            // skip the word
1554            let i = after.iter().position(|&c| c <= 32).unwrap_or(after.len());
1555            q = &after[i..];
1556        }
1557        self.sc.script_mods = mods;
1558        if pen_type != 0 {
1559            self.sc.sc_pdata = plat::evq_pen_data(p0, pen_type, 0, 0); // pressure P0 at the press
1560        }
1561        self.mouse_to(app, bx, by);
1562        let d = self.sc.sc_pdata;
1563        self.inject(app, plat::E_DOWN, btn, bx, by, d);
1564        self.advance_time(app, hold);
1565        for i in 1..=steps {
1566            if pen_type != 0 {
1567                // (PN1) P0 + (P1 - P0) * i / steps
1568                let pr = (p1 - p0).wrapping_mul(i) / steps + p0;
1569                self.sc.sc_pdata = plat::evq_pen_data(pr, pen_type, 0, 0);
1570            }
1571            // p = a + (b - a) * i / steps
1572            let x = bx + (ex - bx).wrapping_mul(i) / steps;
1573            let y = by + (ey - by).wrapping_mul(i) / steps;
1574            self.mouse_to(app, x, y);
1575            self.advance_time(app, 16);
1576        }
1577        let d = self.sc.sc_pdata;
1578        self.inject(app, plat::E_UP, btn, ex, ey, d);
1579        self.sc.script_mods = saved_mods;
1580        self.sc.sc_pdata = saved_pdata; // (PN1) the pointer's data as before
1581    }
1582
1583    // one raw finger event at (x, y) px of the window of the last selector
1584    fn finger_at(&mut self, app: &mut dyn App, typ: u8, x: i32, y: i32) {
1585        self.sc.sc_win = self.sc.sel_win;
1586        self.sc.sc_x = x;
1587        self.sc.sc_y = y;
1588        let btn = if typ == plat::E_MOVE { 0 } else { BTN_LEFT };
1589        self.inject(app, typ, btn, x, y, PTR_TOUCH << plat::PD_TYPE_SHIFT);
1590    }
1591
1592    // tap S / longpress S [MS]: a finger down and up at S, MS ms apart
1593    fn sc_tap(&mut self, app: &mut dyn App, a: &[u8], rest_ms: i32) {
1594        let (s, r) = self.resolve(a);
1595        if s.is_none() {
1596            self.sel_fail();
1597            return;
1598        }
1599        let (x, y) = (self.sc.sel_x, self.sc.sel_y);
1600        let q = skip_ws(r);
1601        let ms = if rest_ms > 0 && q.first().is_some_and(|c| c.is_ascii_digit()) { parse_int(q).0 } else { rest_ms };
1602        self.finger_at(app, plat::E_DOWN, x, y);
1603        self.advance_time(app, ms);
1604        self.finger_at(app, plat::E_UP, x, y);
1605    }
1606
1607    // a finger from b to e at an even speed in ms ms (a sample every 8 ms), then it lifts
1608    fn finger_path(&mut self, app: &mut dyn App, b: [i32; 2], e: [i32; 2], ms: i32, hold: bool) {
1609        self.finger_at(app, plat::E_DOWN, b[0], b[1]);
1610        let ms = ms.max(1);
1611        let steps = (ms / 8).max(1);
1612        for i in 1..=steps {
1613            self.advance_time(app, ms * i / steps - ms * (i - 1) / steps);
1614            let x = b[0] + (e[0] - b[0]).wrapping_mul(i) / steps;
1615            let y = b[1] + (e[1] - b[1]).wrapping_mul(i) / steps;
1616            self.finger_at(app, plat::E_MOVE, x, y);
1617        }
1618        if hold {
1619            self.advance_time(app, 100);
1620        }
1621        self.finger_at(app, plat::E_UP, e[0], e[1]);
1622    }
1623
1624    // swipe S1 S2 [MS] [hold]
1625    fn sc_swipe(&mut self, app: &mut dyn App, a: &[u8]) {
1626        let (s, r) = self.resolve(a);
1627        if s.is_none() {
1628            self.sel_fail();
1629            return;
1630        }
1631        let b = [self.sc.sel_x, self.sc.sel_y];
1632        let press_win = self.sc.sel_win;
1633        let (s, r) = self.resolve(skip_ws(r));
1634        if s.is_none() {
1635            self.sel_fail();
1636            return;
1637        }
1638        // the finger stays with the first window
1639        self.point_to_window(self.sc.sel_win, press_win);
1640        self.sc.sel_win = press_win;
1641        let e = [self.sc.sel_x, self.sc.sel_y];
1642        let mut q = skip_ws(r);
1643        let mut ms = 300;
1644        if q.first().is_some_and(|c| c.is_ascii_digit()) {
1645            let (n, r) = parse_int(q);
1646            ms = n;
1647            q = skip_ws(r);
1648        }
1649        self.finger_path(app, b, e, ms, word_eq(q, "hold").is_some());
1650    }
1651
1652    // flick S DX DY [MS]
1653    fn sc_flick(&mut self, app: &mut dyn App, a: &[u8]) {
1654        let (s, r) = self.resolve(a);
1655        if s.is_none() {
1656            self.sel_fail();
1657            return;
1658        }
1659        let b = [self.sc.sel_x, self.sc.sel_y];
1660        let (dx, r) = parse_int(r);
1661        let (dy, r) = parse_int(r);
1662        let q = skip_ws(r);
1663        let ms = if q.first().is_some_and(|c| c.is_ascii_digit()) { parse_int(q).0 } else { 60 };
1664        let dpi = self.win_dpi(self.sc.sel_win);
1665        let e = [b[0] + dp_at(dx, dpi), b[1] + dp_at(dy, dpi)];
1666        self.finger_path(app, b, e, ms, false);
1667    }
1668
1669    // finger down S | move S | up | cancel
1670    fn sc_finger(&mut self, app: &mut dyn App, a: &[u8]) {
1671        if a.first().is_some_and(|c| c.is_ascii_digit()) {
1672            let (n, r) = parse_int(a);
1673            self.sc.finger_id = n as u32;
1674            self.sc_finger(app, skip_ws(r));
1675            self.sc.finger_id = 0;
1676            return;
1677        }
1678        if word_eq(a, "up").is_some() || word_eq(a, "cancel").is_some() {
1679            let (x, y) = (self.sc.sc_x, self.sc.sc_y);
1680            let cancel = word_eq(a, "cancel").is_some();
1681            self.sc.sel_win = self.sc.sc_win;
1682            if cancel {
1683                self.inject(app, plat::E_UP, 0, x, y, PTR_TOUCH << plat::PD_TYPE_SHIFT | plat::PD_CANCEL);
1684            } else {
1685                self.finger_at(app, plat::E_UP, x, y);
1686            }
1687            return;
1688        }
1689        let (typ, s) = if let Some(r) = word_eq(a, "down") {
1690            (plat::E_DOWN, r)
1691        } else if let Some(r) = word_eq(a, "move") {
1692            (plat::E_MOVE, r)
1693        } else {
1694            self.fail(b"finger: down S, move S, up or cancel");
1695            return;
1696        };
1697        if self.resolve(skip_ws(s)).0.is_none() {
1698            self.sel_fail();
1699            return;
1700        }
1701        if typ == plat::E_MOVE {
1702            // the finger stays with its window
1703            self.point_to_window(self.sc.sel_win, self.sc.sc_win);
1704            self.sc.sel_win = self.sc.sc_win;
1705        }
1706        let (x, y) = (self.sc.sel_x, self.sc.sel_y);
1707        self.finger_at(app, typ, x, y);
1708    }
1709
1710    // pinch S D0 D1 [MS] / pan2 S DX DY [MS]: two fingers, ids 0 and 1, at the same time
1711    fn sc_two(&mut self, app: &mut dyn App, a: &[u8], pinch: bool) {
1712        let (s, r) = self.resolve(a);
1713        if s.is_none() {
1714            self.sel_fail();
1715            return;
1716        }
1717        let c = [self.sc.sel_x, self.sc.sel_y];
1718        let (p0, r) = parse_int(r);
1719        let (p1, r) = parse_int(r);
1720        let q = skip_ws(r);
1721        let ms = if q.first().is_some_and(|c| c.is_ascii_digit()) { parse_int(q).0 } else { 300 }.max(1);
1722        let dpi = self.win_dpi(self.sc.sel_win);
1723        // the half distance at the start and the end, the way of the point between them
1724        let (h0, h1, way) = if pinch {
1725            (dp_at(p0, dpi) / 2, dp_at(p1, dpi) / 2, [0, 0])
1726        } else {
1727            (dp_at(40, dpi), dp_at(40, dpi), [dp_at(p0, dpi), dp_at(p1, dpi)])
1728        };
1729        self.two_at(app, plat::E_DOWN, c, h0);
1730        let steps = (ms / 8).max(1);
1731        for i in 1..=steps {
1732            self.advance_time(app, ms * i / steps - ms * (i - 1) / steps);
1733            let h = h0 + (h1 - h0) * i / steps;
1734            self.two_at(app, plat::E_MOVE, [c[0] + way[0] * i / steps, c[1] + way[1] * i / steps], h);
1735        }
1736        self.two_at(app, plat::E_UP, [c[0] + way[0], c[1] + way[1]], h1);
1737    }
1738
1739    // both fingers, h px left and right of c
1740    fn two_at(&mut self, app: &mut dyn App, typ: u8, c: [i32; 2], h: i32) {
1741        for (id, sign) in [(0u32, -1), (1, 1)] {
1742            self.sc.finger_id = id;
1743            self.finger_at(app, typ, c[0] + sign * h, c[1]);
1744        }
1745        self.sc.finger_id = 0;
1746    }
1747
1748    // wheel S DY [shift|ctrl|alt]...: the words hold those modifiers during the wheel event
1749    // (not during the move to S)
1750    fn sc_wheel(&mut self, app: &mut dyn App, a: &[u8]) {
1751        let (s, r) = self.resolve(a);
1752        if s.is_none() {
1753            self.sel_fail();
1754            return;
1755        }
1756        let (dy, r) = parse_int(r);
1757        let saved_mods = self.sc.script_mods;
1758        let mut mods = saved_mods;
1759        let mut q = skip_ws(r);
1760        while !q.is_empty() {
1761            let m = mod_word(q);
1762            if m == 0 {
1763                let mut d = b"unknown wheel option ".to_vec();
1764                d.extend_from_slice(q);
1765                self.fail(&d);
1766                return;
1767            }
1768            mods |= m;
1769            q = next_word(q);
1770        }
1771        let (x, y) = (self.sc.sel_x, self.sc.sel_y);
1772        self.mouse_to(app, x, y);
1773        let (sx, sy) = (self.sc.sc_x, self.sc.sc_y);
1774        self.sc.script_mods = mods;
1775        self.inject(app, plat::E_WHEEL, 0, sx, sy, dy as u16 as u32);
1776        self.sc.script_mods = saved_mods;
1777    }
1778
1779    // point_to_window(from, to): sel_x / sel_y (px of window `from`) as px of window `to`
1780    fn point_to_window(&mut self, from: u32, to: u32) {
1781        if from == to {
1782            return;
1783        }
1784        let dpi = self.win_dpi(from);
1785        let (dx, dy) = self.uiw_desk_pos(from);
1786        let x = dx + px2dip_at(self.sc.sel_x, dpi);
1787        let y = dy + px2dip_at(self.sc.sel_y, dpi);
1788        let (tx, ty) = self.uiw_desk_pos(to);
1789        let tdpi = self.win_dpi(to);
1790        self.sc.sel_x = dp_at(x - tx, tdpi);
1791        self.sc.sel_y = dp_at(y - ty, tdpi);
1792    }
1793
1794    // pointer mouse | pen [P [TX TY]] | eraser [P [TX TY]] | touch -- (PN1) pen data of the
1795    // following pointer events
1796    fn sc_pointer(&mut self, app: &mut dyn App, a: &[u8]) {
1797        let names = ["mouse", "pen", "eraser", "touch"];
1798        let Some(t) = names.iter().position(|n| word_eq(a, n).is_some()) else {
1799            let mut d = b"unknown pointer type ".to_vec();
1800            d.extend_from_slice(a);
1801            self.fail(&d);
1802            return;
1803        };
1804        let mut pressure = -1;
1805        let (mut tx, mut ty) = (0, 0);
1806        let q = next_word(a);
1807        if q.first().is_some_and(|c| c.is_ascii_digit()) {
1808            let (pv, r) = parse_pct(q);
1809            pressure = pv;
1810            let r = skip_ws(r);
1811            if r.first().is_some_and(|&c| c == b'-' || c.is_ascii_digit()) {
1812                let (x, r) = parse_int(r);
1813                let (y, _) = parse_int(skip_ws(r));
1814                tx = x;
1815                ty = y;
1816            }
1817        }
1818        self.sc.sc_pdata = plat::evq_pen_data(pressure, t as u32, tx, ty);
1819        // (a finger or a mouse from now on: the scroll bars follow; touch.rs)
1820        if self.m.touch.on != (t as u32 == PTR_TOUCH) {
1821            self.touch_mode_set(t as u32 == PTR_TOUCH);
1822            self.run_passes(app);
1823        }
1824    }
1825
1826    // key CHORD...
1827    fn sc_key(&mut self, app: &mut dyn App, a: &[u8]) {
1828        let Some(keys) = self.chords(a) else { return };
1829        for k in keys {
1830            self.press(app, k >> 16, k & 0xFFFF);
1831        }
1832    }
1833
1834    // keys [N] CHORD...: the chords N times over, all queued for ONE pass of the window with
1835    // the keyboard (input faster than frames, the keys a tool window hands on)
1836    fn sc_keys(&mut self, app: &mut dyn App, a: &[u8]) {
1837        let (n, r) = if a.first().is_some_and(|c| c.is_ascii_digit()) { parse_int(a) } else { (1, a) };
1838        let Some(keys) = self.chords(r) else { return };
1839        let w = self.m.win.active & (WIN_MAX as u32 - 1);
1840        if w != 0 && !self.uiw_drawable(w) {
1841            self.fail(b"the window with the keyboard is not shown");
1842            return;
1843        }
1844        let all: Vec<u32> = (0..n.max(0)).flat_map(|_| keys.iter().copied()).collect();
1845        self.uiw_use(w);
1846        self.keys_inject(&all);
1847        self.m.win.edges |= 1 << w; // cleared after the first pass
1848        self.uiw_use(0);
1849        self.run_passes(app);
1850    }
1851
1852    // chords(a) -> the keys of "ctrl+s tab shift+f10 ..." (VK | MOD_* << 16), None after a
1853    // FAIL (an unknown key)
1854    fn chords(&mut self, a: &[u8]) -> Option<Vec<u32>> {
1855        let mut out = Vec::new();
1856        let mut p = a;
1857        loop {
1858            p = skip_ws(p);
1859            if p.is_empty() {
1860                return Some(out);
1861            }
1862            let Some((mods, vk, rest)) = chord(p) else {
1863                self.fail(b"unknown key");
1864                return None;
1865            };
1866            out.push(vk | mods << 16);
1867            p = rest;
1868        }
1869    }
1870
1871    // a key pressed and released with modifiers (and those keydown holds)
1872    fn press(&mut self, app: &mut dyn App, mods: u32, vk: u32) {
1873        let held = self.sc.script_mods;
1874        self.sc.script_mods = held | mods;
1875        self.inject(app, plat::E_KEY_DOWN, 0, 0, 0, vk);
1876        self.inject(app, plat::E_KEY_UP, 0, 0, 0, vk);
1877        self.sc.script_mods = held;
1878    }
1879
1880    // (E2) keydown / keyup CHORD [scan N] [left|right|numpad]: one key down (an auto-repeat when
1881    // it is down already) or up; shift / ctrl / alt stay held between their keydown and keyup
1882    fn sc_keyraw(&mut self, app: &mut dyn App, a: &[u8], down: bool) {
1883        let Some((mods, vk, rest)) = chord(skip_ws(a)) else {
1884            self.fail(b"unknown key");
1885            return;
1886        };
1887        let (mut scan, mut loc) = (0i32, KL_STANDARD);
1888        let mut q = skip_ws(rest);
1889        while !q.is_empty() {
1890            if let Some(r) = word_eq(q, "scan") {
1891                let (n, r) = parse_int(skip_ws(r));
1892                scan = n;
1893                q = skip_ws(r);
1894                continue;
1895            }
1896            loc = if word_eq(q, "left").is_some() {
1897                KL_LEFT
1898            } else if word_eq(q, "right").is_some() {
1899                KL_RIGHT
1900            } else if word_eq(q, "numpad").is_some() {
1901                KL_NUMPAD
1902            } else {
1903                let mut d = b"unknown keydown / keyup option ".to_vec();
1904                d.extend_from_slice(q);
1905                self.fail(&d);
1906                return;
1907            };
1908            q = next_word(q);
1909        }
1910        let own = match vk {
1911            VK_SHIFT => MOD_SHIFT,
1912            VK_CONTROL => MOD_CTRL,
1913            VK_MENU => MOD_ALT,
1914            _ => 0,
1915        };
1916        let held = self.sc.script_mods;
1917        if down {
1918            // a modifier's own event carries its new state (as Win32 and Wayland do)
1919            let repeat = if self.keydown[(vk & 255) as usize] != 0 { 1 << 31 } else { 0 };
1920            self.sc.script_mods = held | own | mods;
1921            self.inject(app, plat::E_KEY_DOWN, 0, scan, loc as i32, vk | repeat);
1922            self.sc.script_mods = held | own;
1923        } else {
1924            self.sc.script_mods = (held | mods) & !own;
1925            self.inject(app, plat::E_KEY_UP, 0, scan, loc as i32, vk);
1926            self.sc.script_mods = held & !own;
1927        }
1928    }
1929
1930    // commit "text" [N]: the commits of an input method (plat::ime_commit_text) to the window
1931    // with the keyboard, as the platform queues them
1932    fn sc_commit(&mut self, app: &mut dyn App, a: &[u8]) {
1933        let Some((s, r)) = parse_qstring(a, 1024) else {
1934            self.fail(b"expected a quoted string");
1935            return;
1936        };
1937        let text = String::from_utf8_lossy(&s).repeat(parse_int(r).0.max(1) as usize);
1938        let w = (self.m.win.active & (WIN_MAX as u32 - 1)) as u8;
1939        let mut at = 0;
1940        while at < text.len() {
1941            let n = plat::ime_commit_text(w, &text[at..]);
1942            if n == 0 {
1943                self.fail(b"the commit was not queued");
1944                return;
1945            }
1946            at += n;
1947            self.process_events(app);
1948        }
1949    }
1950
1951    // (E4) preedit "text" [CURSOR]: an input method's composition in the window with the keyboard
1952    // (plat::ime_preedit_set; the caret a byte offset, default its end; "" ends it)
1953    fn sc_preedit(&mut self, app: &mut dyn App, a: &[u8]) {
1954        let Some((s, r)) = parse_qstring(a, 1024) else {
1955            self.fail(b"expected a quoted string");
1956            return;
1957        };
1958        let text = String::from_utf8_lossy(&s).into_owned();
1959        let r = skip_ws(r);
1960        let cursor = if r.first().is_some_and(|c| c.is_ascii_digit()) { parse_int(r).0.max(0) as usize } else { text.len() };
1961        let w = (self.m.win.active & (WIN_MAX as u32 - 1)) as u8;
1962        plat::ime_preedit_set(w, &text, cursor);
1963        self.process_events(app);
1964    }
1965
1966    // type "text"
1967    fn sc_type(&mut self, app: &mut dyn App, a: &[u8]) {
1968        let Some((s, _)) = parse_qstring(a, 256) else {
1969            self.fail(b"expected a quoted string");
1970            return;
1971        };
1972        let mut i = 0;
1973        loop {
1974            let (cp, n) = utf8_decode(&s, i);
1975            if cp == 0 {
1976                break;
1977            }
1978            i = n;
1979            if cp == 10 {
1980                self.inject(app, plat::E_KEY_DOWN, 0, 0, 0, VK_RETURN);
1981                self.inject(app, plat::E_KEY_UP, 0, 0, 0, VK_RETURN);
1982            } else {
1983                self.inject(app, plat::E_TEXT, 0, 0, 0, cp);
1984            }
1985        }
1986    }
1987
1988    // advance_time(ms): run the virtual clock in 16 ms steps (timers, animations)
1989    fn advance_time(&mut self, app: &mut dyn App, ms: i32) {
1990        let mut left = ms;
1991        while left > 0 {
1992            let step = left.min(16);
1993            left -= step;
1994            let t = self.now_ms.wrapping_add(step as u32);
1995            self.set_time(t);
1996            if self.touch_busy() {
1997                self.process_events(app); // the resting finger, the hover after it (touch.rs)
1998            }
1999            let mut fired = self.tasks_ready(); // a task's sleep() ended (tasks.rs)
2000            loop {
2001                let (id, _) = self.timer_due();
2002                if id == u32::MAX {
2003                    break;
2004                }
2005                self.cmd_run(UIC_TIMER, id);
2006                fired = true;
2007            }
2008            // a frame asked for (need) comes with the next step, as in the window loop
2009            if self.animating || fired || self.need {
2010                self.need = false;
2011                self.run_passes(app);
2012            }
2013        }
2014    }
2015
2016    // shot FILE [window ID|desktop] [X Y W H]
2017    fn sc_shot(&mut self, app: &mut dyn App, a: &[u8]) {
2018        let _ = app;
2019        // the file name is the first word
2020        let i = a.iter().position(|&c| c == b' ' || c == b'\t').unwrap_or(a.len());
2021        let file = String::from_utf8_lossy(&a[..i]).into_owned();
2022        let mut rest = skip_ws(if i < a.len() { &a[i + 1..] } else { &a[i..] });
2023        let mut win = 0u32;
2024        let mut desktop = false;
2025        if let Some(r) = word_eq(rest, "desktop") {
2026            desktop = true;
2027            rest = r;
2028        } else if let Some(r) = word_eq(rest, "window") {
2029            let (w, r) = self.window_arg(r);
2030            if w < 0 {
2031                self.fail(b"no such window");
2032                return;
2033            }
2034            win = w as u32;
2035            rest = r;
2036        }
2037        let mut crop = None;
2038        let q = skip_ws(rest);
2039        if !q.is_empty() {
2040            let (x, r) = parse_int(q);
2041            let (y, r) = parse_int(r);
2042            let (w, r) = parse_int(r);
2043            let (h, _) = parse_int(r);
2044            crop = Some([x, y, w, h]);
2045        }
2046        self.render();
2047        self.gp_settle_all();
2048        let ok = if desktop {
2049            self.shot_desktop(&file, crop)
2050        } else {
2051            let sf = self.win_fb(win);
2052            match crop {
2053                Some(c) => match crop_surface(sf, self.win_dpi(win), c) {
2054                    Some(v) => Ui::save_png(&v, &file),
2055                    None => false,
2056                },
2057                None => Ui::save_png(sf, &file),
2058            }
2059        };
2060        if !ok {
2061            self.fail(b"cannot write the screenshot");
2062            return;
2063        }
2064        plat::write_stdout(alloc::format!("wrote {}\n", file).as_bytes());
2065    }
2066
2067    // shot_desktop(path, crop) -> written: every shown window on the virtual desktop (main
2068    // window at 0, 0; px at the main window's dpi), in stacking order; the virtual screen
2069    // (`screen`) belongs to it
2070    fn shot_desktop(&mut self, path: &str, crop: Option<[i32; 4]>) -> bool {
2071        let dpi = self.wp.rt.dpi; // the main window is live
2072        let mut bb = [0, 0, px2dip_at(self.wp.rt.fb.w, dpi), px2dip_at(self.wp.rt.fb.h, dpi)];
2073        let scr = self.m.win.screen;
2074        if scr[2] > 0 && scr[3] > 0 {
2075            bb = [bb[0].min(scr[0]), bb[1].min(scr[1]), bb[2].max(scr[0] + scr[2]), bb[3].max(scr[1] + scr[3])];
2076        }
2077        for i in 1..WIN_MAX as u32 {
2078            if !self.uiw_drawable(i) {
2079                continue;
2080            }
2081            let r = self.win_desk_rect(i);
2082            bb[0] = bb[0].min(r[0]);
2083            bb[1] = bb[1].min(r[1]);
2084            bb[2] = bb[2].max(r[0] + r[2]);
2085            bb[3] = bb[3].max(r[1] + r[3]);
2086        }
2087        let mut sf = Surface::new(dp_at(bb[2] - bb[0], dpi), dp_at(bb[3] - bb[1], dpi), FMT_XRGB);
2088        if sf.px.is_empty() {
2089            return false;
2090        }
2091        let mut dc = gfx::Dc::new(sf.w, sf.h);
2092        dc.set_color(0xFF5A_6B7C); // desktop
2093        let (sw, sh) = (sf.w, sf.h);
2094        dc.fill(&mut sf, 0, 0, sw, sh);
2095        // windows bottom to top: the main window, then by stacking order
2096        self.sd_blit(&mut sf, 0, bb[0], bb[1], dpi);
2097        let mut last_z: i64 = -1;
2098        loop {
2099            let mut next = None;
2100            let mut nz: i64 = i32::MAX as i64;
2101            for i in 1..WIN_MAX as u32 {
2102                if !self.uiw_drawable(i) {
2103                    continue;
2104                }
2105                let z = self.m.win.recs[i as usize].z as i32 as i64;
2106                if z > last_z && z < nz {
2107                    nz = z;
2108                    next = Some(i);
2109                }
2110            }
2111            let Some(i) = next else { break };
2112            last_z = nz;
2113            self.sd_blit(&mut sf, i, bb[0], bb[1], dpi);
2114            self.uiw_popups_blit(&mut sf, i, bb[0], bb[1], dpi); // (U3) its popups
2115        }
2116        self.uiw_popups_blit(&mut sf, 0, bb[0], bb[1], dpi); // popups of the main window above all
2117        match crop {
2118            Some(c) => match crop_surface(&sf, dpi, c) {
2119                Some(v) => Ui::save_png(&v, path),
2120                None => false,
2121            },
2122            None => Ui::save_png(&sf, path),
2123        }
2124    }
2125
2126    // sd_blit(sf, i, x0, y0, dpi): copy a window's framebuffer to its place on the desktop
2127    fn sd_blit(&mut self, sf: &mut Surface, i: u32, x0: i32, y0: i32, dpi: i32) {
2128        let r = self.win_desk_rect(i);
2129        let x = dp_at(r[0] - x0, dpi);
2130        let y = dp_at(r[1] - y0, dpi);
2131        blit_copy(sf, self.win_fb(i), x, y);
2132    }
2133
2134    // win_desk_rect(i) -> the window's outer rectangle on the virtual desktop (DIP)
2135    fn win_desk_rect(&self, i: u32) -> [i32; 4] {
2136        let (x, y) = self.uiw_desk_pos(i);
2137        let dpi = self.win_dpi(i);
2138        let fb = self.win_fb(i);
2139        [x, y, px2dip_at(fb.w, dpi), px2dip_at(fb.h, dpi)]
2140    }
2141
2142    // expect S condition [arg]  |  expect window ID condition
2143    fn sc_expect(&mut self, a: &[u8]) {
2144        if let Some(r) = word_eq(a, "window") {
2145            self.expect_window(r);
2146            return;
2147        }
2148        if let Some(r) = word_eq(a, "sound") {
2149            // the last sound ui.beep / ui.dialog_sound asked for since the last check
2150            let want = skip_ws(r);
2151            let got = self.m.sound.last.take();
2152            let name = got.map_or("none", plat::audio::sound_name);
2153            if !want.starts_with(name.as_bytes()) || (want.len() > name.len() && want[name.len()] > b' ') {
2154                self.fail(alloc::format!("sound {name}").as_bytes());
2155            }
2156            return;
2157        }
2158        if let Some(r) = word_eq(a, "keyboard") {
2159            // the on-screen keyboard a finger asked for: shown | hidden (touch.rs)
2160            let on = self.m.touch.kb_on;
2161            if word_eq(skip_ws(r), "shown").is_some() != on {
2162                self.fail(if on { b"keyboard shown" } else { b"keyboard hidden" });
2163            }
2164            return;
2165        }
2166        if let Some(r) = word_eq(a, "wm") {
2167            // what the title bar / the resize band asked the window manager for (chrome.rs)
2168            let want = String::from(String::from_utf8_lossy(skip_ws(r)).trim_end());
2169            let got = super::chrome::wm_req_name(core::mem::take(&mut self.m.chrome.wm_req));
2170            if want != got {
2171                self.fail(alloc::format!("wm {got}").as_bytes());
2172            }
2173            return;
2174        }
2175        if let Some(r) = word_eq(a, "scrollcopy") {
2176            // frames that moved pixels by a scroll copy since the last check: at least N
2177            // (default 1), or none (scrollcopy.rs)
2178            let want = skip_ws(r);
2179            let got = core::mem::take(&mut self.m.scopy.frames);
2180            self.m.scopy.cells = 0;
2181            let ok = if word_eq(want, "none").is_some() { got == 0 } else { got as i32 >= parse_int(want).0.max(1) };
2182            if !ok {
2183                self.fail(alloc::format!("scroll copies: {got}").as_bytes());
2184            }
2185            return;
2186        }
2187        // app themes and styles (theme.rs)
2188        if let Some(r) = word_eq(a, "theme") {
2189            self.expect_theme(skip_ws(r));
2190            return;
2191        }
2192        if let Some(r) = word_eq(a, "style") {
2193            let want = String::from_utf8_lossy(skip_ws(r));
2194            let have = self.style_name();
2195            if want.trim() != have {
2196                self.fail(alloc::format!("style is {}", have).as_bytes());
2197            }
2198            return;
2199        }
2200        if let Some(r) = word_eq(a, "token") {
2201            self.expect_token(skip_ws(r));
2202            return;
2203        }
2204        if let Some(r) = word_eq(a, "pixel") {
2205            self.expect_pixel(r);
2206            return;
2207        }
2208        if let Some(r) = word_eq(a, "same") {
2209            self.expect_same(skip_ws(r));
2210            return;
2211        }
2212        let (s, rest) = self.resolve(a);
2213        if s.is_none() && self.sc.sel_err == 3 {
2214            self.sel_fail(); // bad selector syntax: report and stop
2215            return;
2216        }
2217        let cond = skip_ws(rest);
2218        // existence tests work with a missing widget
2219        if word_eq(cond, "missing").is_some() {
2220            if s.is_some() {
2221                self.fail(b"widget exists");
2222            }
2223            return;
2224        }
2225        let Some(s) = s else {
2226            self.sel_fail();
2227            return;
2228        };
2229        let rec = self.sel_rec(s);
2230        const STATES: [(&str, u16, bool); 14] = [
2231            ("hover", SS_HOVER, true),
2232            ("nohover", SS_HOVER, false),
2233            ("unpressed", SS_PRESSED, false),
2234            ("focused", SS_FOCUSED, true),
2235            ("unfocused", SS_FOCUSED, false),
2236            ("unselected", SS_SELECTED, false),
2237            ("closed", SS_OPEN, false),
2238            ("checked", SS_CHECKED, true),
2239            ("unchecked", SS_CHECKED, false),
2240            ("disabled", SS_DISABLED, true),
2241            ("enabled", SS_DISABLED, false),
2242            ("selected", SS_SELECTED, true),
2243            ("open", SS_OPEN, true),
2244            ("pressed", SS_PRESSED, true),
2245        ];
2246        for (n, mask, want) in STATES {
2247            if word_eq(cond, n).is_some() {
2248                let set = rec.state & mask != 0;
2249                if set != want {
2250                    self.fail(if set { b"state is set" } else { b"state is not set" });
2251                }
2252                return;
2253            }
2254        }
2255        if let Some(r) = word_eq(cond, "value") {
2256            let (lo, rest) = parse_int(r);
2257            // value MIN..MAX
2258            let hi = if rest.starts_with(b"..") { parse_int(&rest[2..]).0 } else { lo };
2259            if rec.value < lo || rec.value > hi {
2260                self.fail(alloc::format!("value is {}", rec.value).as_bytes());
2261            }
2262        } else if let Some(r) = word_eq(cond, "max") {
2263            if parse_int(r).0 != rec.max {
2264                self.fail(alloc::format!("max is {}", rec.max).as_bytes());
2265            }
2266        } else if let Some(r) = word_eq(cond, "text") {
2267            let Some((t, _)) = parse_qstring(skip_ws(r), LINE_MAX) else {
2268                self.fail(b"expected a quoted string");
2269                return;
2270            };
2271            let have = rec.label.as_deref().unwrap_or("");
2272            if have.as_bytes() != t.as_slice() {
2273                let mut d = b"text is \"".to_vec();
2274                d.extend_from_slice(have.as_bytes());
2275                d.push(b'"');
2276                self.fail(&d);
2277            }
2278        } else if word_eq(cond, "exists").is_some() {
2279        } else if let Some(r) = word_eq(cond, "rect") {
2280            self.expect_rect(r, &rec);
2281        } else {
2282            self.fail(b"unknown condition");
2283        }
2284    }
2285
2286    // expect_rect(p, rec): "X Y W H" -- the record's rectangle in DIP (its window's dpi); a
2287    // '*' takes any value (a theme's control height: "rect * * * 36")
2288    fn expect_rect(&mut self, p: &[u8], rec: &Sem) {
2289        let mut want = [None; 4];
2290        let mut q = p;
2291        for v in want.iter_mut() {
2292            q = skip_ws(q);
2293            if q.first() == Some(&b'*') {
2294                q = &q[1..];
2295                continue;
2296            }
2297            let (n, r) = parse_int(q);
2298            *v = Some(n);
2299            q = r;
2300        }
2301        let dpi = self.win_dpi(rec.win as u32);
2302        let have = rec.rect.map(|v| px2dip_at(v, dpi));
2303        if have.iter().zip(want).any(|(h, w)| w.is_some_and(|w| w != *h)) {
2304            self.fail_more(alloc::format!("rect is {} {}", have[0], have[1]).as_bytes());
2305            plat::write_stdout(alloc::format!(" {} {}\n", have[2], have[3]).as_bytes());
2306        }
2307    }
2308
2309    // expect theme "NAME" | none | error "TEXT" | error none: the app theme (theme.rs), the
2310    // error of the last `theme file`
2311    fn expect_theme(&mut self, p: &[u8]) {
2312        if let Some(r) = word_eq(p, "error") {
2313            let r = skip_ws(r);
2314            let have = self.sc.theme_err.clone();
2315            if word_eq(r, "none").is_some() {
2316                if let Some(h) = have {
2317                    self.fail(alloc::format!("error: {}", h).as_bytes());
2318                }
2319                return;
2320            }
2321            let Some((t, _)) = parse_qstring(r, 256) else {
2322                self.fail(b"expected a quoted string");
2323                return;
2324            };
2325            let t = String::from_utf8_lossy(&t).into_owned();
2326            match have {
2327                Some(h) if h.contains(&t) => {}
2328                Some(h) => self.fail(alloc::format!("error: {}", h).as_bytes()),
2329                None => self.fail(b"no error"),
2330            }
2331            return;
2332        }
2333        let have = self.app_theme().map(|t| t.name.clone());
2334        if word_eq(p, "none").is_some() {
2335            if let Some(h) = have {
2336                self.fail(alloc::format!("theme is \"{}\"", h).as_bytes());
2337            }
2338            return;
2339        }
2340        let want = match parse_qstring(p, 256) {
2341            Some((t, _)) => String::from_utf8_lossy(&t).into_owned(),
2342            None => String::from(String::from_utf8_lossy(p).trim()),
2343        };
2344        match have {
2345            Some(h) if h == want => {}
2346            Some(h) => self.fail(alloc::format!("theme is \"{}\"", h).as_bytes()),
2347            None => self.fail(b"no theme"),
2348        }
2349    }
2350
2351    // expect token NAME #RRGGBB | #AARRGGBB: a colour token of the theme now in use
2352    fn expect_token(&mut self, p: &[u8]) {
2353        let name = String::from_utf8_lossy(&p[..p.iter().position(|&c| c == b' ' || c == b'\t').unwrap_or(p.len())]).into_owned();
2354        let want = super::theme::parse_color(String::from_utf8_lossy(next_word(p)).trim());
2355        let (Some(i), Some(want)) = (super::theme::token_index(&name), want) else {
2356            self.fail(b"expect token NAME #RRGGBB | #AARRGGBB");
2357            return;
2358        };
2359        let have = self.color(i as u32 * 4);
2360        if have != want {
2361            self.fail(alloc::format!("token is #{:08X}", have).as_bytes());
2362        }
2363    }
2364
2365    // expect pixel X Y #RRGGBB: the colour of the main window's pixel at (X, Y) DIP, rendered now
2366    fn expect_pixel(&mut self, p: &[u8]) {
2367        let (x, r) = parse_int(p);
2368        let (y, r) = parse_int(r);
2369        let Some(want) = super::theme::parse_color(String::from_utf8_lossy(skip_ws(r)).trim()) else {
2370            self.fail(b"expect pixel X Y #RRGGBB");
2371            return;
2372        };
2373        self.render();
2374        self.gp_settle_all();
2375        let dpi = self.win_dpi(0);
2376        let sf = self.win_fb(0);
2377        let (px, py) = (dp_at(x, dpi), dp_at(y, dpi));
2378        if px < 0 || py < 0 || px >= sf.w || py >= sf.h {
2379            self.fail(b"outside the window");
2380            return;
2381        }
2382        let o = py as usize * sf.stride as usize + px as usize * 4;
2383        let have = u32::from_le_bytes([sf.px[o], sf.px[o + 1], sf.px[o + 2], sf.px[o + 3]]) & 0xFF_FFFF;
2384        if have != want & 0xFF_FFFF {
2385            self.fail(alloc::format!("pixel is #{:06X}", have).as_bytes());
2386        }
2387    }
2388
2389    // expect same FILE.png [X Y W H]: the main window rendered now (the crop, DIP, as `shot`
2390    // takes it) is the shot saved in FILE (the same PNG bytes: the same pixels)
2391    fn expect_same(&mut self, p: &[u8]) {
2392        let p = skip_ws(p);
2393        let i = p.iter().position(|&c| c == b' ' || c == b'\t').unwrap_or(p.len());
2394        let path = String::from(String::from_utf8_lossy(&p[..i]).trim());
2395        let q = skip_ws(&p[i..]);
2396        self.render();
2397        self.gp_settle_all();
2398        let sf = self.win_fb(0);
2399        let png = if q.is_empty() {
2400            img_png::encode(&sf.px, sf.w as usize, sf.h as usize, sf.stride as usize, false)
2401        } else {
2402            let (x, r) = parse_int(q);
2403            let (y, r) = parse_int(r);
2404            let (w, r) = parse_int(r);
2405            let (h, _) = parse_int(r);
2406            match crop_surface(sf, self.win_dpi(0), [x, y, w, h]) {
2407                Some(v) => img_png::encode(&v.px, v.w as usize, v.h as usize, v.stride as usize, false),
2408                None => {
2409                    self.fail(b"the crop lies outside the window");
2410                    return;
2411                }
2412            }
2413        };
2414        let file = super::perm::trusted(|| plat::read_file(&path));
2415        match file {
2416            None => self.fail(b"no such file"),
2417            Some(f) if f != png => self.fail(b"the pixels differ"),
2418            Some(_) => {}
2419        }
2420    }
2421
2422    // expect_window(p): "ID open|closed|visible|active|modal|keys|fullscreen|windowed" or "ID
2423    // rect X Y W H"
2424    fn expect_window(&mut self, p: &[u8]) {
2425        let (i, r) = self.window_arg(p);
2426        let cond = skip_ws(r);
2427        let mut st = 0u32; // bit 0 visible, 1 active, 2 modal, 3 gets the keys (kbnav.rs), 4 full screen
2428        if i >= 0 && self.uiw_drawable(i as u32) {
2429            st |= 1;
2430            if i as u32 == self.m.win.active {
2431                st |= 2;
2432            }
2433            if i != 0 && i as u32 == self.m.win.modal {
2434                st |= 4;
2435            }
2436            if i as u32 == self.uiw_keys_window() {
2437                st |= 8;
2438            }
2439            if self.m.win.recs[i as usize].fs {
2440                st |= 16;
2441            }
2442        }
2443        const STATES: [(&str, u32, bool); 8] = [
2444            ("open", 1, true),
2445            ("closed", 1, false),
2446            ("visible", 1, true),
2447            ("active", 2, true),
2448            ("modal", 4, true),
2449            ("keys", 8, true),
2450            ("fullscreen", 16, true),
2451            ("windowed", 16, false),
2452        ];
2453        for (n, mask, want) in STATES {
2454            if word_eq(cond, n).is_some() {
2455                let set = st & mask != 0;
2456                if set != want {
2457                    self.fail(if set { b"window state is set" } else { b"window state is not set" });
2458                }
2459                return;
2460            }
2461        }
2462        if let Some(r) = word_eq(cond, "rect") {
2463            if i < 0 {
2464                self.fail(b"no such window");
2465                return;
2466            }
2467            let rect = self.m.win.recs[i as usize].rect.get(); // ui-win: the WINRECT (the app's)
2468            let have = [rect.x, rect.y, rect.w, rect.h];
2469            let mut q = r;
2470            for &h in have.iter() {
2471                let (n, rr) = parse_int(q);
2472                q = rr;
2473                if n != h {
2474                    self.fail_more(alloc::format!("rect is {} {}", rect.x, rect.y).as_bytes());
2475                    plat::write_stdout(alloc::format!(" {} {}\n", rect.w, rect.h).as_bytes());
2476                    return;
2477                }
2478            }
2479            return;
2480        }
2481        self.fail(b"unknown window condition");
2482    }
2483
2484    // close window ID: the user closes the window (E_CLOSE of that window)
2485    fn sc_close(&mut self, app: &mut dyn App, a: &[u8]) {
2486        let Some(r) = word_eq(a, "window") else {
2487            self.fail(b"expected: close window ID");
2488            return;
2489        };
2490        let (i, _) = self.window_arg(r);
2491        if i < 0 {
2492            self.fail(b"no such window");
2493            return;
2494        }
2495        self.sc.sc_win = i as u32;
2496        self.inject(app, plat::E_CLOSE, 0, 0, 0, 0);
2497        self.run_passes(app);
2498    }
2499
2500    // scale P [window ID]: dpi of the main window (its DIP size is kept) or of one window
2501    fn sc_scale(&mut self, app: &mut dyn App, a: &[u8]) {
2502        let (p, r) = parse_int(a);
2503        let dpi = super::runtime::scale_to_dpi(p);
2504        let q = skip_ws(r);
2505        if let Some(r) = word_eq(q, "window") {
2506            let (i, _) = self.window_arg(r);
2507            if i < 0 {
2508                self.fail(b"no such window");
2509                return;
2510            }
2511            if i != 0 {
2512                self.uiw_dpi(i as u32, dpi);
2513                self.run_passes(app);
2514                return;
2515            }
2516        }
2517        // keep the client size in DIP
2518        let w = self.px2dip(self.wp.rt.fb.w);
2519        let h = self.px2dip(self.wp.rt.fb.h);
2520        self.uiw_dpi(0, dpi);
2521        let (w, h) = (self.dp(w), self.dp(h));
2522        self.resize(app, w, h);
2523        self.run_passes(app);
2524    }
2525
2526    // wait real MS: real time passes while the app's own threads work; their wake-ups reach
2527    // App::command as they come (E_WAKE -> UIC_WAKE, tasks, ...), and the virtual clock follows
2528    // the real one in steps of at most 16 ms (timers fire, frames asked for come)
2529    fn wait_real(&mut self, app: &mut dyn App, ms: i32) {
2530        let end = plat::time_us() + ms.max(0) as u64 * 1000;
2531        let mut clock = plat::time_us(); // the real time the virtual clock has caught up with
2532        loop {
2533            let now = plat::time_us();
2534            let gone = ((now - clock) / 1000) as i32;
2535            if gone > 0 {
2536                clock += gone as u64 * 1000;
2537                self.advance_time(app, gone);
2538            }
2539            self.process_events(app); // the wake-ups that came (E_WAKE)
2540            if self.need {
2541                self.need = false;
2542                self.run_passes(app);
2543            }
2544            if now >= end {
2545                break;
2546            }
2547            // headless: plat::wake() from other threads arrives through the wait object
2548            plat::wait_events((now + 16_000).min(end), &mut |e| {
2549                plat::evq_push(*e);
2550            });
2551        }
2552        self.process_events(app);
2553        self.run_passes(app);
2554    }
2555
2556    // sync [MS] [soft]: wait (real time, default 10 s) until no background job is pending (the
2557    // app's hook, async tasks: tasks.rs); reaching MS fails, with soft it is only noted
2558    fn sc_sync(&mut self, app: &mut dyn App, a: &[u8]) {
2559        let (ms, r) = parse_int(a);
2560        let soft = word_eq(skip_ws(a), "soft").is_some() || word_eq(skip_ws(r), "soft").is_some();
2561        let ms = if ms > 0 { ms } else { 10000 };
2562        let deadline = plat::time_us() + ms as u64 * 1000;
2563        while self.uiw_sync_pending() || self.tasks_busy() || self.spell_busy() {
2564            // headless: plat::wake() from worker threads arrives through the wait object;
2565            // queue it like a window's E_WAKE (also keeps the loop from spinning)
2566            plat::wait_events(plat::time_us() + 2_000, &mut |e| {
2567                plat::evq_push(*e);
2568            });
2569            self.process_events(app);
2570            self.run_passes(app);
2571            if plat::time_us() >= deadline {
2572                if soft {
2573                    plat::write_stdout(alloc::format!("sync: background jobs still running after {ms} ms (soft: no failure)\n").as_bytes());
2574                } else {
2575                    self.fail(b"background jobs still running");
2576                }
2577                break;
2578            }
2579        }
2580        self.process_events(app); // completions (E_WAKE) reach App::command
2581        self.spell_poll(); // (their wake-up may still wait in the wait object)
2582        self.run_passes(app);
2583    }
2584
2585    // bench N: time N frames: build (passes), full render, partial render (no change);
2586    // bench N anim | drag S1 S2 [opts] | wheel S DY [mods] | key CHORD | type "s", each
2587    // [MAX_US]: frames that move
2588    fn sc_bench(&mut self, app: &mut dyn App, a: &[u8]) {
2589        let (n, r) = parse_int(a);
2590        let w = skip_ws(r);
2591        if !w.is_empty() {
2592            self.sc_bench_moving(app, n.max(1) as usize, w);
2593            return;
2594        }
2595        let n = if n > 0 { n as u64 } else { 100 };
2596        let t0 = plat::time_us();
2597        for _ in 0..n {
2598            self.run_passes(app);
2599        }
2600        let build = plat::time_us() - t0;
2601        let t0 = plat::time_us();
2602        for _ in 0..n {
2603            self.dirty_force_full();
2604            self.render();
2605        }
2606        let full = plat::time_us() - t0;
2607        let t0 = plat::time_us();
2608        for _ in 0..n {
2609            self.run_passes(app);
2610            self.render();
2611        }
2612        let idle = plat::time_us() - t0;
2613        let s = alloc::format!(
2614            "bench: frame build {} us, full render {} us, unchanged frame {} us ({}x{} px)\n",
2615            build / n,
2616            full / n,
2617            idle / n,
2618            self.wp.rt.fb.w,
2619            self.wp.rt.fb.h
2620        );
2621        plat::write_stdout(s.as_bytes());
2622    }
2623
2624    // bench N anim | drag S1 S2 [middle|right|shift|ctrl|alt]... | wheel S DY [shift|ctrl|alt]... |
2625    // key CHORD | type "s", each with an optional MAX_US: n frames that move per run, up to three
2626    // runs with a limit (the first whose median is at most MAX_US passes)
2627    fn sc_bench_moving(&mut self, app: &mut dyn App, n: usize, a: &[u8]) {
2628        enum Kind {
2629            Anim,
2630            Drag { from: (i32, i32), to: (i32, i32), btn: u32, mods: u32, win: u32 },
2631            Wheel { at: (i32, i32), dy: i32, mods: u32, win: u32 },
2632            Key { mods: u32, vk: u32 },
2633            Type(Vec<u32>),
2634        }
2635        // the words of a drag / wheel up to the limit (a number)
2636        let opts = |r: &[u8], drag: bool| -> Result<(u32, u32, usize), Vec<u8>> {
2637            let (mut btn, mut mods, mut q) = (BTN_LEFT, 0, r);
2638            loop {
2639                q = skip_ws(q);
2640                if q.is_empty() || q[0].is_ascii_digit() {
2641                    return Ok((btn, mods, r.len() - q.len()));
2642                }
2643                let m = mod_word(q);
2644                if m != 0 {
2645                    mods |= m;
2646                } else if drag && word_eq(q, "right").is_some() {
2647                    btn = BTN_RIGHT;
2648                } else if drag && word_eq(q, "middle").is_some() {
2649                    btn = BTN_MIDDLE;
2650                } else {
2651                    let mut d = b"unknown bench option ".to_vec();
2652                    d.extend_from_slice(q);
2653                    return Err(d);
2654                }
2655                let i = q.iter().position(|&c| c == b' ' || c == b'\t').unwrap_or(q.len());
2656                q = &q[i..];
2657            }
2658        };
2659        let (kind, rest): (Kind, &[u8]) = if let Some(r) = word_eq(a, "anim") {
2660            (Kind::Anim, r)
2661        } else if let Some(r) = word_eq(a, "drag") {
2662            let (s, r) = self.resolve(skip_ws(r));
2663            if s.is_none() {
2664                self.sel_fail();
2665                return;
2666            }
2667            let (from, win) = ((self.sc.sel_x, self.sc.sel_y), self.sc.sel_win);
2668            let (s, r) = self.resolve(skip_ws(r));
2669            if s.is_none() {
2670                self.sel_fail();
2671                return;
2672            }
2673            self.point_to_window(self.sc.sel_win, win);
2674            let to = (self.sc.sel_x, self.sc.sel_y);
2675            match opts(r, true) {
2676                Ok((btn, mods, k)) => (Kind::Drag { from, to, btn, mods, win }, &r[k..]),
2677                Err(d) => {
2678                    self.fail(&d);
2679                    return;
2680                }
2681            }
2682        } else if let Some(r) = word_eq(a, "wheel") {
2683            let (s, r) = self.resolve(skip_ws(r));
2684            if s.is_none() {
2685                self.sel_fail();
2686                return;
2687            }
2688            let (at, win) = ((self.sc.sel_x, self.sc.sel_y), self.sc.sel_win);
2689            let (dy, r) = parse_int(skip_ws(r));
2690            match opts(r, false) {
2691                Ok((_, mods, k)) => (Kind::Wheel { at, dy, mods, win }, &r[k..]),
2692                Err(d) => {
2693                    self.fail(&d);
2694                    return;
2695                }
2696            }
2697        } else if let Some(r) = word_eq(a, "key") {
2698            let Some((mods, vk, r)) = chord(skip_ws(r)) else {
2699                self.fail(b"unknown key");
2700                return;
2701            };
2702            (Kind::Key { mods, vk }, r)
2703        } else if let Some(r) = word_eq(a, "type") {
2704            let Some((s, r)) = parse_qstring(skip_ws(r), 256) else {
2705                self.fail(b"expected a quoted string");
2706                return;
2707            };
2708            (Kind::Type(String::from_utf8_lossy(&s).chars().map(|c| c as u32).collect()), r)
2709        } else {
2710            self.fail(b"expected: bench N [anim | drag S1 S2 [options] | wheel S DY [options] | key CHORD | type \"s\"] [MAX_US]");
2711            return;
2712        };
2713        let (max, _) = parse_int(skip_ws(rest));
2714        let name = match kind {
2715            Kind::Anim => "anim",
2716            Kind::Drag { .. } => "drag",
2717            Kind::Wheel { .. } => "wheel",
2718            Kind::Key { .. } => "key",
2719            Kind::Type(_) => "type",
2720        };
2721        let runs = if max > 0 { 3 } else { 1 };
2722        let mut best = u64::MAX;
2723        for run in 0..runs {
2724            // (the frame, its render) in us
2725            let mut t: Vec<(u64, u64)> = Vec::with_capacity(n);
2726            match kind {
2727                Kind::Anim => {
2728                    for _ in 0..n {
2729                        t.push(self.bench_frame(app, |ui, app| ui.run_passes(app)));
2730                    }
2731                }
2732                Kind::Drag { from, to, btn, mods, win } => {
2733                    // back and forth: every other run from the end to the start
2734                    let (a, b) = if run % 2 == 0 { (from, to) } else { (to, from) };
2735                    let saved = self.sc.script_mods;
2736                    self.sc.sel_win = win;
2737                    self.mouse_to(app, a.0, a.1);
2738                    self.sc.script_mods = mods;
2739                    let d = self.sc.sc_pdata;
2740                    self.inject(app, plat::E_DOWN, btn, a.0, a.1, d);
2741                    for i in 1..=n as i32 {
2742                        let (x, y) = (a.0 + (b.0 - a.0) * i / n as i32, a.1 + (b.1 - a.1) * i / n as i32);
2743                        t.push(self.bench_frame(app, |ui, app| ui.mouse_to(app, x, y)));
2744                    }
2745                    let d = self.sc.sc_pdata;
2746                    self.inject(app, plat::E_UP, btn, b.0, b.1, d);
2747                    self.sc.script_mods = saved;
2748                }
2749                Kind::Wheel { at, dy, mods, win } => {
2750                    self.sc.sel_win = win;
2751                    self.mouse_to(app, at.0, at.1);
2752                    let saved = self.sc.script_mods;
2753                    self.sc.script_mods = mods;
2754                    for i in 0..n {
2755                        let v = if i % 2 == 0 { dy } else { -dy };
2756                        let (x, y) = (self.sc.sc_x, self.sc.sc_y);
2757                        t.push(self.bench_frame(app, |ui, app| ui.inject(app, plat::E_WHEEL, 0, x, y, v as u16 as u32)));
2758                    }
2759                    self.sc.script_mods = saved;
2760                }
2761                Kind::Key { mods, vk } => {
2762                    for _ in 0..n {
2763                        t.push(self.bench_frame(app, |ui, app| ui.press(app, mods, vk)));
2764                    }
2765                }
2766                Kind::Type(ref cps) => {
2767                    for _ in 0..n {
2768                        t.push(self.bench_frame(app, |ui, app| {
2769                            for &cp in cps {
2770                                ui.inject(app, plat::E_TEXT, 0, 0, 0, cp);
2771                            }
2772                        }));
2773                    }
2774                }
2775            }
2776            let mut s: Vec<u64> = t.iter().map(|x| x.0).collect();
2777            s.sort_unstable();
2778            let (mean, median, top) = (s.iter().sum::<u64>() / s.len() as u64, s[s.len() / 2], s[s.len() - 1]);
2779            let render = t.iter().map(|x| x.1).sum::<u64>() / t.len() as u64;
2780            best = best.min(median);
2781            let o = alloc::format!(
2782                "bench {}: {} frames, mean {} us (render {} us), median {} us, max {} us ({}x{} px)\n",
2783                name,
2784                s.len(),
2785                mean,
2786                render,
2787                median,
2788                top,
2789                self.wp.rt.fb.w,
2790                self.wp.rt.fb.h
2791            );
2792            plat::write_stdout(o.as_bytes());
2793            if max <= 0 || median <= max as u64 {
2794                return;
2795            }
2796        }
2797        self.fail(alloc::format!("the median stayed above {} us in {} runs (best {} us)", max, runs, best).as_bytes());
2798    }
2799
2800    // one timed frame of bench: the clock 16 ms on (due timers queued), f (the input, the
2801    // passes), the render of the dirty cells -> (us, the render's us); --verify-dirty's
2802    // comparison follows untimed
2803    fn bench_frame(&mut self, app: &mut dyn App, f: impl FnOnce(&mut Ui, &mut dyn App)) -> (u64, u64) {
2804        let verify = self.verify_dirty;
2805        self.verify_dirty = false;
2806        let t0 = plat::time_us();
2807        let t = self.now_ms.wrapping_add(16);
2808        self.set_time(t);
2809        loop {
2810            let (id, _) = self.timer_due();
2811            if id == u32::MAX {
2812                break;
2813            }
2814            self.cmd_run(UIC_TIMER, id);
2815        }
2816        f(self, app);
2817        let t1 = plat::time_us();
2818        self.render();
2819        let t2 = plat::time_us();
2820        self.verify_dirty = verify;
2821        if verify {
2822            self.verify_dirty();
2823        }
2824        (t2.saturating_sub(t0), t2.saturating_sub(t1))
2825    }
2826
2827    // ------------------------------------------------------------------ (K1) drop / clip
2828
2829    // k1_word(p, cap) -> (the word ("quoted" or up to a blank; an over-long plain word is cut),
2830    // the rest after it and its blanks); None = no word
2831    fn k1_word(p: &[u8], cap: usize) -> Option<(Vec<u8>, &[u8])> {
2832        let p = skip_ws(p);
2833        if p.is_empty() {
2834            return None;
2835        }
2836        if p[0] == b'"' {
2837            let (w, r) = parse_qstring(p, cap)?;
2838            return Some((w, skip_ws(r)));
2839        }
2840        let e = p.iter().position(|&c| c == b' ' || c == b'\t').unwrap_or(p.len());
2841        let w = p[..e.min(cap - 1)].to_vec();
2842        Some((w, skip_ws(&p[e..])))
2843    }
2844
2845    // drop X Y FILE... | drop X Y [files F...] [text "s"] [html "s"] [url U]... [image F]
2846    // [copy|move|link] [sync] [allows copy|move|link...] | drop X Y self: dropped on the scoped
2847    // window (the main window for "any"); `self`: the app's own drag that left its window
2848    // (dragout) comes back; `allows`: the effects its source allows, given with the drop
2849    fn sc_drop(&mut self, app: &mut dyn App, a: &[u8]) {
2850        const BAD: &[u8] = b"expected: drop X Y FILE... | drop X Y [files F...] [text \"s\"] [html \"s\"] [url U] [image F] [copy|move|link] [sync] [allows copy|move|link...] | drop X Y self | drop expect copy|move|link|none|pending";
2851        if let Some((verb, r)) = Ui::k1_word(a, 16)
2852            && verb == b"expect"
2853        {
2854            self.sc_drop_expect(r);
2855            return;
2856        }
2857        let (x, r) = parse_int(a);
2858        let (y, r) = parse_int(r);
2859        let w = self.scope_index().max(0) as u32;
2860        let dpi = self.win_dpi(w);
2861        let (px, py) = (dp_at(x, dpi), dp_at(y, dpi));
2862        let Some((data, allowed)) = self.sc_drag_data(r) else {
2863            self.dnd.sync_next = false;
2864            self.fail(BAD);
2865            return;
2866        };
2867        if data.types() == 0 && word_eq(skip_ws(r), "self").is_none() {
2868            self.dnd.sync_next = false;
2869            self.fail(BAD);
2870            return;
2871        }
2872        let n = data.files.len() as u32;
2873        self.drop_inject(data, allowed);
2874        self.sc.sc_win = w;
2875        self.inject(app, plat::E_DROP, 0, px, py, n);
2876        self.run_passes(app);
2877    }
2878
2879    // drop expect copy|move|link|none|pending: what the source of the last drop was answered
2880    // (drop_defer / drop_finish, else the effect it was delivered with), or that it waits
2881    fn sc_drop_expect(&mut self, a: &[u8]) {
2882        let name = |e: u32| match e {
2883            plat::DE_COPY => "copy",
2884            plat::DE_MOVE => "move",
2885            plat::DE_LINK => "link",
2886            _ => "none",
2887        };
2888        let want = match Ui::k1_word(a, 16).map(|w| w.0) {
2889            Some(w) if w == b"copy" => Some(plat::DE_COPY),
2890            Some(w) if w == b"move" => Some(plat::DE_MOVE),
2891            Some(w) if w == b"link" => Some(plat::DE_LINK),
2892            Some(w) if w == b"none" => Some(0),
2893            Some(w) if w == b"pending" => None,
2894            _ => {
2895                self.fail(b"expected: drop expect copy|move|link|none|pending");
2896                return;
2897            }
2898        };
2899        match (self.dnd_answered(), want) {
2900            (None, _) => self.fail(b"no drop yet"),
2901            (Some(have), want) if have == want => {}
2902            (Some(None), _) => self.fail(b"the answer waits (drop_defer)"),
2903            (Some(Some(e)), _) => self.fail(alloc::format!("the source was answered {}", name(e)).as_bytes()),
2904        }
2905    }
2906
2907    // sc_drag_data(words) -> (the content of `drop`, the effects its source allows; 0: not said)
2908    // (None: a bad word): plain words are paths; files / text / html / url / image name a kind;
2909    // copy / move / link the effect; self nothing; sync: the next drop is answered at the drop;
2910    // allows: the copy / move / link words after it are the effects the source allows
2911    fn sc_drag_data(&mut self, a: &[u8]) -> Option<(DragData, u32)> {
2912        let mut d = DragData::default();
2913        let mut r = a;
2914        let mut kind: &[u8] = b"files";
2915        let (mut allows, mut allowed) = (false, 0u32);
2916        plat::drop_reset();
2917        while let Some((word, rest)) = Ui::k1_word(r, 4096) {
2918            r = rest;
2919            let w = word.as_slice();
2920            if !matches!(w, b"copy" | b"move" | b"link") {
2921                allows = false;
2922            }
2923            match w {
2924                b"files" | b"text" | b"html" | b"url" | b"image" if !matches!(kind, b"text" | b"html" | b"url" | b"image") => {
2925                    kind = match w {
2926                        b"files" => b"files",
2927                        b"text" => b"text",
2928                        b"html" => b"html",
2929                        b"url" => b"url",
2930                        _ => b"image",
2931                    };
2932                    continue;
2933                }
2934                b"copy" | b"move" | b"link" if kind == b"files" => {
2935                    let e = match w {
2936                        b"copy" => plat::DE_COPY,
2937                        b"move" => plat::DE_MOVE,
2938                        _ => plat::DE_LINK,
2939                    };
2940                    if allows {
2941                        allowed |= e;
2942                    } else {
2943                        d.effects = e;
2944                    }
2945                    continue;
2946                }
2947                b"allows" if kind == b"files" => {
2948                    allows = true;
2949                    continue;
2950                }
2951                b"self" if kind == b"files" => continue,
2952                b"sync" if kind == b"files" => {
2953                    self.dnd.sync_next = true;
2954                    continue;
2955                }
2956                _ => {}
2957            }
2958            let s = String::from_utf8_lossy(w).into_owned();
2959            match kind {
2960                b"text" => d.text = Some(s),
2961                b"html" => d.html = Some(s),
2962                b"url" => d.urls.push(s),
2963                b"image" => d.image = Some(super::perm::trusted(|| plat::read_file(&s))?),
2964                _ => {
2965                    plat::drop_add(&s);
2966                }
2967            }
2968            kind = b"files"; // one value per kind word; plain words are paths again
2969        }
2970        if matches!(kind, b"text" | b"html" | b"url" | b"image") {
2971            return None; // a kind without its value
2972        }
2973        d.files = plat::drop_take();
2974        Some((d, allowed))
2975    }
2976
2977    // dragover X Y [files] [text] [url] [html] [image] [copy|move|link] | dragleave: another
2978    // program's drag hovers over the scoped window (the kinds it offers; default files)
2979    fn sc_dragover(&mut self, app: &mut dyn App, a: &[u8], leave: bool) {
2980        let w = self.scope_index().max(0) as u32;
2981        self.sc.sc_win = w;
2982        if leave {
2983            self.inject(app, plat::E_DRAG, 0, -1, -1, 0);
2984            return;
2985        }
2986        if let Some((verb, r)) = Ui::k1_word(a, 16)
2987            && verb == b"expect"
2988        {
2989            self.sc_dragover_expect(r);
2990            return;
2991        }
2992        let (x, r) = parse_int(a);
2993        let (y, mut r) = parse_int(r);
2994        let dpi = self.win_dpi(w);
2995        let (px, py) = (dp_at(x, dpi), dp_at(y, dpi));
2996        let (mut types, mut effects) = (0u32, 0u32);
2997        while let Some((word, rest)) = Ui::k1_word(r, 16) {
2998            r = rest;
2999            match word.as_slice() {
3000                b"files" => types |= plat::DT_FILES,
3001                b"text" => types |= plat::DT_TEXT,
3002                b"url" => types |= plat::DT_URL,
3003                b"html" => types |= plat::DT_HTML,
3004                b"image" => types |= plat::DT_IMAGE,
3005                b"copy" => effects |= plat::DE_COPY,
3006                b"move" => effects |= plat::DE_MOVE,
3007                b"link" => effects |= plat::DE_LINK,
3008                _ => {
3009                    self.fail(b"expected: dragover X Y [files] [text] [url] [html] [image] [copy|move|link]");
3010                    return;
3011                }
3012            }
3013        }
3014        if types == 0 {
3015            types = plat::DT_FILES;
3016        }
3017        if effects == 0 {
3018            effects = plat::DE_COPY | plat::DE_MOVE;
3019        }
3020        self.inject(app, plat::E_DRAG, 0, px, py, types | effects << 8);
3021    }
3022
3023    // dragover expect copy|move|link|none [ctrl] [shift]: the platform's answer to the drag that
3024    // hovers now (the drop targets of the last pass and their drop_effect_set, plat::drag_decide
3025    // with those modifier keys), as IDropTarget::DragOver / XdndStatus / set_actions would give it
3026    fn sc_dragover_expect(&mut self, a: &[u8]) {
3027        const BAD: &[u8] = b"expected: dragover expect copy|move|link|none [ctrl] [shift]";
3028        let Some((want, mut r)) = Ui::k1_word(a, 16) else {
3029            self.fail(BAD);
3030            return;
3031        };
3032        let want = match want.as_slice() {
3033            b"copy" => plat::DE_COPY,
3034            b"move" => plat::DE_MOVE,
3035            b"link" => plat::DE_LINK,
3036            b"none" => 0,
3037            _ => {
3038                self.fail(BAD);
3039                return;
3040            }
3041        };
3042        let mut mods = 0u8;
3043        while let Some((word, rest)) = Ui::k1_word(r, 16) {
3044            r = rest;
3045            match word.as_slice() {
3046                b"ctrl" => mods |= plat::MOD_CTRL,
3047                b"shift" => mods |= plat::MOD_SHIFT,
3048                _ => {
3049                    self.fail(BAD);
3050                    return;
3051                }
3052            }
3053        }
3054        let name = |e: u32| match e {
3055            plat::DE_COPY => "copy",
3056            plat::DE_MOVE => "move",
3057            plat::DE_LINK => "link",
3058            _ => "none",
3059        };
3060        match self.dnd_answer_now(mods) {
3061            None => self.fail(b"no drag hovers (dragover X Y first)"),
3062            Some((_, e, all)) if e != want => {
3063                let msg = alloc::format!("the platform would answer {} (the target takes {})", name(e), {
3064                    let v: Vec<&str> = [plat::DE_COPY, plat::DE_MOVE, plat::DE_LINK].into_iter().filter(|f| all & f != 0).map(name).collect();
3065                    if v.is_empty() { String::from("none") } else { v.join("+") }
3066                });
3067                self.fail(msg.as_bytes());
3068            }
3069            Some(_) => {}
3070        }
3071    }
3072
3073    // dragout expect none | text "s" | html "s" | url "s" | files N | image | kinds N, dragout
3074    // end copy|move|link|none: what the app's drag handed to the platform when it left the
3075    // window (headless), and the platform's answer (E_DRAG_END)
3076    fn sc_dragout(&mut self, app: &mut dyn App, a: &[u8]) {
3077        const BAD: &[u8] = b"expected: dragout expect none|text \"s\"|html \"s\"|url \"s\"|files N|image|kinds N | dragout end copy|move|link|none";
3078        let Some((verb, r)) = Ui::k1_word(a, 16) else {
3079            self.fail(BAD);
3080            return;
3081        };
3082        if verb == b"end" {
3083            let e = match Ui::k1_word(r, 16).map(|w| w.0) {
3084                Some(w) if w == b"copy" => plat::DE_COPY,
3085                Some(w) if w == b"move" => plat::DE_MOVE,
3086                Some(w) if w == b"link" => plat::DE_LINK,
3087                Some(w) if w == b"none" => 0,
3088                _ => {
3089                    self.fail(BAD);
3090                    return;
3091                }
3092            };
3093            self.inject(app, plat::E_DRAG_END, 0, 0, 0, e);
3094            return;
3095        }
3096        if verb != b"expect" {
3097            self.fail(BAD);
3098            return;
3099        }
3100        let Some((what, r)) = Ui::k1_word(r, 16) else {
3101            self.fail(BAD);
3102            return;
3103        };
3104        let out = self.dnd_out_data().cloned();
3105        let arg = Ui::k1_word(r, 4096).map(|w| w.0);
3106        let got: Result<(), String> = match (what.as_slice(), &out) {
3107            (b"none", None) => Ok(()),
3108            (b"none", Some(_)) => Err(String::from("a drag left the window")),
3109            (_, None) => Err(String::from("no drag left the window")),
3110            (b"image", Some(d)) => if d.image.is_some() { Ok(()) } else { Err(String::from("no image")) },
3111            (b"files" | b"kinds", Some(d)) => {
3112                let want = parse_int(arg.as_deref().unwrap_or(b"")).0;
3113                let n = if what == b"files" { d.files.len() as i32 } else { d.types() as i32 };
3114                if n == want { Ok(()) } else { Err(alloc::format!("{n}")) }
3115            }
3116            (b"text" | b"html" | b"url", Some(d)) => {
3117                let want = String::from_utf8_lossy(arg.as_deref().unwrap_or(b"")).into_owned();
3118                let have = match what.as_slice() {
3119                    b"text" => d.text.clone(),
3120                    b"html" => d.html.clone(),
3121                    _ => d.urls.first().cloned(),
3122                };
3123                if have.as_deref() == Some(want.as_str()) { Ok(()) } else { Err(alloc::format!("{have:?}")) }
3124            }
3125            _ => Err(String::from("bad kind")),
3126        };
3127        if let Err(e) = got {
3128            self.fail(e.as_bytes());
3129        }
3130    }
3131
3132    // clip text "s" | file F | dib F | clear | save F [bmp] | expect none|text|image|png
3133    fn sc_clip(&mut self, app: &mut dyn App, a: &[u8]) {
3134        const BAD: &[u8] = b"expected: clip text \"s\" | file F | dib F | clear | save F [bmp] | expect none|text|image|png";
3135        let Some((verb, r)) = Ui::k1_word(a, 16) else {
3136            self.fail(BAD);
3137            self.run_passes(app);
3138            return;
3139        };
3140        let v4 = |s: &[u8]| verb.len() >= 4 && &verb[..4] == s;
3141        let v3 = |s: &[u8]| verb.len() >= 3 && &verb[..3] == s;
3142        if verb == b"data" {
3143            // clipboard_platform: deterministic external rich-format fixtures.
3144            let mut rest = r;
3145            let mut entries = Vec::new();
3146            let mut valid = true;
3147            while !rest.is_empty() && entries.len() < 4 {
3148                let item = Ui::k1_word(rest, 16).and_then(|(kind, next)| {
3149                    let fmt = match kind.as_slice() { b"text" => CLIP_TEXT, b"html" => CLIP_HTML, b"rtf" => CLIP_RTF, b"docx" => CLIP_DOCX, _ => return None };
3150                    let (file, next) = Ui::k1_word(next, 1040)?;
3151                    Some((fmt, super::perm::trusted(|| plat::read_file(&String::from_utf8_lossy(&file)))?, next))
3152                });
3153                match item {
3154                    Some((fmt, data, next)) => { entries.push((fmt, data)); rest = next; }
3155                    None => { valid = false; break; }
3156                }
3157            }
3158            let refs: Vec<_> = entries.iter().map(|(fmt, data)| (*fmt, data.as_slice())).collect();
3159            if !valid || !rest.is_empty() || self.clip_set_many(&refs) != 0 {
3160                self.fail(b"clip data requires one to four text|html|rtf|docx PATH pairs with valid payloads");
3161            }
3162        } else if verb == b"utf8" {
3163            // Opt-in file payloads exercise exact clipboard bytes, including CR and large
3164            // text, without the script parser's line-ending removal/string-length limit.
3165            match Ui::k1_word(r, 1040).and_then(|(name, _)| super::perm::trusted(|| plat::read_file(&String::from_utf8_lossy(&name)))) {
3166                Some(data) if data.len() <= 4 << 20 && core::str::from_utf8(&data).is_ok() => { self.clip_set(CLIP_TEXT, &data); }
3167                _ => self.fail(b"clip utf8 requires a readable UTF-8 file of at most 4 MiB"),
3168            }
3169        } else if v4(b"text") {
3170            match parse_qstring(r, 1040) {
3171                Some((t, _)) => {
3172                    self.clip_set(CLIP_TEXT, &t);
3173                }
3174                None => self.fail(BAD),
3175            }
3176        } else if v4(b"file") || v3(b"dib") {
3177            match Ui::k1_word(r, 1040) {
3178                None => self.fail(BAD),
3179                Some((f, _)) => {
3180                    let name = String::from_utf8_lossy(&f).into_owned();
3181                    match super::perm::trusted(|| plat::read_file(&name)) {
3182                        None => {
3183                            let mut d = b"cannot read ".to_vec();
3184                            d.extend_from_slice(&f);
3185                            self.fail(&d);
3186                        }
3187                        Some(data) => {
3188                            // a BMP file: the image/bmp path; dib: the CF_DIB path
3189                            let bmp = if v3(b"dib") {
3190                                Some(plat::clip_dib_to_bmp(&data))
3191                            } else if data.len() >= 2 && data[0] == b'B' && data[1] == b'M' {
3192                                Some(plat::clip_bmp_fix(&data))
3193                            } else {
3194                                None
3195                            };
3196                            match bmp {
3197                                Some(None) => self.fail(b"invalid DIB / BMP data"),
3198                                Some(Some(b)) => self.hclip_img_set_parts(None, Some(&b)),
3199                                None => self.hclip_img_set_parts(Some(&data), None),
3200                            }
3201                        }
3202                    }
3203                }
3204            }
3205        } else if v4(b"clea") {
3206            self.hclip_img_clear();
3207            self.hclip_text_clear();
3208        } else if v4(b"save") {
3209            match Ui::k1_word(r, 1040) {
3210                None => self.fail(BAD),
3211                Some((f, rest)) => {
3212                    let name = String::from_utf8_lossy(&f).into_owned();
3213                    let data = match rest {
3214                        b"text" => self.clip_read(CLIP_TEXT),
3215                        b"html" => self.clip_read(CLIP_HTML),
3216                        b"rtf" => self.clip_read(CLIP_RTF),
3217                        b"docx" => self.clip_read(CLIP_DOCX),
3218                        _ if rest.first() == Some(&b'b') => self.dr.hi_bmp.clone(),
3219                        _ => self.hclip_img_get(CLIP_PNG),
3220                    };
3221                    match data {
3222                        Some(d) => {
3223                            super::perm::trusted(|| plat::write_file(&name, &d));
3224                        }
3225                        None => self.fail(b"the clipboard holds no image"),
3226                    }
3227                }
3228            }
3229        } else if v4(b"expe") {
3230            match Ui::k1_word(r, 16) {
3231                None => self.fail(BAD),
3232                Some((w, _)) => {
3233                    let text = self.hclip_text_has();
3234                    let png = self.hclip_img_has(CLIP_PNG);
3235                    let image = self.hclip_img_has(CLIP_IMAGE);
3236                    let w4 = |s: &[u8]| w.len() >= 4 && &w[..4] == s;
3237                    let ok = if w4(b"none") {
3238                        Some(!text && !image && self.m.edit.hclip_rich.is_empty())
3239                    } else if w == b"text-only" {
3240                        Some(text && !image && self.m.edit.hclip_rich.is_empty())
3241                    } else if w4(b"text") {
3242                        Some(text)
3243                    } else if w == b"html" {
3244                        Some(self.clip_has(CLIP_HTML))
3245                    } else if w == b"rtf" {
3246                        Some(self.clip_has(CLIP_RTF))
3247                    } else if w == b"docx" {
3248                        Some(self.clip_has(CLIP_DOCX))
3249                    } else if w4(b"imag") {
3250                        Some(image)
3251                    } else if w.len() >= 3 && &w[..3] == b"png" {
3252                        Some(png)
3253                    } else {
3254                        None
3255                    };
3256                    match ok {
3257                        None => self.fail(BAD),
3258                        Some(false) => self.fail(alloc::format!("unexpected clipboard content (text {})", text as u32).as_bytes()),
3259                        Some(true) => {}
3260                    }
3261                }
3262            }
3263        } else {
3264            self.fail(BAD);
3265        }
3266        self.run_passes(app);
3267    }
3268}