Skip to main content

ui/
chrome.rs

1// chrome -- window chrome and overlays: content dialog, info bar, status bar, title bar,
2// caption buttons, the chrome of secondary windows, edge grips       [src/ui/ui_chrome.s]
3//
4//   dialog_begin(id, title, open) -> true while open (emit the content, then
5//        dialog_buttons and dialog_end; always call dialog_end)
6//        WinUI ContentDialog on L_DIALOG: smoke over the window, centred 320..548 DIP
7//        wide, title, content; everything below is inert while it is open
8//   dialog_buttons(primary, secondary, close) -> DLG_NONE / DLG_PRIMARY / DLG_SECONDARY /
9//        DLG_CLOSE -- footer buttons (labels, None = none; primary is the accent button);
10//        Enter = primary, Esc = close.  Any result clears the open flag.
11//   dialog_end()
12//   infobar(id, severity, title, msg) -> EV (EV_ACT: the close button was clicked)
13//        IB_INFO / IB_SUCCESS / IB_WARNING / IB_ERROR; the close button has the id id + 1;
14//        placed too narrow, the message and then the title are cut with "…"
15//   statusbar_begin() / statusbar_end()
16//        32 DIP bar (layer fill, top divider) laying out its children left to right,
17//        vertically centred, 16 DIP apart (captions: text(TS_CAPTION, ...))
18//   edge_grips()   resize handles for frameless windows on Linux (the runtime calls it;
19//        also for resizable secondary windows with the library frame); the caption buttons
20//        win over them where they are
21//   titlebar_begin(title, icon) / titlebar_end() / titlebar_open()
22//        custom title bar row (style: SM_TB_H, SM_TB_H_LARGE with next_flags(ST_LARGE)):
23//        icon + title, then the caller's widgets; empty parts are an HB_DRAG region, the end
24//        adds the caption buttons (HB_MIN / HB_MAX / HB_CLOSE; with SM_CAP_LEFT the begin puts
25//        them first, close / min / max).  The system's layout (SysSettings::titlebar: GNOME's
26//        button-layout) wins over the style's: its left buttons come first, its right ones
27//        last, missing ones are not shown; right-to-left mirrors the whole bar (as GTK does).
28//        A double click, a middle and a right click on the empty part do what the system says
29//        (default: maximize / restore, nothing, the window menu; a left press moves the
30//        window).  Headless runs only record what they would ask the window manager for
31//        (scripts: expect wm ...)
32// Windows (win.rs):
33//   win_chrome_begin(i) / win_chrome_end(i) -> natural content height (DIP)
34//        border, title bar (28 DIP tool / 32 DIP dialog: title, close button) and the
35//        content box of a secondary window; inside a WF_DIALOG window dialog_buttons puts
36//        its footer below the content box (pinned to the bottom)
37//
38// Ids: dialog parts (id << 3 | k) | 0x80000000; title bar 0xFFFF7400.. (min +1, max +2,
39// close +3); status bar 0xFFFF7410, 0xFFFF7411; edge grips 0xFFFF7430..
40
41use alloc::string::String;
42
43use super::ctx::Hit;
44
45use super::consts::*;
46use super::keys::*;
47use super::layout::BOX_STACK_MAX;
48use super::win::{Shared, UWI_BUILDING, WCH_BASE, WCH_BTN, WCH_CLOSE, WCH_CONTENT, WCH_FOOTER};
49use super::{Sp, Ui};
50
51const TB_ID: u32 = 0xFFFF_7400;
52const TB_MIN_ID: u32 = 0xFFFF_7401;
53const TB_MAX_ID: u32 = 0xFFFF_7402;
54const TB_CLOSE_ID: u32 = 0xFFFF_7403;
55const SB_ID: u32 = 0xFFFF_7410;
56const SB_INNER_ID: u32 = 0xFFFF_7411;
57const GRIP_ID: u32 = 0xFFFF_7430; // + 0..7
58
59// what a headless run would have asked the window manager for (S::wm_req: kind | arg << 8;
60// scripts: expect wm ...)
61const WMR_MOVE: u32 = 1;
62const WMR_MENU: u32 = 2;
63const WMR_CMD: u32 = 3; // plat::WCMD_* in arg
64const WMR_RESIZE: u32 = 4; // plat::EDGE_* in arg
65
66/// state shared by all windows
67#[derive(Default)]
68pub(crate) struct S {
69    /// width of the next caption button (DIP, 0 = SM_CAP_W)
70    cap_w: i32,
71    /// id of the first edge grip of the window being built
72    grip_base: u32,
73    /// EDGE_* bits of the tiled edges of the window being built (no handles there)
74    grip_off: u32,
75    /// (headless) the last request for the window manager since the last check (WMR_*)
76    pub(crate) wm_req: u32,
77}
78
79/// the request a headless run recorded (S::wm_req) as a script names it: none, move, menu,
80/// minimize, maximize, restore, close, lower, fullscreen, windowed, resize n|s|w|e|nw|ne|sw|se
81pub(crate) fn wm_req_name(r: u32) -> String {
82    let arg = r >> 8;
83    let s = match r & 255 {
84        WMR_MOVE => "move",
85        WMR_MENU => "menu",
86        WMR_CMD => match arg {
87            plat::WCMD_MINIMIZE => "minimize",
88            plat::WCMD_MAXIMIZE => "maximize",
89            plat::WCMD_RESTORE => "restore",
90            plat::WCMD_CLOSE => "close",
91            plat::WCMD_LOWER => "lower",
92            plat::WCMD_FULLSCREEN => "fullscreen",
93            plat::WCMD_WINDOWED => "windowed",
94            _ => "command",
95        },
96        WMR_RESIZE => {
97            let mut e = String::from("resize ");
98            e.push_str(if arg & plat::EDGE_TOP != 0 { "n" } else if arg & plat::EDGE_BOTTOM != 0 { "s" } else { "" });
99            e.push_str(if arg & plat::EDGE_LEFT != 0 { "w" } else if arg & plat::EDGE_RIGHT != 0 { "e" } else { "" });
100            return e;
101        }
102        _ => "none",
103    };
104    String::from(s)
105}
106
107/// per-window state (ui_chrome_wb0: content dialog, title bar, window chrome; swapped by
108/// win.rs)
109#[derive(Default)]
110pub(crate) struct W {
111    dlg_clip: [i32; 4],
112    dlg_openp: Option<Shared<i32>>,
113    pub(crate) dlg_active: bool,
114    dlg_nest: u32,
115    dlg_id: u32,
116    dlg_phase: u32,
117    dlg_layer: u32,
118    dlg_dis: u32,
119    /// the dialog opened in this pass (ui.dialog_sound, sound.rs)
120    pub(crate) dlg_fresh: bool,
121    tb_state: u32,
122    /// This titlebar overlays app content and places the agent badge by the window controls.
123    tb_overlay: bool,
124    /// the title bar being built, the style's window_titlebar behind it (reset with the pass)
125    pub(crate) tb_rect: Option<[i32; 4]>,
126    /// dialog_buttons closed the content box of a dialog window
127    wc_footer: bool,
128    /// the caption buttons' hit boxes of this pass: edge_grips puts them on top again, so
129    /// the buttons win over the resize band where they are (cap_n: how many; reset with
130    /// the pass)
131    cap_hits: [Hit; 4],
132    pub(crate) cap_n: u8,
133}
134
135// a dialog part id: (id << 3 | k) | 0x80000000
136fn dlg_part(id: u32, k: u32) -> u32 {
137    id.wrapping_mul(8).wrapping_add(k) | 0x8000_0000
138}
139
140impl Ui {
141    // ------------------------------------------------------------------ content dialog
142
143    /// ui_dialog_begin(id, title, open) -> true if the dialog is open
144    #[cfg_attr(jfgui_dev, track_caller)]
145    pub fn dialog_begin(&mut self, id: u32, title: &str, open: &Shared<i32>) -> bool {
146        let _src = self.src_scope();
147        if self.wp.chrome.dlg_active {
148            self.wp.chrome.dlg_nest += 1; // a dialog inside a dialog is not supported
149            return false;
150        }
151        // slot: aux = open last pass, scrollx = focus before
152        if open.get() == 0 {
153            // closed: give the focus back once
154            let s = self.slot(id);
155            if s.aux != 0 {
156                s.aux = 0;
157                let f = s.scrollx as u32;
158                self.wp.ctx.focus = f;
159                self.repass();
160            }
161            return false;
162        }
163        {
164            let c = &mut self.wp.chrome;
165            c.dlg_active = true;
166            c.dlg_id = id;
167            c.dlg_openp = Some(open.clone());
168            c.dlg_phase = 1;
169            c.dlg_fresh = false;
170        }
171        let focus = self.wp.ctx.focus;
172        let s = self.slot(id);
173        if s.aux == 0 {
174            // just opened: the keyboard focus moves into the dialog
175            s.aux = 1;
176            s.scrollx = focus as i32;
177            self.wp.ctx.focus = id;
178            self.wp.ctx.focus_visible = false;
179            self.wp.chrome.dlg_fresh = true;
180        }
181        self.wp.chrome.dlg_layer = self.layer_set(L_DIALOG);
182        self.modal(L_DIALOG);
183        // the dialog is laid out and hit tested against the whole window
184        self.wp.chrome.dlg_clip = self.wp.ctx.uiclip;
185        let (w, h) = (self.wp.rt.fb.w, self.wp.rt.fb.h);
186        self.wp.ctx.uiclip = [0, 0, w, h];
187        self.wp.chrome.dlg_dis = self.wp.ctx.disabled_depth;
188        self.wp.ctx.disabled_depth = 0;
189        // smoke (style)
190        let st = self.style;
191        let sp = Sp { x: 0, y: 0, w, h, ..Default::default() };
192        st.dialog_smoke(self, &sp);
193        self.wp.ctx.last_rect = [0, 0, w, h];
194        self.hit(dlg_part(id, 1), HB_BLOCK);
195        // size: the natural size of the last pass, 320 .. 548 DIP wide
196        let (mut dw, nh) = {
197            let s = self.slot(id);
198            (s.measw, s.meash)
199        };
200        let min = self.dp(st.metric(SM_DLG_MIN));
201        if dw < min {
202            dw = min;
203        }
204        let max = self.dp(st.metric(SM_DLG_MAX));
205        if dw > max {
206            dw = max;
207        }
208        let m = self.dp(st.metric(SM_DLG_MARGIN)); // space kept free around it
209        if dw > w - m {
210            dw = w - m;
211        }
212        if dw < 1 {
213            dw = 1;
214        }
215        let x = (w - dw) >> 1;
216        let y = ((h - nh) >> 1).max(0);
217        self.next_rect_px(x, y, dw, 0);
218        self.box_begin(id, BOX_V | BOX_BLOCK, 0);
219        let r = self.wp.ctx.last_rect;
220        // footer height from its last measurement
221        let fh = self.slot(dlg_part(id, 4)).meash;
222        // paint (style): shadow, surface, border
223        let mut sp = Sp { value: fh, ..Default::default() };
224        sp.place(r);
225        st.dialog_frame(self, &sp);
226        // record
227        self.wp.ctx.last_rect = r;
228        self.wp.ctx.last_state = SS_OPEN as u32;
229        self.sem(K_DIALOG, id, Some(title), 0);
230        // content: title, then the caller's widgets
231        let gap = st.metric(SM_DLG_CONTENT_GAP);
232        let flags = st.metric(SM_DLG_CONTENT_BOX) as u32;
233        self.box_begin(dlg_part(id, 3), flags, gap);
234        let ts = st.metric(SM_DLG_TITLE_TS) as u32;
235        self.text(ts, title);
236        true
237    }
238
239    /// ui_dialog_buttons(primary, secondary, close) -> DLG_* (any result clears the open
240    /// flag).  Inside a content dialog: its footer.  Otherwise, inside a WF_DIALOG window:
241    /// a footer pinned to the window's bottom; a result clears the window's open flag.
242    #[cfg_attr(jfgui_dev, track_caller)]
243    pub fn dialog_buttons(&mut self, primary: Option<&str>, secondary: Option<&str>, close: Option<&str>) -> u32 {
244        let _src = self.src_scope();
245        if self.wp.chrome.dlg_active {
246            let c = &mut self.wp.chrome;
247            if c.dlg_nest != 0 || c.dlg_phase != 1 {
248                return DLG_NONE;
249            }
250            c.dlg_phase = 2;
251            let id = c.dlg_id;
252            self.box_end(); // content
253            let r = self.dlg_footer(dlg_part(id, 4), dlg_part(id, 5), primary, secondary, close);
254            if r != 0 {
255                if let Some(o) = &self.wp.chrome.dlg_openp {
256                    o.set(0);
257                }
258                self.repass();
259            }
260            return r;
261        }
262        // a dialog window: close its content box, the footer follows at the bottom
263        if self.wp.chrome.wc_footer {
264            return DLG_NONE;
265        }
266        let cur = self.uiw_cur;
267        if cur == 0 {
268            return DLG_NONE;
269        }
270        let rec = &self.m.win.recs[(cur as usize) & (WIN_MAX - 1)];
271        if rec.flags & WF_DIALOG == 0 || rec.state & UWI_BUILDING == 0 {
272            return DLG_NONE;
273        }
274        let openp = rec.openp.clone();
275        self.wp.chrome.wc_footer = true;
276        let ids = cur << 4 | WCH_BASE;
277        self.wc_pop_to(ids + WCH_CONTENT);
278        let r = self.dlg_footer(ids + WCH_FOOTER, ids + WCH_BTN, primary, secondary, close);
279        if r != 0 {
280            if let Some(o) = openp {
281                o.set(0);
282            }
283            self.repass();
284        }
285        r
286    }
287
288    // dlg_footer(footer_id, button_id, primary, secondary, close) -> DLG_*: the button row
289    // of a dialog (ids button_id .. +2); Enter = primary, Esc = close
290    fn dlg_footer(&mut self, fid: u32, bid: u32, p: Option<&str>, s: Option<&str>, c: Option<&str>) -> u32 {
291        let st = self.style;
292        let gap = st.metric(SM_DLG_FOOTER_GAP);
293        let flags = st.metric(SM_DLG_FOOTER_BOX) as u32;
294        self.box_begin(fid, flags, gap);
295        let order = st.metric(SM_DLG_BTN_ORDER);
296        let mut r = DLG_NONE;
297        if order == 0 {
298            // a single button takes the right half
299            let n = p.is_some() as u32 + s.is_some() as u32 + c.is_some() as u32;
300            if n == 1 {
301                self.spacer(-1);
302            }
303            if let Some(l) = p {
304                self.next_size(-1, 0);
305                if self.accent_button(bid, l) & EV_ACT != 0 {
306                    r = DLG_PRIMARY;
307                }
308            }
309            if let Some(l) = s {
310                self.next_size(-1, 0);
311                if self.button(bid + 1, l) & EV_ACT != 0 {
312                    r = DLG_SECONDARY;
313                }
314            }
315            if let Some(l) = c {
316                self.next_size(-1, 0);
317                if self.button(bid + 2, l) & EV_ACT != 0 {
318                    r = DLG_CLOSE;
319                }
320            }
321        } else {
322            // SM_DLG_BTN_ORDER 1 (macOS): right-aligned at their natural width, primary
323            // last; 2 (KDE): right-aligned, primary first
324            self.spacer(-1);
325            if order != 2 {
326                if let Some(l) = c {
327                    if self.button(bid + 2, l) & EV_ACT != 0 {
328                        r = DLG_CLOSE;
329                    }
330                }
331                if let Some(l) = s {
332                    if self.button(bid + 1, l) & EV_ACT != 0 {
333                        r = DLG_SECONDARY;
334                    }
335                }
336                if let Some(l) = p {
337                    if self.accent_button(bid, l) & EV_ACT != 0 {
338                        r = DLG_PRIMARY;
339                    }
340                }
341            } else {
342                if let Some(l) = p {
343                    if self.accent_button(bid, l) & EV_ACT != 0 {
344                        r = DLG_PRIMARY;
345                    }
346                }
347                if let Some(l) = s {
348                    if self.button(bid + 1, l) & EV_ACT != 0 {
349                        r = DLG_SECONDARY;
350                    }
351                }
352                if let Some(l) = c {
353                    if self.button(bid + 2, l) & EV_ACT != 0 {
354                        r = DLG_CLOSE;
355                    }
356                }
357            }
358        }
359        self.box_end(); // footer
360        // keyboard: Enter = primary, Esc = close (keys a focused widget did not take)
361        if r != 0 {
362            return r;
363        }
364        if p.is_some() && self.key(VK_RETURN) {
365            return DLG_PRIMARY;
366        }
367        if self.key(VK_ESCAPE) {
368            return DLG_CLOSE;
369        }
370        DLG_NONE
371    }
372
373    /// ui_dialog_end
374    #[cfg_attr(jfgui_dev, track_caller)]
375    pub fn dialog_end(&mut self) {
376        let _src = self.src_scope();
377        if self.wp.chrome.dlg_nest != 0 {
378            self.wp.chrome.dlg_nest -= 1;
379            return;
380        }
381        if !self.wp.chrome.dlg_active {
382            return;
383        }
384        if self.wp.chrome.dlg_phase == 1 {
385            // no footer: Esc still closes
386            if self.key(VK_ESCAPE) {
387                if let Some(o) = &self.wp.chrome.dlg_openp {
388                    o.set(0);
389                }
390                self.repass();
391            }
392            self.box_end(); // content
393        }
394        self.box_end(); // outer
395        let c = &self.wp.chrome;
396        let (clip, dis, layer) = (c.dlg_clip, c.dlg_dis, c.dlg_layer);
397        self.wp.ctx.uiclip = clip;
398        self.wp.ctx.disabled_depth = dis;
399        self.layer_set(layer);
400        self.wp.chrome.dlg_active = false;
401    }
402
403    // ------------------------------------------------------------------ info bar
404
405    /// ui_infobar(id, severity, title, msg) -> EV (EV_ACT when the close button was clicked)
406    #[cfg_attr(jfgui_dev, track_caller)]
407    pub fn infobar(&mut self, id: u32, severity: u32, title: &str, msg: &str) -> u32 {
408        let _src = self.src_scope();
409        let sev = severity & 3;
410        let st = self.style;
411        // measure (style)
412        let mut sp = Sp { flags: sev, label: Some(title), label2: Some(msg), ..Default::default() };
413        let (w, h) = st.infobar_size(self, &mut sp);
414        self.place(w, h);
415        let r = self.wp.ctx.last_rect;
416        // narrower than the texts: the message cut with "…" (left out where little of it
417        // would stay), then the title; the style measures them again (s[0] / s[1])
418        let fit = if w > r[2] { Some(self.infobar_fit(title, msg, sp.s[0], sp.s[1], w - r[2])) } else { None };
419        if let Some((t, m)) = &fit {
420            sp.label = Some(t);
421            sp.label2 = if m.is_empty() { None } else { Some(m) };
422            st.infobar_size(self, &mut sp);
423        }
424        // paint (style): card, icon, title, message; it reports the close button's place
425        sp.place(r);
426        st.infobar(self, &mut sp);
427        let (cx, cs) = (sp.s[2], sp.s[3]);
428        self.wp.ctx.last_rect = [cx, ((r[3] - cs) >> 1) + r[1], cs, cs];
429        let ev = self.behave(id.wrapping_add(1), HB_FOCUS);
430        // paint (style): the close button
431        sp.place(self.wp.ctx.last_rect);
432        sp.ev = ev;
433        st.infobar_close(self, &sp);
434        if ev & EV_FOCUS != 0 && self.wp.ctx.focus_visible {
435            let lr = self.wp.ctx.last_rect;
436            st.focus_rect(self, lr[0], lr[1], lr[2], lr[3]);
437        }
438        self.sem(K_BUTTON, id.wrapping_add(1), Some("Close"), 0);
439        // record of the bar: label = title, value = severity
440        self.wp.ctx.last_rect = r;
441        self.wp.ctx.last_state = 0;
442        self.sem(K_BOX, id, Some(title), sev as i32);
443        ev & EV_ACT
444    }
445
446    // infobar_fit(title, msg, title width, message width, px too wide) -> (title, message):
447    // the message (TS_BODY) gives way first, then the title (TS_BODY_STRONG); the styles
448    // measure them in these text styles
449    fn infobar_fit(&mut self, title: &str, msg: &str, tw: i32, mw: i32, over: i32) -> (String, String) {
450        let keep = mw - over;
451        if !msg.is_empty() && keep >= self.dp(32) {
452            self.ts_font(TS_BODY);
453            return (String::from(title), self.ellipsize(msg.as_bytes(), keep).0);
454        }
455        let over = over - mw;
456        if over <= 0 {
457            return (String::from(title), String::new());
458        }
459        self.ts_font(TS_BODY_STRONG);
460        (self.ellipsize(title.as_bytes(), (tw - over).max(1)).0, String::new())
461    }
462
463    // ------------------------------------------------------------------ status bar
464
465    /// ui_statusbar_begin: a 32 DIP bar; call it last in a vertical layout (after a fill)
466    #[cfg_attr(jfgui_dev, track_caller)]
467    pub fn statusbar_begin(&mut self) {
468        let _src = self.src_scope();
469        let st = self.style;
470        let h = st.metric(SM_SB_H); // height (DIP)
471        self.next_size(0, h);
472        self.box_begin(SB_ID, BOX_H, 0);
473        let r = self.wp.ctx.last_rect;
474        let mut sp = Sp::default();
475        sp.place(r);
476        st.statusbar(self, &sp);
477        // content row, inset at both sides
478        let pad = self.dp(st.metric(SM_SB_PAD));
479        let mut w = r[2] - pad - pad;
480        if w <= 0 {
481            w = 1;
482        }
483        self.next_rect_px(r[0] + pad, r[1], w, r[3]);
484        let gap = st.metric(SM_SB_GAP); // gap between the items (DIP)
485        self.box_begin(SB_INNER_ID, BOX_H | BOX_XCENTER, gap);
486    }
487
488    /// ui_statusbar_end
489    #[cfg_attr(jfgui_dev, track_caller)]
490    pub fn statusbar_end(&mut self) {
491        let _src = self.src_scope();
492        self.box_end();
493        self.box_end();
494    }
495
496    // ------------------------------------------------------------------ title bar
497
498    /// ui_titlebar_begin(title, icon): a title bar row (icon 0 = none)
499    #[cfg_attr(jfgui_dev, track_caller)]
500    pub fn titlebar_begin(&mut self, title: &str, icon: u32) {
501        let _src = self.src_scope();
502        self.titlebar_begin_opt(Some(title), icon);
503    }
504
505    /// ui_titlebar_begin with title 0: no title text (the record has no label)  // pn-core
506    #[cfg_attr(jfgui_dev, track_caller)]
507    pub fn titlebar_begin_opt(&mut self, title: Option<&str>, icon: u32) {
508        let _src = self.src_scope();
509        self.titlebar_begin_inner(title, icon, false);
510    }
511
512    /// Overlay native window controls and a drag region without painting a titlebar surface.
513    /// Use `next_rect_px` to place it over the empty top edge of a file/start screen.
514    /// The agent indicator remains visible beside the caption buttons when control is enabled.
515    #[cfg_attr(jfgui_dev, track_caller)]
516    pub fn titlebar_begin_overlay(&mut self, title: Option<&str>, icon: u32) {
517        let _src = self.src_scope();
518        self.titlebar_begin_inner(title, icon, true);
519    }
520
521    fn titlebar_begin_inner(&mut self, title: Option<&str>, icon: u32, overlay: bool) {
522        let st = self.style;
523        self.wp.chrome.tb_overlay = overlay;
524        let f = self.take_flags();
525        let h = st.metric(if f & ST_LARGE != 0 { SM_TB_H_LARGE } else { SM_TB_H });
526        self.next_size(0, h);
527        self.box_begin(TB_ID, BOX_H | BOX_XCENTER, 0);
528        // window state (a headless run looks active)
529        self.wp.chrome.tb_state = if self.headless { plat::WST_ACTIVE } else { plat::window_state() };
530        let tbs = self.wp.chrome.tb_state;
531        // background (style)
532        let mut sp = Sp { value: (tbs & plat::WST_ACTIVE != 0) as i32, ..Default::default() };
533        sp.place(self.wp.ctx.last_rect);
534        if !overlay { st.window_titlebar(self, &sp); }
535        self.wp.chrome.tb_rect = if overlay { None } else { Some(self.wp.ctx.last_rect) };
536        // the whole bar is a drag region; widgets placed on it later win the hit test
537        let ev = self.behave(TB_ID, HB_DRAG);
538        self.tb_clicks(ev, tbs);
539        self.wp.ctx.last_state = 0;
540        self.sem(K_TITLEBAR, TB_ID, title, 0);
541        // caption buttons at the left end (the system's layout, else SM_CAP_LEFT: macOS)
542        let left = self.tb_side(false);
543        if left[0] != 0 {
544            if st.metric(SM_CAP_LEFT) != 0 {
545                self.spacer(st.metric(SM_TB_PAD));
546            }
547            self.tb_caps(left);
548        }
549        // icon and title
550        self.spacer(st.metric(SM_TB_PAD)); // space before the icon / title (DIP)
551        if icon != 0 {
552            let s = st.metric(SM_TB_ICON); // icon size (DIP; 0 = the style shows none)
553            if s != 0 {
554                self.icon(icon, s);
555                self.spacer(st.metric(SM_TB_PAD));
556            }
557        }
558        if self.agent_enabled() && !overlay {
559            self.next_size(82, 24);
560            if self.button(super::agent::ID_BADGE, if self.agent_connected() { "Agent ●" } else { "Agent ○" }) & EV_ACT != 0 {
561                self.set_agent_enabled(false);
562            }
563            self.tooltip(if self.agent_connected() { "Agent verbunden · Zum Abschalten klicken" } else { "Agentensteuerung aktiv · Zum Abschalten klicken" });
564            self.spacer(8);
565        }
566        let Some(title) = title else { return };
567        let mut ts = st.metric(SM_TB_TITLE_TS) as u32;
568        if tbs & plat::WST_ACTIVE == 0 {
569            ts |= TX_DISABLED;
570        }
571        self.text(ts, title);
572    }
573
574    /// ui_titlebar_end: caption buttons at the right end (unless the style puts them at the
575    /// left: SM_CAP_LEFT)
576    #[cfg_attr(jfgui_dev, track_caller)]
577    pub fn titlebar_end(&mut self) {
578        let _src = self.src_scope();
579        let st = self.style;
580        if self.wp.chrome.tb_overlay {
581            self.spacer(-1);
582            if self.agent_enabled() {
583                self.next_size(82, 24);
584                if self.button(super::agent::ID_BADGE, if self.agent_connected() { "Agent ●" } else { "Agent ○" }) & EV_ACT != 0 {
585                    self.set_agent_enabled(false);
586                }
587                self.tooltip(if self.agent_connected() { "Agent verbunden · Zum Abschalten klicken" } else { "Agentensteuerung aktiv · Zum Abschalten klicken" });
588                self.spacer(8);
589            }
590        }
591        let right = self.tb_side(true);
592        if right[0] != 0 {
593            if !self.wp.chrome.tb_overlay { self.spacer(-1); }
594            self.tb_caps(right);
595            if st.metric(SM_CAP_LEFT) != 0 {
596                self.spacer(st.metric(SM_TB_PAD)); // the style's buttons keep off the edge
597            }
598        }
599        self.box_end();
600        self.wp.chrome.tb_rect = None;
601    }
602
603    /// The title bar being built, between `titlebar_begin` and `titlebar_end` (None elsewhere
604    /// and on an overlay, which paints no bar): its rectangle (px) and whether the window is
605    /// active.  For a style whose bar has the same colours in every theme (classic): a widget
606    /// drawn right on it cannot use the theme's T_TEXT.
607    pub fn titlebar_open(&self) -> Option<([i32; 4], bool)> {
608        let c = &self.wp.chrome;
609        c.tb_rect.map(|r| (r, c.tb_state & plat::WST_ACTIVE != 0))
610    }
611
612    // tb_clicks(ev, state): the presses on the empty part of the title bar (behave's events
613    // ev, plat::WST_* state): a double click, a middle and a right press do what the system
614    // says (SysSettings::titlebar), a left press lets the system move the window.  The window
615    // manager owns the pointer afterwards: the press is forgotten (not in headless runs, which
616    // only record the request)
617    fn tb_clicks(&mut self, ev: u32, tbs: u32) {
618        let tb = self.tb_sys;
619        let c = &self.wp.ctx;
620        let (act, btn) = if ev & EV_DOUBLE != 0 && tb.double != plat::TB_ACT_NONE {
621            (tb.double, BTN_LEFT)
622        } else if c.active == TB_ID && c.pressed & (1 << BTN_LEFT) != 0 {
623            (u8::MAX, BTN_LEFT) // move
624        } else if c.hot == TB_ID && c.pressed & (1 << BTN_MIDDLE) != 0 && tb.middle != plat::TB_ACT_NONE {
625            (tb.middle, BTN_MIDDLE)
626        } else if c.ractive == TB_ID && c.pressed & (1 << BTN_RIGHT) != 0 && tb.right != plat::TB_ACT_NONE {
627            (tb.right, BTN_RIGHT)
628        } else {
629            return;
630        };
631        let (mx, my) = (c.mx, c.my);
632        match act {
633            u8::MAX => self.wm_move(),
634            plat::TB_ACT_MAXIMIZE => self.tb_cmd(if tbs & plat::WST_MAXIMIZED != 0 { plat::WCMD_RESTORE } else { plat::WCMD_MAXIMIZE }),
635            plat::TB_ACT_MINIMIZE => self.tb_cmd(plat::WCMD_MINIMIZE),
636            plat::TB_ACT_LOWER => self.tb_cmd(plat::WCMD_LOWER),
637            _ => self.wm_menu(mx, my),
638        }
639        if self.headless {
640            return;
641        }
642        // the OS owns the pointer now: forget the press
643        let c = &mut self.wp.ctx;
644        match btn {
645            BTN_LEFT => c.active = 0,
646            BTN_RIGHT => c.ractive = 0,
647            _ => {}
648        }
649        c.mbtn &= !(1 << btn);
650    }
651
652    // wm_move / wm_menu(x, y) / wm_resize(edge): plat::begin_move, window_menu (Linux; the
653    // OS's own caption has it elsewhere), begin_resize -- headless runs only record them
654    fn wm_move(&mut self) {
655        if self.headless {
656            self.m.chrome.wm_req = WMR_MOVE;
657        } else {
658            plat::begin_move();
659        }
660    }
661
662    fn wm_menu(&mut self, _x: i32, _y: i32) {
663        if self.headless {
664            self.m.chrome.wm_req = WMR_MENU;
665            return;
666        }
667        #[cfg(target_os = "linux")]
668        plat::window_menu(_x, _y);
669    }
670
671    fn wm_resize(&mut self, edge: u32) {
672        if self.headless {
673            self.m.chrome.wm_req = WMR_RESIZE | edge << 8;
674        } else {
675            plat::begin_resize(edge);
676        }
677    }
678
679    // tb_side(right) -> the caption buttons of one end of the title bar, left to right
680    // (plat::TB_BTN_*, 0 ends): the system's layout, else the style's (SM_CAP_LEFT: close,
681    // minimize, maximize at the left; else minimize, maximize, close at the right)
682    fn tb_side(&self, right: bool) -> [u8; 3] {
683        if let Some((l, r)) = self.tb_sys.buttons {
684            return if right { r } else { l };
685        }
686        match (self.style.metric(SM_CAP_LEFT) != 0, right) {
687            (true, false) => [plat::TB_BTN_CLOSE, plat::TB_BTN_MIN, plat::TB_BTN_MAX],
688            (false, true) => [plat::TB_BTN_MIN, plat::TB_BTN_MAX, plat::TB_BTN_CLOSE],
689            _ => [0; 3],
690        }
691    }
692
693    // tb_caps(buttons): the caption buttons of one end, in order
694    fn tb_caps(&mut self, b: [u8; 3]) {
695        for k in b {
696            match k {
697                plat::TB_BTN_CLOSE => self.tb_cap_close(),
698                plat::TB_BTN_MIN => self.tb_cap_min(),
699                plat::TB_BTN_MAX => self.tb_cap_max(),
700                _ => break,
701            }
702        }
703    }
704
705    // tb_cap_min / tb_cap_max / tb_cap_close: one caption button of the title bar and its
706    // window command
707    fn tb_cap_min(&mut self) {
708        if self.cap_button(TB_MIN_ID, IC_CHROME_MIN, HB_MIN, "Minimize") & EV_ACT != 0 {
709            self.tb_cmd(plat::WCMD_MINIMIZE);
710        }
711    }
712
713    fn tb_cap_max(&mut self) {
714        let max = self.wp.chrome.tb_state & plat::WST_MAXIMIZED != 0;
715        let (ic, l) = if max { (IC_CHROME_RESTORE, "Restore") } else { (IC_CHROME_MAX, "Maximize") };
716        if self.cap_button(TB_MAX_ID, ic, HB_MAX, l) & EV_ACT != 0 {
717            let max = self.wp.chrome.tb_state & plat::WST_MAXIMIZED != 0;
718            self.tb_cmd(if max { plat::WCMD_RESTORE } else { plat::WCMD_MAXIMIZE });
719        }
720    }
721
722    fn tb_cap_close(&mut self) {
723        if self.cap_button(TB_CLOSE_ID, IC_CHROME_CLOSE, HB_CLOSE, "Close") & EV_ACT != 0 {
724            self.tb_cmd(plat::WCMD_CLOSE);
725        }
726    }
727
728    // tb_cmd(cmd): plat::window_cmd (headless runs record it)
729    fn tb_cmd(&mut self, cmd: u32) {
730        if self.headless {
731            self.m.chrome.wm_req = WMR_CMD | cmd << 8;
732        } else {
733            plat::window_cmd(cmd);
734        }
735    }
736
737    // cap_button(id, icon, hbflags, label) -> EV: a caption button over the full height of
738    // the title bar (close: red hover with a white glyph)
739    fn cap_button(&mut self, id: u32, icon: u32, hb: u32, label: &str) -> u32 {
740        let st = self.style;
741        let bh = self.box_rect()[3]; // bar height
742        let w = if self.m.chrome.cap_w != 0 { self.m.chrome.cap_w } else { st.metric(SM_CAP_W) };
743        let w = self.dp(w);
744        self.place(w, bh);
745        let n = self.wp.ctx.hits[self.wp.ctx.hit_cur].len();
746        let ev = self.behave(id, hb);
747        // its hit box, for edge_grips
748        if let Some(h) = self.wp.ctx.hits[self.wp.ctx.hit_cur].get(n).copied() {
749            let c = &mut self.wp.chrome;
750            if let Some(slot) = c.cap_hits.get_mut(c.cap_n as usize) {
751                *slot = h;
752                c.cap_n += 1;
753            }
754        }
755        // paint (style)
756        let active = (self.wp.chrome.tb_state & plat::WST_ACTIVE != 0) as i32;
757        let mut sp = Sp { ev, flags: hb, icon, value: active, ..Default::default() };
758        sp.place(self.wp.ctx.last_rect);
759        st.caption_button(self, &sp);
760        self.sem(K_BUTTON, id, Some(label), 0);
761        ev
762    }
763
764    // ------------------------------------------------------------------ window chrome
765
766    /// win_chrome_begin(i): (window i is live and its pass has begun) the border, the title
767    /// bar (drag region with the title, close button) and the content box of a secondary
768    /// window
769    pub(crate) fn win_chrome_begin(&mut self, i: u32) {
770        let st = self.style;
771        let iu = (i as usize) & (WIN_MAX - 1);
772        let (flags, tbh) = (self.m.win.recs[iu].flags, self.m.win.recs[iu].tbh);
773        let ids = i << 4 | WCH_BASE; // chrome ids
774        self.wp.chrome.wc_footer = false;
775        // active look: tool windows share the app's activation, others their own
776        let mut tbs = 0;
777        if self.uiw_app_active() {
778            tbs = plat::WST_ACTIVE;
779            if flags & WF_TOOL == 0 && i != self.m.win.active {
780                tbs = 0;
781            }
782        }
783        self.wp.chrome.tb_state = tbs;
784        let b = self.uiw_border();
785        if b != 0 {
786            // paint (style): the window border
787            let (w, h) = (self.wp.rt.fb.w, self.wp.rt.fb.h);
788            let sp = Sp { x: 0, y: 0, w, h, value: b, ..Default::default() };
789            st.window_border(self, &sp);
790        }
791        if tbh != 0 {
792            self.next_size(0, tbh);
793            self.box_begin(ids, BOX_H | BOX_XCENTER, 0);
794            // background (style)
795            let mut sp = Sp { value: (tbs & plat::WST_ACTIVE != 0) as i32, ..Default::default() };
796            sp.place(self.wp.ctx.last_rect);
797            st.window_titlebar(self, &sp);
798            // the whole bar is a drag region (the platform moves the window: Windows through
799            // WM_NCHITTEST, Linux with a press here)
800            self.behave(ids, HB_DRAG);
801            if self.m.win.recs[(i as usize) & (WIN_MAX - 1)].flags & WF_TOUCH_HOLD != 0 {
802                self.touch_grab(super::touch::AX_ALL | (TF_HOLD | TF_HOLD_LONG) as u8);
803                self.touch_hold_show(ids);
804            }
805            if !self.headless && self.wp.ctx.active == ids && self.wp.ctx.pressed & (1 << BTN_LEFT) != 0 {
806                plat::begin_move(); // (the live window: plat::win_use)
807                self.wp.ctx.active = 0; // the OS owns the pointer now
808                self.wp.ctx.mbtn &= !(1 << BTN_LEFT);
809            } else if self.headless && self.mptype == PTR_TOUCH && self.wp.ctx.active == ids && self.wp.ctx.mbtn & (1 << BTN_LEFT) != 0 {
810                let pressed = self.wp.ctx.pressed & (1 << BTN_LEFT) != 0;
811                self.touch_window_drag(i, pressed); // (a script's finger moves it as the platform would)
812            }
813            let title = alloc::string::String::from(self.uiw_title(i));
814            self.wp.ctx.last_state = 0;
815            self.sem(K_TITLEBAR, ids, Some(&title), 0);
816            self.spacer(st.metric(SM_WC_TITLE_PAD)); // space before the title (DIP)
817            // close button at the left end (SM_CAP_LEFT: macOS; the system's layout puts it
818            // there; a layout without it keeps the style's side: the window has no other way
819            // to be closed with the pointer)
820            let mut capw = 0; // its width (DIP)
821            let left = match self.tb_sys.buttons {
822                Some((l, r)) if l.contains(&plat::TB_BTN_CLOSE) || r.contains(&plat::TB_BTN_CLOSE) => l.contains(&plat::TB_BTN_CLOSE) as i32,
823                _ => st.metric(SM_CAP_LEFT),
824            };
825            if left != 0 {
826                capw = self.wc_close_btn(ids, i);
827                if capw != 0 {
828                    self.spacer(st.metric(SM_WC_TITLE_PAD));
829                }
830            }
831            // the title fills the bar and is cut with "…" in narrow windows, so the close
832            // button always stays inside (U3)
833            self.next_size(SIZE_FILL, 0);
834            let mut ts = st.metric(SM_TB_TITLE_TS) as u32 | TX_ELLIPSIS;
835            if tbs & plat::WST_ACTIVE == 0 {
836                ts |= TX_DISABLED;
837            }
838            self.text(ts, &title);
839            self.spacer(st.metric(SM_WC_TITLE_GAP)); // space after the title (DIP)
840            if left == 0 {
841                self.wc_close_btn(ids, i);
842            } else if capw != 0 {
843                // left close button: the same space at the right keeps the title centred
844                self.spacer(capw);
845                self.spacer(st.metric(SM_WC_TITLE_PAD));
846            }
847            self.box_end();
848        }
849        // content
850        self.next_size(0, -1);
851        if flags & WF_DIALOG != 0 {
852            let gap = st.metric(SM_DLG_CONTENT_GAP);
853            let bf = st.metric(SM_DLG_CONTENT_BOX) as u32 | BOX_FIXED;
854            self.box_begin(ids + WCH_CONTENT, bf, gap);
855            // dialog: the content area on the layer colour, the footer on the background
856            let mut sp = Sp::default();
857            sp.place(self.wp.ctx.last_rect);
858            st.window_dialog_fill(self, &sp);
859        } else {
860            self.box_begin(ids + WCH_CONTENT, BOX_V | BOX_FIXED, 0);
861        }
862    }
863
864    // wc_close_btn(ids, i) -> button width (DIP; 0 = none, WF_NOCLOSE): the close button of
865    // a secondary window's title bar and its command
866    fn wc_close_btn(&mut self, ids: u32, i: u32) -> i32 {
867        let (flags, id) = {
868            let r = &self.m.win.recs[(i as usize) & (WIN_MAX - 1)];
869            (r.flags, r.id)
870        };
871        if flags & WF_NOCLOSE != 0 {
872            return 0;
873        }
874        let st = self.style;
875        let w = st.metric(if flags & WF_TOOL != 0 { SM_CAP_W_TOOL } else { SM_CAP_W });
876        // (for a finger, unless the window is so narrow that the title bar would be gone)
877        let wide = super::layout::px2dip_at(self.wp.rt.fb.w, self.wp.rt.dpi) >= super::touch::CAPTION_MIN_W;
878        let w = if self.m.touch.on && wide { w.max(super::touch::CAPTION_DIP) } else { w };
879        self.m.chrome.cap_w = w;
880        let ev = self.cap_button(ids + WCH_CLOSE, IC_CHROME_CLOSE, HB_CLOSE, "Close");
881        self.m.chrome.cap_w = 0;
882        if ev & EV_ACT != 0 {
883            self.cmd_run(UIC_WINCLOSE, id);
884        }
885        w
886    }
887
888    /// win_chrome_end(i) -> natural height of the content (DIP, content box + footer):
889    /// closes the content box (unless dialog_buttons did)
890    pub(crate) fn win_chrome_end(&mut self, i: u32) -> i32 {
891        let ids = i << 4 | WCH_BASE;
892        let footer = self.wp.chrome.wc_footer;
893        if !footer {
894            self.wc_pop_to(ids + WCH_CONTENT);
895        }
896        let mut h = self.slot(ids + WCH_CONTENT).meash;
897        if footer {
898            h += self.slot(ids + WCH_FOOTER).meash;
899        }
900        self.px2dip(h)
901    }
902
903    // wc_pop_to(id): close boxes up to and including the box `id` (boxes the app left open
904    // inside it are closed with a warning); nothing when it is not open
905    fn wc_pop_to(&mut self, id: u32) {
906        for _ in 0..BOX_STACK_MAX {
907            let b = self.box_id();
908            if b == id {
909                self.box_end();
910                return;
911            }
912            if b == self.wp.lay.root_id {
913                return;
914            }
915            self.warn("ui_box_begin without ui_box_end in a window");
916            self.box_end();
917        }
918    }
919
920    // ------------------------------------------------------------------ edge grips
921
922    /// ui_edge_grips: invisible resize handles along the window edges for windows without a
923    /// system frame on Linux (AF_CUSTOM_TITLEBAR or the library's CSD title bar): a press
924    /// starts plat::begin_resize with the edge; the pointer shows the matching arrows.
925    /// Called by the runtime after App::frame (so the grips win the hit test); the caption
926    /// buttons of the pass are put on top of them again, so a button wins over the band
927    /// where it is (its top edge, the corner of a close button at the window's corner) and the
928    /// band stays usable elsewhere.  (GTK puts the band outside the visible frame, into the
929    /// shadow; the library's windows have no shadow margin.)  Nothing on Windows (WM_NCHITTEST
930    /// does it there) or while maximized / full screen, none on the tiled edges of a tiled
931    /// window (plat::WST_TILED_*: the screen edge stays the title bar's); headless runs only
932    /// with the forced library title bar (jfgui_CSD=1: scripts test the band), recording the
933    /// request.
934    pub(crate) fn edge_grips(&mut self) {
935        // (Windows: the system's frame, for a finger it is invisible and greedy: win_touch.rs)
936        let on = if self.headless { plat::getenv("jfgui_CSD").is_some_and(|v| v.starts_with('1')) } else { !cfg!(windows) || self.m.touch.on };
937        // (with a finger the main window fills the phone's page: its edges belong to the content)
938        if !on || (self.m.touch.on && self.uiw_cur == 0) {
939            return;
940        }
941        // ids: the main window's, else in the window's chrome range
942        let i = self.uiw_cur & (WIN_MAX as u32 - 1);
943        self.m.chrome.grip_base = if i == 0 { GRIP_ID } else { (i << 4) + 8 | WCH_BASE };
944        let state = if !self.headless {
945            plat::window_state()
946        } else if self.m.win.recs[i as usize].fs {
947            plat::WST_FULLSCREEN
948        } else {
949            0
950        };
951        if state & (plat::WST_MAXIMIZED | plat::WST_FULLSCREEN) != 0 {
952            return;
953        }
954        // tiled: no handles on the edges that touch the tiling (as GTK; a corner touching one
955        // goes too)
956        self.m.chrome.grip_off = if state & plat::WST_TILED != 0 { state >> plat::WST_TILED_SHIFT & 15 } else { 0 };
957        let st = self.style;
958        let t = self.dp(st.metric(SM_GRIP)); // edge handle thickness
959        let c = self.dp(st.metric(SM_GRIP_CORNER)); // corner handle size
960        // a finger needs more than a pointer (not at the top: the title bar moves the window)
961        let (tf, cf) = if self.m.touch.on { (t.max(self.dp(14)), c.max(self.dp(32))) } else { (t, c) };
962        let (w, h) = (self.wp.rt.fb.w, self.wp.rt.fb.h);
963        use plat::{EDGE_BOTTOM, EDGE_TOP};
964        // right-to-left: the logical left edge is the window's right one
965        let (left, right) = if self.rtl { (plat::EDGE_RIGHT, plat::EDGE_LEFT) } else { (plat::EDGE_LEFT, plat::EDGE_RIGHT) };
966        let (nwse, nesw) = if self.rtl { (CUR_SIZE_NESW, CUR_SIZE_NWSE) } else { (CUR_SIZE_NWSE, CUR_SIZE_NESW) };
967        let content = self.wp.ctx.hits[self.wp.ctx.hit_cur].len();
968        // edges: top, bottom, left, right (between the corners)
969        self.grip(0, EDGE_TOP, CUR_SIZE_NS, c, 0, w - c - c, t);
970        self.grip(1, EDGE_BOTTOM, CUR_SIZE_NS, cf, h - tf, w - cf - cf, tf);
971        self.grip(2, left, CUR_SIZE_WE, 0, c, tf, h - c - cf);
972        self.grip(3, right, CUR_SIZE_WE, w - tf, c, tf, h - c - cf);
973        // corners (squares)
974        self.grip(4, EDGE_TOP | left, nwse, 0, 0, c, c);
975        self.grip(5, EDGE_TOP | right, nesw, w - c, 0, c, c);
976        self.grip(6, EDGE_BOTTOM | left, nesw, 0, h - cf, cf, cf);
977        self.grip(7, EDGE_BOTTOM | right, nwse, w - cf, h - cf, cf, cf);
978        let layer = self.wp.ctx.cmd_layer_ui as u8;
979        // a finger's wider handles must not swallow the small widgets at the edge (the bottom bar
980        // of a panel): those on top again too; a list or a bar stays below the handle
981        if self.m.touch.on {
982            let small = self.dp(super::touch::TARGET_DIP);
983            let c = &mut self.wp.ctx;
984            let list = &mut c.hits[c.hit_cur];
985            for k in 0..content {
986                let mut b = list[k];
987                let (bw, bh) = (b.x1 - b.x0, b.y1 - b.y0);
988                let widget = b.id != 0 && b.flags as u32 & (HB_BLOCK | HB_DRAG) == 0 && bw > 0 && bh > 0 && bw < small && bh < small;
989                let edge = b.x0 < tf || b.x1 > w - tf || b.y1 > h - tf || (b.y1 > h - cf && (b.x0 < cf || b.x1 > w - cf));
990                if widget && edge && list.len() < super::ctx::HITS_MAX {
991                    b.continuation = true;
992                    b.layer = b.layer.max(layer);
993                    list.push(b);
994                }
995            }
996        }
997        // the caption buttons on top again (the same widget: continuation boxes)
998        for k in 0..self.wp.chrome.cap_n as usize {
999            let mut h = self.wp.chrome.cap_hits[k];
1000            h.continuation = true;
1001            h.layer = h.layer.max(layer);
1002            let c = &mut self.wp.ctx;
1003            if c.hits[c.hit_cur].len() < super::ctx::HITS_MAX {
1004                c.hits[c.hit_cur].push(h);
1005            }
1006        }
1007    }
1008
1009    // grip(k, edge, cursor, x, y, w, h): one handle; a press resizes
1010    #[allow(clippy::too_many_arguments)]
1011    fn grip(&mut self, k: u32, edge: u32, cur: u32, x: i32, y: i32, w: i32, h: i32) {
1012        if edge & self.m.chrome.grip_off != 0 {
1013            return; // a tiled edge
1014        }
1015        let id = k.wrapping_add(self.m.chrome.grip_base);
1016        self.hit_rect(id, x, y, w, h, (cur & 0xFF) << HB_CURSOR_SHIFT);
1017        if self.wp.ctx.active == id && self.wp.ctx.pressed & (1 << BTN_LEFT) != 0 {
1018            self.wm_resize(edge);
1019            if !self.headless {
1020                // the window manager owns the pointer now: forget the press
1021                self.wp.ctx.active = 0;
1022                self.wp.ctx.mbtn &= !(1 << BTN_LEFT);
1023            }
1024        }
1025    }
1026}