Skip to main content

siglus_scene_vm/
desktop_messagebox.rs

1//! Desktop winit message box support.
2//!
3//! Desktop ports keep modal dialogs inside the winit event loop. Mobile ports use
4//! the native callback path in `host.rs`.
5
6#![cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))]
7
8use std::collections::VecDeque;
9use std::sync::{Arc, Mutex};
10use std::time::Instant;
11
12use anyhow::{Context, Result};
13use egui_wgpu::{Renderer as EguiRenderer, ScreenDescriptor};
14use winit::dpi::{LogicalSize, PhysicalPosition};
15use winit::event::{ElementState, KeyEvent, MouseButton, WindowEvent};
16use winit::event_loop::ActiveEventLoop;
17use winit::keyboard::{KeyCode, PhysicalKey};
18use winit::window::{Window, WindowAttributes, WindowId};
19
20use crate::render::Renderer;
21use crate::runtime::native_ui::{NativeMessageBoxRequest, NativeUiBackend};
22
23fn configure_egui_default_font(ctx: &egui::Context) {
24    let mut fonts = egui::FontDefinitions::default();
25    fonts.font_data.insert(
26        "siglus_default".to_string(),
27        egui::FontData::from_static(include_bytes!("../assets/fonts/default.ttf")).into(),
28    );
29    fonts
30        .families
31        .entry(egui::FontFamily::Proportional)
32        .or_default()
33        .insert(0, "siglus_default".to_string());
34    fonts
35        .families
36        .entry(egui::FontFamily::Monospace)
37        .or_default()
38        .insert(0, "siglus_default".to_string());
39    ctx.set_fonts(fonts);
40}
41
42#[derive(Clone, Default)]
43pub struct DesktopMessageBoxBridge {
44    queue: Arc<Mutex<VecDeque<NativeMessageBoxRequest>>>,
45}
46
47impl DesktopMessageBoxBridge {
48    pub fn new() -> Self {
49        Self::default()
50    }
51
52    pub fn backend(&self) -> Arc<dyn NativeUiBackend> {
53        Arc::new(DesktopMessageBoxBackend {
54            queue: Arc::clone(&self.queue),
55        })
56    }
57
58    pub fn pop_request(&self) -> Option<NativeMessageBoxRequest> {
59        self.queue.lock().ok().and_then(|mut q| q.pop_front())
60    }
61}
62
63struct DesktopMessageBoxBackend {
64    queue: Arc<Mutex<VecDeque<NativeMessageBoxRequest>>>,
65}
66
67impl NativeUiBackend for DesktopMessageBoxBackend {
68    fn show_system_messagebox(&self, request: NativeMessageBoxRequest) {
69        if let Ok(mut q) = self.queue.lock() {
70            q.push_back(request);
71        } else {
72            log::error!("desktop messagebox queue lock failed");
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy)]
78struct ButtonRect {
79    x0: f32,
80    y0: f32,
81    x1: f32,
82    y1: f32,
83}
84
85impl ButtonRect {
86    fn contains(self, x: f32, y: f32) -> bool {
87        x >= self.x0 && x <= self.x1 && y >= self.y0 && y <= self.y1
88    }
89}
90
91pub struct DesktopMessageBoxWindow {
92    request: NativeMessageBoxRequest,
93    window: &'static Window,
94    window_id: WindowId,
95    renderer: Renderer,
96    egui_renderer: EguiRenderer,
97    egui_ctx: egui::Context,
98    start_time: Instant,
99    selected: usize,
100    cursor_pos: Option<(f32, f32)>,
101}
102
103impl DesktopMessageBoxWindow {
104    pub fn new(elwt: &ActiveEventLoop, request: NativeMessageBoxRequest) -> Result<Self> {
105        let button_count = request.buttons.len().max(1) as f64;
106        let width = (420.0f64).max(220.0 + button_count * 112.0);
107        let height = 190.0f64;
108        let title = if request.title.trim().is_empty() {
109            "Siglus".to_string()
110        } else {
111            request.title.clone()
112        };
113        let window = elwt
114            .create_window(
115                WindowAttributes::default()
116                    .with_title(title)
117                    .with_inner_size(LogicalSize::new(width, height))
118                    .with_min_inner_size(LogicalSize::new(360.0, 160.0))
119                    .with_resizable(false),
120            )
121            .context("create desktop messagebox window")?;
122        let window: &'static Window = Box::leak(Box::new(window));
123        let renderer = pollster::block_on(Renderer::new(window)).context("messagebox renderer init")?;
124        let egui_renderer = EguiRenderer::new(&renderer.device, renderer.config.format, None, 1);
125        let egui_ctx = egui::Context::default();
126        configure_egui_default_font(&egui_ctx);
127        let selected = request.buttons.len().saturating_sub(1).min(1);
128        window.request_redraw();
129        Ok(Self {
130            request,
131            window_id: window.id(),
132            window,
133            renderer,
134            egui_renderer,
135            egui_ctx,
136            start_time: Instant::now(),
137            selected,
138            cursor_pos: None,
139        })
140    }
141
142    pub fn window_id(&self) -> WindowId {
143        self.window_id
144    }
145
146    pub fn request_id(&self) -> u64 {
147        self.request.request_id
148    }
149
150    pub fn request_redraw(&self) {
151        self.window.request_redraw();
152    }
153
154    pub fn hide(&self) {
155        self.window.set_visible(false);
156    }
157
158    pub fn cancel_value(&self) -> i64 {
159        self.request
160            .buttons
161            .last()
162            .map(|button| button.value)
163            .unwrap_or(0)
164    }
165
166    pub fn handle_window_event(&mut self, event: WindowEvent) -> Option<i64> {
167        match event {
168            WindowEvent::CloseRequested => Some(self.cancel_value()),
169            WindowEvent::Resized(size) => {
170                self.renderer.resize(size.width.max(1), size.height.max(1));
171                self.window.request_redraw();
172                None
173            }
174            WindowEvent::CursorMoved { position, .. } => {
175                let pos = self.logical_pos(position);
176                self.cursor_pos = Some(pos);
177                if let Some(idx) = self.hit_test_button(pos.0, pos.1) {
178                    self.selected = idx;
179                }
180                self.window.request_redraw();
181                None
182            }
183            WindowEvent::MouseInput {
184                state: ElementState::Released,
185                button: MouseButton::Left,
186                ..
187            } => {
188                let pos = self.cursor_pos?;
189                let idx = self.hit_test_button(pos.0, pos.1)?;
190                self.selected = idx;
191                self.request.buttons.get(idx).map(|button| button.value)
192            }
193            WindowEvent::KeyboardInput {
194                event:
195                    KeyEvent {
196                        state: ElementState::Pressed,
197                        physical_key: PhysicalKey::Code(code),
198                        ..
199                    },
200                ..
201            } => self.handle_key(code),
202            WindowEvent::RedrawRequested => {
203                if let Err(err) = self.render() {
204                    log::error!("desktop messagebox render failed: {err:#}");
205                }
206                None
207            }
208            _ => None,
209        }
210    }
211
212    fn handle_key(&mut self, code: KeyCode) -> Option<i64> {
213        match code {
214            KeyCode::Escape => Some(self.cancel_value()),
215            KeyCode::Enter | KeyCode::Space => self
216                .request
217                .buttons
218                .get(self.selected.min(self.request.buttons.len().saturating_sub(1)))
219                .map(|button| button.value),
220            KeyCode::ArrowLeft | KeyCode::ArrowUp => {
221                let len = self.request.buttons.len();
222                if len > 0 {
223                    self.selected = if self.selected == 0 { len - 1 } else { self.selected - 1 };
224                    self.window.request_redraw();
225                }
226                None
227            }
228            KeyCode::ArrowRight | KeyCode::ArrowDown | KeyCode::Tab => {
229                let len = self.request.buttons.len();
230                if len > 0 {
231                    self.selected = (self.selected + 1) % len;
232                    self.window.request_redraw();
233                }
234                None
235            }
236            _ => None,
237        }
238    }
239
240    fn logical_pos(&self, position: PhysicalPosition<f64>) -> (f32, f32) {
241        let p = position.to_logical::<f64>(self.window.scale_factor());
242        (p.x as f32, p.y as f32)
243    }
244
245    fn button_rects_for_size(&self, logical_w: f32, logical_h: f32) -> Vec<ButtonRect> {
246        let count = self.request.buttons.len().max(1);
247        let button_w = 96.0f32;
248        let button_h = 32.0f32;
249        let gap = 12.0f32;
250        let total_w = count as f32 * button_w + count.saturating_sub(1) as f32 * gap;
251        let mut x = ((logical_w - total_w) * 0.5).max(16.0);
252        let y = (logical_h - 52.0).max(104.0);
253        let mut rects = Vec::with_capacity(count);
254        for _ in 0..count {
255            rects.push(ButtonRect {
256                x0: x,
257                y0: y,
258                x1: x + button_w,
259                y1: y + button_h,
260            });
261            x += button_w + gap;
262        }
263        rects
264    }
265
266    fn hit_test_button(&self, x: f32, y: f32) -> Option<usize> {
267        let size = self.window.inner_size();
268        let scale = self.window.scale_factor() as f32;
269        let logical_w = size.width as f32 / scale.max(1.0);
270        let logical_h = size.height as f32 / scale.max(1.0);
271        self.button_rects_for_size(logical_w, logical_h)
272            .into_iter()
273            .position(|rect| rect.contains(x, y))
274    }
275
276    fn render(&mut self) -> Result<()> {
277        let size = self.window.inner_size();
278        if size.width == 0 || size.height == 0 {
279            return Ok(());
280        }
281        let scale = self.window.scale_factor() as f32;
282        self.egui_ctx.set_pixels_per_point(scale);
283        let logical_w = size.width as f32 / scale.max(1.0);
284        let logical_h = size.height as f32 / scale.max(1.0);
285        let button_rects = self.button_rects_for_size(logical_w, logical_h);
286        let message = self.request.message.clone();
287        let title = self.request.title.clone();
288        let buttons = self.request.buttons.clone();
289        let selected = self.selected;
290        let raw_input = egui::RawInput {
291            screen_rect: Some(egui::Rect::from_min_size(
292                egui::Pos2::ZERO,
293                egui::vec2(logical_w, logical_h),
294            )),
295            time: Some(self.start_time.elapsed().as_secs_f64()),
296            ..Default::default()
297        };
298        let output = self.egui_ctx.run(raw_input, |ctx| {
299            egui::CentralPanel::default()
300                .frame(
301                    egui::Frame::default()
302                        .fill(egui::Color32::from_rgb(246, 247, 250))
303                        .inner_margin(egui::Margin::same(18.0)),
304                )
305                .show(ctx, |ui| {
306                    let full = ui.max_rect();
307                    let painter = ui.painter();
308                    let icon_rect = egui::Rect::from_min_size(
309                        egui::pos2(full.left() + 4.0, full.top() + 12.0),
310                        egui::vec2(36.0, 36.0),
311                    );
312                    painter.circle_filled(
313                        icon_rect.center(),
314                        18.0,
315                        egui::Color32::from_rgb(66, 133, 244),
316                    );
317                    painter.text(
318                        icon_rect.center(),
319                        egui::Align2::CENTER_CENTER,
320                        "?",
321                        egui::FontId::proportional(24.0),
322                        egui::Color32::WHITE,
323                    );
324
325                    let text_x = icon_rect.right() + 16.0;
326                    let title_text = if title.trim().is_empty() { "Siglus" } else { title.as_str() };
327                    painter.text(
328                        egui::pos2(text_x, full.top() + 8.0),
329                        egui::Align2::LEFT_TOP,
330                        title_text,
331                        egui::FontId::proportional(16.0),
332                        egui::Color32::from_rgb(35, 38, 42),
333                    );
334                    painter.text(
335                        egui::pos2(text_x, full.top() + 42.0),
336                        egui::Align2::LEFT_TOP,
337                        message,
338                        egui::FontId::proportional(18.0),
339                        egui::Color32::from_rgb(20, 22, 25),
340                    );
341
342                    for (idx, rect) in button_rects.iter().enumerate() {
343                        let r = egui::Rect::from_min_max(
344                            egui::pos2(rect.x0, rect.y0),
345                            egui::pos2(rect.x1, rect.y1),
346                        );
347                        let is_selected = idx == selected;
348                        let fill = if is_selected {
349                            egui::Color32::from_rgb(43, 107, 235)
350                        } else {
351                            egui::Color32::from_rgb(255, 255, 255)
352                        };
353                        let stroke = if is_selected {
354                            egui::Stroke::new(1.5, egui::Color32::from_rgb(28, 86, 210))
355                        } else {
356                            egui::Stroke::new(1.0, egui::Color32::from_rgb(166, 174, 186))
357                        };
358                        painter.rect_filled(r, egui::Rounding::same(4.0), fill);
359                        painter.line_segment([r.left_top(), r.right_top()], stroke);
360                        painter.line_segment([r.right_top(), r.right_bottom()], stroke);
361                        painter.line_segment([r.right_bottom(), r.left_bottom()], stroke);
362                        painter.line_segment([r.left_bottom(), r.left_top()], stroke);
363                        let label = buttons
364                            .get(idx)
365                            .map(|button| button.label.as_str())
366                            .unwrap_or("OK");
367                        let text_color = if is_selected {
368                            egui::Color32::WHITE
369                        } else {
370                            egui::Color32::from_rgb(25, 27, 30)
371                        };
372                        painter.text(
373                            r.center(),
374                            egui::Align2::CENTER_CENTER,
375                            label,
376                            egui::FontId::proportional(15.0),
377                            text_color,
378                        );
379                    }
380                });
381        });
382
383        let screen_desc = ScreenDescriptor {
384            size_in_pixels: [size.width, size.height],
385            pixels_per_point: scale,
386        };
387        let paint_jobs = self.egui_ctx.tessellate(output.shapes, scale);
388        for (id, delta) in &output.textures_delta.set {
389            self.egui_renderer
390                .update_texture(&self.renderer.device, &self.renderer.queue, *id, delta);
391        }
392
393        let frame = match self.renderer.surface.get_current_texture() {
394            Ok(frame) => frame,
395            Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
396                self.renderer.resize(self.renderer.config.width, self.renderer.config.height);
397                return Ok(());
398            }
399            Err(wgpu::SurfaceError::OutOfMemory) => anyhow::bail!("messagebox surface out of memory"),
400            Err(wgpu::SurfaceError::Timeout) => return Ok(()),
401        };
402        let view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default());
403        let mut encoder = self
404            .renderer
405            .device
406            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
407                label: Some("siglus_messagebox_egui_encoder"),
408            });
409        self.egui_renderer.update_buffers(
410            &self.renderer.device,
411            &self.renderer.queue,
412            &mut encoder,
413            &paint_jobs,
414            &screen_desc,
415        );
416        {
417            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
418                label: Some("siglus_messagebox_egui_pass"),
419                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
420                    view: &view,
421                    resolve_target: None,
422                    ops: wgpu::Operations {
423                        load: wgpu::LoadOp::Clear(wgpu::Color {
424                            r: 0.965,
425                            g: 0.970,
426                            b: 0.980,
427                            a: 1.0,
428                        }),
429                        store: wgpu::StoreOp::Store,
430                    },
431                })],
432                depth_stencil_attachment: None,
433                timestamp_writes: None,
434                occlusion_query_set: None,
435            });
436            self.egui_renderer.render(&mut pass, &paint_jobs, &screen_desc);
437        }
438        self.renderer.queue.submit(Some(encoder.finish()));
439        frame.present();
440        for id in output.textures_delta.free {
441            self.egui_renderer.free_texture(&id);
442        }
443        Ok(())
444    }
445}