Skip to main content

ui/
shell.rs

1// shell -- the parts of an application shell: navigation pane, command bar, splitter,
2// breadcrumb, segmented control, wizard step list
3//
4//   nav_begin(id, &mut NavState)        vertical navigation pane (next_size sets its width;
5//                                       the style's surface), items scroll when they do not fit
6//   nav_toggle(id, label)               the pane's menu button: toggles NavState::compact
7//   nav_section(label)                  a group caption (compact: a divider)
8//   nav_item(id, icon, label, selected, badge) -> EV   EV_ACT when clicked; badge: a count
9//   nav_footer()                        the items after it stay at the bottom of the pane
10//   nav_end()
11//   command_bar(id, items) -> Option<id>   a row of subtle icon + label buttons; what does not
12//                                       fit goes into a "…" menu; CBI_END starts the group
13//                                       aligned to the end, CBI_SEP a separator; ST_ACCENT /
14//                                       ST_DISABLED / ST_CHECKED per item
15//   split_begin(id, pos, vertical)      two panes: the first `pos` DIP wide (vertical: high),
16//   split_next()                        a draggable grip, the second fills
17//   split_end(&mut pos) -> changed      the dragged position (DIP)
18//   breadcrumb(id, parts) -> Option<i>  path of links, the last part is the current page
19//   segmented(id, &mut sel, items) -> EV   a segmented control (EV_ACT when sel changed)
20//   wizard_steps(id, steps, current, states) -> Option<i>   vertical step list; a click on
21//                                       a done step (or one before the current) returns it
22//
23// Ids: parts (id << 3 | k) | 0x80000000; items of segmented / wizard / breadcrumb id + 1 + i.
24
25use alloc::vec::Vec;
26
27use crate::consts::*;
28use crate::keys::*;
29use crate::{Sp, Ui};
30
31/// the app-owned state of a navigation pane
32#[derive(Clone, Copy, Default, Debug)]
33pub struct NavState {
34    /// icons only
35    pub compact: bool,
36}
37
38/// an item of a command bar
39#[derive(Clone, Copy, Debug)]
40pub struct CmdItem<'a> {
41    pub id: u32,
42    pub icon: u32,
43    pub label: &'a str,
44    /// ST_ACCENT, ST_DISABLED, ST_CHECKED, CBI_SEP, CBI_END
45    pub flags: u32,
46}
47
48impl<'a> CmdItem<'a> {
49    pub const fn new(id: u32, icon: u32, label: &'a str) -> CmdItem<'a> {
50        CmdItem { id, icon, label, flags: 0 }
51    }
52    /// add flags (ST_ACCENT, CBI_SEP, ...; in any order with enabled())
53    pub const fn flags(mut self, f: u32) -> CmdItem<'a> {
54        self.flags |= f;
55        self
56    }
57    /// disabled unless `on`
58    pub const fn enabled(mut self, on: bool) -> CmdItem<'a> {
59        if !on {
60            self.flags |= ST_DISABLED;
61        }
62        self
63    }
64}
65
66#[derive(Clone, Copy, Default)]
67struct NavFrame {
68    compact: bool,
69    scrolled: bool,
70}
71
72#[derive(Clone, Copy, Default)]
73struct SplitFrame {
74    id: u32,
75    pos: i32,
76    vertical: bool,
77    rect: [i32; 4],
78    changed: bool,
79}
80
81/// shared state: the open navigation panes and splitters
82#[derive(Default)]
83pub(crate) struct S {
84    nav: Vec<NavFrame>,
85    split: Vec<SplitFrame>,
86}
87
88fn part(id: u32, k: u32) -> u32 {
89    id.wrapping_mul(8).wrapping_add(k) | 0x8000_0000
90}
91
92impl Ui {
93    // ------------------------------------------------------------------ navigation pane
94
95    /// ui_nav_begin(id, &mut nav): open the pane (always call nav_end)
96    #[cfg_attr(jfgui_dev, track_caller)]
97    pub fn nav_begin(&mut self, id: u32, nav: &mut NavState) {
98        let _src = self.src_scope();
99        self.box_begin(id, BOX_V | BOX_CLIP | BOX_BLOCK, 0);
100        let r = self.box_rect();
101        let sh = self.style.shell();
102        let mut sp = Sp { flags: nav.compact as u32, ..Sp::default() };
103        sp.place(r);
104        sh.nav_pane(self, &sp);
105        self.next_size(-1, -1);
106        self.scroll_begin(part(id, 1), SC_V | pad(4));
107        self.m.shell.nav.push(NavFrame { compact: nav.compact, scrolled: true });
108    }
109
110    fn nav_top(&self) -> NavFrame {
111        self.m.shell.nav.last().copied().unwrap_or_default()
112    }
113
114    /// ui_nav_toggle(id, label): the menu button of the pane (toggles compact)
115    #[cfg_attr(jfgui_dev, track_caller)]
116    pub fn nav_toggle(&mut self, id: u32, label: &str, nav: &mut NavState) -> u32 {
117        let _src = self.src_scope();
118        self.next_icon(IC_MENU);
119        self.next_flags(ST_SUBTLE | ST_ICON_ONLY);
120        self.next_size(40, 36);
121        let ev = self.button(id, label);
122        if ev & EV_ACT != 0 {
123            nav.compact = !nav.compact;
124            self.repass();
125        }
126        ev
127    }
128
129    /// ui_nav_section(label): a group caption (a divider when the pane is compact)
130    #[cfg_attr(jfgui_dev, track_caller)]
131    pub fn nav_section(&mut self, label: &str) {
132        let _src = self.src_scope();
133        let f = self.nav_top();
134        let sh = self.style.shell();
135        let mut sp = Sp { flags: if f.compact { NAV_COMPACT } else { 0 }, label: Some(label), ..Sp::default() };
136        let (w, h) = sh.nav_section_size(self, &mut sp);
137        self.next_size(-1, 0);
138        let r = self.place(w, h);
139        sp.place(r);
140        sh.nav_section(self, &sp);
141        self.wp.ctx.last_state = 0;
142        self.sem(K_TEXT, 0, Some(label), 0);
143    }
144
145    /// ui_nav_item(id, icon, label, selected, badge) -> EV_* (EV_ACT clicked)
146    #[cfg_attr(jfgui_dev, track_caller)]
147    pub fn nav_item(&mut self, id: u32, icon: u32, label: &str, selected: bool, badge: Option<&str>) -> u32 {
148        let _src = self.src_scope();
149        let f = self.nav_top();
150        let flags = self.take_flags();
151        let sh = self.style.shell();
152        let mut sp = Sp {
153            flags: if f.compact { NAV_COMPACT } else { 0 },
154            icon,
155            label: Some(label),
156            label2: badge,
157            value: selected as i32,
158            ..Sp::default()
159        };
160        let (w, h) = sh.nav_item_size(self, &mut sp);
161        self.next_size(-1, 0);
162        self.place(w, h);
163        let hb = HB_FOCUS | if flags & ST_DISABLED != 0 { HB_DISABLED } else { 0 };
164        let ev = self.behave(id, hb);
165        self.sp_placed(&mut sp, ev);
166        sh.nav_item(self, &sp);
167        self.focus_ring_draw(ev);
168        if selected {
169            self.wp.ctx.last_state |= SS_SELECTED as u32;
170        }
171        self.sem(K_BUTTON, id, Some(label), selected as i32);
172        if f.compact {
173            self.tooltip(label);
174        }
175        ev
176    }
177
178    /// ui_nav_footer: end the scrolling item list; the items after it stay at the bottom
179    #[cfg_attr(jfgui_dev, track_caller)]
180    pub fn nav_footer(&mut self) {
181        let _src = self.src_scope();
182        let Some(f) = self.m.shell.nav.last_mut() else { return };
183        if f.scrolled {
184            f.scrolled = false;
185            self.scroll_end();
186            self.spacer(4);
187        }
188    }
189
190    /// ui_nav_end
191    #[cfg_attr(jfgui_dev, track_caller)]
192    pub fn nav_end(&mut self) {
193        let _src = self.src_scope();
194        let Some(f) = self.m.shell.nav.pop() else { return };
195        if f.scrolled {
196            self.scroll_end();
197        } else {
198            self.spacer(4);
199        }
200        self.box_end();
201    }
202
203    // ------------------------------------------------------------------ command bar
204
205    /// ui_command_bar(id, items) -> the id of the item clicked in this pass
206    #[cfg_attr(jfgui_dev, track_caller)]
207    pub fn command_bar(&mut self, id: u32, items: &[CmdItem]) -> Option<u32> {
208        let _src = self.src_scope();
209        self.box_begin(id, BOX_H | BOX_XCENTER, 4);
210        let r = self.box_rect();
211        let sh = self.style.shell();
212        let mut sp = Sp::default();
213        sp.place(r);
214        sh.command_bar(self, &sp);
215        let st = self.style;
216        // widths of the items as buttons, the "…" button
217        let mut widths = Vec::with_capacity(items.len());
218        for it in items {
219            let mut f = ST_SUBTLE;
220            if it.flags & ST_ACCENT != 0 {
221                f = ST_ACCENT;
222            }
223            let mut sp = Sp { flags: f, icon: it.icon, label: Some(it.label), ..Sp::default() };
224            let (w, _) = st.button_size(self, &mut sp);
225            widths.push(w);
226        }
227        let mut more_sp = Sp { flags: ST_SUBTLE | ST_ICON_ONLY, icon: IC_MORE, ..Sp::default() };
228        let (more_w, _) = st.button_size(self, &mut more_sp);
229        let gap = self.dp(4);
230        let sep = self.dp(9);
231        let avail = r[2];
232        // which items fit (in order; the end group competes the same way)
233        let total: i32 = widths.iter().sum::<i32>()
234            + gap * items.len() as i32
235            + items.iter().filter(|i| i.flags & CBI_SEP != 0).count() as i32 * sep;
236        let mut shown = alloc::vec![true; items.len()];
237        if total > avail {
238            let mut used = more_w + gap;
239            for (i, it) in items.iter().enumerate() {
240                let w = widths[i] + gap + if it.flags & CBI_SEP != 0 { sep } else { 0 };
241                if used + w <= avail {
242                    used += w;
243                } else {
244                    shown[i] = false;
245                }
246            }
247        }
248        let mut clicked = None;
249        let mut ended = false;
250        for (i, it) in items.iter().enumerate() {
251            if !shown[i] {
252                continue;
253            }
254            if it.flags & CBI_END != 0 && !ended {
255                ended = true;
256                self.spacer(-1);
257            } else if it.flags & CBI_SEP != 0 {
258                self.separator();
259            }
260            self.next_icon(it.icon);
261            let mut f = it.flags & (ST_DISABLED | ST_CHECKED);
262            f |= if it.flags & ST_ACCENT != 0 { ST_ACCENT } else { ST_SUBTLE };
263            self.next_flags(f);
264            if self.button(it.id, it.label) & EV_ACT != 0 {
265                clicked = Some(it.id);
266            }
267        }
268        if shown.iter().any(|s| !s) {
269            if !ended {
270                self.spacer(-1);
271            }
272            self.next_icon(IC_MORE);
273            self.next_flags(ST_SUBTLE | ST_ICON_ONLY);
274            let mid = part(id, 1);
275            self.dropdown_button(mid, crate::i18n::tr("Weitere Befehle"));
276            if self.flyout_begin(mid, FL_ALIGN_END) {
277                for (i, it) in items.iter().enumerate() {
278                    if shown[i] {
279                        continue;
280                    }
281                    self.next_icon(it.icon);
282                    self.next_flags(it.flags & (ST_DISABLED | ST_CHECKED));
283                    if self.menu_item(it.id, it.label) & EV_ACT != 0 {
284                        clicked = Some(it.id);
285                    }
286                }
287            }
288            self.flyout_end();
289        }
290        self.box_end();
291        clicked
292    }
293
294    // ------------------------------------------------------------------ splitter
295
296    /// ui_split_begin(id, pos, vertical): two panes, the first `pos` DIP wide (vertical:
297    /// high); emit the first pane, split_next, the second pane, split_end
298    #[cfg_attr(jfgui_dev, track_caller)]
299    pub fn split_begin(&mut self, id: u32, pos: i32, vertical: bool) {
300        let _src = self.src_scope();
301        // the split and its first pane get their size from outside, never from the content
302        self.box_begin(part(id, 0), BOX_FIXED | if vertical { BOX_V } else { BOX_H }, 0);
303        let rect = self.box_rect();
304        let total = self.px2dip(if vertical { rect[3] } else { rect[2] });
305        let grip = 6;
306        let mut p = pos.max(80);
307        if total > 0 {
308            p = p.min((total - grip - 80).max(80));
309        }
310        if vertical {
311            self.next_size(-1, p);
312        } else {
313            self.next_size(p, -1);
314        }
315        self.box_begin(part(id, 1), BOX_V | BOX_CLIP | BOX_FIXED, 0);
316        self.focus_pane_begin(part(id, 1)); // F6 visits both sides (kbnav.rs)
317        self.m.shell.split.push(SplitFrame { id, pos: p, vertical, rect, changed: false });
318    }
319
320    /// ui_split_next: the grip, then the second pane
321    #[cfg_attr(jfgui_dev, track_caller)]
322    pub fn split_next(&mut self) {
323        let _src = self.src_scope();
324        let Some(mut f) = self.m.shell.split.pop() else { return };
325        self.focus_pane_end();
326        self.box_end();
327        let g = 6;
328        if f.vertical {
329            self.next_size(-1, g);
330        } else {
331            self.next_size(g, -1);
332        }
333        let r = self.place(self.dp(g), self.dp(g));
334        let cur = if f.vertical { CUR_SIZE_NS } else { CUR_SIZE_WE };
335        let ev = self.behave(part(f.id, 2), HB_SPLITTER | cur << HB_CURSOR_SHIFT);
336        // the keyboard (F8 reaches the grip): arrows move it
337        let d = self.kb_grip(ev, !f.vertical);
338        if d != 0 {
339            let total = self.px2dip(if f.vertical { f.rect[3] } else { f.rect[2] });
340            let p = f.pos.saturating_add(d).clamp(80, (total - g - 80).max(80));
341            if p != f.pos {
342                f.pos = p;
343                f.changed = true;
344                self.repass();
345            }
346        }
347        if ev & EV_HELD != 0 {
348            let (mx, my) = self.mouse();
349            let p = if f.vertical { my - f.rect[1] } else { mx - f.rect[0] } - (self.dp(g) >> 1);
350            let total = self.px2dip(if f.vertical { f.rect[3] } else { f.rect[2] });
351            let p = self.px2dip(p).clamp(80, (total - g - 80).max(80));
352            if p != f.pos {
353                f.pos = p;
354                f.changed = true;
355                self.repass();
356            }
357        }
358        let sh = self.style.shell();
359        let mut sp = Sp { ev, flags: f.vertical as u32, ..Sp::default() };
360        sp.place(r);
361        sh.split_grip(self, &sp);
362        if self.focus_ring(ev) {
363            let st = self.style;
364            st.focus_ring(self, r, 0);
365        }
366        // the record: a range (the first pane's size in DIP of the whole)
367        let total = self.px2dip(if f.vertical { f.rect[3] } else { f.rect[2] });
368        let rec = self.sem(K_SLIDER, part(f.id, 2), Some(crate::i18n::tr("Teiler")), f.pos);
369        rec.max = total;
370        self.next_size(-1, -1);
371        self.box_begin(part(f.id, 3), BOX_V | BOX_CLIP | BOX_FIXED, 0);
372        self.focus_pane_begin(part(f.id, 3));
373        self.m.shell.split.push(f);
374    }
375
376    /// ui_split_end(&mut pos) -> the grip moved (pos updated, DIP; the mouse or the keyboard:
377    /// F8 focuses the grip, arrows move it)
378    #[cfg_attr(jfgui_dev, track_caller)]
379    pub fn split_end(&mut self, pos: &mut i32) -> bool {
380        let _src = self.src_scope();
381        let Some(f) = self.m.shell.split.pop() else { return false };
382        self.focus_pane_end();
383        self.box_end();
384        self.box_end();
385        if f.changed {
386            *pos = f.pos;
387        }
388        f.changed
389    }
390
391    // ------------------------------------------------------------------ breadcrumb
392
393    /// ui_breadcrumb(id, parts) -> the index of a clicked part (the last one is the current
394    /// page and not a link)
395    #[cfg_attr(jfgui_dev, track_caller)]
396    pub fn breadcrumb(&mut self, id: u32, parts: &[&str]) -> Option<usize> {
397        let _src = self.src_scope();
398        self.box_begin(0, BOX_H | BOX_XCENTER, 2);
399        let mut out = None;
400        let n = parts.len();
401        for (i, p) in parts.iter().enumerate() {
402            if i + 1 < n {
403                self.next_flags(ST_SUBTLE | ST_COMPACT);
404                if self.label_button(id.wrapping_add(1 + i as u32), p, TS_BODY | TX_SECONDARY, 6) & EV_ACT != 0 {
405                    out = Some(i);
406                }
407                let s = self.dp(16);
408                let r = self.place(s, s);
409                let sh = self.style.shell();
410                let mut sp = Sp::default();
411                sp.place(r);
412                sh.breadcrumb_sep(self, &sp);
413            } else {
414                self.text(TS_BODY_STRONG, p);
415            }
416        }
417        self.box_end();
418        out
419    }
420
421    // ------------------------------------------------------------------ segmented control
422
423    /// ui_segmented(id, &mut sel, items) -> EV_* (EV_ACT when sel changed)
424    #[cfg_attr(jfgui_dev, track_caller)]
425    pub fn segmented(&mut self, id: u32, sel: &mut i32, items: &[&str]) -> u32 {
426        let _src = self.src_scope();
427        let flags = self.take_flags();
428        let old = *sel;
429        let pad = self.dp(14);
430        self.ts_font(TS_BODY_STRONG);
431        let ws: Vec<i32> = items.iter().map(|s| self.text.width(s) + pad * 2).collect();
432        let total: i32 = ws.iter().sum();
433        let h = self.dp(if flags & ST_COMPACT != 0 { 28 } else { 32 });
434        let r = self.place(total, h);
435        let disabled = if flags & ST_DISABLED != 0 { HB_DISABLED } else { 0 };
436        let ev = self.behave(id, HB_FOCUS | disabled);
437        let state = self.wp.ctx.last_state;
438        let n = items.len() as i32;
439        if ev & EV_FOCUS != 0 && n > 0 {
440            let mut s = *sel;
441            if self.key(VK_LEFT) {
442                s -= 1;
443            }
444            if self.key(VK_RIGHT) {
445                s += 1;
446            }
447            s = s.clamp(0, n - 1);
448            if s != *sel {
449                *sel = s;
450                self.wp.ctx.focus_visible = true;
451                self.repass();
452            }
453        }
454        let sh = self.style.shell();
455        let mut tsp = Sp { value2: 1, ..Sp::default() };
456        tsp.place(r);
457        sh.segment(self, &tsp);
458        // item widths scaled to the placed width
459        let scale = |w: i32| if total > 0 { (w as i64 * r[2] as i64 / total as i64) as i32 } else { w };
460        let mut x = r[0];
461        for (i, label) in items.iter().enumerate() {
462            let w = if i + 1 == items.len() { r[0] + r[2] - x } else { scale(ws[i]) };
463            let ir = [x, r[1], w, r[3]];
464            let iid = id.wrapping_add(1 + i as u32);
465            let iev = self.hit_rect(iid, ir[0], ir[1], ir[2], ir[3], disabled);
466            if iev & EV_ACT != 0 && *sel != i as i32 {
467                *sel = i as i32;
468                self.wp.ctx.focus = id;
469                self.wp.ctx.focus_visible = false;
470                self.repass();
471            }
472            let mut f = 0;
473            if i == 0 {
474                f |= SEG_FIRST;
475            }
476            if i + 1 == items.len() {
477                f |= SEG_LAST;
478            }
479            let mut sp = Sp { ev: iev, value: (*sel == i as i32) as i32, flags: f, label: Some(label), ..Sp::default() };
480            sp.place(ir);
481            sp.state = if disabled != 0 { 3 } else { 0 };
482            sh.segment(self, &sp);
483            if *sel == i as i32 {
484                self.wp.ctx.last_state |= SS_SELECTED as u32;
485            }
486            self.sem(K_TAB, iid, Some(label), i as i32);
487            x += w;
488        }
489        if ev & EV_FOCUS != 0 && self.wp.ctx.focus_visible {
490            let st = self.style;
491            st.focus_ring(self, r, 4);
492        }
493        self.wp.ctx.last_rect = r;
494        self.wp.ctx.last_state = state;
495        self.sem(K_BOX, id, None, *sel).max = n;
496        if *sel != old { (ev & !EV_ACT) | EV_ACT } else { ev & !EV_ACT }
497    }
498
499    // ------------------------------------------------------------------ wizard steps
500
501    /// ui_wizard_steps(id, steps, current, states) -> a step to go back to (clicked);
502    /// states: WS_* per step (missing: done before the current, todo after it)
503    #[cfg_attr(jfgui_dev, track_caller)]
504    pub fn wizard_steps(&mut self, id: u32, steps: &[&str], current: usize, states: &[i32]) -> Option<usize> {
505        let _src = self.src_scope();
506        let mut out = None;
507        let sh = self.style.shell();
508        let h = self.dp(48);
509        for (i, label) in steps.iter().enumerate() {
510            let s = states.get(i).copied().unwrap_or(if i < current {
511                WS_DONE
512            } else if i == current {
513                WS_CURRENT
514            } else {
515                WS_TODO
516            });
517            let s = if i == current { WS_CURRENT } else { s };
518            self.next_size(-1, 0);
519            let r = self.place(self.dp(200), h);
520            let can = i < current || (s == WS_DONE && i != current);
521            let iid = id.wrapping_add(1 + i as u32);
522            let ev = self.behave(iid, if can { HB_FOCUS } else { HB_DISABLED });
523            if ev & EV_ACT != 0 && can {
524                out = Some(i);
525            }
526            let mut sp = Sp {
527                ev,
528                value: i as i32 + 1,
529                value2: s,
530                label: Some(label),
531                flags: (i + 1 < steps.len()) as u32,
532                ..Sp::default()
533            };
534            sp.place(r);
535            sh.wizard_step(self, &sp);
536            self.focus_ring_draw(ev);
537            if i == current {
538                self.wp.ctx.last_state |= SS_SELECTED as u32;
539            }
540            self.sem(K_TAB, iid, Some(label), s);
541        }
542        out
543    }
544
545    /// ui_page_header(title, subtitle): a page title (TS_TITLE) with an optional secondary
546    /// line under it
547    #[cfg_attr(jfgui_dev, track_caller)]
548    pub fn page_header(&mut self, title: &str, subtitle: &str) {
549        let _src = self.src_scope();
550        self.box_begin(0, BOX_V, 2);
551        self.text(TS_TITLE, title);
552        if !subtitle.is_empty() {
553            self.text(TS_BODY | TX_SECONDARY | TX_ELLIPSIS, subtitle);
554        }
555        self.box_end();
556    }
557
558    /// ui_key_value(key, value, key_width): a "label: value" row (TS_BODY, the key in the
559    /// secondary colour, key_width DIP)
560    #[cfg_attr(jfgui_dev, track_caller)]
561    pub fn key_value(&mut self, key: &str, value: &str, key_width: i32) {
562        let _src = self.src_scope();
563        self.box_begin(0, BOX_H | BOX_XCENTER, 12);
564        self.next_size(key_width, 0);
565        self.text(TS_BODY | TX_SECONDARY | TX_ELLIPSIS, key);
566        self.next_size(-1, 0);
567        self.text(TS_BODY | TX_ELLIPSIS, value);
568        self.box_end();
569    }
570
571    /// ui_empty_state(icon, title, text): a centred hint for an empty view (icon 0 = none);
572    /// the text wraps at 360 DIP, or at the enclosing box's width when that is narrower
573    #[cfg_attr(jfgui_dev, track_caller)]
574    pub fn empty_state(&mut self, icon: u32, title: &str, text: &str) {
575        let _src = self.src_scope();
576        // the text wraps at 360 DIP, narrower in a narrow panel (the enclosing box's width)
577        let avail = self.px2dip(self.box_rect()[2]);
578        self.box_begin(0, BOX_V | BOX_CENTER | BOX_XCENTER, 8);
579        if icon != 0 {
580            let s = self.dp(40);
581            let r = self.place(s, s);
582            let c = self.color(T_TEXT3);
583            crate::stock::icon_in(self, icon, r, s, c);
584        }
585        self.text(TS_SUBTITLE | TX_CENTER, title);
586        if !text.is_empty() {
587            self.next_size(if avail > 80 { avail.min(360) } else { 360 }, 0);
588            self.text(TS_BODY | TX_SECONDARY | TX_WRAP | TX_CENTER, text);
589        }
590        self.box_end();
591    }
592}