GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
A bundled glyph belongs to a renderer, not to the process
`upload_icon` cached `(name, px) -> image id` in a process-lifetime
static. But an image id names an entry in one renderer's image table,
and a renderer does not outlive its session: window_runner repairs a
lost Wayland transport by opening a new session around the same
`Application`, which rebuilds the VkRenderer and with it the image
table. A draw for an id that table does not hold is skipped rather than
reported — the contract the doc comment above `run` already states.
So after a reconnect every bundled cce-icons glyph in the process drew
nothing at all, silently. cce-status-interface hit this first and fixed
it app-side (73ab926, its own tinted-glyph cache), but the toolkit's
cache had the same bug for everyone else. Reproduced in a shadow session
with CCE_UI_FAULT_RECONNECT: cce-gallery's copy-icon button is a glyph
before the injected drop and a bare plate after it, with every other
widget in the window intact.
Two halves, because there are two places an id gets held.
The cache: `vk::renderer_epoch()` is a new counter of image tables
built, and `upload_icon` keys its cache on it. Epoch 0 spans the first
renderer AND everything queued before it existed, so the uploads an
`Application::new` makes are not thrown away and re-done before the
first frame. The id cache itself has to stay — this is called from
widget rebuilds, and uploading per call would exhaust the 256-image
budget in seconds — which is why the fix is invalidation rather than
`icon.rs::upload_themed`'s cache-the-decode-only shape.
The widgets: a `Button` built with `new_icon` captured the id once, so
clearing the cache underneath it changed nothing — it outlives the
renderer too. It now holds the glyph NAME (`icon_name`,
`with_icon_name`) and re-resolves through `upload_icon` on every read,
which after the above is a hash lookup that yields a live id on the
first frame following a rebuild. `Ramp`'s delete button moves to the
same path. `with_icon(id, w, h)` is unchanged and still means "the app
owns this upload", which is the right contract for app-rendered
content — and is now documented as carrying the app's duty to replace
it from `renderer_init`.
src/lib.rs | 33 +++++++++++++++++++--
src/vk/image.rs | 28 ++++++++++++++++++
src/vk/mod.rs | 3 +-
src/widget/input/button.rs | 71 +++++++++++++++++++++++++++++++++++++++++-----
src/widget/input/ramp.rs | 12 ++++----
5 files changed, 130 insertions(+), 17 deletions(-)
diff --git a/src/lib.rs b/src/lib.rs
index 058de85..1f316ea 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -72,14 +72,43 @@ pub fn icons_dir() -> String {
/// `(image id, pixel w, pixel h)` for `PaintCtx::image` / `ImageView`; cached
/// per `(name, px)` so widget rebuilds reuse the one upload. `None` when the
/// icon is missing or unparsable (callers keep a text fallback).
+///
+/// **The cache is per renderer, not per process.** An image id names an entry
+/// in one renderer's image table, and a renderer does not outlive its
+/// session: `window_runner` repairs a lost Wayland transport by opening a new
+/// session around the same `Application`, which rebuilds the renderer and its
+/// image table. A draw for an id that table does not hold is skipped rather
+/// than reported, so a cache that survived the rebuild left every bundled
+/// glyph in the process silently undrawn — a status bar that reconnected kept
+/// its numbers and lost its icons, and the same went for every
+/// [`Button::new_icon`] face, treelist chevron and ramp delete button in the
+/// DE. Keying the cache on [`vk::renderer_epoch`] makes the first lookup after
+/// a rebuild a miss, which re-rasterizes and re-uploads into the live
+/// renderer.
+///
+/// The id cache itself has to stay: this is called from widget rebuilds, so
+/// uploading per call would burn through the renderer's 256-image budget in
+/// seconds. Caching only the decode — what [`icon::upload_themed`] does — is
+/// right for a caller that uploads rarely and owns what it gets back, and
+/// wrong here.
+///
+/// [`Button::new_icon`]: widget::Button::new_icon
pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
use std::collections::HashMap;
use std::sync::Mutex;
- static CACHE: Mutex<Option<HashMap<(String, u32), Option<(u32, u32, u32)>>>> =
+ /// The cached ids, and the renderer epoch they were uploaded to.
+ static CACHE: Mutex<Option<(u32, HashMap<(String, u32), Option<(u32, u32, u32)>>)>> =
Mutex::new(None);
+ let epoch = crate::vk::renderer_epoch();
let key = (name.to_string(), px);
let mut guard = CACHE.lock().unwrap();
- let cache = guard.get_or_insert_with(HashMap::new);
+ let (cached_epoch, cache) = guard.get_or_insert_with(|| (epoch, HashMap::new()));
+ if *cached_epoch != epoch {
+ // The renderer these ids named is gone, and its image table went with
+ // it — so this is a forget, not a teardown; there is nothing to free.
+ cache.clear();
+ *cached_epoch = epoch;
+ }
if let Some(hit) = cache.get(&key) {
return *hit;
}
diff --git a/src/vk/image.rs b/src/vk/image.rs
index 97a2463..0fdf565 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -80,6 +80,9 @@ enum Pending {
static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
+/// How many image tables have been built in this process. See
+/// [`renderer_epoch`].
+static STAGES_BUILT: AtomicU32 = AtomicU32::new(0);
/// Pixel buffers the renderer has finished with, waiting to be refilled.
/// Bounded: a streaming caller needs one or two in flight, and holding more
/// frame-sized buffers than that is just memory.
@@ -146,6 +149,27 @@ fn retire_buffer(mut buf: Vec<u8>) {
}
}
+/// Which renderer's image table the ids handed out right now belong to.
+///
+/// `0` until the first renderer exists, and again for the whole life of that
+/// first renderer: uploads queued before it was built (from `Application::new`
+/// and from anything the app did on the way to its first frame) are drained
+/// into it, so they are that epoch's images, not a previous one's.
+/// Every later renderer — `window_runner` builds one per session, and a lost
+/// Wayland transport starts a new session around the same `Application` —
+/// counts as the next epoch.
+///
+/// This is what lets a long-lived cache of image ids notice that its ids have
+/// stopped naming anything. It is the cheap half of the contract; the other
+/// half is the app's, because only the app knows how to produce the pixels
+/// again (see [`Application::renderer_init`], and `upload_icon` for the
+/// toolkit's own use of this).
+///
+/// [`Application::renderer_init`]: crate::engine::Application::renderer_init
+pub fn renderer_epoch() -> u32 {
+ STAGES_BUILT.load(Ordering::Relaxed).saturating_sub(1)
+}
+
/// Queue an image's GPU resources for destruction.
pub fn free_image(id: u32) {
PENDING.lock().unwrap().push(Pending::Free { id });
@@ -232,6 +256,10 @@ impl ImageStage {
render_pass: vk::RenderPass,
frames_in_flight: usize,
) -> Self {
+ // One image table per renderer, so this is the renderer count — see
+ // `renderer_epoch`, which is what tells a cache of ids that its
+ // renderer is gone.
+ STAGES_BUILT.fetch_add(1, Ordering::Relaxed);
unsafe {
let bindings = [
vk::DescriptorSetLayoutBinding::default()
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index ddefa51..c5dc437 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -40,7 +40,8 @@ mod text;
pub use core::VkCore;
pub use image::{
- free_image, recycle_buffer, update_pixels, upload_pixels, upload_rgba, ImageQuad, PixelFormat,
+ free_image, recycle_buffer, renderer_epoch, update_pixels, upload_pixels, upload_rgba, ImageQuad,
+ PixelFormat,
};
pub use renderer::{Batch2D, Frame2D, PlatePush, VkRenderer, MAX_PLATE_FEATURES};
pub use rt::{RtCamera, RtMaterial, RtOffscreen, RtTriangle};
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index ce6238e..d71a5ec 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -40,7 +40,33 @@ pub struct Button {
label: Option<String>,
/// Icon face: an uploaded texture `(image id, pixel w, pixel h)` drawn
/// centered in place of the label (see [`crate::upload_icon`]).
+ ///
+ /// Set directly by [`Adapted::with_icon`] for an app that owns its own
+ /// upload — and then it is the APP's job to replace it when the renderer
+ /// is rebuilt, because an image id names an entry in one renderer's image
+ /// table and nothing here can produce those pixels again.
+ /// [`icon_name`] is the way out of that for a bundled glyph.
+ ///
+ /// [`Adapted::with_icon`]: Adapted::<Button>::with_icon
+ /// [`icon_name`]: Button::icon_name
icon: Option<(u32, f32, f32)>,
+ /// A bundled cce-icons glyph name, when the face came from one
+ /// ([`Adapted::with_icon_name`], [`Button::new_icon`]). Takes precedence
+ /// over [`icon`]: the id is then re-resolved through
+ /// [`crate::upload_icon`] on every read rather than captured once.
+ ///
+ /// That indirection is the whole point. An id captured at construction
+ /// dies with its renderer — `window_runner` builds a new one around the
+ /// same `Application` when it repairs a lost Wayland transport, and a
+ /// draw for an id the new image table does not hold is skipped rather
+ /// than reported, so every icon button in the process went blank and
+ /// stayed blank. `upload_icon`'s cache is keyed on the renderer epoch, so
+ /// re-reading through it costs a hash lookup per frame and yields a live
+ /// id on the first frame after a rebuild.
+ ///
+ /// [`Adapted::with_icon_name`]: Adapted::<Button>::with_icon_name
+ /// [`icon`]: Button::icon
+ icon_name: Option<String>,
/// Opacity of the icon face — the ONLY state lever an icon has, since
/// `PaintCtx::image` carries no color and images ignore vertex color. A
/// disabled icon button dims instead of graying its glyph.
@@ -90,6 +116,7 @@ impl Button {
justify: Justification::Center,
label: None,
icon: None,
+ icon_name: None,
icon_alpha: 1.0,
hovered: false,
focused: false,
@@ -131,16 +158,29 @@ impl Button {
/// rather than on a plate. `fallback` is the label drawn instead when the
/// icon set is missing on this machine.
pub fn new_icon(name: &str, fallback: &str, x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
- let b = Button::adapted(ButtonKind::CopyIcon, x, y, w, h);
- match crate::upload_icon(name, 32) {
- Some((id, iw, ih)) => b.with_icon(id, iw as f32, ih as f32),
- None => b.with_label(fallback),
- }
+ Button::adapted(ButtonKind::CopyIcon, x, y, w, h).with_icon_name(name, fallback)
}
/// Whether an icon face is set (hosts size icon buttons square).
pub fn has_icon(&self) -> bool {
- self.icon.is_some()
+ self.icon_face().is_some()
+ }
+
+ /// The face to draw: the live id for a named bundled glyph, else whatever
+ /// the app handed to [`Adapted::with_icon`].
+ ///
+ /// Named glyphs re-resolve here instead of being captured, so the face
+ /// survives a renderer rebuild — see the [`icon_name`] field.
+ ///
+ /// [`Adapted::with_icon`]: Adapted::<Button>::with_icon
+ /// [`icon_name`]: Button::icon_name
+ fn icon_face(&self) -> Option<(u32, f32, f32)> {
+ match &self.icon_name {
+ Some(name) => {
+ crate::upload_icon(name, 32).map(|(id, w, h)| (id, w as f32, h as f32))
+ }
+ None => self.icon,
+ }
}
/// Where the icon face draws inside `rect`: centered, inset one 4px margin
@@ -152,7 +192,7 @@ impl Button {
/// is neither a quad nor a label. Keeping the geometry here means the icon
/// lands in the same place on both paths.
pub fn icon_rect(&self, rect: Rect) -> Option<(u32, Rect, f32)> {
- let (image, iw, ih) = self.icon?;
+ let (image, iw, ih) = self.icon_face()?;
let s = (rect.width.min(rect.height) - 8.0).max(4.0);
let (dw, dh) = if iw >= ih {
(s, s * ih / iw.max(1.0))
@@ -238,6 +278,23 @@ impl Adapted<Button> {
self
}
+ /// Icon face from a bundled cce-icons glyph, by NAME — the form to prefer
+ /// over [`with_icon`] whenever the artwork is one of cce-icons', because
+ /// the id is re-resolved per read and so survives a renderer rebuild (see
+ /// the [`icon_name`] field). `fallback` is the label drawn instead when
+ /// the icon set is missing on this machine.
+ ///
+ /// [`with_icon`]: Adapted::<Button>::with_icon
+ /// [`icon_name`]: Button::icon_name
+ pub fn with_icon_name(mut self, name: &str, fallback: &str) -> Self {
+ if crate::upload_icon(name, 32).is_some() {
+ self.icon_name = Some(name.to_string());
+ self
+ } else {
+ self.with_label(fallback)
+ }
+ }
+
/// Dim the icon face — see the `icon_alpha` field. 1.0 is fully opaque.
pub fn with_icon_alpha(mut self, alpha: f32) -> Self {
self.icon_alpha = alpha;
diff --git a/src/widget/input/ramp.rs b/src/widget/input/ramp.rs
index a02935d..5bb0cfa 100644
--- a/src/widget/input/ramp.rs
+++ b/src/widget/input/ramp.rs
@@ -150,13 +150,11 @@ impl Ramp {
// (x) and value (y), labeled like the dropdowns.
let key_pad = Slider2D::new().with_label("Key");
// A square x-icon button (cce-icons); label fallback if the icon set
- // is missing on this machine.
- let del_button = match crate::upload_icon("x", 32) {
- Some((id, w, h)) => {
- Button::new(0.0, 0.0, 22.0, 22.0).with_icon(id, w as f32, h as f32)
- }
- None => Button::new(0.0, 0.0, 64.0, 22.0).with_label("Delete"),
- };
+ // is missing on this machine. By NAME, not by a captured id: an id
+ // does not survive the renderer rebuild a reconnect performs, and the
+ // widget outlives the renderer (see `Button::icon_name`).
+ let del_button =
+ Button::new(0.0, 0.0, 22.0, 22.0).with_icon_name("x", "Delete");
// Short names on purpose: the strip's columns are narrow, and these
// render inside param rows too ("Bevel (Raised)" used to clip).
// Labeled: the dropdowns draw their own detached labels, sitting on