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