rust_widgets 2.9.2

Pure Rust cross-platform native GUI library with hardware-adaptive rendering, 180 widgets, touch/gesture support, i18n, and SVG-pipeline-accurate output
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
// SPDX-FileCopyrightText: Copyright (c) 2026 Mike Li/Mikewolfli/Wei Li(mikewolfli@163.com)
// SPDX-License-Identifier: MIT

//! SafeArea widget — insets content to avoid notches, status bars, home indicators (BLUE11 R10.14).
use crate::core::{Color, Rect, Size};
use crate::event::{Event, EventHandler};
use crate::render::RenderContext;
use crate::widget::capability::coercion::expect_f32;
use crate::widget::capability::properties_trait::{base_property_get, base_property_set};
use crate::widget::capability::types::{CapabilityAccessError, CapabilityValue};
use crate::widget::capability::WidgetProperties;
use crate::widget::{BaseWidget, Draw, SimpleRegistry, Widget, WidgetKind};
use crate::{impl_widget_property_hooks, property_names_of};
use std::cell::RefCell;
use std::rc::Rc;

use super::theme_snapshot::ThemeSnapshot;

/// Safe area insets for mobile devices.
///
/// Re-exported from [`crate::platform::types`] rather than defined here: the platform trait
/// returns this type, so one definition serves both layers (principle #54). The field
/// documentation, and the reason the default is a portrait-phone shape rather than zero, live
/// with that definition.
pub use crate::platform::types::SafeAreaInsets;

impl Default for SafeAreaInsets {
    fn default() -> Self {
        Self { top: 44, bottom: 34, left: 0, right: 0 }
    }
}

/// SafeArea widget — wraps content with safe area insets (BLUE11 R10.14).
///
/// The widget does not reposition its children itself. It publishes
/// [`SafeArea::content_rect`] and paints the inset margins, leaving the host
/// layout to place content inside that rectangle. Children laid out against
/// [`Widget::geometry`] instead of `content_rect` will overlap system UI.
pub struct SafeArea {
    base: BaseWidget,
    insets: SafeAreaInsets,
    /// Background color for the safe area margins.
    margin_color: Color,
    /// Optional child placed inside the safe content rectangle.
    content_child: Option<crate::core::ObjectId>,
    /// Optional shared registry, used to forward events to the content child (C-19).
    registry: Option<Rc<RefCell<SimpleRegistry>>>,
}

