GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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,
};