GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
perf(text): share cached buffers, cache boxed text; quiet the loop's debug checks
The frame's text path deep-copied every shaped buffer it drew: a cache hit
in get_text_buffer_attrs returned `buffer.clone()`, which copies every line
and glyph, and built a String key to look it up. Boxed text (wrap/align) was
worse: cloned, then re-shaped from scratch every frame.
- The cache holds `Rc<Buffer>`, keyed text → variants so a lookup borrows
the text; LRU by a counter instead of `Instant::now()` per hit.
- `shared_text_buffer` / `shared_laid_out_buffer` hand out the Rc; the
frame (`collect_dl_text` → `DlText`), `shaped_cluster_offsets` and
`ShapingMeasure` use them. The public `get_text_buffer*` functions and
`TextItem` are unchanged and still return an owned copy.
- Boxed text is cached under its box (wrap width, height, alignment) and
the scale factor.
- `layout::split_font_string` borrows the family; `parse_font_string`
wraps it.
Measured in release on a cache hit: a single run 939 ns → 79 ns; a short
boxed label 3.8 µs → 68 ns (longer text costs more before, the same after).
The runner read `CCE_PRESENT_DEBUG` from the environment on every render
and frame callback; it now asks the renderer's OnceLock flag, and the
three pasted timestamp snippets are `debug_clock_ms`. The status-bar app_id
check, which built the whole `settings()` on every pointer motion and press,
is decided once per session (`is_status_bar`).
Also clears the five compiler warnings in the test build: an unused
static in motion.rs (moved into the one non-test fn that uses it), a
duplicated #[test], and three needless `mut`s.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NwZ8nBQYEej9ic3tVC5v7Z
src/backend/window_runner.rs | 401 ++++++++++++++++++++++++++++++++-----------
src/config.rs | 1 -
src/layout.rs | 14 +-
src/motion.rs | 8 +-
src/scene/paint.rs | 8 +-
src/vk/mod.rs | 1 +
src/vk/renderer.rs | 2 +-
src/widget/display/graph.rs | 2 +-
src/widget/shaping.rs | 4 +-
9 files changed, 328 insertions(+), 113 deletions(-)
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 3c4d16f..0d72b4b 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -37,27 +37,129 @@ pub use smithay_client_toolkit::seat::pointer::CursorIcon as PointerCursorIcon;
use calloop::EventLoop;
use calloop_wayland_source::WaylandSource;
use cosmic_text::{FontSystem, Buffer, Attrs, Metrics};
-use crate::widget::{WidgetHost, TextItem, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
+use std::rc::Rc;
+use crate::widget::{WidgetHost, MouseButton, ElementState, MouseScrollDelta, KeyEvent, Key, NamedKey, Position};
use crate::wayland::detect_scale_factor;
use crate::vk::{Batch2D, Frame2D, TextSpan, VkRenderer};
-#[derive(Hash, PartialEq, Eq, Clone)]
-struct BufferCacheKey {
- text: String,
+/// One shaped buffer in the text cache, keyed by everything that shapes it
+/// beyond its text (the text is the outer map's key, so a lookup borrows it
+/// rather than allocating).
+struct CachedBuffer {
+ /// Physical font size, in thousandths of a px.
size_milli: u32,
+ /// The family as the font string names it, size stripped.
font: Option<String>,
is_vertical: bool,
attrs: crate::scene::paint::TextAttrs,
+ /// The scale factor's bits: a laid-out buffer's wrap width and box are
+ /// logical px turned physical by it.
+ scale_bits: u32,
+ /// `Some` for a boxed [`Prim::Text`](crate::scene::paint::Prim::Text) laid
+ /// out by [`get_text_buffer_laid_out`]; `None` for a single run.
+ layout: Option<crate::scene::paint::TextLayout>,
+ /// The laid-out buffer's vertical offset in its box (0 for a single run).
+ voff: f32,
+ /// Shared, not cloned, on a hit: a `Buffer` owns every shaped line and
+ /// glyph, and the frame used to deep-copy one per text prim per frame.
+ buffer: Rc<Buffer>,
+ /// [`BUFFER_TICK`] at the last hit, for least-recently-used eviction.
+ last_used: u64,
}
-#[derive(Clone)]
-struct CachedBuffer {
- buffer: Buffer,
- last_accessed: std::time::Instant,
+impl CachedBuffer {
+ fn matches(&self, k: &BufferKey<'_>) -> bool {
+ self.size_milli == k.size_milli
+ && self.font.as_deref() == k.font
+ && self.is_vertical == k.is_vertical
+ && self.attrs == k.attrs
+ && self.scale_bits == k.scale_bits
+ && self.layout == k.layout
+ }
}
+/// A cache lookup's key, borrowed from the caller.
+struct BufferKey<'a> {
+ size_milli: u32,
+ font: Option<&'a str>,
+ is_vertical: bool,
+ attrs: crate::scene::paint::TextAttrs,
+ scale_bits: u32,
+ layout: Option<crate::scene::paint::TextLayout>,
+}
+
+/// The text cache holds at most this many buffers; past it the least
+/// recently used [`BUFFER_EVICT`] go.
+const BUFFER_CAP: usize = 2000;
+const BUFFER_EVICT: usize = 100;
+
std::thread_local! {
- static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<BufferCacheKey, CachedBuffer>> = std::cell::RefCell::new(std::collections::HashMap::new());
+ /// Text → every shaped variant of it. Variants per text are few (a size,
+ /// a weight, a box), so they are scanned rather than hashed.
+ static BUFFER_CACHE: std::cell::RefCell<std::collections::HashMap<String, Vec<CachedBuffer>>> =
+ std::cell::RefCell::new(std::collections::HashMap::new());
+ static BUFFER_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
+ static BUFFER_TICK: std::cell::Cell<u64> = const { std::cell::Cell::new(0) };
+}
+
+fn buffer_tick() -> u64 {
+ BUFFER_TICK.with(|t| {
+ let n = t.get() + 1;
+ t.set(n);
+ n
+ })
+}
+
+/// The cached buffer for `text` under `key`, and its vertical offset.
+fn buffer_cache_get(text: &str, key: &BufferKey<'_>) -> Option<(Rc<Buffer>, f32)> {
+ BUFFER_CACHE.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ let hit = cache.get_mut(text)?.iter_mut().find(|e| e.matches(key))?;
+ hit.last_used = buffer_tick();
+ Some((Rc::clone(&hit.buffer), hit.voff))
+ })
+}
+
+fn buffer_cache_put(text: &str, key: &BufferKey<'_>, buffer: Rc<Buffer>, voff: f32) {
+ BUFFER_CACHE.with(|cache| {
+ let mut cache = cache.borrow_mut();
+ if BUFFER_COUNT.with(|c| c.get()) >= BUFFER_CAP {
+ let mut ticks: Vec<u64> = cache.values().flatten().map(|e| e.last_used).collect();
+ ticks.sort_unstable();
+ let cutoff = ticks[BUFFER_EVICT.min(ticks.len()) - 1];
+ cache.retain(|_, v| {
+ v.retain(|e| e.last_used > cutoff);
+ !v.is_empty()
+ });
+ BUFFER_COUNT.with(|c| c.set(cache.values().map(Vec::len).sum()));
+ }
+ let entry = CachedBuffer {
+ size_milli: key.size_milli,
+ font: key.font.map(str::to_owned),
+ is_vertical: key.is_vertical,
+ attrs: key.attrs,
+ scale_bits: key.scale_bits,
+ layout: key.layout,
+ voff,
+ buffer,
+ last_used: buffer_tick(),
+ };
+ match cache.get_mut(text) {
+ Some(v) => v.push(entry),
+ None => {
+ cache.insert(text.to_owned(), vec![entry]);
+ }
+ }
+ BUFFER_COUNT.with(|c| c.set(c.get() + 1));
+ });
+}
+
+/// The `CCE_PRESENT_DEBUG` traces' timestamp: wall-clock milliseconds, mod
+/// 100 s, short enough to read down a column of lines.
+fn debug_clock_ms() -> u128 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map_or(0, |d| d.as_millis() % 100_000)
}
/// A droplet spec resolved against a concrete rect: the push-constant fields
@@ -190,40 +292,40 @@ pub fn get_text_buffer_attrs(
font: Option<&str>,
text_attrs: crate::scene::paint::TextAttrs,
) -> Buffer {
- let scale = crate::scale::scale_factor();
- let mut font_size = size;
- let mut family_name = None;
+ Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs))
+}
- if let Some(font_str) = font {
- let (parsed_family, parsed_size) = crate::layout::parse_font_string(font_str);
- if let Some(ps) = parsed_size {
- font_size = ps;
+/// [`get_text_buffer_attrs`] without the copy: the cached buffer itself,
+/// shared. What the frame and the toolkit's own measuring use — a `Buffer`
+/// owns every shaped line and glyph, so the clone the public functions hand
+/// out costs as much as the text is long.
+pub(crate) fn shared_text_buffer(
+ fs: &mut FontSystem,
+ text: &str,
+ size: f32,
+ font: Option<&str>,
+ text_attrs: crate::scene::paint::TextAttrs,
+) -> Rc<Buffer> {
+ let scale = crate::scale::scale_factor();
+ let (family_name, font_size) = match font {
+ Some(font_str) => {
+ let (family, parsed_size) = crate::layout::split_font_string(font_str);
+ (Some(family), parsed_size.unwrap_or(size))
}
- family_name = Some(parsed_family);
- }
+ None => (None, size),
+ };
let physical_size = font_size * scale;
- let size_key = (physical_size * 1000.0).round() as u32;
let is_vertical = crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed);
- let key = BufferCacheKey {
- text: text.to_string(),
- size_milli: size_key,
- font: family_name.clone(),
+ let key = BufferKey {
+ size_milli: (physical_size * 1000.0).round() as u32,
+ font: family_name,
is_vertical,
attrs: text_attrs,
+ scale_bits: scale.to_bits(),
+ layout: None,
};
-
- let cached = BUFFER_CACHE.with(|cache| {
- let mut cache = cache.borrow_mut();
- if let Some(cached_item) = cache.get_mut(&key) {
- cached_item.last_accessed = std::time::Instant::now();
- Some(cached_item.buffer.clone())
- } else {
- None
- }
- });
-
- if let Some(buf) = cached {
+ if let Some((buf, _)) = buffer_cache_get(text, &key) {
return buf;
}
@@ -238,7 +340,7 @@ pub fn get_text_buffer_attrs(
let (sans_fallback, serif_fallback, mono_fallback, _) = crate::layout::read_preferred_fonts();
- let resolved_storage = family_name.as_deref().and_then(|font_name| match font_name {
+ let resolved_storage = family_name.and_then(|font_name| match font_name {
"monospace" if !mono_fallback.is_empty() => find_cased_family(fs, &mono_fallback),
"sans-serif" if !sans_fallback.is_empty() => find_cased_family(fs, &sans_fallback),
"serif" if !serif_fallback.is_empty() => find_cased_family(fs, &serif_fallback),
@@ -251,8 +353,8 @@ pub fn get_text_buffer_attrs(
None
};
- let family = if let Some(ref font_family) = family_name {
- match font_family.as_str() {
+ let family = if let Some(font_family) = family_name {
+ match font_family {
"monospace" => {
if !mono_fallback.is_empty() {
if let Some(ref cased) = resolved_storage {
@@ -310,24 +412,8 @@ pub fn get_text_buffer_attrs(
buf.set_text(fs, text, attrs, shaping);
buf.shape_until_scroll(fs, true);
- BUFFER_CACHE.with(|cache| {
- let mut cache = cache.borrow_mut();
- if cache.len() >= 2000 {
- let mut items: Vec<(BufferCacheKey, std::time::Instant)> = cache
- .iter()
- .map(|(k, v)| (k.clone(), v.last_accessed))
- .collect();
- items.sort_by_key(|&(_, time)| time);
- for (k, _) in items.iter().take(100) {
- cache.remove(k);
- }
- }
- cache.insert(key, CachedBuffer {
- buffer: buf.clone(),
- last_accessed: std::time::Instant::now(),
- });
- });
-
+ let buf = Rc::new(buf);
+ buffer_cache_put(text, &key, Rc::clone(&buf), 0.0);
buf
}
@@ -346,7 +432,7 @@ pub fn shaped_cluster_offsets(
font: Option<&str>,
) -> Vec<(usize, f32)> {
let scale = crate::scale::scale_factor().max(1.0);
- let buffer = get_text_buffer(fs, text, size, font);
+ let buffer = shared_text_buffer(fs, text, size, font, crate::scene::paint::TextAttrs::default());
let mut out: Vec<(usize, f32)> = Vec::new();
let mut total: f32 = 0.0;
for (start, x, w) in normalized_glyph_starts(&buffer, text) {
@@ -395,11 +481,10 @@ pub(crate) fn normalized_glyph_starts(buffer: &Buffer, text: &str) -> Vec<(usize
}
/// Shape a boxed [`Prim::Text`] (word-wrap + alignment) and return `(buffer, vertical_offset)`.
-/// Reuses [`get_text_buffer_attrs`] for all the family resolution — that returns a *clone* of the
-/// cached single-run buffer, so re-applying metrics/size/align here does not touch the cache — then
-/// re-lays-it-out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
-/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside the
-/// box per `align_v`. Uncached by construction (each box may differ in width/align).
+/// Starts from [`get_text_buffer_attrs`]'s single run for all the family resolution, then
+/// re-lays it out: a 1.4 line-height (the placed-text convention), the wrap width, per-line
+/// horizontal alignment, and re-shapes. The vertical offset positions the shaped block inside
+/// the box per `align_v`. Cached beside the single runs, keyed by the box as well.
pub fn get_text_buffer_laid_out(
fs: &mut FontSystem,
text: &str,
@@ -408,20 +493,47 @@ pub fn get_text_buffer_laid_out(
text_attrs: crate::scene::paint::TextAttrs,
layout: crate::scene::paint::TextLayout,
) -> (Buffer, f32) {
+ let (buf, voff) = shared_laid_out_buffer(fs, text, size, font, text_attrs, layout);
+ (Buffer::clone(&buf), voff)
+}
+
+/// [`get_text_buffer_laid_out`] without the copy, as [`shared_text_buffer`] is to
+/// [`get_text_buffer_attrs`]. Until 2026-10-04 a boxed text was re-shaped from scratch
+/// every frame it was drawn; the box is part of the key now.
+pub(crate) fn shared_laid_out_buffer(
+ fs: &mut FontSystem,
+ text: &str,
+ size: f32,
+ font: Option<&str>,
+ text_attrs: crate::scene::paint::TextAttrs,
+ layout: crate::scene::paint::TextLayout,
+) -> (Rc<Buffer>, f32) {
use crate::scene::paint::{AlignH, AlignV};
let scale = crate::scale::scale_factor();
- // Resolved family + attrs come for free (a cache clone we are free to mutate).
- let mut buf = get_text_buffer_attrs(fs, text, size, font, text_attrs);
-
- // The font string may override the size ("family:size") — mirror get_text_buffer_attrs.
- let mut font_size = size;
- if let Some(font_str) = font {
- if let (_, Some(ps)) = crate::layout::parse_font_string(font_str) {
- font_size = ps;
+ // The font string may override the size ("family:size") — mirror shared_text_buffer.
+ let (family, font_size) = match font {
+ Some(font_str) => {
+ let (family, parsed_size) = crate::layout::split_font_string(font_str);
+ (Some(family), parsed_size.unwrap_or(size))
}
- }
+ None => (None, size),
+ };
let physical_size = font_size * scale;
+ let key = BufferKey {
+ size_milli: (physical_size * 1000.0).round() as u32,
+ font: family,
+ is_vertical: crate::IS_VERTICAL.load(std::sync::atomic::Ordering::Relaxed),
+ attrs: text_attrs,
+ scale_bits: scale.to_bits(),
+ layout: Some(layout),
+ };
+ if let Some(hit) = buffer_cache_get(text, &key) {
+ return hit;
+ }
+
+ // Resolved family + attrs come from the single run; this copy is ours to re-lay-out.
+ let mut buf = Buffer::clone(&shared_text_buffer(fs, text, size, font, text_attrs));
let line_height = physical_size * 1.4;
buf.set_metrics(fs, Metrics::new(physical_size, line_height));
buf.set_size(fs, layout.wrap_width.map(|w| w * scale), Some(layout.box_height * scale));
@@ -444,6 +556,8 @@ pub fn get_text_buffer_laid_out(
AlignV::Middle => ((layout.box_height - total_h) / 2.0).max(0.0),
AlignV::Bottom => (layout.box_height - total_h).max(0.0),
};
+ let buf = Rc::new(buf);
+ buffer_cache_put(text, &key, Rc::clone(&buf), voff);
(buf, voff)
}
@@ -459,11 +573,23 @@ pub struct TextBounds {
pub bottom: i32,
}
+/// A display-list text prim ready for the glyph pass: a [`TextItem`](crate::widget::TextItem) whose
+/// buffer is the cache's own, shared rather than copied.
+pub(crate) struct DlText {
+ pub(crate) buffer: Rc<Buffer>,
+ pub(crate) x: f32,
+ pub(crate) y: f32,
+ pub(crate) color: cosmic_text::Color,
+ pub(crate) bounds: Option<[f32; 4]>,
+ pub(crate) clip_circle: Option<[f32; 3]>,
+ pub(crate) clip_rrect: Option<[f32; 5]>,
+}
+
/// The display list's Text prims, shaped through the shared buffer cache and
/// held for the glyph pass (the [`TextSpan`]s built by [`dl_text_spans`] borrow
/// these). Clip = the paint walk's item clip ∩ the prim's own bounds, in
/// logical space. Shared by the window's frame and the context-menu popup's.
-pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<TextItem>) {
+pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::DisplayList, out: &mut Vec<DlText>) {
for item in &dl.items {
if let crate::scene::paint::Prim::Text { text, x, y, font_size, color, alpha, font, bounds, attrs, layout } = &item.prim {
let clip = item.clip.map(|c| [c.x, c.y, c.x + c.width, c.y + c.height]);
@@ -472,13 +598,13 @@ pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::Dis
(Some(a), None) => Some(a),
(None, b) => b,
};
- // Boxed text (wrap/align) shapes uncached and shifts down by the vertical
- // offset; ordinary labels take the shared cached buffer.
+ // Boxed text (wrap/align) is laid out in its box and shifts down by the
+ // vertical offset; ordinary labels are a single run. Both cached.
let (buffer, y_off) = match layout {
- Some(l) => get_text_buffer_laid_out(fs, text, *font_size, font.as_deref(), *attrs, *l),
- None => (get_text_buffer_attrs(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
+ Some(l) => shared_laid_out_buffer(fs, text, *font_size, font.as_deref(), *attrs, *l),
+ None => (shared_text_buffer(fs, text, *font_size, font.as_deref(), *attrs), 0.0),
};
- out.push(TextItem {
+ out.push(DlText {
buffer,
x: *x,
y: *y + y_off,
@@ -499,7 +625,7 @@ pub(crate) fn collect_dl_text(fs: &mut FontSystem, dl: &crate::scene::paint::Dis
/// The glyph pass's spans for `items`: each clamped to the surface and its
/// own bounds, then by the popover-occlusion clamp against `overlays`.
pub(crate) fn dl_text_spans<'a>(
- items: &'a [TextItem],
+ items: &'a [DlText],
scale_f32: f32,
bounds: TextBounds,
overlays: &[(f32, f32, f32, f32)],
@@ -595,7 +721,7 @@ pub(crate) fn dl_batches_2d(dl_batches: &[DlBatch], scale_f32: f32) -> Vec<Batch
/// of, and showed as a plate with no legible entries.
fn popover_occlusion_clamp(
overlay_rects: &[(f32, f32, f32, f32)],
- ti: &TextItem,
+ ti: &DlText,
scale_f32: f32,
item_bounds: &mut TextBounds,
) {
@@ -4119,7 +4245,13 @@ pub struct EngineState<A: Application> {
/// This frame's display-list text, shaped and held here so the `TextSpan`s built
/// in the render pass can borrow the buffers (Phase 6 —
/// [`Application::display_list_text`]).
- pub dl_text_items: Vec<TextItem>,
+ pub(crate) dl_text_items: Vec<DlText>,
+
+ /// The app is the status bar (`app_id` `cce-status…`): no CSD move,
+ /// resize or resize cursors. Read from `settings()` once per session —
+ /// the checks it serves run on every pointer motion and press, and
+ /// `settings()` builds two `String`s each call.
+ pub(crate) is_status_bar: bool,
/// Drag-and-drop destination state (see [`crate::backend::dnd`]). The
/// manager is absent when the compositor exposes no wl_data_device_manager;
@@ -4342,7 +4474,7 @@ impl<A: Application> EngineState<A> {
if let Some(icon) = inner.cursor_icon(lx, ly) {
return icon;
}
- if inner.settings().app_id.starts_with("cce-status")
+ if self.is_status_bar
|| !inner.standard_csd()
|| !inner.csd_resize_borders()
{
@@ -4628,8 +4760,8 @@ impl<A: Application> EngineState<A> {
let _callback = surface.frame(&self.qh, ());
self.frame_callback_pending = true;
self.frame_callback_armed_at = Some(std::time::Instant::now());
- if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
- let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
+ if crate::vk::present_debug() {
+ let t = debug_clock_ms();
eprintln!("[vk] t={} armed frame callback", t);
}
}
@@ -5084,7 +5216,7 @@ impl<A: Application> PointerHandler for EngineState<A> {
};
// Client-Side Decorations (CSD) Drag & Resize Handling
- let is_status_bar = self.inner.as_ref().unwrap().settings().app_id.starts_with("cce-status");
+ let is_status_bar = self.is_status_bar;
// Never on the menu popup: its presses are the menu's, and
// its coordinates, translated into the window's, would
// otherwise read as a resize border or a movable plate.
@@ -5713,8 +5845,8 @@ impl<A: Application> wayland_client::Dispatch<wl_callback::WlCallback, ()> for E
) {
if let wl_callback::Event::Done { .. } = event {
state.frame_callback_pending = false;
- if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
- let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
+ if crate::vk::present_debug() {
+ let t = debug_clock_ms();
let waited = state.frame_callback_armed_at.map(|a| a.elapsed().as_millis()).unwrap_or(0);
eprintln!("[vk] t={} frame-done (waited {}ms)", t, waited);
}
@@ -6188,6 +6320,7 @@ fn run_session<'l, A: Application>(
touch_offset: (0.0, 0.0),
touch_scroll_at: None,
dl_text_items: Vec::new(),
+ is_status_bar: false,
};
if let Err(e) = event_queue.roundtrip(&mut engine_state) {
@@ -6209,6 +6342,7 @@ fn run_session<'l, A: Application>(
};
let settings = inner.settings();
crate::scale::set_app_id(settings.app_id.clone());
+ engine_state.is_status_bar = settings.app_id.starts_with("cce-status");
engine_state.logical_width = settings.width as f32;
engine_state.logical_height = settings.height as f32;
engine_state.inner = Some(inner);
@@ -6233,7 +6367,7 @@ fn run_session<'l, A: Application>(
surface.set_buffer_scale(buffer_scale);
engine_state.committed_buffer_scale = buffer_scale;
- if settings.app_id.starts_with("cce-status") {
+ if engine_state.is_status_bar {
let compositor = engine_state.compositor_state.wl_compositor();
let region = compositor.create_region(&qh, ());
region.add(0, 0, settings.width as i32, settings.height as i32);
@@ -6345,8 +6479,7 @@ fn run_session<'l, A: Application>(
/// in the per-iteration path, so a `std::env::var` call here would be I/O
/// on the loop that is under measurement.
fn loop_debug() -> bool {
- static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
- *FLAG.get_or_init(|| std::env::var_os("CCE_PRESENT_DEBUG").is_some())
+ crate::vk::present_debug()
}
/// Loop cadence while something is in motion: one tick per frame.
@@ -6412,11 +6545,7 @@ fn run_session<'l, A: Application>(
break;
}
if let Some(start) = iter_start {
- let t = std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_millis()
- % 100000;
+ let t = debug_clock_ms();
eprintln!(
"[vk] t={} loop dispatch={}us pending_cb={}",
t,
@@ -6621,8 +6750,8 @@ fn run_session<'l, A: Application>(
.is_none_or(|t| t.elapsed().as_millis() > 250)
{
engine_state.frame_callback_pending = false;
- if std::env::var("CCE_PRESENT_DEBUG").is_ok() {
- let t = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis() % 100000;
+ if crate::vk::present_debug() {
+ let t = debug_clock_ms();
eprintln!("[vk] t={} starvation fallback fired (callback never came)", t);
}
}
@@ -6871,3 +7000,83 @@ mod reconnect_tests {
);
}
}
+
+#[cfg(test)]
+mod text_cache_tests {
+ use super::*;
+ use crate::scene::paint::{AlignH, AlignV, TextAttrs, TextLayout};
+
+ fn boxed(wrap: f32) -> TextLayout {
+ TextLayout { wrap_width: Some(wrap), box_height: 80.0, align_h: AlignH::Center, align_v: AlignV::Middle }
+ }
+
+ /// A hit hands back the cached buffer itself: the frame used to deep-copy
+ /// every text prim's shaped buffer, every frame.
+ #[test]
+ fn a_hit_shares_the_buffer_rather_than_copying_it() {
+ let mut fs = crate::geometry_font_system().lock().unwrap();
+ let attrs = TextAttrs::default();
+ let a = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), attrs);
+ let b = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), attrs);
+ assert!(Rc::ptr_eq(&a, &b));
+ let bold = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace"), TextAttrs { weight: Some(700), ..attrs });
+ assert!(!Rc::ptr_eq(&a, &bold), "attrs are part of the key");
+ let sized = shared_text_buffer(&mut fs, "shared run", 14.0, Some("monospace 18"), attrs);
+ assert!(!Rc::ptr_eq(&a, &sized), "a size in the font string is part of the key");
+ }
+
+ /// Boxed text is cached by its box, and a hit is what a fresh layout of
+ /// the same box would be.
+ #[test]
+ fn a_laid_out_buffer_is_cached_by_its_box() {
+ let mut fs = crate::geometry_font_system().lock().unwrap();
+ let text = "a line long enough to wrap inside a narrow box";
+ let attrs = TextAttrs::default();
+ let (a, va) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
+ let (b, vb) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
+ assert!(Rc::ptr_eq(&a, &b));
+ assert_eq!(va, vb);
+ let (wide, _) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(400.0));
+ assert!(!Rc::ptr_eq(&a, &wide), "a different box is a different layout");
+ let single = shared_text_buffer(&mut fs, text, 14.0, None, attrs);
+ assert!(!Rc::ptr_eq(&a, &single), "a box never answers for the single run");
+
+ // The cached layout against one shaped from nothing.
+ BUFFER_CACHE.with(|c| c.borrow_mut().clear());
+ BUFFER_COUNT.with(|c| c.set(0));
+ let (fresh, vf) = shared_laid_out_buffer(&mut fs, text, 14.0, None, attrs, boxed(90.0));
+ assert!(!Rc::ptr_eq(&a, &fresh));
+ assert_eq!(va, vf);
+ let runs = |b: &Buffer| b.layout_runs().map(|r| (r.line_y, r.line_w, r.glyphs.len())).collect::<Vec<_>>();
+ assert_eq!(runs(&a), runs(&fresh));
+ }
+
+ /// The cache holds at most `BUFFER_CAP` buffers, and eviction takes the
+ /// least recently used, not the oldest inserted.
+ #[test]
+ fn eviction_keeps_the_cap_and_the_recently_used() {
+ BUFFER_CACHE.with(|c| c.borrow_mut().clear());
+ BUFFER_COUNT.with(|c| c.set(0));
+ let key = |size_milli| BufferKey {
+ size_milli,
+ font: None,
+ is_vertical: false,
+ attrs: TextAttrs::default(),
+ scale_bits: 1.0f32.to_bits(),
+ layout: None,
+ };
+ let empty = || Rc::new(Buffer::new_empty(Metrics::new(10.0, 10.0)));
+ for i in 0..BUFFER_CAP as u32 {
+ buffer_cache_put(&format!("t{i}"), &key(i), empty(), 0.0);
+ }
+ // The first inserted is touched, so it is no longer the least recent.
+ assert!(buffer_cache_get("t0", &key(0)).is_some());
+ buffer_cache_put("over", &key(0), empty(), 0.0);
+ let count = BUFFER_CACHE.with(|c| c.borrow().values().map(Vec::len).sum::<usize>());
+ assert_eq!(count, BUFFER_CAP - BUFFER_EVICT + 1);
+ assert_eq!(BUFFER_COUNT.with(|c| c.get()), count);
+ assert!(buffer_cache_get("t0", &key(0)).is_some(), "recently used survives");
+ assert!(buffer_cache_get("t1", &key(1)).is_none(), "least recently used goes");
+ assert!(buffer_cache_get("over", &key(0)).is_some());
+ }
+}
diff --git a/src/config.rs b/src/config.rs
index d8b14ec..6d26959 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -1296,7 +1296,6 @@ mod tests {
assert_eq!(crate::color::root_plate_corner_radius(), 17.0, "legacy spelling is not read");
}
- #[test]
/// A string list (`rounded_apps "a" "b"`) must survive the JSON round
/// trip cce-data-editor saves through — it used to come back as ONE arg,
/// `rounded_apps "a b"`, and the compositor's allowlist then matched
diff --git a/src/layout.rs b/src/layout.rs
index 2da665e..93b11ce 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -448,15 +448,21 @@ pub fn read_config_value(target_key: &str) -> Option<String> {
}
pub fn parse_font_string(s: &str) -> (String, Option<f32>) {
+ let (family, size) = split_font_string(s);
+ (family.to_string(), size)
+}
+
+/// [`parse_font_string`] borrowing the family from `s` instead of copying it.
+pub fn split_font_string(s: &str) -> (&str, Option<f32>) {
let s = s.trim();
if let Some(last_space_idx) = s.rfind(' ') {
let (family, size_str) = s.split_at(last_space_idx);
let size_str = size_str.trim();
if let Ok(size) = size_str.parse::<f32>() {
- return (family.trim().to_string(), Some(size));
+ return (family.trim(), Some(size));
}
}
- (s.to_string(), None)
+ (s, None)
}
static SECTION_PADDING: RwLock<f32> = RwLock::new(8.0);
@@ -6593,7 +6599,7 @@ mod tests {
fn section_text_and_rows_share_one_inset() {
let mut pc = ProbeTarget::default();
let (left, cw) = (100.0f32, 320.0f32);
- let mut sec: SectionContext<'_, ProbeTarget> =
+ let sec: SectionContext<'_, ProbeTarget> =
SectionContext::new(&mut pc, left, 50.0, cw, "Probe", false, false);
let cols = sec.row_layout(2, 8.0);
@@ -6657,7 +6663,7 @@ mod tests {
#[test]
fn section_ax_is_continuous() {
let mut pc = ProbeTarget::default();
- let mut sec: SectionContext<'_, ProbeTarget> =
+ let sec: SectionContext<'_, ProbeTarget> =
SectionContext::new(&mut pc, 100.0, 50.0, 320.0, "Probe", false, false);
for off in [0.0f32, 1.0, 11.0, 11.999, 12.0, 13.0, 24.0] {
assert!(
diff --git a/src/motion.rs b/src/motion.rs
index 7554c36..aedaead 100644
--- a/src/motion.rs
+++ b/src/motion.rs
@@ -18,8 +18,7 @@
//! half a second and nothing needs restarting or reloading. `CCE_ANIMATIONS`
//! (`0`/`off` or `1`/`on`) overrides the file for one process, for testing.
-use std::sync::Mutex;
-use std::time::{Duration, Instant};
+use std::time::Duration;
/// Where the switch lives. Shared with `cce-power-apply`, the writer.
pub const STATE_PATH: &str = "/run/cce/animations";
@@ -43,8 +42,6 @@ pub fn read_state() -> Option<bool> {
parse(&std::fs::read_to_string(STATE_PATH).ok()?)
}
-static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
-
// Under `cfg(test)` the switch is the SUITE's, not the machine's: on,
// unless a test forces it with [`force_for_test`]. Until 2026-09-28
// `enabled` read `/run/cce/animations` in the test binary too, so three
@@ -87,6 +84,9 @@ fn enabled_on_this_machine() -> bool {
if let Some(forced) = *ENV.get_or_init(|| std::env::var("CCE_ANIMATIONS").ok().and_then(|v| parse(&v))) {
return forced;
}
+ use std::sync::Mutex;
+ use std::time::Instant;
+ static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
let now = Instant::now();
match *cache {
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index c5ce66a..dce741e 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -933,8 +933,8 @@ pub enum AlignV {
/// Box layout for a [`Prim::Text`]: word-wrap width (`Some` ⇒ multiline wrap; `None` ⇒ single
/// run) and horizontal/vertical alignment within a box of `box_height`. All lengths are logical.
-/// The backend shapes an uncached buffer (`get_text_buffer_laid_out`) so the wrap/align do not
-/// pollute the shared single-run cache, and applies the vertical offset from the shaped height.
+/// The backend shapes it with `get_text_buffer_laid_out`, cached under the box as well as the
+/// text, and applies the vertical offset from the shaped height.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct TextLayout {
pub wrap_width: Option<f32>,
@@ -1930,8 +1930,8 @@ impl PaintCtx {
}
/// Boxed text: word-wrap + horizontal/vertical alignment within a box (a placed text box).
- /// Unlike [`text_with`](PaintCtx::text_with), the backend shapes this uncached with the box
- /// layout applied. `x, y` are the box's top-left; the backend applies the vertical offset.
+ /// Unlike [`text_with`](PaintCtx::text_with), the backend shapes this with the box layout
+ /// applied (cached per box). `x, y` are the box's top-left; the backend applies the vertical offset.
#[allow(clippy::too_many_arguments)]
pub fn text_boxed(
&mut self,
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 41bd80e..2e16c77 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -55,6 +55,7 @@ pub use image::{
upload_rgba_mipmapped, ImageQuad, PixelFormat,
};
pub use renderer::{Batch2D, Frame2D, PlatePush, VkRenderer, MAX_PLATE_FEATURES};
+pub(crate) use renderer::present_debug;
pub use rt::{RtCamera, RtEnvironment, RtImage, RtImagePixels, RtMaterial, RtOffscreen, RtTriangle};
pub use scene::{MeshId, SceneDraw, SceneImage, Vertex3D};
pub use text::TextSpan;
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index d646c34..fe776ce 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -2475,7 +2475,7 @@ impl VkRenderer {
/// or `present img N...` with no matching completion line is blocked inside
/// the driver; see the off-viewport freeze notes on the present-mode choice
/// in `create_swapchain`).
-fn present_debug() -> bool {
+pub(crate) fn present_debug() -> bool {
static FLAG: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*FLAG.get_or_init(|| std::env::var_os("CCE_PRESENT_DEBUG").is_some())
}
diff --git a/src/widget/display/graph.rs b/src/widget/display/graph.rs
index 01b2f7e..f18aa2a 100644
--- a/src/widget/display/graph.rs
+++ b/src/widget/display/graph.rs
@@ -2007,7 +2007,7 @@ mod tests {
#[test]
fn dual_geometry_views_stay_consistent() {
- let mut g = two_nodes();
+ let g = two_nodes();
let ctx = UiContext::new();
// The plain view (designer path) and the rounded view (render_widget path) describe
diff --git a/src/widget/shaping.rs b/src/widget/shaping.rs
index 8b8860d..8658b10 100644
--- a/src/widget/shaping.rs
+++ b/src/widget/shaping.rs
@@ -36,7 +36,7 @@ impl Measure for ShapingMeasure {
if let Some(w) = self.cache.get(&key) {
return *w;
}
- let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+ let buf = crate::backend::window_runner::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
// The glyphs' extent, trailing spaces included — the same measure
// `offsets` ends on. (A layout run's `line_w` leaves trailing
// whitespace out, so a width taken from it disagreed with where
@@ -58,7 +58,7 @@ impl ShapingMeasure {
/// combining mark) takes the cluster's start.
pub fn offsets(&mut self, text: &str, size: f32, font: &str, attrs: TextAttrs) -> Vec<(usize, f32)> {
let scale = crate::scale::scale_factor().max(0.01);
- let buf = crate::backend::window_runner::get_text_buffer_attrs(&mut self.fs, text, size, Some(font), attrs);
+ let buf = crate::backend::window_runner::shared_text_buffer(&mut self.fs, text, size, Some(font), attrs);
let glyphs = crate::backend::window_runner::normalized_glyph_starts(&buf, text);
let mut starts: Vec<(usize, f32)> = Vec::with_capacity(glyphs.len() + 1);
let mut width = 0.0f32;