git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit4d0c08a3670bba8813766497b7728b3b869ce556
parent0fbfa43b9b
authorLucas Galante <[email protected]>
date2026-07-21 09:17
feat: ImageView widget over Prim::Image, demoed in the gallery

A narrow-trait display widget that fits a GPU texture into its rect via
fit_rect. It BORROWS the image id — upload_rgba/free_image stay
app-side, so one id can back several views and a dropped view leaks
nothing. Optional letterbox floor, alpha, intrinsic size = native dims.
DemoApp shows one shared procedural gradient through Contain and
Stretch side by side.

Deferred with reasons (preview-pane refactor plan): KeyValueList — no
real consumer yet (cce-files' pane is flat-path; revisit with
cce-system-settings); read-only text view and retained Section widget —
single-consumer APIs, premature to freeze.

Co-Authored-By: Claude Fable 5 <[email protected]>

 src/main.rs                      |  56 ++++++++++++++--
 src/widget/display/image_view.rs | 137 +++++++++++++++++++++++++++++++++++++++
 src/widget/display/mod.rs        |   2 +
 src/widget/mod.rs                |   2 +-
 4 files changed, 191 insertions(+), 6 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index efbdd65..a6e6260 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -22,12 +22,12 @@
 use cce_ui::engine::{Application, EngineState, LogicalPosition, LogicalSize, WindowSettings};
 use cce_ui::scene::arena::Arena;
 use cce_ui::scene::layout::{
-    compute_layout, CrossAlign, LayoutBox, Length, Rect, Size as LSize, Style,
+    compute_layout, CrossAlign, FitMode, LayoutBox, Length, Rect, Size as LSize, Style,
 };
 use cce_ui::scene::paint::{DisplayList, PaintCtx};
 use cce_ui::widget::{
-    Adapted, Button, Dropdown, WidgetHost, WidgetId, ElementState, Event, KeyEvent, MouseButton,
-    MouseScrollDelta, Slider, TextBox, Toggle,
+    Adapted, Button, Dropdown, ImageView, WidgetHost, WidgetId, ElementState, Event, KeyEvent,
+    MouseButton, MouseScrollDelta, Slider, TextBox, Toggle,
 };
 use wayland_client::QueueHandle;
 
