Skip to main content

ui/
list.rs

1// list -- scroll views, scroll bars, lists, tabs and expanders (WinUI 3 style)
2//                                                                  [src/ui/ui_list.s]
3//
4//   scroll_begin(id, flags)                clipped viewport that takes its size like a box
5//        (next_size / parent) and lays its children out in a content area that may be
6//        larger; flags = SC_V (default) | SC_H | SC_NOBAR | SC_NOWHEEL | BOX_BG | BOX_CARD |
7//        PAD_n ...  Mouse wheel (48 DIP per notch, smooth), overlay scroll bars (2 DIP, 6 DIP
8//        while pointed at, draggable thumb, click on the track pages), Tab focus is scrolled
9//        into view.
10//   scroll_end()
11//   scroll_to(id, x, y)                    jump to a position in DIP (-1 keeps that axis)
12//   scroll_pos(id) -> (x, y)               current position in DIP
13//   scroll_extent(id) -> (w, h)            content size in DIP (last measure; 0 before) (U3)
14//   scrollbar(id, &mut pos, content, view, vertical) -> EV
15//        standalone scroll bar (natural thickness 12 DIP) for custom canvases; pos runs from
16//        0 to content - view in the caller's unit; EV_ACT when pos changed
17//   list_begin(id, &mut sel, count, item_h | LF_*) -> (first, end)
18//        virtualized list (own scroll view, fixed row height in DIP, 0 = 40); emit the rows
19//        first .. end-1; row i has the widget id `id + 1 + i`.  Up/Down/Home/End/PgUp/PgDn
20//        move the selection (sel, -1 = none) and scroll it into view; Enter: the selected
21//        row reports EV_DOUBLE (list_end too), Space: the first check box / switch of the
22//        selected row acts; the menu key: the selected row's right click.  The list is one Tab
23//        stop; the widgets inside custom rows are Tab stops in the selected row only.  Focus
24//        alone selects nothing: a focused list without a selection has the focus visual
25//        around it.
26//        LF_REORDER (U1): a row dragged more than 4 DIP is moved: an accent insertion line
27//        follows the pointer, the view scrolls near its edges, Esc cancels; on release
28//        list_end returns EV_DRAG with from | to << 16 (the app moves item `from` so that it
29//        ends at index `to`) and sel = to.  Widgets inside custom rows keep their clicks.
30//        The keyboard moves the selected row the same way: Ctrl+Up / Ctrl+Down one place,
31//        Ctrl+Home / Ctrl+End to the top / bottom.
32//   list_item(i, label) -> EV              text row (EV_ACT clicked, EV_DOUBLE, EV_RCLICK)
33//   list_item_begin(i) -> EV               custom row: opens a BOX_H inside the row
34//   list_item_end()
35//   list_end(&mut sel) -> (EV, sel)        EV_ACT if sel changed this pass, EV_DOUBLE;
36//                                          EV_DRAG: a row was dropped (from | to << 16)
37//        (Rust: list_begin reads the selection into the list, list_end writes it back --
38//        the asm keeps a pointer to the app's variable in between; list_sel() reads it in
39//        the rows)
40//   tabs(id, &mut sel, names) -> EV        text tab header row, tab i has the id
41//                                          `id + 1 + i`; EV_ACT when sel changed; placed
42//                                          narrower than the tabs, the widest ones give
43//                                          way (labels cut with "…")
44//   expander_begin(id, header, &mut open) -> open   (emit the content when 1)
45//   expander_end()
46//
47// Widget ids used internally: (id << 3 | k) | 0x80000000 for sub parts (k = 1..7),
48// (id + 1 + i) | 0xC0000000 for the content box of custom list rows.
49
50use alloc::vec::Vec;
51
52use super::consts::*;
53use super::keys::*;
54use super::{Sp, Ui};
55
56// nesting stacks (fixed: scroll views, lists and expanders inside each other; deeper ones
57// share the last record, limit_hit warns)
58const SCROLL_MAX: usize = 8;
59pub(crate) const SCR_F_LIST: u32 = 0x4000_0000; // internal: the list emits the semantic record itself
60const LIST_MAX: usize = 4;
61const EXP_MAX: usize = 16;
62/// tab widths on the stack; more tabs (any number) use the heap
63const TABS_INLINE: usize = 32;
64const WHEEL_DIP: i32 = 48;
65
66// tab widths: on the stack up to TABS_INLINE tabs (no allocation), on the heap beyond
67#[derive(Default)]
68struct Tw {
69    inline: [i32; TABS_INLINE],
70    heap: Vec<i32>,
71}
72
73impl Tw {
74    // get(n): room for the widths of n tabs
75    fn get(&mut self, n: usize) -> &mut [i32] {
76        if n <= TABS_INLINE {
77            &mut self.inline[..n]
78        } else {
79            self.heap.resize(n, 0);
80            &mut self.heap[..]
81        }
82    }
83}
84
85// tabs_fit(widths, room, min): the widest tabs narrowed to one width so that the row fits
86// room (none below min)
87fn tabs_fit(ws: &mut [i32], room: i32, min: i32) {
88    let mut sorted = ws.to_vec();
89    sorted.sort_unstable();
90    let (mut rest, mut cap) = (room, i32::MAX);
91    for (k, &w) in sorted.iter().enumerate() {
92        let left = (sorted.len() - k) as i32;
93        if w.saturating_mul(left) > rest {
94            cap = rest / left;
95            break;
96        }
97        rest -= w;
98    }
99    let cap = cap.max(min);
100    for w in ws.iter_mut() {
101        *w = (*w).min(cap);
102    }
103}
104
105// scroll view slot fields (the box slot of the view; the animation fields are free):
106// SLS_FOCUS = afrom | ato << 16 (focus id seen at the last scroll_end), SLS_TX = at0 / SLS_TY
107// = adur (target position px), SLS_T = aux (ms of the last smooth scrolling step).
108// Scroll bar slot: SB_MODE = scrollx (0 idle, 1 dragging the thumb, 2 paging), SB_GRAB = aux.
109// Tabs slot (selection pill animation): TB_CUR = scrollx, TB_TGT = scrolly, TB_T = aux,
110// TB_INIT = at0.
111
112/// a scroll view on the stack  [SCR_*]
113#[derive(Clone, Copy, Default)]
114pub(crate) struct Scr {
115    id: u32,
116    flags: u32,
117    /// viewport rectangle (px)
118    rect: [i32; 4],
119    /// index of the viewport's hit record (current list)
120    hit0: usize,
121    /// first semantic record of the content
122    sem0: usize,
123    layer: u32,
124    /// events of the viewport hit box (lists)
125    ev: u32,
126    /// its content is a scroll copy region (scrollcopy.rs)
127    copy: bool,
128}
129
130/// a list on the stack  [LS_*]
131#[derive(Clone, Copy, Default)]
132pub(crate) struct Ls {
133    id: u32,
134    count: i32,
135    /// the selection (the asm keeps a pointer to the app's variable)
136    sel: i32,
137    /// row height px
138    ih: i32,
139    /// next row index expected
140    next: i32,
141    /// sel at list_begin
142    old: i32,
143    ev: u32,
144    /// events of the viewport (focus)
145    vev: u32,
146    /// y of row 0 (px, scrolled)  (U1)
147    y0: i32,
148    /// LF_* flags
149    flags: u32,
150    /// from | to << 16 of a drop in this pass, -1 = none
151    drop: i32,
152    /// keyboard: Enter (the selected row reports EV_DOUBLE), Space (its check box acts)
153    kdbl: bool,
154    ktog: bool,
155    /// the row being emitted is no Tab stop host (kbnav.rs: not the selected row)
156    notab: bool,
157}
158
159/// state shared by all windows (none: ui_list.s keeps everything per window)
160#[derive(Default)]
161pub(crate) struct S {}
162
163/// per-window state (ui_list_wb0 .. ui_list_wb1, swapped by win.rs): scroll view, list and
164/// expander stacks, the dragged row
165#[derive(Default)]
166pub(crate) struct W {
167    /// SCROLL_MAX entries + the dummy the asm writes when the stack overflows
168    scr: [Scr; SCROLL_MAX + 1],
169    list: [Ls; LIST_MAX + 1],
170    exp: [u32; EXP_MAX],
171    scr_sp: u32,
172    list_sp: u32,
173    exp_sp: u32,
174    /// (U1) list whose row is dragged (0 = none)
175    ldrag_list: u32,
176    ldrag_from: i32,
177    /// insertion slot 0 .. count
178    ldrag_to: i32,
179    /// autoscroll clock
180    ldrag_t: u32,
181}
182
183// clamp_mem(v, max) -> (v moved into 0 .. max, it had to be moved)
184fn clamp_mem(v: i32, max: i32) -> (i32, bool) {
185    let mut x = if v < 0 { 0 } else { v };
186    if x > max {
187        x = max;
188    }
189    (x, x != v)
190}
191
192// anim_decay(cur, target, steps) -> cur moved towards target (the remaining distance shrinks
193// to 150/256 per step; the last pixel snaps)
194pub(crate) fn anim_decay(cur: i32, target: i32, steps: u32) -> i32 {
195    if steps == 0 {
196        return cur;
197    }
198    let d = target.wrapping_sub(cur);
199    let sign = d >> 31;
200    let mut rem = ((d ^ sign).wrapping_sub(sign)) as u32 as u64; // |remaining|
201    for _ in 0..steps {
202        rem = rem * 150 >> 8;
203    }
204    let mut r = rem as u32;
205    if r <= 1 {
206        r = 0;
207    }
208    let r = ((r as i32) ^ sign).wrapping_sub(sign);
209    target.wrapping_sub(r)
210}
211
212// thumb_pos(pos, free, range) -> free * pos / range (0 when range or free <= 0)
213fn thumb_pos(pos: i32, free: i32, range: i32) -> i32 {
214    if range <= 0 || free <= 0 {
215        return 0;
216    }
217    ((pos as u32 as u64 * free as u32 as u64) / range as u32 as u64) as u32 as i32
218}
219
220// into_view(pos, size, vpos, vsize) -> scroll delta that brings pos .. pos+size into
221// vpos .. vpos+vsize (the start wins when it does not fit)
222pub(crate) fn into_view(pos: i32, size: i32, vpos: i32, vsize: i32) -> i32 {
223    let start = pos.wrapping_sub(vpos);
224    if start < 0 {
225        return start;
226    }
227    let end = pos.wrapping_add(size).wrapping_sub(vpos).wrapping_sub(vsize);
228    if end <= 0 {
229        return 0;
230    }
231    if end <= start { end } else { start }
232}
233
234// scr_axes(flags) -> bit 0 vertical, bit 1 horizontal scrolling
235fn scr_axes(flags: u32) -> u32 {
236    if flags & SC_H != 0 {
237        if flags & SC_V != 0 { 3 } else { 2 }
238    } else {
239        1
240    }
241}
242
243// anim_steps(&t0) -> number of 16 ms steps since t0 (0..24); t0 = now if > 0
244pub(crate) fn anim_steps(now: u32, t0: &mut i32) -> u32 {
245    let d = (now as i32).wrapping_sub(*t0);
246    if d <= 0 {
247        return 0;
248    }
249    *t0 = now as i32;
250    let mut n = (d as u32).wrapping_add(8) >> 4;
251    if n == 0 {
252        n = 1;
253    }
254    n.min(24)
255}
256
257impl Ui {
258    // ------------------------------------------------------------------ scroll views
259
260    // scr_top: the innermost scroll entry index (the dummy when the stack overflowed)
261    pub(crate) fn scr_top(&self) -> usize {
262        let i = self.wp.list.scr_sp.wrapping_sub(1) as usize;
263        if i < SCROLL_MAX { i } else { SCROLL_MAX }
264    }
265
266    // scr_max(entry) -> (max x, max y) px from the last measured content
267    fn scr_max(&mut self, e: &Scr) -> (i32, i32) {
268        let s = *self.slot(e.id);
269        let mut my = 0;
270        let mut mx = 0;
271        if e.flags & SC_H == 0 || e.flags & SC_V != 0 {
272            my = s.meash.wrapping_sub(e.rect[3]).max(0);
273        }
274        if e.flags & SC_H != 0 {
275            mx = s.measw.wrapping_sub(e.rect[2]).max(0);
276        }
277        (mx, my)
278    }
279
280    // scr_open(id, flags, hbflags) -> EV of the viewport hit box: places the viewport (a
281    // clipped box), pushes the scroll entry, registers the viewport hit box (behave when
282    // hbflags has HB_FOCUS, else a plain hit box)
283    pub(crate) fn scr_open(&mut self, id: u32, flags: u32, hbflags: u32) -> u32 {
284        let axes = scr_axes(flags);
285        let mut bf = (flags & 0x00FF_FFFF & !BOX_V) | BOX_CLIP;
286        if axes != 2 {
287            bf |= BOX_V; // vertical (or both): children top to bottom
288        }
289        self.box_begin(id, bf, 0);
290        // stack entry
291        let i = (self.wp.list.scr_sp as usize).min(SCROLL_MAX);
292        if i == SCROLL_MAX {
293            self.limit_hit(super::ctx::LIM_SCROLLS);
294        }
295        self.wp.list.scr_sp = self.wp.list.scr_sp.wrapping_add(1);
296        let c = &self.wp.ctx;
297        let mut e = Scr {
298            id,
299            flags,
300            rect: c.last_rect,
301            layer: c.cmd_layer_ui,
302            sem0: self.sems.len(),
303            hit0: c.hits[c.hit_cur].len(),
304            ev: 0,
305            copy: false,
306        };
307        self.slot(id);
308        // viewport hit box (the content's hit boxes follow it)
309        if hbflags & HB_FOCUS != 0 {
310            e.ev = self.behave(id, hbflags);
311        } else {
312            self.hit(id, hbflags);
313        }
314        self.wp.list.scr[i] = e;
315        e.ev
316    }
317
318    // scr_settle(entry) -> the position had to be clamped: clamps target and position to the
319    // content and advances smooth scrolling
320    fn scr_settle(&mut self, e: &Scr) -> bool {
321        let (mx, my) = self.scr_max(e);
322        let now = self.now_ms;
323        let anim_off = self.anim_off;
324        let s = self.slot(e.id);
325        // never measured (first pass): keep a position set by scroll_to
326        if s.measw | s.meash == 0 {
327            return false;
328        }
329        s.at0 = clamp_mem(s.at0 as i32, mx).0 as u32;
330        s.adur = clamp_mem(s.adur as i32, my).0 as u32;
331        let (tx, ty) = (s.at0 as i32, s.adur as i32);
332        let mut animating = false;
333        if s.scrollx != tx || s.scrolly != ty {
334            if anim_off {
335                s.scrollx = tx;
336                s.scrolly = ty;
337            } else {
338                let steps = anim_steps(now, &mut s.aux);
339                s.scrollx = anim_decay(s.scrollx, tx, steps);
340                s.scrolly = anim_decay(s.scrolly, ty, steps);
341                if s.scrollx != tx || s.scrolly != ty {
342                    animating = true;
343                }
344            }
345        }
346        let (x, cx) = clamp_mem(s.scrollx, mx);
347        let (y, cy) = clamp_mem(s.scrolly, my);
348        s.scrollx = x;
349        s.scrolly = y;
350        if animating {
351            self.animating = true;
352        }
353        cx || cy
354    }
355
356    // scr_offset: settle the innermost view and shift its content by the position (a view
357    // that can scroll: its content moves with the position, scrollcopy.rs)
358    pub(crate) fn scr_offset(&mut self) {
359        let top = self.scr_top();
360        let e = self.wp.list.scr[top];
361        self.scr_settle(&e);
362        let s = *self.slot(e.id);
363        self.box_offset(s.scrollx, s.scrolly);
364        let (mx, my) = self.scr_max(&e);
365        if mx | my != 0 {
366            self.wp.list.scr[top].copy = true;
367            self.scroll_copy_begin(e.id, e.rect[0].wrapping_sub(s.scrollx) as i64, e.rect[1].wrapping_sub(s.scrolly) as i64);
368        }
369    }
370
371    // scr_follow(entry): keyboard focus moved onto a widget of this view: scroll it into view
372    fn scr_follow(&mut self, e: &Scr) {
373        let f = self.wp.ctx.focus;
374        let s = self.slot(e.id);
375        if f == (s.afrom as u32 | (s.ato as u32) << 16) {
376            return;
377        }
378        s.afrom = f as u16;
379        s.ato = (f >> 16) as u16;
380        if f == 0 || !self.wp.ctx.focus_visible || f == e.id {
381            return;
382        }
383        self.scr_show(e, f);
384    }
385
386    // scr_show(entry, id): scroll widget id into view, when it is in this view
387    fn scr_show(&mut self, e: &Scr, id: u32) {
388        // its semantic record among ours (last match)
389        let Some(r) = self.sems.get(e.sem0..).and_then(|v| v.iter().rev().find(|r| r.id == id)).map(|r| self.phys_rect(r.rect)) else {
390            return;
391        };
392        let axes = scr_axes(e.flags);
393        let m = self.dp(8); // margin: keep the focus visual inside
394        // as far as the content goes: a margin that does not fit at its end is no reason for
395        // another pass (never measured: the first pass keeps the position, scr_settle)
396        let (mx, my) = self.scr_max(e);
397        let measured = {
398            let s = self.slot(e.id);
399            s.measw | s.meash != 0
400        };
401        if axes & 1 != 0 {
402            let d = into_view(r[1].wrapping_sub(m), r[3].wrapping_add(m * 2), e.rect[1], e.rect[3]);
403            let s = self.slot(e.id);
404            let to = s.scrolly.wrapping_add(d);
405            let to = if measured { to.clamp(0, my) } else { to };
406            if to != s.scrolly {
407                s.scrolly = to;
408                s.adur = to as u32;
409                self.repass();
410            }
411        }
412        if axes & 2 != 0 {
413            let d = into_view(r[0].wrapping_sub(m), r[2].wrapping_add(m * 2), e.rect[0], e.rect[2]);
414            let s = self.slot(e.id);
415            let to = s.scrollx.wrapping_add(d);
416            let to = if measured { to.clamp(0, mx) } else { to };
417            if to != s.scrollx {
418                s.scrollx = to;
419                s.at0 = to as u32;
420                self.repass();
421            }
422        }
423    }
424
425    // scr_bars(entry): overlay scroll bars for the axes whose content overflows
426    fn scr_bars(&mut self, e: &Scr) {
427        let st = self.style;
428        let t = st.scrollbar_thickness(self); // bar thickness (hover area)
429        let axes = scr_axes(e.flags);
430        let s = *self.slot(e.id);
431        let vneed = axes & 1 != 0 && s.meash > e.rect[3];
432        let hneed = axes & 2 != 0 && s.measw > e.rect[2];
433        let r = e.rect;
434        if vneed {
435            let h = if hneed { r[3].wrapping_sub(t) } else { r[3] };
436            self.wp.ctx.last_rect = [r[0].wrapping_add(r[2]).wrapping_sub(t), r[1], t, h];
437            let id = e.id.wrapping_mul(8).wrapping_add(1) | 0x8000_0000;
438            let (p, _) = self.sbar_core(id, s.scrolly, s.meash, r[3], true);
439            if p != s.scrolly {
440                let sl = self.slot(e.id);
441                sl.scrolly = p;
442                sl.adur = p as u32;
443                self.repass();
444            }
445        }
446        if hneed {
447            let s = *self.slot(e.id);
448            let w = if vneed { r[2].wrapping_sub(t) } else { r[2] };
449            self.wp.ctx.last_rect = [r[0], r[1].wrapping_add(r[3]).wrapping_sub(t), w, t];
450            let id = e.id.wrapping_mul(8).wrapping_add(2) | 0x8000_0000;
451            let (p, _) = self.sbar_core(id, s.scrollx, s.measw, r[2], false);
452            if p != s.scrollx {
453                let sl = self.slot(e.id);
454                sl.scrollx = p;
455                sl.at0 = p as u32;
456                self.repass();
457            }
458        }
459    }
460
461    // scr_wheel(entry): the wheel scrolls the view when the pointer is over its content
462    fn scr_wheel(&mut self, e: &Scr) {
463        if e.flags & SC_NOWHEEL != 0 {
464            return;
465        }
466        let c = &self.wp.ctx;
467        if c.wheel_y | c.wheel_x == 0 {
468            return;
469        }
470        // hovered: the hot widget is one of our hit boxes on our layer
471        let hot = c.hot;
472        if hot == 0 {
473            return;
474        }
475        let list = &c.hits[c.hit_cur];
476        let found = list.get(e.hit0..).is_some_and(|v| v.iter().any(|h| h.id == hot && h.layer as u32 == e.layer));
477        if !found {
478            return;
479        }
480        let step = self.dp(WHEEL_DIP);
481        let c = &self.wp.ctx;
482        let mut dy = c.wheel_y.wrapping_neg().wrapping_mul(step) / 120; // wheel up = towards the start
483        let mut dx = c.wheel_x.wrapping_mul(step) / 120;
484        let axes = scr_axes(e.flags);
485        // the vertical wheel scrolls sideways in horizontal views or with Shift
486        if axes & 1 == 0 || (axes & 2 != 0 && self.mods & MOD_SHIFT != 0) {
487            dx = dx.wrapping_add(dy);
488            dy = 0;
489        }
490        if axes & 1 == 0 {
491            dy = 0;
492        }
493        if axes & 2 == 0 {
494            dx = 0;
495        }
496        let (mx, my) = self.scr_max(e);
497        let now = self.now_ms;
498        let anim_off = self.anim_off;
499        let s = self.slot(e.id);
500        let ny = clamp_mem((s.adur as i32).wrapping_add(dy), my).0;
501        let nx = clamp_mem((s.at0 as i32).wrapping_add(dx), mx).0;
502        if ny == s.adur as i32 && nx == s.at0 as i32 {
503            return; // at the limit: leave the wheel to outer views
504        }
505        // starting from rest: the smooth scrolling clock starts now
506        if s.scrolly == s.adur as i32 && s.scrollx == s.at0 as i32 {
507            s.aux = now as i32;
508        }
509        s.adur = ny as u32;
510        s.at0 = nx as u32;
511        if anim_off {
512            s.scrolly = ny;
513            s.scrollx = nx;
514        }
515        self.wp.ctx.wheel_y = 0;
516        self.wp.ctx.wheel_x = 0;
517        if anim_off {
518            self.repass();
519        } else {
520            self.animating = true;
521        }
522    }
523
524    // scr_close: end the innermost scroll view: measure, clamp, bars, wheel
525    pub(crate) fn scr_close(&mut self) {
526        if self.wp.list.scr_sp == 0 {
527            return;
528        }
529        let e = self.wp.list.scr[self.scr_top()];
530        if e.copy {
531            self.scroll_copy_end();
532        }
533        self.box_end();
534        self.scr_follow(&e);
535        if let Some(id) = self.agent_reveal() {
536            self.scr_show(&e, id); // a click from outside (agent.rs)
537        }
538        if self.scr_settle(&e) {
539            self.repass(); // the content shrank under the position
540        }
541        if e.flags & SC_NOBAR == 0 {
542            self.scr_bars(&e);
543        }
544        self.scr_wheel(&e);
545        if e.flags & SC_NOWHEEL == 0 {
546            // a finger may roll it (touch.rs)
547            let (mx, my) = self.scr_max(&e);
548            let s = *self.slot(e.id);
549            let axes = scr_axes(e.flags);
550            let ax = if axes & 1 != 0 { super::touch::AX_V } else { 0 } | if axes & 2 != 0 { super::touch::AX_H } else { 0 };
551            self.pan_push(e.id, e.hit0, e.layer as u8, false, ax, [s.scrollx, s.scrolly], [mx, my]);
552        }
553        self.wp.ctx.last_rect = e.rect;
554        if e.flags & SCR_F_LIST == 0 {
555            // semantic record: value = position in DIP (vertical, or horizontal for SC_H views)
556            self.wp.ctx.last_state = 0;
557            let s = *self.slot(e.id);
558            let p = if scr_axes(e.flags) & 1 != 0 { s.scrolly } else { s.scrollx };
559            let v = self.px2dip(p);
560            self.sem(K_BOX, e.id, None, v);
561        }
562        self.wp.list.scr_sp -= 1;
563    }
564
565    /// the innermost scroll view: (id, viewport rectangle px) -- data views page by it
566    pub(crate) fn scr_view(&self) -> (u32, [i32; 4]) {
567        let e = self.wp.list.scr[self.scr_top()];
568        (e.id, e.rect)
569    }
570
571    /// ui_scroll_begin(id, flags)
572    #[cfg_attr(jfgui_dev, track_caller)]
573    pub fn scroll_begin(&mut self, id: u32, flags: u32) {
574        let _src = self.src_scope();
575        self.scr_open(id, flags & !SCR_F_LIST, 0);
576        self.scr_offset();
577    }
578
579    /// ui_scroll_end
580    #[cfg_attr(jfgui_dev, track_caller)]
581    pub fn scroll_end(&mut self) {
582        let _src = self.src_scope();
583        self.scr_close();
584    }
585
586    /// ui_scroll_to(id, x, y): DIP, -1 keeps that axis; jumps without animation
587    pub fn scroll_to(&mut self, id: u32, x: i32, y: i32) {
588        let (px, py) = (self.dp(x), self.dp(y));
589        let s = self.slot(id);
590        if x >= 0 {
591            s.scrollx = px;
592            s.at0 = px as u32;
593        }
594        if y >= 0 {
595            s.scrolly = py;
596            s.adur = py as u32;
597        }
598        self.repass();
599    }
600
601    /// ui_scroll_pos(id) -> (x, y) DIP
602    pub fn scroll_pos(&mut self, id: u32) -> (i32, i32) {
603        let s = *self.slot(id);
604        (self.px2dip(s.scrollx), self.px2dip(s.scrolly))
605    }
606
607    /// ui_scroll_extent(id) -> (w, h): measured content size of scroll view `id` (DIP; 0
608    /// before its first pass).  With the view size (last_rect after scroll_end) the app can
609    /// tell whether the content overflows.  (U3)
610    pub fn scroll_extent(&mut self, id: u32) -> (i32, i32) {
611        let s = *self.slot(id);
612        (self.px2dip(s.measw), self.px2dip(s.meash))
613    }
614
615    // ------------------------------------------------------------------ scroll bars
616
617    // sbar_core(id, pos, content, view, vertical) -> (new pos, EV): the bar occupies
618    // last_rect; nothing is drawn when the content fits
619    fn sbar_core(&mut self, id: u32, pos: i32, content: i32, view: i32, vertical: bool) -> (i32, u32) {
620        let rect = self.wp.ctx.last_rect;
621        let mut pos = pos;
622        let mut ev = 0;
623        let range = content.wrapping_sub(view);
624        if range <= 0 {
625            pos = 0;
626        } else if view <= 0 || self.m.touch.on {
627            // (a finger: no bar and no hit box; the room stays, so nothing jumps; touch.rs)
628            pos = pos.max(0).min(range);
629        } else {
630            ev = self.behave(id, 0);
631            self.slot(id);
632            let exp = self.anim(id, if ev & (EV_HOVER | EV_HELD) != 0 { 256 } else { 0 }, 120);
633            // track along the axis, margins at both ends
634            let (mut start, mut len) = if vertical { (rect[1], rect[3]) } else { (rect[0], rect[2]) };
635            let st = self.style;
636            let m = st.scrollbar_margin(self);
637            start = start.wrapping_add(m);
638            len = len.wrapping_sub(m).wrapping_sub(m).max(1);
639            // thumb length = track * view / content, at least the style's minimum
640            let mut thumb = ((len as u32 as u64 * view as u32 as u64) / content as u32 as u64) as u32 as i32;
641            let mn = st.scrollbar_min_thumb(self);
642            if thumb < mn {
643                thumb = mn;
644            }
645            if thumb > len {
646                thumb = len;
647            }
648            let free = len - thumb;
649            // press: on the thumb starts a drag, on the track pages
650            let c = &self.wp.ctx;
651            let (active, pressed, held) = (c.active == id, c.pressed & (1 << BTN_LEFT) != 0, ev & EV_HELD != 0);
652            let (press, cur) = if vertical { (c.press_y, c.my) } else { (c.press_x, c.mx) };
653            if active && pressed {
654                let tp = thumb_pos(pos, free, range);
655                let p = press.wrapping_sub(start);
656                let s = self.slot(id);
657                if p < tp {
658                    pos = pos.wrapping_sub(view);
659                    s.scrollx = 2;
660                } else if p >= tp.wrapping_add(thumb) {
661                    pos = pos.wrapping_add(view);
662                    s.scrollx = 2;
663                } else {
664                    s.aux = p - tp;
665                    s.scrollx = 1;
666                }
667            } else if held {
668                let s = *self.slot(id);
669                if s.scrollx == 1 {
670                    let t = cur.wrapping_sub(start).wrapping_sub(s.aux).max(0); // new thumb start
671                    if free > 0 {
672                        let t = t.min(free);
673                        // pos = thumb * range / free (rounded)
674                        let f = free as u32 as u64;
675                        pos = ((t as u32 as u64 * range as u32 as u64 + (f >> 1)) / f) as u32 as i32;
676                    }
677                }
678            } else {
679                self.slot(id).scrollx = 0;
680            }
681            if pos < 0 {
682                pos = 0;
683            }
684            if pos > range {
685                pos = range;
686            }
687            // paint (style)
688            let mut sp = Sp { value: exp as i32, flags: vertical as u32, ..Sp::default() };
689            sp.place(rect);
690            sp.s[0] = start; // track start (px, along the axis)
691            sp.s[1] = thumb; // thumb length
692            sp.value2 = thumb_pos(pos, free, range); // thumb offset from the track start
693            st.scrollbar(self, &sp);
694        }
695        self.wp.ctx.last_rect = rect;
696        (pos, ev)
697    }
698
699    /// ui_scrollbar(id, &mut pos, content, view, vertical) -> EV_*
700    #[cfg_attr(jfgui_dev, track_caller)]
701    pub fn scrollbar(&mut self, id: u32, pos: &mut i32, content: i32, view: i32, vertical: bool) -> u32 {
702        let _src = self.src_scope();
703        let st = self.style;
704        let t = st.scrollbar_thickness(self);
705        if vertical {
706            self.place(t, 0);
707        } else {
708            self.place(0, t);
709        }
710        let (p, mut ev) = self.sbar_core(id, *pos, content, view, vertical);
711        if p != *pos {
712            *pos = p;
713            ev |= EV_ACT;
714            self.repass();
715        }
716        // semantic record (a range: value = position, max = content - view)
717        let mut s = 0;
718        if ev & EV_HOVER != 0 {
719            s |= SS_HOVER as u32;
720        }
721        if ev & EV_HELD != 0 {
722            s |= SS_PRESSED as u32;
723        }
724        self.wp.ctx.last_state = s;
725        self.sem(K_SLIDER, id, None, *pos).max = content.wrapping_sub(view).max(0);
726        ev
727    }
728
729    // ------------------------------------------------------------------ lists
730
731    // list_top: the innermost list entry index
732    fn list_top(&self) -> usize {
733        let i = self.wp.list.list_sp.wrapping_sub(1) as usize;
734        if i < LIST_MAX { i } else { LIST_MAX }
735    }
736
737    // list_keys(li): selection keys of a focused list (the list's scroll view is on top);
738    // without such a key the selection stays as it is, -1 too (the focus alone selects no row)
739    fn list_keys(&mut self, li: usize) {
740        let e = self.wp.list.list[li];
741        let count = e.count;
742        if count <= 0 {
743            return;
744        }
745        let cur = e.sel;
746        let mut new = None;
747        let top = self.wp.list.scr[self.scr_top()];
748        // page = rows per view (at least 1)
749        let page = (top.rect[3] / e.ih).max(1);
750        // Ctrl+arrows on a list with LF_REORDER: the selected row moves (like a drop)
751        if e.flags & LF_REORDER != 0 && cur >= 0 && cur < count {
752            let mut to = cur;
753            if self.kb_chord(VK_UP, MOD_CTRL) {
754                to = cur - 1;
755            } else if self.kb_chord(VK_DOWN, MOD_CTRL) {
756                to = cur + 1;
757            } else if self.kb_chord(VK_HOME, MOD_CTRL) {
758                to = 0;
759            } else if self.kb_chord(VK_END, MOD_CTRL) {
760                to = count - 1;
761            }
762            let to = to.clamp(0, count - 1);
763            if to != cur {
764                let l = &mut self.wp.list.list[li];
765                l.drop = (to << 16) | cur;
766                l.sel = to;
767                self.wp.ctx.focus_visible = true;
768                self.repass();
769                let s = self.slot(top.id);
770                let d = into_view(to.wrapping_mul(e.ih), e.ih, s.scrolly, top.rect[3]);
771                s.scrolly = s.scrolly.wrapping_add(d);
772                s.adur = s.scrolly as u32;
773                return;
774            }
775        }
776        if cur >= 0 && self.key(VK_RETURN) {
777            self.wp.list.list[li].kdbl = true;
778            self.repass();
779        }
780        if cur >= 0 && self.key(VK_SPACE) {
781            self.wp.list.list[li].ktog = true;
782            self.wp.ctx.focus_visible = true;
783            self.repass();
784        }
785        if self.key(VK_UP) {
786            new = Some(if cur > 0 { cur - 1 } else { 0 });
787        }
788        if self.key(VK_DOWN) {
789            new = Some(if cur < 0 { 0 } else { cur.wrapping_add(1) });
790        }
791        if self.key(VK_HOME) {
792            new = Some(0);
793        }
794        if self.key(VK_END) {
795            new = Some(count - 1);
796        }
797        if self.key(VK_PRIOR) {
798            new = Some(cur.wrapping_sub(page));
799        }
800        if self.key(VK_NEXT) {
801            new = Some(cur.wrapping_add(page));
802        }
803        let Some(mut new) = new else {
804            return;
805        };
806        if new < 0 {
807            new = 0;
808        }
809        if new >= count {
810            new = count - 1;
811        }
812        if new == cur {
813            return;
814        }
815        self.wp.list.list[li].sel = new;
816        self.wp.ctx.focus_visible = true;
817        self.repass();
818        // scroll the row into view (immediately)
819        let s = self.slot(top.id);
820        let d = into_view(new.wrapping_mul(e.ih), e.ih, s.scrolly, top.rect[3]);
821        s.scrolly = s.scrolly.wrapping_add(d);
822        s.adur = s.scrolly as u32;
823    }
824
825    // list_drag(li): the list whose row is being dragged, before its rows: release -> drop
826    // (drop, sel), Esc / lost pointer -> cancel, pointer near the edges -> scroll
827    fn list_drag(&mut self, li: usize) {
828        let e = self.wp.list.list[li];
829        if e.vev & EV_HELD != 0 {
830            if self.key(VK_ESCAPE) {
831                self.wp.ctx.active = 0;
832                self.wp.list.ldrag_list = 0;
833                self.repass();
834                return;
835            }
836            // autoscroll: within 24 DIP of the top / bottom edge (or beyond it)
837            let top = self.wp.list.scr[self.scr_top()];
838            let zone = self.dp(24);
839            let my = self.wp.ctx.my;
840            let mut depth = zone.wrapping_sub(my.wrapping_sub(top.rect[1])); // > 0: inside the top zone
841            if depth <= 0 {
842                let b = top.rect[1].wrapping_add(top.rect[3]).wrapping_sub(my);
843                depth = zone.wrapping_sub(b);
844                if depth <= 0 {
845                    self.wp.list.ldrag_t = self.now_ms;
846                    return;
847                }
848                depth = depth.wrapping_neg(); // < 0: bottom zone, scroll down
849            }
850            // px per 16 ms step: 2 + depth / 4, at most one row
851            self.animating = true;
852            let now = self.now_ms;
853            let mut t0 = self.wp.list.ldrag_t as i32;
854            let steps = anim_steps(now, &mut t0);
855            self.wp.list.ldrag_t = t0 as u32;
856            if steps == 0 {
857                return;
858            }
859            let sign = depth >> 31;
860            let mut sp = ((((depth ^ sign).wrapping_sub(sign)) as u32 >> 2) + 2) as i32;
861            if sp > e.ih {
862                sp = e.ih;
863            }
864            sp = sp.wrapping_mul(steps as i32);
865            if depth <= 0 {
866                sp = sp.wrapping_neg();
867            }
868            let s = self.slot(top.id);
869            let mut y = s.scrolly.wrapping_sub(sp);
870            if y < 0 {
871                y = 0;
872            }
873            s.scrolly = y;
874            s.adur = y as u32;
875            self.repass();
876        } else if e.vev & EV_COMMIT != 0 {
877            // released: from -> to
878            let from = self.wp.list.ldrag_from;
879            let mut to = self.wp.list.ldrag_to;
880            if to > from {
881                to -= 1; // below the item: it leaves a gap above
882            }
883            self.wp.list.ldrag_list = 0;
884            if to == from || from as u32 >= e.count as u32 || to as u32 >= e.count as u32 {
885                return;
886            }
887            let l = &mut self.wp.list.list[li];
888            l.drop = (to << 16) | from;
889            l.sel = to; // the moved item stays selected
890            self.repass();
891        } else {
892            self.wp.list.ldrag_list = 0;
893            self.repass();
894        }
895    }
896
897    // list_drag_slot(li): insertion slot 0 .. count from the pointer (y0 known)
898    fn list_drag_slot(&mut self, li: usize) {
899        let e = self.wp.list.list[li];
900        let half = ((e.ih as u32) >> 1) as i32;
901        let p = self.wp.ctx.my.wrapping_sub(e.y0).wrapping_add(half).max(0);
902        let mut to = p / e.ih;
903        if to > e.count {
904            to = e.count;
905        }
906        self.wp.list.ldrag_to = to;
907    }
908
909    // list_drag_line(li): the style's insertion marker at the drop slot (inside the view)
910    fn list_drag_line(&mut self, li: usize) {
911        let e = self.wp.list.list[li];
912        let r = self.box_rect();
913        let mut sp = Sp { x: r[0], w: r[2], ..Sp::default() };
914        sp.y = self.wp.list.ldrag_to.wrapping_mul(e.ih).wrapping_add(e.y0); // the line's centre
915        sp.h = 0;
916        let st = self.style;
917        st.list_drop_line(self, &sp);
918    }
919
920    /// ui_list_begin(id, &mut sel, count, item_h | LF_*) -> (first, end): the rows to emit
921    #[cfg_attr(jfgui_dev, track_caller)]
922    pub fn list_begin(&mut self, id: u32, sel: &mut i32, count: i32, item_h: u32) -> (i32, i32) {
923        let _src = self.src_scope();
924        let flags = item_h & 0xFFFF_0000; // (U1) LF_* flags
925        let count = count.max(0);
926        let ihd = (item_h & 0xFFFF) as i32;
927        let ih = self.dp(if ihd > 0 { ihd } else { 40 }).max(1);
928        // list entry
929        let li = (self.wp.list.list_sp as usize).min(LIST_MAX);
930        if li == LIST_MAX {
931            self.limit_hit(super::ctx::LIM_LISTS);
932        }
933        self.wp.list.list_sp = self.wp.list.list_sp.wrapping_add(1);
934        self.wp.list.list[li] = Ls { id, count, sel: *sel, ih, next: 0, old: *sel, ev: 0, vev: 0, y0: 0, flags, drop: -1, kdbl: false, ktog: false, notab: false };
935        // viewport: a vertical scroll view that takes the keyboard focus (Space / Enter are the
936        // list's; the menu key goes to the selected row)
937        if *sel >= 0 && *sel < count {
938            self.kb_ctx_redirect(id, id.wrapping_add(1).wrapping_add(*sel as u32));
939        }
940        let vev = self.scr_open(id, SC_V | SCR_F_LIST, HB_FOCUS | HB_OWNKEYS);
941        self.wp.list.list[li].vev = vev;
942        if vev & EV_FOCUS != 0 {
943            self.list_keys(li);
944        }
945        if self.wp.list.ldrag_list == id {
946            self.list_drag(li); // (U1) a row of this list is dragged
947        }
948        self.scr_offset();
949        // visible rows from the clip rectangle
950        let y0 = self.box_rect()[1]; // y of row 0
951        self.wp.list.list[li].y0 = y0;
952        if self.wp.list.ldrag_list == id {
953            self.list_drag_slot(li);
954        }
955        let k = self.wp.ctx.uiclip;
956        let first = (k[1].wrapping_sub(y0).max(0) / ih).min(count);
957        let end = ((k[3].wrapping_sub(y0).max(0).wrapping_add(ih).wrapping_sub(1)) / ih).min(count).max(first);
958        self.wp.list.list[li].next = first;
959        if first != 0 {
960            self.place(0, first.wrapping_mul(ih)); // rows above the view
961        }
962        *sel = self.wp.list.list[li].sel;
963        (first, end)
964    }
965
966    // li_row(i, label) -> EV: place row i, behaviour, background, pill, text, record
967    fn li_row(&mut self, i: i32, label: Option<&str>) -> u32 {
968        let li = self.list_top();
969        let e = self.wp.list.list[li];
970        // rows skipped by the caller keep their space
971        let d = i.wrapping_sub(e.next);
972        if d > 0 {
973            self.place(0, d.wrapping_mul(e.ih));
974        }
975        self.wp.list.list[li].next = i.wrapping_add(1);
976        self.place(0, e.ih);
977        let iid = e.id.wrapping_add(i as u32).wrapping_add(1);
978        let ev0 = self.behave(iid, 0);
979        let mut ev = ev0;
980        let disabled = self.wp.ctx.last_state == SS_DISABLED as u32;
981        // a press on a row gives the list the keyboard focus
982        let c = &mut self.wp.ctx;
983        if c.active == iid && c.pressed & (1 << BTN_LEFT) != 0 {
984            c.focus = e.id;
985            c.focus_visible = false;
986        }
987        // (U1) LF_REORDER: dragging a row starts moving it; the list takes the pointer
988        if e.flags & LF_REORDER != 0 && ev & EV_DRAG != 0 && self.wp.list.ldrag_list == 0 && self.wp.ctx.active == iid {
989            let l = &mut self.wp.list;
990            l.ldrag_list = e.id;
991            l.ldrag_from = i;
992            l.ldrag_to = i;
993            l.ldrag_t = self.now_ms;
994            self.wp.ctx.active = e.id;
995            self.wp.ctx.focus = e.id;
996            ev &= !(EV_ACT | EV_DRAG);
997            self.repass();
998        }
999        if ev & EV_ACT != 0 && self.wp.list.list[li].sel != i {
1000            self.wp.list.list[li].sel = i;
1001            self.wp.list.list[li].ev |= EV_ACT;
1002            self.repass();
1003        }
1004        let selected = self.wp.list.list[li].sel == i;
1005        // Enter / Space on the focused list (list_keys) act on the selected row
1006        if selected && core::mem::take(&mut self.wp.list.list[li].kdbl) {
1007            ev |= EV_DOUBLE;
1008        }
1009        self.m.kbnav.toggle = selected && core::mem::take(&mut self.wp.list.list[li].ktog);
1010        if ev & EV_DOUBLE != 0 {
1011            self.wp.list.list[li].ev |= EV_DOUBLE;
1012        }
1013        // paint (style): background, selection pill, focus visual, label
1014        let mut fl = 0;
1015        if self.wp.list.ldrag_list == e.id && self.wp.list.ldrag_from == i {
1016            fl |= 1; // (U1) the dragged row looks pressed
1017        }
1018        if e.vev & EV_FOCUS != 0 && self.wp.ctx.focus_visible {
1019            fl |= 2;
1020        }
1021        let mut sp = Sp { ev, value: selected as i32, value2: disabled as i32, label, flags: fl, ..Sp::default() };
1022        sp.place(self.wp.ctx.last_rect);
1023        let st = self.style;
1024        st.list_row(self, &sp);
1025        if selected {
1026            self.wp.ctx.last_state |= SS_SELECTED as u32;
1027        }
1028        self.sem(K_LISTITEM, iid, label, i);
1029        ev0 | (ev & EV_DOUBLE)
1030    }
1031
1032    /// ui_list_item(i, label) -> EV_*
1033    #[cfg_attr(jfgui_dev, track_caller)]
1034    pub fn list_item(&mut self, i: i32, label: &str) -> u32 {
1035        let _src = self.src_scope();
1036        let ev = self.li_row(i, Some(super::basic::cstr(label)));
1037        self.m.kbnav.toggle = false; // (no check box in a text row)
1038        ev
1039    }
1040
1041    /// ui_list_item_begin(i) -> EV_*: row i with a BOX_H (gap 8 DIP) for custom content
1042    #[cfg_attr(jfgui_dev, track_caller)]
1043    pub fn list_item_begin(&mut self, i: i32) -> u32 {
1044        let _src = self.src_scope();
1045        let ev = self.li_row(i, None);
1046        let li = self.list_top();
1047        let id = self.wp.list.list[li].id;
1048        // the widgets of rows that are not selected are no Tab stops (kbnav.rs)
1049        let notab = self.wp.list.list[li].sel != i;
1050        self.wp.list.list[li].notab = notab;
1051        if notab {
1052            self.kb_notab(true);
1053        }
1054        let cid = id.wrapping_add(i as u32).wrapping_add(1) | 0xC000_0000; // the row's content box
1055        let x0 = self.dp(16);
1056        let wm = self.dp(28);
1057        let r = self.wp.ctx.last_rect;
1058        let mut w = r[2].wrapping_sub(wm);
1059        if w <= 0 {
1060            w = 1;
1061        }
1062        self.next_rect_px(r[0].wrapping_add(x0), r[1], w, r[3]);
1063        // BOX_FIXED: the row has a fixed height, nobody depends on the content box's natural
1064        // size -- a new row must not cost an extra layout pass
1065        self.box_begin(cid, BOX_H | BOX_XCENTER | BOX_FIXED, 8);
1066        ev
1067    }
1068
1069    /// the innermost list's selection while its rows are emitted (the asm's rows see the
1070    /// app's variable change at once; here the list holds it until list_end), -1 outside a
1071    /// list
1072    pub fn list_sel(&self) -> i32 {
1073        if self.wp.list.list_sp == 0 {
1074            return -1;
1075        }
1076        self.wp.list.list[self.list_top()].sel
1077    }
1078
1079    /// list_row_focused(id): id is the selected row of the innermost list and that list has
1080    /// the keyboard focus (the row acts for the menu key)
1081    pub(crate) fn list_row_focused(&self, id: u32) -> bool {
1082        if self.wp.list.list_sp == 0 {
1083            return false;
1084        }
1085        let e = &self.wp.list.list[self.list_top()];
1086        e.sel >= 0 && self.wp.ctx.focus == e.id && id == e.id.wrapping_add(1).wrapping_add(e.sel as u32)
1087    }
1088
1089    /// ui_list_item_end
1090    #[cfg_attr(jfgui_dev, track_caller)]
1091    pub fn list_item_end(&mut self) {
1092        let _src = self.src_scope();
1093        self.box_end();
1094        let li = self.list_top();
1095        if core::mem::take(&mut self.wp.list.list[li].notab) {
1096            self.kb_notab(false);
1097        }
1098        self.m.kbnav.toggle = false;
1099    }
1100
1101    /// ui_list_end(&mut sel) -> (EV_* (EV_ACT if sel changed, EV_DOUBLE), sel); EV_DRAG: a
1102    /// row was dropped, the second value is from | to << 16
1103    #[cfg_attr(jfgui_dev, track_caller)]
1104    pub fn list_end(&mut self, sel: &mut i32) -> (u32, i32) {
1105        let _src = self.src_scope();
1106        if self.wp.list.list_sp == 0 {
1107            return (0, -1);
1108        }
1109        let li = self.list_top();
1110        let e = self.wp.list.list[li];
1111        // rows below the view
1112        let d = e.count.wrapping_sub(e.next);
1113        if d > 0 {
1114            self.place(0, d.wrapping_mul(e.ih));
1115        }
1116        if self.wp.list.ldrag_list == e.id {
1117            self.list_drag_line(li); // (U1) insertion line while a row is dragged
1118        }
1119        self.scr_close();
1120        let e = self.wp.list.list[li];
1121        let s = e.sel;
1122        *sel = s;
1123        // focus visual on the whole list when nothing is selected
1124        if e.vev & EV_FOCUS != 0 && self.wp.ctx.focus_visible && s < 0 {
1125            let r = self.wp.ctx.last_rect;
1126            let st = self.style;
1127            st.focus_rect(self, r[0], r[1], r[2], r[3]);
1128        }
1129        let mut stt = 0;
1130        if e.vev & EV_HOVER != 0 {
1131            stt |= SS_HOVER as u32;
1132        }
1133        if e.vev & EV_FOCUS != 0 {
1134            stt |= SS_FOCUSED as u32;
1135        }
1136        self.wp.ctx.last_state = stt;
1137        self.sem(K_LIST, e.id, None, s).max = e.count;
1138        let mut ev = e.ev;
1139        if s != e.old {
1140            ev |= EV_ACT;
1141        }
1142        if e.kdbl {
1143            ev |= EV_DOUBLE; // Enter while the selected row was not emitted
1144        }
1145        self.m.kbnav.toggle = false;
1146        self.wp.list.list_sp -= 1;
1147        if e.drop != -1 {
1148            return (ev | EV_DRAG, e.drop); // (U1) a dropped row
1149        }
1150        (ev, s)
1151    }
1152
1153    // ------------------------------------------------------------------ tabs
1154
1155    // tab_sizes(flags, names, widths) -> (paint record, tabs, total width): the tabs up to
1156    // the first empty name, their widths into `widths` (Tw: any number of tabs)
1157    fn tab_sizes<'a>(&mut self, flags: u32, names: &[&'a str], widths: &mut Tw) -> (Sp<'a>, usize, i32) {
1158        let st = self.style;
1159        let mut sp = Sp { flags, ..Sp::default() };
1160        st.tab_metrics(self, &mut sp); // padding, height, pill width
1161        let pad = sp.s[0];
1162        // The style selects its tab face; existing styles retain the body face.
1163        st.tab_font(self, false);
1164        let n = names.iter().position(|s| super::basic::cstr(s).is_empty()).unwrap_or(names.len());
1165        let w = widths.get(n);
1166        let mut total = 0i32;
1167        for (k, name) in names[..n].iter().enumerate() {
1168            w[k] = self.text.width(super::basic::cstr(name)).wrapping_add(pad).wrapping_add(pad);
1169            total = total.wrapping_add(w[k]);
1170        }
1171        (sp, n, total)
1172    }
1173
1174    /// Tab strip with horizontal overflow arrows. Mouse scrolling and Left/Right,
1175    /// Home/End on the strip retain ordinary tab semantics; changed selections
1176    /// and resized viewports reveal the selected tab. Internal IDs follow the
1177    /// usual `(id * 8 + part) | 0x80000000` convention (parts 1, 2, 3 and 7).
1178    #[cfg_attr(jfgui_dev, track_caller)]
1179    pub fn tabs_scrolling(&mut self, id: u32, sel: &mut i32, names: &[&str]) -> u32 {
1180        let _src = self.src_scope();
1181        let flags = self.take_flags();
1182        let mut tw = Tw::default();
1183        let (sp, count, total) = self.tab_sizes(flags, names, &mut tw);
1184        let widths = tw.get(count);
1185        let rect = self.place(total, sp.s[1]);
1186        let base = id.wrapping_mul(8) | 0x8000_0000;
1187        let state = self.slot(base + 7);
1188        let changed = state.measw != rect[2] || state.meash != total || state.aux != *sel;
1189        state.measw = rect[2];
1190        state.meash = total;
1191        state.aux = *sel;
1192        if total <= rect[2] {
1193            self.next_rect_px(rect[0], rect[1], rect[2], rect[3]);
1194            self.next_flags(flags);
1195            return self.tabs(id, sel, names);
1196        }
1197        let arrow = self.dp(22).min((rect[2] / 3).max(0));
1198        let view = (rect[2] - arrow * 2).max(0);
1199        let sid = base + 1;
1200        let old = *sel;
1201        self.next_rect_px(rect[0] + arrow, rect[1], view, rect[3]);
1202        self.scroll_begin(sid, SC_H | SC_NOBAR | BOX_H);
1203        let width_dip = ((total as i64 * 96 + self.dpi() as i64 - 1) / self.dpi() as i64) as i32;
1204        self.next_size(width_dip, self.px2dip(rect[3]));
1205        self.next_flags(flags);
1206        let ev = self.tabs(id, sel, names);
1207        self.scroll_end();
1208        let (offset, _) = self.scroll_pos(sid);
1209        let viewport = self.px2dip(view);
1210        if (changed || old != *sel) && *sel >= 0 && (*sel as usize) < count {
1211            let left: i32 = widths[..*sel as usize].iter().sum();
1212            let right = self.px2dip(left + widths[*sel as usize]);
1213            let left = self.px2dip(left);
1214            let next = if left < offset { left }
1215                else if right > offset + viewport { (right - viewport).min(left) }
1216                else { offset };
1217            if next != offset { self.scroll_to(sid, next, 0); }
1218        }
1219        self.slot(base + 7).aux = *sel;
1220        let maximum = (width_dip - viewport).max(0);
1221        for (part, x, icon, label, next, enabled) in [
1222            (2, rect[0], IC_CHEV_LEFT, crate::i18n::tr("Vorherige Register"), (offset - viewport / 2).max(0), offset > 0),
1223            (3, rect[0] + rect[2] - arrow, IC_CHEV_RIGHT, crate::i18n::tr("Weitere Register"), (offset + viewport / 2).min(maximum), offset < maximum),
1224        ] {
1225            self.next_rect_px(x, rect[1], arrow, rect[3]);
1226            self.next_icon(icon);
1227            self.next_flags(flags | ST_SUBTLE | ST_COMPACT | ST_ICON_ONLY | if enabled { 0 } else { ST_DISABLED });
1228            if self.button(base + part, label) & EV_ACT != 0 { self.scroll_to(sid, next, 0); }
1229            self.tooltip(label);
1230        }
1231        self.wp.ctx.last_rect = rect;
1232        ev
1233    }
1234
1235    /// ui_tabs(id, &mut sel, names) -> EV_* (EV_ACT when sel changed)
1236    #[cfg_attr(jfgui_dev, track_caller)]
1237    pub fn tabs(&mut self, id: u32, sel: &mut i32, names: &[&str]) -> u32 {
1238        let _src = self.src_scope();
1239        let old = *sel;
1240        let st = self.style;
1241        let flags = self.take_flags();
1242        let (mut tw, mut tfull) = (Tw::default(), Tw::default());
1243        let (mut sp, n, total) = self.tab_sizes(flags, names, &mut tw);
1244        let widths = tw.get(n);
1245        let (h, pillw) = (sp.s[1], sp.s[2]);
1246        let count = n as i32;
1247        self.place(total, h);
1248        let strip = self.wp.ctx.last_rect;
1249        // a strip narrower than the tabs: the widest ones give way, their labels cut with "…"
1250        let full = tfull.get(n);
1251        full.copy_from_slice(widths);
1252        if total > strip[2] && n > 0 {
1253            let pad = sp.s[0];
1254            st.tab_font(self, false);
1255            let min = pad * 2 + self.text.width("…");
1256            tabs_fit(widths, strip[2], min);
1257        }
1258        let disabled = if flags & ST_DISABLED != 0 { HB_DISABLED } else { 0 };
1259        let ev = self.behave(id, HB_FOCUS | disabled);
1260        let state = self.wp.ctx.last_state;
1261        // keyboard
1262        if ev & EV_FOCUS != 0 && count != 0 {
1263            let mut s = *sel;
1264            if self.key(VK_LEFT) {
1265                s = s.wrapping_sub(1);
1266            }
1267            if self.key(VK_RIGHT) {
1268                s = s.wrapping_add(1);
1269            }
1270            if self.key(VK_HOME) {
1271                s = 0;
1272            }
1273            if self.key(VK_END) {
1274                s = count - 1;
1275            }
1276            if s < 0 {
1277                s = 0;
1278            }
1279            if s >= count {
1280                s = count - 1;
1281            }
1282            if s != *sel {
1283                *sel = s;
1284                self.wp.ctx.focus_visible = true;
1285                self.repass();
1286            }
1287        }
1288        // the tabs
1289        let mut x = strip[0];
1290        for i in 0..n {
1291            let label = super::basic::cstr(names[i]);
1292            self.wp.ctx.last_rect = [x, strip[1], widths[i], h];
1293            let tid = id.wrapping_add(i as u32).wrapping_add(1);
1294            let tev = self.behave(tid, disabled);
1295            if tev & EV_ACT != 0 {
1296                self.wp.ctx.focus = id;
1297                self.wp.ctx.focus_visible = false;
1298                if *sel != i as i32 {
1299                    *sel = i as i32;
1300                    self.repass();
1301                }
1302            }
1303            // paint (style): background and label of tab i
1304            sp.place(self.wp.ctx.last_rect);
1305            sp.ev = tev;
1306            sp.value = (*sel == i as i32) as i32;
1307            sp.value2 = (self.wp.ctx.last_state == SS_DISABLED as u32) as i32;
1308            sp.label = Some(label);
1309            if widths[i] < full[i] {
1310                st.tab_font(self, *sel == i as i32);
1311                let short = self.ellipsize(label.as_bytes(), widths[i] - sp.s[0] * 2).0;
1312                st.tab(self, &Sp { label: Some(&short), ..sp });
1313            } else {
1314                st.tab(self, &sp);
1315            }
1316            if *sel == i as i32 {
1317                self.wp.ctx.last_state |= SS_SELECTED as u32;
1318            }
1319            self.sem(K_TAB, tid, Some(label), i as i32);
1320            x = x.wrapping_add(widths[i]);
1321        }
1322        // selection pill sliding to the selected tab
1323        let s = *sel;
1324        if s >= 0 && (s as u32) < count as u32 {
1325            let s = s as usize;
1326            let tx: i32 = widths[..s].iter().fold(0i32, |a, &w| a.wrapping_add(w)); // x of the selected tab (relative)
1327            let target = tx.wrapping_add(widths[s].wrapping_sub(pillw) >> 1);
1328            let now = self.now_ms;
1329            let anim_off = self.anim_off;
1330            let sl = self.slot(id);
1331            if sl.at0 == 0 {
1332                sl.at0 = 1;
1333                sl.scrollx = target;
1334                sl.scrolly = target;
1335            }
1336            if target != sl.scrolly {
1337                if sl.scrollx == sl.scrolly {
1338                    sl.aux = now as i32;
1339                }
1340                sl.scrolly = target;
1341            }
1342            let mut animating = false;
1343            if sl.scrollx != target {
1344                if anim_off {
1345                    sl.scrollx = target;
1346                } else {
1347                    let steps = anim_steps(now, &mut sl.aux);
1348                    sl.scrollx = anim_decay(sl.scrollx, target, steps);
1349                    if sl.scrollx != target {
1350                        animating = true;
1351                    }
1352                }
1353            }
1354            let px = sl.scrollx;
1355            if animating {
1356                self.animating = true;
1357            }
1358            // paint (style): the selection pill
1359            let mut pp = sp;
1360            pp.place(strip);
1361            pp.value = px; // pill x (relative to the strip)
1362            pp.value2 = (state == SS_DISABLED as u32) as i32;
1363            st.tab_pill(self, &pp);
1364            // focus visual around the selected tab
1365            if ev & EV_FOCUS != 0 && self.wp.ctx.focus_visible {
1366                let fx = strip[0].wrapping_add(tx);
1367                st.focus_rect(self, fx, strip[1], widths[s], h);
1368            }
1369        }
1370        // record of the whole strip: value = selection
1371        self.wp.ctx.last_rect = strip;
1372        self.wp.ctx.last_state = state;
1373        self.sem(K_BOX, id, None, *sel).max = count;
1374        let mut r = ev & !EV_ACT;
1375        if *sel != old {
1376            r |= EV_ACT;
1377        }
1378        r
1379    }
1380
1381    // ------------------------------------------------------------------ expander
1382
1383    /// ui_expander_begin(id, header, &mut open) -> open: a card-like header (48 DIP) with a
1384    /// chevron; when open the content follows in a padded card below it (BOX_V, PAD_16, gap
1385    /// 8 DIP).  Always call expander_end.
1386    #[cfg_attr(jfgui_dev, track_caller)]
1387    pub fn expander_begin(&mut self, id: u32, header: &str, open: &mut i32) -> i32 {
1388        let _src = self.src_scope();
1389        let header = super::basic::cstr(header);
1390        // outer column
1391        self.box_begin(id.wrapping_mul(8).wrapping_add(1) | 0x8000_0000, BOX_V, 0);
1392        // header
1393        let st = self.style;
1394        let mut sp = Sp { label: Some(header), ..Sp::default() };
1395        let (w, h) = st.expander_size(self, &mut sp);
1396        self.place(w, h);
1397        let hdr = self.wp.ctx.last_rect;
1398        let ev = self.behave(id, HB_FOCUS);
1399        let state = self.wp.ctx.last_state;
1400        if ev & EV_ACT != 0 {
1401            *open = (*open ^ 1) & 1;
1402            self.repass();
1403        }
1404        let o = *open;
1405        // paint (style): header
1406        sp.place(hdr);
1407        sp.ev = ev;
1408        sp.value = o;
1409        sp.value2 = (state == SS_DISABLED as u32) as i32;
1410        st.expander_header(self, &sp);
1411        // focus visual, record
1412        self.wp.ctx.last_rect = hdr;
1413        if ev & EV_FOCUS != 0 && self.wp.ctx.focus_visible {
1414            st.focus_rect(self, hdr[0], hdr[1], hdr[2], hdr[3]);
1415        }
1416        let mut s = state;
1417        if o != 0 {
1418            s |= SS_OPEN as u32;
1419        }
1420        self.wp.ctx.last_state = s;
1421        self.sem(K_BUTTON, id, Some(header), o);
1422        // remember whether a content box was opened
1423        if self.wp.list.exp_sp as usize >= EXP_MAX {
1424            self.limit_hit(super::ctx::LIM_EXPANDERS);
1425        }
1426        let l = &mut self.wp.list;
1427        if (l.exp_sp as usize) < EXP_MAX {
1428            l.exp[l.exp_sp as usize] = o as u32;
1429        }
1430        l.exp_sp = l.exp_sp.wrapping_add(1);
1431        if o != 0 {
1432            // content card: square top corners, shared edge with the header
1433            self.box_begin(id.wrapping_mul(8).wrapping_add(2) | 0x8000_0000, BOX_V | PAD_16, 8);
1434            sp.place(self.wp.ctx.last_rect);
1435            st.expander_body(self, &sp);
1436        }
1437        o
1438    }
1439
1440    /// ui_expander_end
1441    #[cfg_attr(jfgui_dev, track_caller)]
1442    pub fn expander_end(&mut self) {
1443        let _src = self.src_scope();
1444        let l = &mut self.wp.list;
1445        if l.exp_sp == 0 {
1446            return;
1447        }
1448        l.exp_sp -= 1;
1449        let i = l.exp_sp as usize;
1450        if i < EXP_MAX && l.exp[i] != 0 {
1451            self.box_end(); // content card
1452        }
1453        self.box_end(); // column
1454    }
1455}