Skip to main content

buffr_ui/
permissions_prompt.rs

1//! Permissions-prompt overlay widget.
2//!
3//! Painted into the same softbuffer used by `Statusline` / `InputBar` /
4//! `TabStrip`. Floats as a centered popup over the CEF region; the
5//! caller draws the border and background and then calls `paint_at`.
6//!
7//! The widget is purely render-time. Decisions, queueing, and CEF
8//! callback dispatch live in `apps/buffr` — this struct just describes
9//! "what to paint right now".
10//!
11//! # Layout (inside the popup inner rect)
12//!
13//! ```text
14//! +---------------------------------------------------------------+
15//! | <origin> wants: camera, microphone     (2 more pending)       |
16//! | [a]llow [d]eny [A]llow always [D]eny always [Esc]defer        |
17//! +---------------------------------------------------------------+
18//! ```
19
20use crate::font;
21
22/// Content height in pixels. Two text rows with padding.
23pub const PERMISSIONS_PROMPT_HEIGHT: u32 = 60;
24
25/// Render input for [`PermissionsPrompt::paint_at`]. Mirrors the data the
26/// buffr permissions queue exposes; `Capability` is decoupled from the
27/// `buffr-permissions` crate to keep `buffr-ui` from picking up a
28/// rusqlite dep.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct PermissionsPrompt {
31    /// Display origin — typically the requesting URL's origin (scheme +
32    /// host + port). Truncated from the right at paint time when too
33    /// long for the content width.
34    pub origin: String,
35    /// Human-readable capability labels (e.g. "camera", "microphone").
36    /// The widget joins them with `, ` for the action line.
37    pub capabilities: Vec<String>,
38    /// How many more requests are queued behind this one. `0` hides the
39    /// `(N more pending)` indicator.
40    pub queue_len: u32,
41}
42
43impl PermissionsPrompt {
44    /// Paint the prompt content into the inner popup rect
45    /// `(content_x, content_y, content_w, PERMISSIONS_PROMPT_HEIGHT)`.
46    /// The caller is responsible for drawing the popup border and
47    /// background before calling this. Returns `PERMISSIONS_PROMPT_HEIGHT`.
48    pub fn paint_at(
49        &self,
50        buffer: &mut [u32],
51        width: usize,
52        height: usize,
53        content_x: u32,
54        content_y: u32,
55        content_w: u32,
56    ) -> u32 {
57        if width == 0 || height == 0 || content_w == 0 {
58            return PERMISSIONS_PROMPT_HEIGHT;
59        }
60        if buffer.len() < width * height {
61            return PERMISSIONS_PROMPT_HEIGHT;
62        }
63        let top = content_y as i32;
64        if top >= height as i32 {
65            return PERMISSIONS_PROMPT_HEIGHT;
66        }
67
68        // Two text rows.
69        let text_x: i32 = content_x as i32 + 8;
70        let text_y0 = top + 8;
71        let text_y1 = top + 8 + (font::glyph_h() as i32 + 8);
72
73        let caps_joined = self.capabilities.join(", ");
74        let line1 = if caps_joined.is_empty() {
75            format!("{} wants permission", self.origin)
76        } else {
77            format!("{} wants: {caps_joined}", self.origin)
78        };
79
80        let queue_text = if self.queue_len > 0 {
81            Some(format!("({} more pending)", self.queue_len))
82        } else {
83            None
84        };
85        let queue_w = queue_text
86            .as_ref()
87            .map(|s| font::text_width(s) as i32)
88            .unwrap_or(0);
89        let right_pad: i32 = 8;
90        let right_edge = (content_x + content_w) as i32;
91        let line1_max_px = if queue_text.is_some() {
92            (right_edge - text_x - queue_w - right_pad - 12).max(0) as usize
93        } else {
94            (right_edge - text_x - right_pad).max(0) as usize
95        };
96        let line1_truncated = truncate_to_width(&line1, line1_max_px);
97        font::draw_text(
98            buffer,
99            width,
100            height,
101            text_x,
102            text_y0,
103            line1_truncated,
104            COLOUR_PROMPT_FG,
105        );
106
107        if let Some(qtext) = queue_text {
108            let qx = right_edge - right_pad - queue_w;
109            font::draw_text(
110                buffer,
111                width,
112                height,
113                qx,
114                text_y0,
115                &qtext,
116                COLOUR_PROMPT_PENDING,
117            );
118        }
119
120        font::draw_text(
121            buffer,
122            width,
123            height,
124            text_x,
125            text_y1,
126            ACTION_HINT,
127            COLOUR_PROMPT_HINT,
128        );
129
130        PERMISSIONS_PROMPT_HEIGHT
131    }
132}
133
134/// Action hint string. `[a]` = allow once, `[d]` = deny once,
135/// `[A]` = allow always, `[D]` = deny always, `[Esc]` = defer.
136pub const ACTION_HINT: &str = "[a]llow [d]eny [A]llow always [D]eny always [Esc]defer";
137
138/// Truncate `s` to at most `max_px` pixels of rendered width. Adds a
139/// trailing `..` ellipsis when the original didn't fit. Mirrors the
140/// helper in `lib.rs` — duplicated here to avoid a `pub(crate)` leak.
141fn truncate_to_width(s: &str, max_px: usize) -> &str {
142    if font::text_width(s) <= max_px {
143        return s;
144    }
145    if max_px < font::text_width("..") {
146        return "";
147    }
148    let mut end = s.len();
149    while end > 0 {
150        let prefix = &s[..end];
151        if !s.is_char_boundary(end) {
152            end -= 1;
153            continue;
154        }
155        if font::text_width(prefix) + font::text_width("..") <= max_px {
156            return prefix;
157        }
158        end -= 1;
159    }
160    ""
161}
162
163const COLOUR_PROMPT_FG: u32 = 0xFF_F0_E8_D8;
164const COLOUR_PROMPT_HINT: u32 = 0xFF_C8_C8_C0;
165const COLOUR_PROMPT_PENDING: u32 = 0xFF_FF_C8_8C;
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170
171    fn make_buf(w: usize, h: usize) -> Vec<u32> {
172        vec![0u32; w * h]
173    }
174
175    #[test]
176    fn paint_at_does_not_panic_normal_case() {
177        let w = 800;
178        let h = 400;
179        let mut buf = make_buf(w, h);
180        let p = PermissionsPrompt {
181            origin: "https://example.com".into(),
182            capabilities: vec!["camera".into()],
183            queue_len: 0,
184        };
185        let ret = p.paint_at(&mut buf, w, h, 100, 100, 600);
186        assert_eq!(ret, PERMISSIONS_PROMPT_HEIGHT);
187        // At least something was written inside the rect.
188        assert!(buf.iter().any(|&px| px != 0));
189    }
190
191    #[test]
192    fn paint_at_queue_and_no_queue_differ() {
193        let w = 800;
194        let h = 400;
195        let mut buf_zero = make_buf(w, h);
196        let mut buf_nonzero = make_buf(w, h);
197        let zero = PermissionsPrompt {
198            origin: "https://x".into(),
199            capabilities: vec!["camera".into()],
200            queue_len: 0,
201        };
202        let nonzero = PermissionsPrompt {
203            origin: "https://x".into(),
204            capabilities: vec!["camera".into()],
205            queue_len: 3,
206        };
207        zero.paint_at(&mut buf_zero, w, h, 100, 100, 600);
208        nonzero.paint_at(&mut buf_nonzero, w, h, 100, 100, 600);
209        assert_ne!(buf_zero, buf_nonzero);
210    }
211
212    #[test]
213    fn paint_at_skips_when_top_y_off_screen() {
214        let w = 200;
215        let h = 60;
216        let mut buf = make_buf(w, h);
217        let p = PermissionsPrompt {
218            origin: "x".into(),
219            capabilities: vec![],
220            queue_len: 0,
221        };
222        p.paint_at(&mut buf, w, h, 0, 1000, 200);
223        assert!(buf.iter().all(|&px| px == 0));
224    }
225
226    #[test]
227    fn paint_at_handles_empty_capabilities_list() {
228        let w = 600;
229        let h = 400;
230        let mut buf = make_buf(w, h);
231        let p = PermissionsPrompt {
232            origin: "https://x".into(),
233            capabilities: vec![],
234            queue_len: 0,
235        };
236        let ret = p.paint_at(&mut buf, w, h, 0, 0, 600);
237        assert_eq!(ret, PERMISSIONS_PROMPT_HEIGHT);
238    }
239
240    #[test]
241    fn truncate_to_width_short_unchanged() {
242        assert_eq!(truncate_to_width("ok", 1000), "ok");
243    }
244
245    #[test]
246    fn truncate_to_width_zero_budget_returns_empty() {
247        assert_eq!(truncate_to_width("anything", 1), "");
248    }
249}