Skip to main content

zenity_rs/ui/
message.rs

1//! Message dialog implementation (info, warning, error, question).
2
3use std::time::{Duration, Instant};
4
5use crate::{
6    backend::{MouseButton, Window, WindowEvent, create_window},
7    error::Error,
8    render::{Canvas, Font, rgb},
9    ui::{
10        BASE_BUTTON_HEIGHT, BASE_BUTTON_SPACING, BASE_CORNER_RADIUS, ButtonPreset, Colors,
11        DialogResult, Icon, KEY_ESCAPE, KEY_RETURN,
12        widgets::{Widget, button::Button},
13    },
14};
15
16const BASE_ICON_SIZE: u32 = 48;
17const BASE_PADDING: u32 = 20;
18const BASE_MIN_WIDTH: u32 = 150;
19const BASE_MAX_TEXT_WIDTH: f32 = 150.0;
20
21/// Message dialog builder.
22pub struct MessageBuilder {
23    title: String,
24    text: String,
25    icon: Option<Icon>,
26    buttons: ButtonPreset,
27    timeout: Option<u32>,
28    width: Option<u32>,
29    height: Option<u32>,
30    no_wrap: bool,
31    no_markup: bool,
32    ellipsize: bool,
33    switch: bool,
34    extra_buttons: Vec<String>,
35    colors: Option<&'static Colors>,
36}
37
38impl MessageBuilder {
39    pub fn new() -> Self {
40        Self {
41            title: String::new(),
42            text: String::new(),
43            icon: None,
44            buttons: ButtonPreset::Ok,
45            timeout: None,
46            width: None,
47            height: None,
48            no_wrap: false,
49            no_markup: false,
50            ellipsize: false,
51            switch: false,
52            extra_buttons: Vec::new(),
53            colors: None,
54        }
55    }
56
57    /// Set timeout in seconds. Dialog will auto-close after this time.
58    pub fn timeout(mut self, seconds: u32) -> Self {
59        self.timeout = Some(seconds);
60        self
61    }
62
63    pub fn title(mut self, title: &str) -> Self {
64        self.title = title.to_string();
65        self
66    }
67
68    pub fn text(mut self, text: &str) -> Self {
69        self.text = text.to_string();
70        self
71    }
72
73    pub fn icon(mut self, icon: Icon) -> Self {
74        self.icon = Some(icon);
75        self
76    }
77
78    pub fn buttons(mut self, buttons: ButtonPreset) -> Self {
79        self.buttons = buttons;
80        self
81    }
82
83    pub fn colors(mut self, colors: &'static Colors) -> Self {
84        self.colors = Some(colors);
85        self
86    }
87
88    pub fn width(mut self, width: u32) -> Self {
89        self.width = Some(width);
90        self
91    }
92
93    pub fn height(mut self, height: u32) -> Self {
94        self.height = Some(height);
95        self
96    }
97
98    pub fn no_wrap(mut self, no_wrap: bool) -> Self {
99        self.no_wrap = no_wrap;
100        self
101    }
102
103    pub fn no_markup(mut self, no_markup: bool) -> Self {
104        self.no_markup = no_markup;
105        self
106    }
107
108    pub fn ellipsize(mut self, ellipsize: bool) -> Self {
109        self.ellipsize = ellipsize;
110        self
111    }
112
113    pub fn switch(mut self, switch: bool) -> Self {
114        self.switch = switch;
115        self
116    }
117
118    pub fn extra_button(mut self, label: &str) -> Self {
119        self.extra_buttons.push(label.to_string());
120        self
121    }
122
123    pub fn show(self) -> Result<DialogResult, Error> {
124        let colors = self.colors.unwrap_or_else(|| crate::ui::detect_theme());
125
126        // First pass: calculate LOGICAL dimensions using a temporary font at scale 1.0
127        let temp_font = Font::load(1.0);
128        let mut labels = self.buttons.labels();
129
130        // Apply --switch mode: if switch is true, use only extra buttons
131        if self.switch {
132            labels = self.extra_buttons.clone();
133        } else {
134            // Append extra buttons to preset buttons
135            labels.extend(self.extra_buttons.clone());
136        }
137
138        // Reverse labels so that when we position them right-to-left,
139        // the last buttons (standard Yes/No) appear on the right
140        let num_labels = labels.len();
141        labels.reverse();
142        // Map reversed index back to original index for correct exit codes
143        let original_index: Vec<usize> = (0..num_labels).rev().collect();
144
145        // Calculate logical button widths and determine layout
146        let temp_buttons: Vec<Button> = labels
147            .iter()
148            .map(|l| Button::new(l, &temp_font, 1.0))
149            .collect();
150
151        // Calculate total width if all buttons are in one row
152        let total_buttons_width: u32 = temp_buttons.iter().map(|b| b.width()).sum::<u32>()
153            + (temp_buttons.len().saturating_sub(1) as u32 * BASE_BUTTON_SPACING);
154
155        // Determine button layout: vertical if they don't fit, horizontal if they do
156        let available_width = BASE_MAX_TEXT_WIDTH as u32 + BASE_PADDING * 2;
157        let use_vertical_layout = total_buttons_width > available_width || temp_buttons.len() > 3;
158
159        let logical_buttons_width = if use_vertical_layout {
160            // For vertical layout, width is just the widest button
161            temp_buttons.iter().map(|b| b.width()).max().unwrap_or(0)
162        } else {
163            total_buttons_width
164        };
165
166        let logical_icon_width = if self.icon.is_some() {
167            BASE_ICON_SIZE + BASE_PADDING
168        } else {
169            0
170        };
171
172        // --width specifies text area width, not total window width
173        let text_width = self.width.map(|w| w as f32).unwrap_or(BASE_MAX_TEXT_WIDTH);
174
175        // Calculate logical text size with/without wrapping
176        let temp_text = if self.no_wrap {
177            temp_font.render(&self.text).finish()
178        } else {
179            temp_font
180                .render(&self.text)
181                .with_max_width(text_width)
182                .finish()
183        };
184
185        // Use specified text_width for window sizing
186        // When no_wrap is true, width is treated as minimum, content can expand beyond it
187        let logical_content_width = logical_icon_width
188            + if self.no_wrap {
189                // Treat width as minimum: use max of content width and specified width
190                temp_text.width().max(text_width as u32)
191            } else {
192                // Use specified width for wrapping
193                text_width as u32
194            };
195        let logical_inner_width = logical_content_width.max(logical_buttons_width);
196        let calc_width = (logical_inner_width + BASE_PADDING * 2).max(BASE_MIN_WIDTH);
197        let logical_text_height = temp_text.height().max(BASE_ICON_SIZE);
198        let button_area_height = if use_vertical_layout {
199            temp_buttons.len() as u32 * 32
200                + (temp_buttons.len().saturating_sub(1) as u32 * BASE_BUTTON_SPACING)
201        } else {
202            32
203        };
204        let calc_height = BASE_PADDING * 3 + logical_text_height + button_area_height;
205
206        let logical_width = calc_width as u16;
207        let logical_height = self.height.unwrap_or(calc_height) as u16;
208
209        // Create window with LOGICAL dimensions - window will handle physical scaling
210        let mut window = create_window(logical_width, logical_height)?;
211        window.set_title(&self.title)?;
212
213        // Get the actual scale factor from the window (compositor scale)
214        let scale = window.scale_factor();
215
216        // Now create everything at PHYSICAL scale
217        let font = Font::load(scale);
218
219        // Scale dimensions for physical rendering
220        let padding = (BASE_PADDING as f32 * scale) as u32;
221        let button_spacing = (BASE_BUTTON_SPACING as f32 * scale) as u32;
222        let max_text_width = text_width * scale;
223        let button_height = (BASE_BUTTON_HEIGHT as f32 * scale) as u32;
224
225        // Create buttons at physical scale
226        let mut buttons: Vec<Button> = labels
227            .iter()
228            .map(|l| Button::new(l, &font, scale))
229            .collect();
230
231        // Calculate physical dimensions
232        let physical_width = (logical_width as f32 * scale) as u32;
233        let physical_height = (logical_height as f32 * scale) as u32;
234
235        // Pre-render text to get actual height
236        let text_canvas = if self.no_wrap {
237            font.render(&self.text).with_color(colors.text).finish()
238        } else {
239            font.render(&self.text)
240                .with_color(colors.text)
241                .with_max_width(max_text_width)
242                .finish()
243        };
244
245        // Position buttons
246        let mut button_positions = Vec::with_capacity(buttons.len());
247
248        if use_vertical_layout {
249            // Vertical layout: stack buttons vertically, full width
250            for idx in 0..buttons.len() {
251                let button_y = physical_height as i32
252                    - padding as i32
253                    - button_height as i32
254                    - (idx as i32 * (button_height as i32 + button_spacing as i32));
255
256                // Full width with padding on sides
257                let button_x = padding as i32;
258                let button_width = physical_width as i32 - 2 * padding as i32;
259
260                // Update button width and position
261                buttons[idx].set_width(button_width as u32);
262                button_positions.push((button_x, button_y));
263            }
264        } else {
265            // Horizontal layout: right-aligned in a single row
266            let mut button_x = physical_width as i32 - padding as i32;
267            for button in buttons.iter().rev() {
268                button_x -= button.width() as i32;
269                let button_y = physical_height as i32 - padding as i32 - button_height as i32;
270                button_positions.push((button_x, button_y));
271                button_x -= button_spacing as i32;
272            }
273            // Reverse positions since we iterated in reverse
274            button_positions.reverse();
275        }
276
277        for (idx, button) in buttons.iter_mut().enumerate() {
278            button.set_position(button_positions[idx].0, button_positions[idx].1);
279        }
280
281        // Create canvas at PHYSICAL dimensions
282        let mut canvas = Canvas::new(physical_width, physical_height);
283
284        // Clone icon for multiple uses
285        let icon = self.icon.clone();
286
287        // Initial draw
288        draw_dialog(
289            &mut canvas,
290            colors,
291            &font,
292            &self.text,
293            icon.clone(),
294            &buttons,
295            text_canvas.height(),
296            max_text_width,
297            self.no_wrap,
298            scale,
299        );
300        window.set_contents(&canvas)?;
301        window.show()?;
302
303        // Event loop
304        let mut dragging = false;
305        let deadline = self
306            .timeout
307            .map(|secs| Instant::now() + Duration::from_secs(secs as u64));
308
309        loop {
310            // Check timeout
311            if let Some(deadline) = deadline {
312                if Instant::now() >= deadline {
313                    return Ok(DialogResult::Timeout);
314                }
315            }
316
317            // Get event (use polling with sleep if timeout is set)
318            let event = if deadline.is_some() {
319                match window.poll_for_event()? {
320                    Some(e) => e,
321                    None => {
322                        std::thread::sleep(Duration::from_millis(50));
323                        continue;
324                    }
325                }
326            } else {
327                window.wait_for_event()?
328            };
329
330            match &event {
331                WindowEvent::CloseRequested => {
332                    return Ok(DialogResult::Closed);
333                }
334                WindowEvent::RedrawRequested => {
335                    draw_dialog(
336                        &mut canvas,
337                        colors,
338                        &font,
339                        &self.text,
340                        icon.clone(),
341                        &buttons,
342                        text_canvas.height(),
343                        max_text_width,
344                        self.no_wrap,
345                        scale,
346                    );
347                    window.set_contents(&canvas)?;
348                }
349                WindowEvent::KeyPress(key_event) => {
350                    if key_event.keysym == KEY_ESCAPE {
351                        return Ok(DialogResult::Closed);
352                    }
353                    if key_event.keysym == KEY_RETURN && !buttons.is_empty() {
354                        return Ok(DialogResult::Button(0));
355                    }
356                }
357                WindowEvent::ButtonPress(MouseButton::Left, _) => {
358                    dragging = true;
359                }
360                WindowEvent::ButtonRelease(MouseButton::Left, _) => {
361                    if dragging {
362                        dragging = false;
363                    }
364                }
365                _ => {}
366            }
367
368            // Process events for buttons
369            let mut needs_redraw = false;
370            for (i, button) in buttons.iter_mut().enumerate() {
371                if button.process_event(&event) {
372                    needs_redraw = true;
373                }
374                if button.was_clicked() {
375                    return Ok(DialogResult::Button(original_index[i]));
376                }
377            }
378
379            // Handle drag
380            if dragging {
381                if let WindowEvent::CursorMove(_) = &event {
382                    let _ = window.start_drag();
383                    dragging = false;
384                }
385            }
386
387            // Batch process pending events
388            while let Some(event) = window.poll_for_event()? {
389                match &event {
390                    WindowEvent::CloseRequested => {
391                        return Ok(DialogResult::Closed);
392                    }
393                    _ => {
394                        for (i, button) in buttons.iter_mut().enumerate() {
395                            if button.process_event(&event) {
396                                needs_redraw = true;
397                            }
398                            if button.was_clicked() {
399                                return Ok(DialogResult::Button(original_index[i]));
400                            }
401                        }
402                    }
403                }
404            }
405
406            if needs_redraw {
407                draw_dialog(
408                    &mut canvas,
409                    colors,
410                    &font,
411                    &self.text,
412                    icon.clone(),
413                    &buttons,
414                    text_canvas.height(),
415                    max_text_width,
416                    self.no_wrap,
417                    scale,
418                );
419                window.set_contents(&canvas)?;
420            }
421        }
422    }
423}
424
425#[allow(clippy::too_many_arguments)]
426fn draw_dialog(
427    canvas: &mut Canvas,
428    colors: &Colors,
429    font: &Font,
430    text: &str,
431    icon: Option<Icon>,
432    buttons: &[Button],
433    text_height: u32,
434    max_text_width: f32,
435    no_wrap: bool,
436    scale: f32,
437) {
438    // Scale dimensions
439    let icon_size = (BASE_ICON_SIZE as f32 * scale) as u32;
440    let padding = (BASE_PADDING as f32 * scale) as u32;
441    let width = canvas.width() as f32;
442    let height = canvas.height() as f32;
443    let radius = BASE_CORNER_RADIUS * scale;
444
445    // Draw dialog background with shadow and border
446    canvas.fill_dialog_bg(
447        width,
448        height,
449        colors.window_bg,
450        colors.window_border,
451        colors.window_shadow,
452        radius,
453    );
454
455    let mut x = padding as i32;
456    let y = padding as i32;
457
458    // Draw icon
459    if let Some(icon) = icon {
460        draw_icon(canvas, x, y, icon, scale);
461        x += (icon_size + padding) as i32;
462    }
463
464    // Draw text
465    let text_canvas = if no_wrap {
466        font.render(text).with_color(colors.text).finish()
467    } else {
468        font.render(text)
469            .with_color(colors.text)
470            .with_max_width(max_text_width)
471            .finish()
472    };
473
474    // Center text horizontally within text area
475    let text_x = x + ((max_text_width - text_canvas.width() as f32) / 2.0).max(0.0) as i32;
476    // Center text vertically with icon
477    let text_y = y + (icon_size as i32 - text_height as i32) / 2;
478    canvas.draw_canvas(&text_canvas, text_x, text_y.max(y));
479
480    // Draw buttons
481    for button in buttons {
482        button.draw_to(canvas, colors, font);
483    }
484}
485
486fn draw_icon(canvas: &mut Canvas, x: i32, y: i32, icon: Icon, scale: f32) {
487    let icon_size = (BASE_ICON_SIZE as f32 * scale) as u32;
488    let inset = 4.0 * scale;
489
490    let (color, shape) = match icon {
491        Icon::Info => (rgb(66, 133, 244), IconShape::Circle),
492        Icon::Warning => (rgb(251, 188, 4), IconShape::Triangle),
493        Icon::Error => (rgb(234, 67, 53), IconShape::Circle),
494        Icon::Question => (rgb(52, 168, 83), IconShape::Circle),
495        Icon::Custom(_) => (rgb(100, 100, 100), IconShape::Circle),
496    };
497
498    let cx = x as f32 + icon_size as f32 / 2.0;
499    let cy = y as f32 + icon_size as f32 / 2.0;
500    let r = icon_size as f32 / 2.0 - (2.0 * scale);
501
502    match shape {
503        IconShape::Circle => {
504            // Draw filled circle
505            for dy in 0..icon_size {
506                for dx in 0..icon_size {
507                    let px = x as f32 + dx as f32 + 0.5;
508                    let py = y as f32 + dy as f32 + 0.5;
509                    let dist = ((px - cx).powi(2) + (py - cy).powi(2)).sqrt();
510                    if dist <= r {
511                        canvas.fill_rect(
512                            x as f32 + dx as f32,
513                            y as f32 + dy as f32,
514                            1.0,
515                            1.0,
516                            color,
517                        );
518                    }
519                }
520            }
521        }
522        IconShape::Triangle => {
523            // Draw triangle (warning sign)
524            let top = (cx, y as f32 + inset);
525            let left = (x as f32 + inset, y as f32 + icon_size as f32 - inset);
526            let right = (
527                x as f32 + icon_size as f32 - inset,
528                y as f32 + icon_size as f32 - inset,
529            );
530
531            for dy in 0..icon_size {
532                for dx in 0..icon_size {
533                    let px = x as f32 + dx as f32 + 0.5;
534                    let py = y as f32 + dy as f32 + 0.5;
535                    if point_in_triangle(px, py, top, left, right) {
536                        canvas.fill_rect(
537                            x as f32 + dx as f32,
538                            y as f32 + dy as f32,
539                            1.0,
540                            1.0,
541                            color,
542                        );
543                    }
544                }
545            }
546        }
547    }
548
549    // Draw symbol (!, ?, i, x)
550    let symbol = match icon {
551        Icon::Info => "i",
552        Icon::Warning => "!",
553        Icon::Error => "X",
554        Icon::Question => "?",
555        Icon::Custom(_) => "i",
556    };
557
558    let font = Font::load(scale);
559    let symbol_canvas = font.render(symbol).with_color(rgb(255, 255, 255)).finish();
560    let sx = x + (icon_size as i32 - symbol_canvas.width() as i32) / 2;
561    let sy = y + (icon_size as i32 - symbol_canvas.height() as i32) / 2;
562    canvas.draw_canvas(&symbol_canvas, sx, sy);
563}
564
565enum IconShape {
566    Circle,
567    Triangle,
568}
569
570fn point_in_triangle(
571    px: f32,
572    py: f32,
573    (ax, ay): (f32, f32),
574    (bx, by): (f32, f32),
575    (cx, cy): (f32, f32),
576) -> bool {
577    let v0x = cx - ax;
578    let v0y = cy - ay;
579    let v1x = bx - ax;
580    let v1y = by - ay;
581    let v2x = px - ax;
582    let v2y = py - ay;
583
584    let dot00 = v0x * v0x + v0y * v0y;
585    let dot01 = v0x * v1x + v0y * v1y;
586    let dot02 = v0x * v2x + v0y * v2y;
587    let dot11 = v1x * v1x + v1y * v1y;
588    let dot12 = v1x * v2x + v1y * v2y;
589
590    let denom = dot00 * dot11 - dot01 * dot01;
591    if denom == 0.0 {
592        return false;
593    }
594    let inv_denom = 1.0 / denom;
595    let u = (dot11 * dot02 - dot01 * dot12) * inv_denom;
596    let v = (dot00 * dot12 - dot01 * dot02) * inv_denom;
597
598    u >= 0.0 && v >= 0.0 && u + v <= 1.0
599}
600
601impl Default for MessageBuilder {
602    fn default() -> Self {
603        Self::new()
604    }
605}