impl SafeArea {
    /// Creates a widget using the default phone-style insets
    /// ([`SafeAreaInsets::default`]) and a white margin colour.
    ///
    /// `geometry` is in parent-relative logical pixels; the size hint is
    /// 300x200.
    pub fn new(geometry: Rect) -> Self {
        Self {
            base: BaseWidget::new(WidgetKind::SafeArea, geometry, "SafeArea"),
            insets: SafeAreaInsets::default(),
            margin_color: Color::WHITE,
            content_child: None,
            registry: None,
        }
    }
    /// Sets the shared widget registry used to forward events to the content child.
    ///
    /// Without it a content control mounted through [`Self::set_content`] is painted but
    /// receives no events, so a registry-driven host saw a control it could not interact
    /// with (C-19). The interface matches the rest of the container family
    /// (`StackedWidget`, `ScrollArea`, `TabWidget`).
    pub fn set_registry(&mut self, registry: Rc<RefCell<SimpleRegistry>>) {
        self.registry = Some(registry);
    }
    /// Returns the shared widget registry, if set.
    pub fn registry(&self) -> Option<&Rc<RefCell<SimpleRegistry>>> {
        self.registry.as_ref()
    }
    /// Replaces all four insets at once and requests a redraw.
    ///
    /// # Why this re-places the child (C-18)
    ///
    /// The insets *are* the content rectangle's offset. Publishing a new rectangle without
    /// writing it to the mounted child left the child in the old safe rect — exactly the
    /// overlap a safe area exists to prevent. [`Self::apply`] is idempotent, so a no-op
    /// write costs nothing; a real change moves the child now rather than at the next
    /// unrelated resize.
    ///
    /// Insets larger than the widget in a given axis are tolerated: the content
    /// rect saturates to zero extent rather than underflowing, and the margin
    /// bars may then paint outside the widget's own rectangle.
    pub fn set_insets(&mut self, insets: SafeAreaInsets) {
        if self.insets == insets {
            return;
        }
        self.insets = insets;
        self.apply();
        self.base.request_redraw();
    }
    /// Returns the current insets.
    pub fn insets(&self) -> SafeAreaInsets {
        self.insets
    }
    /// Sets the colour painted in the four inset margin bars.
    ///
    /// Requests a redraw on a real change: `draw` fills every inset band with this colour, so a
    /// change that did not repaint stayed invisible until something else forced a frame. This
    /// documentation used to say the opposite ("does not request a redraw"), which contradicted
    /// the three lines below it.
    pub fn set_margin_color(&mut self, color: Color) {
        if self.margin_color == color {
            return;
        }
        self.margin_color = color;
        // `draw` fills every inset band with this colour, so a change that did not repaint was
        // invisible until something else forced a frame.
        self.base.request_redraw();
    }
    /// Sets the top inset, preserving the other three edges.
    pub fn set_top_inset(&mut self, top: u32) {
        self.set_insets(SafeAreaInsets { top, ..self.insets });
    }
    /// Sets the bottom inset, preserving the other three edges.
    pub fn set_bottom_inset(&mut self, bottom: u32) {
        self.set_insets(SafeAreaInsets { bottom, ..self.insets });
    }
    /// Sets the left inset, preserving the other three edges.
    pub fn set_left_inset(&mut self, left: u32) {
        self.set_insets(SafeAreaInsets { left, ..self.insets });
    }
    /// Sets the right inset, preserving the other three edges.
    pub fn set_right_inset(&mut self, right: u32) {
        self.set_insets(SafeAreaInsets { right, ..self.insets });
    }
    /// Returns the rectangle content should occupy: the widget's geometry
    /// inset by the safe area on all four sides.
    ///
    /// In the same parent-relative coordinate space as [`Widget::geometry`].
    /// Width and height saturate at zero when the insets exceed the widget, so
    /// the result is never inverted.
    pub fn content_rect(&self) -> Rect {
        let g = self.geometry();
        Rect::new(
            g.x + self.insets.left as i32,
            g.y + self.insets.top as i32,
            g.width.saturating_sub(self.insets.left + self.insets.right),
            g.height.saturating_sub(self.insets.top + self.insets.bottom),
        )
    }

    /// Mounts `child` as the control laid out inside [`Self::content_rect`].
    ///
    /// The previous API published the rect and left the host to place content, which
    /// meant the one thing a safe area exists to do — keep a child out of the notch — was
    /// the caller's job. Calling [`Self::apply`] (or resizing/mounting) now writes the
    /// content rect to the child through the runtime's geometry entry point.
    pub fn set_content(&mut self, child: Option<crate::core::ObjectId>) {
        if self.content_child == child {
            return;
        }
        if let Some(previous) = self.content_child {
            self.base.remove_child(previous);
        }
        self.content_child = child;
        if let Some(child) = child {
            self.base.add_child_linked(child);
        }
        self.apply();
        self.base.request_redraw();
    }

    /// The mounted content child, if any.
    pub fn content(&self) -> Option<crate::core::ObjectId> {
        self.content_child
    }

    /// Places the content child inside the safe rectangle.
    ///
    /// This is idempotent and cheap, so a host may call it after a layout pass or on every
    /// resize; a control with no child is a no-op.
    pub fn apply(&mut self) {
        if let Some(child) = self.content_child {
            let rect = self.content_rect();
            crate::set_widget_geometry(child, rect.x, rect.y, rect.width, rect.height);
            crate::set_widget_visible(child, true);
        }
    }
}

impl Widget for SafeArea {
    fn base(&self) -> &BaseWidget {
        &self.base
    }
    fn set_state_theme_hook(&mut self) {
        crate::style::reapply_active_theme_state(self);
    }
    fn base_mut(&mut self) -> &mut BaseWidget {
        &mut self.base
    }

    fn set_geometry(&mut self, geometry: Rect) {
        self.base.set_geometry(geometry);
        self.apply();
    }

    fn size_hint(&self) -> Size {
        crate::core::Size::new(300, 200)
    }
    impl_draw_bridge!();
    impl_widget_property_hooks!();
}

