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