@@ -57,6 +57,10 @@ struct DemoApp {
     slider: Adapted<Slider>,
     name_box: Adapted<TextBox>,
     theme_dropdown: Adapted<Dropdown>,
+    // ImageView pair sharing ONE uploaded texture (the widget borrows ids —
+    // upload/free stay app-side): Contain letterboxes, Stretch fills.
+    image_contain: Adapted<ImageView>,
+    image_stretch: Adapted<ImageView>,
 
     // ── App state: the source of truth. Widgets are re-asserted from it every rebuild
     // (`set_toggled` below); `take_*` changes flow back into it, never the reverse.
@@ -78,25 +82,29 @@ impl DemoApp {
     /// The widget root ids, in paint order — what the router dispatches over.
     /// `propagate_event` takes a `WidgetId` and resolves it through the registry, so the
     /// event paths need no raw pointers and no unsafe self-alias.
-    fn root_ids(&self) -> [WidgetId; 5] {
+    fn root_ids(&self) -> [WidgetId; 7] {
         [
             self.button.id(),
             self.toggle.id(),
             self.slider.id(),
             self.name_box.id(),
             self.theme_dropdown.id(),
+            self.image_contain.id(),
+            self.image_stretch.id(),
         ]
     }
 
     /// The widget roots as pointers, for the one genuinely pointer-consuming path left:
     /// registration (the registry stores them). The paint walk takes shared borrows.
-    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 5] {
+    fn roots(&mut self) -> [*mut (dyn WidgetHost + 'static); 7] {
         [
             self.button.as_ptr_mut(),
             self.toggle.as_ptr_mut(),
             self.slider.as_ptr_mut(),
             self.name_box.as_ptr_mut(),
             self.theme_dropdown.as_ptr_mut(),
+            self.image_contain.as_ptr_mut(),
+            self.image_stretch.as_ptr_mut(),
         ]
     }
 
@@ -140,6 +148,22 @@ impl Application for DemoApp {
         _sender: calloop::channel::Sender<Self::Message>,
     ) -> Self {
         cce_ui::scale::set_scale_factor(1.0);
+        // One procedurally generated gradient (no asset dependency), uploaded
+        // once and SHARED by both ImageViews — the widget borrows ids;
+        // upload/free stay app-side. upload_rgba queues into the renderer's
+        // pending list, so calling it before the first frame is safe.
+        const GRADIENT_W: u32 = 64;
+        const GRADIENT_H: u32 = 40;
+        let mut gradient = Vec::with_capacity((GRADIENT_W * GRADIENT_H * 4) as usize);
+        for y in 0..GRADIENT_H {
+            for x in 0..GRADIENT_W {
+                gradient.push((x * 255 / (GRADIENT_W - 1)) as u8);
+                gradient.push((y * 255 / (GRADIENT_H - 1)) as u8);
+                gradient.push(160);
+                gradient.push(255);
+            }
+        }
+        let gradient_id = cce_ui::vk::upload_rgba(gradient, GRADIENT_W, GRADIENT_H);
         Self {
             // Relief styling (raised buttons/toggles/dropdowns, recessed
             // wells) is the `control_relief` config default — no opt-in.
@@ -157,6 +181,13 @@ impl Application for DemoApp {
                 vec!["Forest".into(), "Ocean".into(), "Ember".into()],
                 0,
             ),
+            image_contain: ImageView::new()
+                .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
+                .with_fit(FitMode::Contain { max_upscale: 4.0 })
+                .with_bg([0.10, 0.10, 0.16, 1.0]),
+            image_stretch: ImageView::new()
+                .with_image(gradient_id, GRADIENT_W, GRADIENT_H)
+                .with_fit(FitMode::Stretch),
             toggle_on: false,
             clicks: 0,
             status: "Ready.".to_string(),
@@ -257,6 +288,12 @@ impl Application for DemoApp {
             let dropdown = arena.insert(LayoutBox::leaf(Style::row().shrink(1.0), LSize::new(150.0, CONTROL_H)));
             let slider = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 24.0)));
             let name_box = arena.insert(LayoutBox::leaf(Style::row(), LSize::new(0.0, 30.0)));
+            // ImageView row: same texture through two fit modes side by side.
+            let images = arena.insert(LayoutBox::container(
+                Style::row().gap(14.0).height(Length::Fixed(72.0)),
+            ));
+            let image_contain = arena.insert(LayoutBox::leaf(Style::row().grow(1.0), LSize::new(0.0, 72.0)));
+            let image_stretch = arena.insert(LayoutBox::leaf(Style::row().grow(1.0), LSize::new(0.0, 72.0)));
             let spacer = arena.insert(LayoutBox::container(Style::column().grow(1.0)));
             let status = arena.insert(LayoutBox::leaf(
                 Style::row(),
@@ -270,6 +307,9 @@ impl Application for DemoApp {
             arena.append_child(controls, dropdown);
             arena.append_child(root, slider);
             arena.append_child(root, name_box);
+            arena.append_child(root, images);
+            arena.append_child(images, image_contain);
+            arena.append_child(images, image_stretch);
             arena.append_child(root, spacer);
             arena.append_child(root, status);
             compute_layout(
@@ -290,6 +330,10 @@ impl Application for DemoApp {
             self.slider.set_rect(s.x, s.y, s.width, s.height);
             let n = r(name_box);
             self.name_box.set_rect(n.x, n.y, n.width, n.height);
+            let ic = r(image_contain);
+            self.image_contain.set_rect(ic.x, ic.y, ic.width, ic.height);
+            let is = r(image_stretch);
+            self.image_stretch.set_rect(is.x, is.y, is.width, is.height);
             self.title_rect = r(title);
             self.status_rect = r(status);
 
@@ -378,6 +422,8 @@ impl Application for DemoApp {
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.toggle, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.slider, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.name_box, &mut pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_contain, &mut pc);
+        cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.image_stretch, &mut pc);
         cce_ui::scene::painter::paint_root_into(&self.ui_context, &self.theme_dropdown, &mut pc);
 
         // The dropdown popover — geometry and labels last, on top of everything, exactly
diff --git a/src/widget/display/image_view.rs b/src/widget/display/image_view.rs
new file mode 100644
index 0000000..4aa7fb3
--- /dev/null
+++ b/src/widget/display/image_view.rs
@@ -0,0 +1,137 @@
+//! `ImageView` — a GPU-textured image fitted into the widget's rect via
+//! [`fit_rect`]. The view BORROWS its image id: ids come from
+//! [`crate::vk::upload_rgba`] and stay owned by the app, which frees them with
+//! [`crate::vk::free_image`] when done — the widget never uploads or frees GPU
+//! resources, so one id can back several views and a dropped view leaks
+//! nothing. `image: None` paints only the optional letterbox floor.
+
+use crate::scene::layout::{fit_rect, FitMode, Rect, Size};
+use crate::scene::paint::PaintCtx;
+use crate::widget::{Adapted, Input, Layout, Paint};
+
+#[derive(Debug, Clone)]
+pub struct ImageView {
+    /// (image id, native width, native height).
+    pub image: Option<(u32, u32, u32)>,
+    pub fit: FitMode,
+    pub alpha: f32,
+    /// Floor painted across the whole rect behind the fitted image (the
+    /// letterbox bars); `None` paints no floor.
+    pub bg: Option<[f32; 4]>,
+}
+
+impl ImageView {
+    pub fn new() -> Adapted<ImageView> {
+        Adapted::new(ImageView {
+            image: None,
+            fit: FitMode::Contain { max_upscale: 4.0 },
+            alpha: 1.0,
+            bg: None,
+        })
+    }
+
+    pub fn set_image(&mut self, image: Option<(u32, u32, u32)>) {
+        self.image = image;
+    }
+}
+
+/// By-value builders can't flow through `Deref`, so they live on the wrapped
+/// type (the `UsageBar::with_colors` idiom).
+impl Adapted<ImageView> {
+    pub fn with_image(mut self, id: u32, width: u32, height: u32) -> Self {
+        self.image = Some((id, width, height));
+        self
+    }
+
+    pub fn with_fit(mut self, fit: FitMode) -> Self {
+        self.fit = fit;
+        self
+    }
+
+    pub fn with_alpha(mut self, alpha: f32) -> Self {
+        self.alpha = alpha;
+        self
+    }
+
+    pub fn with_bg(mut self, bg: [f32; 4]) -> Self {
+        self.bg = Some(bg);
+        self
+    }
+}
+
+impl Layout for ImageView {
+    /// Native pixel dimensions; the container may still assign any rect —
+    /// `fit` decides how the image maps into it.
+    fn intrinsic_size(&self) -> Option<Size> {
+        self.image.map(|(_, w, h)| Size::new(w as f32, h as f32))
+    }
+}
+
+impl Paint for ImageView {
+    fn color(&self) -> [f32; 4] {
+        [0.0, 0.0, 0.0, 0.0]
+    }
+
+    fn paint(&self, rect: Rect, ctx: &mut PaintCtx) {
+        if let Some(bg) = self.bg {
+            ctx.quad(rect, bg);
+        }
+        if let Some((id, w, h)) = self.image {
+            let fitted = fit_rect(w, h, rect, self.fit);
+            if fitted.width > 0.0 && fitted.height > 0.0 {
+                ctx.image(id, fitted, self.alpha);
+            }
+        }
+    }
+}
+
+impl Input for ImageView {}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::scene::paint::Prim;
+    use crate::widget::{UiContext, WidgetHost};
+
+    fn image_prims(view: &Adapted<ImageView>, ctx: &UiContext) -> Vec<(u32, Rect, f32)> {
+        let mut pc = PaintCtx::new();
+        crate::scene::painter::paint_root_into(ctx, view, &mut pc);
+        pc.finish()
+            .items
+            .into_iter()
+            .filter_map(|item| match item.prim {
+                Prim::Image { image, rect, alpha } => Some((image, rect, alpha)),
+                _ => None,
+            })
+            .collect()
+    }
+
+    #[test]
+    fn paints_fitted_image_prim() {
+        let ctx = UiContext::new();
+        let mut view = ImageView::new().with_image(7, 50, 50);
+        WidgetHost::set_rect(&mut view, 0.0, 0.0, 400.0, 400.0);
+        let prims = image_prims(&view, &ctx);
+        assert_eq!(prims.len(), 1);
+        let (id, rect, alpha) = prims[0];
+        assert_eq!(id, 7);
+        assert_eq!(alpha, 1.0);
+        // Contain caps at 4x: 200x200 centered in 400x400.
+        assert_eq!((rect.x, rect.y, rect.width, rect.height), (100.0, 100.0, 200.0, 200.0));
+    }
+
+    #[test]
+    fn empty_view_paints_nothing_but_bg() {
+        let ctx = UiContext::new();
+        let mut view = ImageView::new().with_bg([0.1, 0.1, 0.1, 1.0]);
+        WidgetHost::set_rect(&mut view, 0.0, 0.0, 100.0, 100.0);
+        assert!(image_prims(&view, &ctx).is_empty());
+    }
+
+    #[test]
+    fn intrinsic_size_is_native_dims() {
+        let view = ImageView::new().with_image(1, 64, 40);
+        let s = Layout::intrinsic_size(&*view).unwrap();
+        assert_eq!((s.width, s.height), (64.0, 40.0));
+    }
+}
diff --git a/src/widget/display/mod.rs b/src/widget/display/mod.rs
index 9088c96..160defe 100644
--- a/src/widget/display/mod.rs
+++ b/src/widget/display/mod.rs
@@ -13,6 +13,7 @@ pub mod graph;
 pub mod usage_bar;
 pub mod info_box;
 pub mod status_dot;
+pub mod image_view;
 pub mod text_sizer;
 
 pub use self::text_label::TextLabel;
@@ -31,5 +32,6 @@ pub use self::graph::{GraphNode, Graph};
 pub use self::usage_bar::UsageBar;
 pub use self::info_box::InfoBox;
 pub use self::status_dot::{DotStatus, StatusDot};
+pub use self::image_view::ImageView;
 pub use self::text_sizer::{measure_text_width, measure_text, truncate_head, truncate_tail};
 
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 318a528..6958799 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -532,7 +532,7 @@ pub use self::display::{
     TextLabel, Label, StyledLabel, LabelPrim, TextItem, UsageBar,
     InfoBox, StatusDot, InteractiveListItem,
     GraphNode, Graph, Float3, ProgressBar, StatusBar, Splitter, Node, Separator,
-    DotStatus, Panel, serialize_widgets,
+    DotStatus, Panel, ImageView, serialize_widgets,
     truncate_head, truncate_tail,
 };