/// `SafeArea`'s property contract.
///
/// Read/write semantics are carried over unchanged from the centralised
/// `access_read_dialog.in.rs` / `access_write_dialog.in.rs` dispatch, so callers see
/// the same coercions and the same errors as before. The insets are stored as
/// `u32` internally and published as `Float` to match the legacy shape.
impl WidgetProperties for SafeArea {
    fn get(&self, name: &str) -> Result<CapabilityValue, CapabilityAccessError> {
        let insets = self.insets();
        match name {
            "top_inset" => Ok(CapabilityValue::Float(f64::from(insets.top))),
            "bottom_inset" => Ok(CapabilityValue::Float(f64::from(insets.bottom))),
            "left_inset" => Ok(CapabilityValue::Float(f64::from(insets.left))),
            "right_inset" => Ok(CapabilityValue::Float(f64::from(insets.right))),
            _ => base_property_get(self, name),
        }
    }

    fn set(&mut self, name: &str, value: CapabilityValue) -> Result<(), CapabilityAccessError> {
        match name {
            "top_inset" => {
                self.set_top_inset(expect_f32(value)? as u32);
                Ok(())
            }
            "bottom_inset" => {
                self.set_bottom_inset(expect_f32(value)? as u32);
                Ok(())
            }
            "left_inset" => {
                self.set_left_inset(expect_f32(value)? as u32);
                Ok(())
            }
            "right_inset" => {
                self.set_right_inset(expect_f32(value)? as u32);
                Ok(())
            }
            _ => base_property_set(self, name, value),
        }
    }

    fn property_names(&self) -> &'static [&'static str] {
        property_names_of![
            "top_inset",
            "bottom_inset",
            "left_inset",
            "right_inset",
            BASE_PROPERTY_NAMES
        ]
    }
}

impl Draw for SafeArea {
    fn draw(&mut self, ctx: &mut RenderContext) {
        let g = self.geometry();
        let cr = self.content_rect();

        // The inset bars and the content outline resolve explicit style first, then the
        // theme's resolved style for this control, and only then a literal. SafeArea is
        // not in the role table, so it classifies as `Surface` and `apply_active_theme`
        // cannot give it a distinct fill on its own — the bars therefore derive their
        // colour one step away from the theme's foreground, which is what makes an
        // appearance switch visible.
        //
        // The theme reads take and release the global manager's lock internally, so no
        // guard is held across the draw (the mutex is not re-entrant).
        //
        // The resolved style and the window fill used to be two independent manager
        // visits; `ThemeSnapshot` folds them into one read for the frame (C-P01).
        let theme = ThemeSnapshot::capture("safe_area");
        let style = self.base.style();
        let window_fill = theme.window_fill();
        let ink = style
            .text_color
            .or_else(|| theme.style.as_ref().and_then(|t| t.text_color))
            .unwrap_or(Color::BLACK);
        // A caller who set an explicit margin colour keeps it: `set_margin_color` is the
        // control's own override and wins over the theme, the same precedence
        // `WidgetStyle` documents. The default white it starts on is not an override, so
        // it is replaced by the themed surface.
        let margin_color =
            if self.margin_color == Color::WHITE {
                style
                    .background_color
                    .or_else(|| theme.style.as_ref().and_then(|t| t.background_color))
                    .map(|resolved| {
                        if resolved == window_fill {
                            window_fill.blend(&ink, 0.08)
                        } else {
                            resolved
                        }
                    })
                    .unwrap_or_else(|| window_fill.blend(&ink, 0.08))
            } else {
                self.margin_color
            };
        // The content outline is chrome too, and used to be a hardcoded grey that ignored
        // the appearance entirely.
        let border_color = style
            .border_color
            .or_else(|| theme.style.as_ref().and_then(|t| t.border_color))
            .unwrap_or_else(|| window_fill.blend(&ink, 0.25));

        // Fill margin areas (top/bottom/left/right bars)
        if self.insets.top > 0 {
            ctx.fill_rect(Rect::new(g.x, g.y, g.width, self.insets.top), margin_color);
        }
        if self.insets.bottom > 0 {
            ctx.fill_rect(
                Rect::new(
                    g.x,
                    g.y + g.height as i32 - self.insets.bottom as i32,
                    g.width,
                    self.insets.bottom,
                ),
                margin_color,
            );
        }
        if self.insets.left > 0 {
            let left_bar_height = g.height.saturating_sub(self.insets.top + self.insets.bottom);
            ctx.fill_rect(
                Rect::new(g.x, g.y + self.insets.top as i32, self.insets.left, left_bar_height),
                margin_color,
            );
        }
        if self.insets.right > 0 {
            let right_bar_height = g.height.saturating_sub(self.insets.top + self.insets.bottom);
            ctx.fill_rect(
                Rect::new(
                    g.x + g.width as i32 - self.insets.right as i32,
                    g.y + self.insets.top as i32,
                    self.insets.right,
                    right_bar_height,
                ),
                margin_color,
            );
        }
        // Draw content area border (subtle)
        ctx.draw_rect(cr, border_color.with_alpha_f32(0.4));
    }
}

impl EventHandler for SafeArea {
    fn handle_event(&mut self, event: &Event) {
        self.base.handle_event(event);
        // Forward to the mounted content control (C-19). Pointer events only reach it when
        // they land inside the safe rect; keyboard, focus and tick events go through
        // unconditionally, because a focused control owns the keyboard wherever the pointer
        // is — the same split `Carousel` and `CollapsiblePane` make.
        //
        // No coordinate translation is applied: [`SafeArea::apply`] writes the content rect
        // into the child's own geometry, and this library's events are expressed in the same
        // space as that geometry (the convention `StackedWidget` and `TabWidget` follow).
        let Some(child) = self.content_child else {
            return;
        };
        let Some(ref registry) = self.registry else {
            return;
        };
        let content_rect = self.content_rect();
        match event {
            Event::MousePress { pos, .. }
            | Event::MouseRelease { pos, .. }
            | Event::MouseMove { pos }
            | Event::MouseDoubleClick { pos, .. }
            | Event::MouseEnter { pos }
            | Event::MouseLeave { pos } => {
                if content_rect.contains(*pos) {
                    registry.borrow_mut().forward_event(child, event);
                }
            }
            _ => {
                registry.borrow_mut().forward_event(child, event);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::core::Point;

    #[test]
    fn safe_area_default_insets() {
        let sa = SafeArea::new(Rect::new(0, 0, 375, 812));
        let insets = sa.insets();
        assert_eq!(insets.top, 44);
        assert_eq!(insets.bottom, 34);
        assert_eq!(insets.left, 0);
        assert_eq!(insets.right, 0);
    }

    #[test]
    fn safe_area_content_rect_computes_correctly() {
        let sa = SafeArea::new(Rect::new(0, 0, 375, 812));
        let cr = sa.content_rect();
        assert_eq!(cr.x, 0);
        assert_eq!(cr.y, 44);
        assert_eq!(cr.width, 375);
        assert_eq!(cr.height, 812 - 44 - 34);
    }

    #[test]
    fn safe_area_content_rect_with_all_insets() {
        let mut sa = SafeArea::new(Rect::new(0, 0, 375, 812));
        sa.set_insets(SafeAreaInsets { top: 50, bottom: 40, left: 16, right: 16 });
        let cr = sa.content_rect();
        assert_eq!(cr.x, 16);
        assert_eq!(cr.y, 50);
        assert_eq!(cr.width, 375 - 32);
        assert_eq!(cr.height, 812 - 90);
    }

    #[test]
    fn safe_area_set_insets_updates_content_rect() {
        let mut sa = SafeArea::new(Rect::new(0, 0, 400, 800));
        let custom = SafeAreaInsets { top: 60, bottom: 50, left: 10, right: 10 };
        sa.set_insets(custom);
        assert_eq!(sa.insets(), custom);
        let cr = sa.content_rect();
        assert_eq!(cr.x, 10);
        assert_eq!(cr.y, 60);
    }

    #[test]
    fn safe_area_set_margin_color() {
        let mut sa = SafeArea::new(Rect::new(0, 0, 375, 812));
        sa.set_margin_color(Color::BLACK);
        // No crash; color is used in draw.
        assert_eq!(sa.kind(), WidgetKind::SafeArea);
    }

    #[test]
    fn safe_area_event_delegation() {
        let mut sa = SafeArea::new(Rect::new(0, 0, 375, 812));
        // Events should not panic/crash.
        sa.handle_event(&Event::MousePress { pos: Point::new(10, 5), button: 1, modifiers: 0 });
        sa.handle_event(&Event::MouseRelease { pos: Point::new(10, 5), button: 1 });
        assert_eq!(sa.insets().top, 44);
    }

    #[test]
    fn safe_area_svg_output() {
        let mut sa = SafeArea::new(Rect::new(0, 0, 375, 812));
        let svg = crate::widget::svg::render_to_svg(&mut sa);
        assert!(svg.starts_with("<svg"));
    }

    #[test]
    fn safe_area_content_rect_saturating_overflow() {
        let mut sa = SafeArea::new(Rect::new(0, 0, 100, 100));
        let huge = SafeAreaInsets { top: 200, bottom: 200, left: 0, right: 0 };
        sa.set_insets(huge);
        // This should not panic; height saturates to 0.
        let cr = sa.content_rect();
        assert_eq!(cr.width, 100); // left/right are 0, so width unchanged
        assert_eq!(cr.height, 0);
    }

    #[test]
    fn setting_the_margin_colour_repaints() {
        let mut area = SafeArea::new(Rect::new(0, 0, 200, 200));
        let seen = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
        area.base.redraw_requested.connect({
            let seen = std::sync::Arc::clone(&seen);
            move || {
                seen.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
            }
        });

        area.set_margin_color(Color::rgb(1, 2, 3));
        assert_eq!(
            seen.load(std::sync::atomic::Ordering::SeqCst),
            1,
            "the inset bands are filled with this colour"
        );
        area.set_margin_color(Color::rgb(1, 2, 3));
        assert_eq!(
            seen.load(std::sync::atomic::Ordering::SeqCst),
            1,
            "the same colour is not a change"
        );
    }

    /// A mounted content child is placed inside the safe rectangle.
    #[test]
    fn a_content_child_is_placed_inside_the_safe_rect() {
        let mut area = SafeArea::new(Rect::new(0, 0, 200, 200));
        area.set_insets(safe_area_insets());
        let child = 0x5AFE;
        area.set_content(Some(child));
        assert_eq!(area.content(), Some(child));
        area.apply();
        assert_eq!(area.content_rect(), Rect::new(10, 20, 180, 160));
        area.set_content(None);
        assert_eq!(area.content(), None);
    }

    fn safe_area_insets() -> SafeAreaInsets {
        SafeAreaInsets { top: 20, bottom: 20, left: 10, right: 10 }
    }

    /// C-19: a registry-mounted content child receives the events aimed at it, and only those
    /// that land inside the safe rectangle.
    #[test]
    fn the_content_child_receives_forwarded_events() {
        use crate::widget::SimpleRegistry;
        use std::sync::atomic::{AtomicUsize, Ordering};
        use std::sync::Arc;

        let mut area = SafeArea::new(Rect::new(0, 0, 200, 200));
        area.set_insets(safe_area_insets());
        let seen = Arc::new(AtomicUsize::new(0));
        let sink = Arc::clone(&seen);
        let mut registry = SimpleRegistry::new();
        registry.register(
            0x5AFE,
            |_ctx: &mut crate::render::RenderContext| {},
            move |_event: &Event| {
                sink.fetch_add(1, Ordering::SeqCst);
            },
        );
        area.set_registry(Rc::new(RefCell::new(registry)));
        area.set_content(Some(0x5AFE));

        area.handle_event(&Event::MousePress { pos: Point::new(60, 60), button: 1, modifiers: 0 });
        assert_eq!(seen.load(Ordering::SeqCst), 1, "a press inside the safe rect reaches it");
        area.handle_event(&Event::MousePress { pos: Point::new(1, 1), button: 1, modifiers: 0 });
        assert_eq!(seen.load(Ordering::SeqCst), 1, "a press in the margin is not the child's");
    }

    /// C-18: changing the insets re-places the mounted child, not only the published rect.
    #[test]
    fn changing_the_insets_keeps_the_content_rect_and_the_child_in_step() {
        let mut area = SafeArea::new(Rect::new(0, 0, 200, 200));
        area.set_content(Some(0x5AFE));
        area.set_insets(SafeAreaInsets { top: 10, bottom: 0, left: 0, right: 0 });
        assert_eq!(area.content_rect(), Rect::new(0, 10, 200, 190));
        area.set_top_inset(50);
        assert_eq!(area.content_rect(), Rect::new(0, 50, 200, 150));
        // `apply` is the write the single-edge setter now performs; the rect it uses is the one
        // just asserted, so the child cannot stay in the old band.
        area.apply();
        assert_eq!(area.content_rect().y, 50);
    }
}