GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: every symbol the toolkit draws is a cce-icons glyph
The DE's one icon source is cce-icons/svg, but the toolkit drew its own
symbols as characters in whatever face the font fell back to (the
dropdown's ▼, menubar ▼ and ✓, spreadsheet ▲▼, spinbox - and +, the
font picker's "Aa", the breadcrumb's …, markdown's ↳, menu marks and
page chevrons, and a copy button that fell back to an emoji).
- PaintCtx::icon(name, rect, color): a glyph tinted like the text beside
it, with upload_icon_tinted / icon_tint / icon_pixels underneath and a
RenderTarget::icon for the legacy target.
- Context menus draw a leading "✓ ", "● " or "○ " in a row label as
the check / circle / circle-outline glyph, and their page and back
marks as chevrons. Labels keep the mark as text, so hosts that match
their own labels are unaffected.
- The menu popup can draw images: it keeps its own copies of the glyphs
(icon_source + VkRenderer::upload_rgba_now). It also stops draining the
shared upload queue, which let an upload meant for the window land in
the popup's image table.
- Vertical ButtonStrip / Paginator take glyphs by name (with_icons) in
place of a label's first character.
Tests: menu_marks_and_chevrons_are_glyphs, and
every_glyph_the_toolkit_names_is_in_the_icon_set, which fails on a glyph
name with no file (a missing glyph draws nothing, silently).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CLAUDE.md | 46 +++++++
src/backend/menu_popup.rs | 38 +++++-
src/backend/window_runner.rs | 8 ++
src/draw/images.rs | 7 +
src/layout.rs | 3 +
src/lib.rs | 78 ++++++++++--
src/scene/paint.rs | 39 ++++++
src/vk/image.rs | 29 +++++
src/vk/renderer.rs | 23 ++++
src/widget/container/breadcrumb.rs | 22 +++-
src/widget/container/menu.rs | 46 +++++--
src/widget/container/paginator.rs | 38 +++---
src/widget/container/parameters_bg.rs | 5 +-
src/widget/container/spreadsheet.rs | 26 +++-
src/widget/container/treelist.rs | 17 +--
src/widget/core.rs | 233 ++++++++++++++++++++++++++++++----
src/widget/input/button.rs | 5 +-
src/widget/input/button_strip.rs | 82 ++++++------
src/widget/input/dropdown.rs | 74 +++++------
src/widget/input/font_selector.rs | 27 ++--
src/widget/input/spinbox.rs | 15 ++-
src/widget/markdown.rs | 24 +++-
22 files changed, 685 insertions(+), 200 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 8aa0fc5..f8189df 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1763,6 +1763,52 @@ System fonts are loaded only when `$CCE_LOAD_SYSTEM_FONTS` is set (or via
`create_font_system_with_system_fonts()`, used by the font picker). Configured custom font
families are validated at startup with a warning if missing.
+### Every symbol is a cce-icons glyph (since 2026-10-05)
+
+The DE's one icon source is `cce-icons/svg`, and the toolkit draws a symbol
+— a chevron, a check, a mark, a + or a − — ONLY as one of those glyphs, never
+as a character (`▼`, `✓`, `●`, `›`, `+`, an emoji) in whatever face the font
+falls back to, and never built from primitives. Apps hold to the same rule.
+
+- **`PaintCtx::icon(name, rect, color)`** draws a glyph tinted like the text
+ beside it (raw sRGB, as a text colour is; its alpha the image's), rasterized
+ at twice the rect so it is crisp on a 2x output. `icon_untinted` is for the
+ `weather-*` family, the one set that carries its own colours.
+ `RenderTarget::icon` is the same call for the legacy target (a
+ `PopoverCollector` draws nothing). Underneath, `upload_icon_tinted` /
+ `icon_tint` / `icon_pixels`: the artwork is white, so multiplying is
+ tinting.
+- **A context-menu row MARK is a glyph**: a label beginning `MARK_CHECK`
+ (`"✓ "`), `MARK_ON` (`"● "`) or `MARK_OFF` (`"○ "`) draws `check`, `circle`
+ or `circle-outline` at its left and the label without it. The text stays the
+ row's identity, so hosts that match their own labels still match. A page
+ row's `›` and the back band's `‹` are `chevron-right` / `chevron-left`.
+- **The menu popup has images now.** Its renderer used to pass none
+ (`images: &[]`). It keeps its OWN copies (`menu_icon_ids`): a glyph painted
+ by the window renderer's id is looked up with `icon_source` and uploaded
+ once into the popup's table (`VkRenderer::upload_rgba_now`). And it no
+ longer drains the shared upload queue (`set_shared_uploads(false)`) — it
+ did, so an upload queued between the window's frame and the popup's landed
+ in the popup's table and never drew in the window.
+- **A vertical `ButtonStrip` / `Paginator` takes glyphs by name**
+ (`with_icons`). It took the label's first character when that was a word of
+ its own ("📁 Browse") and drew it as text.
+- Converted: dropdown arrow (`arrow_rect`, `ARROW_SIDE`), the params pane's
+ picker face (it drew "▾" AND the arrow), menubar title arrow and checked
+ items, spreadsheet sort marks (`chevron-up` / `chevron-down`), spinbox −/+,
+ font selector ("Aa" → `font`), breadcrumb overflow (`more-horizontal`),
+ markdown's unrenderable embed (`Draw::Icon`, `link`), the tree list's
+ missing-icon fallback (now an empty slot) and the copy button's fallback
+ ("📋" → "Copy").
+- **Kept as shapes**, being indicators and not symbols: `StatusDot`'s LED
+ disc and the plate dock's corner dot.
+
+`every_glyph_the_toolkit_names_is_in_the_icon_set` scans the source for glyph
+names and fails on one with no file — a missing glyph draws NOTHING, silently
+(skipped where the icon set is not checked out). `menu_marks_and_chevrons_are_glyphs`
+holds the menu convention. **A shadow session needs `CCE_ICONS_DIR`**: its
+HOME is isolated, so the default path finds no icons and every glyph is blank.
+
## Debug environment variables
All opt-in, all read once, all quiet when unset — set one and run any client.
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
index fb2798f..7b42bfb 100644
--- a/src/backend/menu_popup.rs
+++ b/src/backend/menu_popup.rs
@@ -279,11 +279,34 @@ impl<A: Application> EngineState<A> {
let mut items = Vec::new();
collect_dl_text(self.font_system.as_mut().unwrap(), &dl, &mut items);
- let (verts, dl_batches, _images, plate_features) = tessellate_display_list(&dl, w, h, scale);
+ let (verts, dl_batches, dl_images, plate_features) = tessellate_display_list(&dl, w, h, scale);
let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
let spans = dl_text_spans(&items, scale, bounds, &[]);
renderer.prepare_text(self.font_system.as_mut().unwrap(), &mut self.swash_cache, &spans);
let batches = dl_batches_2d(&dl_batches, scale);
+ // The menu's glyphs (its marks, its page and back chevrons) were
+ // painted with the WINDOW renderer's ids; this renderer keeps images
+ // of its own, so each is drawn by its copy here, uploaded once.
+ let icon_ids = &mut self.menu_icon_ids;
+ let images: Vec<crate::vk::ImageQuad> = dl_images
+ .iter()
+ .filter_map(|di| {
+ let own = *icon_ids.entry(di.image).or_insert_with(|| {
+ let (name, px, tint) = crate::icon_source(di.image)?;
+ let (rgba, iw, ih) = crate::icon_pixels(&name, px, tint)?;
+ Some(renderer.upload_rgba_now(&rgba, iw, ih))
+ });
+ Some(crate::vk::ImageQuad {
+ image: own?,
+ rect: (di.rect.x * scale, di.rect.y * scale, di.rect.width * scale, di.rect.height * scale),
+ alpha: di.alpha,
+ z_before: di.at,
+ clip: di.clip.map(|c| {
+ ((c.x * scale).max(0.0) as u32, (c.y * scale).max(0.0) as u32, (c.width * scale) as u32, (c.height * scale) as u32)
+ }),
+ })
+ })
+ .collect();
let e = renderer.pending_extent();
if e.width != pw || e.height != ph {
@@ -297,7 +320,7 @@ impl<A: Application> EngineState<A> {
verts: &verts,
batches: &batches,
overlay_verts: &[],
- images: &[],
+ images: &images,
plate_features: &plate_features,
clear_color: [0.0; 4],
damage: None,
@@ -318,7 +341,7 @@ impl<A: Application> PopupHandler for EngineState<A> {
let (x, y) = (config.position.0 as f32 / f, config.position.1 as f32 / f);
let (w, h) = (config.width.max(1) as f32 / f, config.height.max(1) as f32 / f);
let (scale_factor, display) = (self.scale_factor, self.display_ptr);
- let (mp, slot) = (self.menu_popup.as_mut(), &mut self.menu_renderer);
+ let (mp, slot, icon_ids) = (self.menu_popup.as_mut(), &mut self.menu_renderer, &mut self.menu_icon_ids);
let Some(mp) = mp else { return };
mp.placed = Some((x, y, w, h));
let handoff = std::mem::take(&mut mp.handoff);
@@ -342,7 +365,14 @@ impl<A: Application> PopupHandler for EngineState<A> {
let t = std::time::Instant::now();
let made = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) };
log::debug!("[menu_popup] renderer created in {:?}", t.elapsed());
- made.map(|r| *slot = Some(r))
+ // A new renderer holds none of the old one's copies.
+ icon_ids.clear();
+ made.map(|mut r| {
+ // Its images are its own (the menu's glyphs, copied in
+ // `render_menu_popup`); the shared queue is the window's.
+ r.set_shared_uploads(false);
+ *slot = Some(r)
+ })
}
};
// A lost surface is the connection dying under the menu; the window's
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index c8b4c64..2372973 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -123,6 +123,13 @@ pub struct EngineState<A: Application> {
/// surface to the next: a renderer costs a device and every pipeline
/// (tens of ms), a re-attach costs one swapchain.
pub menu_renderer: Option<VkRenderer>,
+ /// The popup renderer's copies of the bundled glyphs the menu draws,
+ /// by the window renderer's id for the same glyph (see
+ /// `crate::icon_source`). The popup keeps images of its own — it does
+ /// not take the shared upload queue — so a glyph the menu paints by the
+ /// window's id is uploaded again into the popup's table, once. Cleared
+ /// with the renderer it names.
+ pub menu_icon_ids: std::collections::HashMap<u32, Option<u32>>,
/// The `wl_display` the renderers were made from, as an address.
pub display_ptr: usize,
@@ -1881,6 +1888,7 @@ fn run_session<'l, A: Application>(
sent_popover_region: None,
menu_popup: None,
menu_renderer: None,
+ menu_icon_ids: std::collections::HashMap::new(),
display_ptr: 0,
exit: false,
redraw: false,
diff --git a/src/draw/images.rs b/src/draw/images.rs
index 96f4ed3..f123c74 100644
--- a/src/draw/images.rs
+++ b/src/draw/images.rs
@@ -47,6 +47,13 @@ pub enum Pending {
static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
static NEXT_ID: AtomicU32 = AtomicU32::new(1);
+
+/// A fresh image id from the process-wide counter, for a renderer that
+/// uploads into its own table directly (`ImageStage::upload_now`) — never
+/// one a queued upload also holds.
+pub(crate) fn next_image_id() -> u32 {
+ NEXT_ID.fetch_add(1, Ordering::Relaxed)
+}
/// How many image tables have been built in this process. See
/// [`renderer_epoch`].
static STAGES_BUILT: AtomicU32 = AtomicU32::new(0);
diff --git a/src/layout.rs b/src/layout.rs
index 93b11ce..34b59e4 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -4112,6 +4112,9 @@ pub trait RenderTarget {
}
fn push_clip_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
fn pop_clip_rect(&mut self) {}
+ /// A bundled cce-icons glyph — see `PaintCtx::icon`. A target that
+ /// cannot draw images (the legacy `PopoverCollector`) draws nothing.
+ fn icon(&mut self, _name: &str, _rect: crate::scene::layout::Rect, _color: [f32; 4]) {}
/// A flush inset control plate ([`PaintCtx::inset_plate`]) — the raised
/// control surface (groove ring down, beveled lip back up). Lets a popover
/// draw the ACTUAL widget surface expanded (the Dropdown's grown trigger).
diff --git a/src/lib.rs b/src/lib.rs
index 6a92977..c98e1dc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -114,13 +114,76 @@ pub fn icons_dir() -> String {
///
/// [`Button::new_icon`]: widget::Button::new_icon
pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
+ upload_icon_as(name, px, None)
+}
+
+/// [`upload_icon`] in a colour: the glyph's pixels multiplied by `rgb`
+/// (raw sRGB, as a [`TextLabel`](widget::display::TextLabel)'s colour is,
+/// so a glyph tinted with a label's colour matches the label). The artwork
+/// is white, so multiplying IS tinting, and what a glyph shades darker (a
+/// knocked-out mark) stays proportionally darker. Cached per
+/// `(name, px, rgb)` like [`upload_icon`], and re-uploaded after a renderer
+/// rebuild the same way.
+///
+/// A `PaintCtx::image` carries alpha and no colour, which is why a colour
+/// has to be baked into the texture; [`icon_tint`] is the colour a
+/// toolkit `[f32; 4]` becomes. The `weather-*` glyphs carry colours of
+/// their own and are drawn with [`upload_icon`].
+pub fn upload_icon_tinted(name: &str, px: u32, rgb: [u8; 3]) -> Option<(u32, u32, u32)> {
+ upload_icon_as(name, px, Some(rgb))
+}
+
+/// The `[u8; 3]` tint a toolkit colour becomes — the conversion a
+/// `TextLabel` applies to its own colour, so glyph and text match.
+pub fn icon_tint(color: [f32; 4]) -> [u8; 3] {
+ [
+ (color[0] * 255.0).round().clamp(0.0, 255.0) as u8,
+ (color[1] * 255.0).round().clamp(0.0, 255.0) as u8,
+ (color[2] * 255.0).round().clamp(0.0, 255.0) as u8,
+ ]
+}
+
+/// A bundled glyph rasterized at `px`, tinted when `tint` is given: the
+/// pixels [`upload_icon`] and [`upload_icon_tinted`] upload, for a caller
+/// that uploads them itself (a renderer with images of its own).
+pub fn icon_pixels(name: &str, px: u32, tint: Option<[u8; 3]>) -> Option<(Vec<u8>, u32, u32)> {
+ let path = format!("{}/{name}.svg", icons_dir());
+ let data = std::fs::read(&path).ok()?;
+ let (mut rgba, w, h) = rasterize_svg(&data, px)?;
+ if let Some(rgb) = tint {
+ for p in rgba.chunks_exact_mut(4) {
+ for (c, &t) in p[..3].iter_mut().zip(rgb.iter()) {
+ *c = ((*c as u16 * t as u16 + 127) / 255) as u8;
+ }
+ }
+ }
+ Some((rgba, w, h))
+}
+
+/// What a bundled glyph's image id holds: `(name, px, tint)`.
+type IconSource = (String, u32, Option<[u8; 3]>);
+
+/// Every id [`upload_icon`] and [`upload_icon_tinted`] have handed out, and
+/// the glyph it holds — see [`icon_source`].
+static ICON_SOURCES: std::sync::Mutex<Option<std::collections::HashMap<u32, IconSource>>> =
+ std::sync::Mutex::new(None);
+
+/// The glyph an image id from [`upload_icon`] / [`upload_icon_tinted`]
+/// holds, or `None` for any other image. What lets a renderer that keeps
+/// images of its own (the context menu's popup) draw the same glyph from a
+/// display list painted with the window's ids.
+pub fn icon_source(id: u32) -> Option<IconSource> {
+ ICON_SOURCES.lock().unwrap().as_ref()?.get(&id).cloned()
+}
+
+fn upload_icon_as(name: &str, px: u32, tint: Option<[u8; 3]>) -> Option<(u32, u32, u32)> {
use std::collections::HashMap;
use std::sync::Mutex;
/// The cached ids, and the renderer epoch they were uploaded to.
- static CACHE: Mutex<Option<(u32, HashMap<(String, u32), Option<(u32, u32, u32)>>)>> =
+ static CACHE: Mutex<Option<(u32, HashMap<IconSource, Option<(u32, u32, u32)>>)>> =
Mutex::new(None);
let epoch = crate::draw::renderer_epoch();
- let key = (name.to_string(), px);
+ let key = (name.to_string(), px, tint);
let mut guard = CACHE.lock().unwrap();
let (cached_epoch, cache) = guard.get_or_insert_with(|| (epoch, HashMap::new()));
if *cached_epoch != epoch {
@@ -132,12 +195,11 @@ pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
if let Some(hit) = cache.get(&key) {
return *hit;
}
- let loaded = (|| {
- let path = format!("{}/{name}.svg", icons_dir());
- let data = std::fs::read(&path).ok()?;
- let (rgba, w, h) = rasterize_svg(&data, px)?;
- Some((crate::draw::upload_rgba(rgba, w, h), w, h))
- })();
+ let loaded = icon_pixels(name, px, tint).map(|(rgba, w, h)| {
+ let id = crate::draw::upload_rgba(rgba, w, h);
+ ICON_SOURCES.lock().unwrap().get_or_insert_with(HashMap::new).insert(id, key.clone());
+ (id, w, h)
+ });
cache.insert(key, loaded);
loaded
}
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index e6136a1..3615abd 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -1170,6 +1170,41 @@ impl PaintCtx {
self.push(Prim::Image { image, rect, alpha });
}
+ /// A bundled cce-icons glyph (`cce-icons/svg/<name>.svg`) drawn at
+ /// `rect`, tinted `color` — given as a text colour is, raw sRGB, so a
+ /// glyph and the label beside it match — with the colour's alpha as the
+ /// image's. Rasterized at twice the rect's longer side so it stays crisp
+ /// on a 2x output, and cached (see [`crate::upload_icon_tinted`]).
+ /// `false`, and nothing drawn, when the glyph is missing.
+ ///
+ /// The ONE way the toolkit draws a symbol: a chevron, a mark, a + or a
+ /// − is this, never a character in whatever face the font falls back to.
+ /// A `weather-*` glyph carries its own colours; draw it with
+ /// [`icon_untinted`](Self::icon_untinted).
+ pub fn icon(&mut self, name: &str, rect: Rect, color: [f32; 4]) -> bool {
+ let px = (rect.width.max(rect.height) * 2.0).ceil().max(1.0) as u32;
+ match crate::upload_icon_tinted(name, px, crate::icon_tint(color)) {
+ Some((id, _, _)) => {
+ self.image(id, rect, color[3]);
+ true
+ }
+ None => false,
+ }
+ }
+
+ /// [`icon`](Self::icon) for a glyph that carries its own colours (the
+ /// `weather-*` family): drawn as it is, at `alpha`.
+ pub fn icon_untinted(&mut self, name: &str, rect: Rect, alpha: f32) -> bool {
+ let px = (rect.width.max(rect.height) * 2.0).ceil().max(1.0) as u32;
+ match crate::upload_icon(name, px) {
+ Some((id, _, _)) => {
+ self.image(id, rect, alpha);
+ true
+ }
+ None => false,
+ }
+ }
+
pub fn rounded_rect(&mut self, rect: Rect, radius: f32, corners: (bool, bool, bool, bool), color: [f32; 4]) {
let rect = self.apply_offset(rect);
self.push(Prim::RoundedRect { rect, radius, corners, color });
@@ -1980,6 +2015,10 @@ impl PaintCtx {
/// plates, rounded rects, bounded text — instead of the flattened
/// `PopoverCollector` view (which stays for legacy tuple hosts).
impl crate::layout::RenderTarget for PaintCtx {
+ fn icon(&mut self, name: &str, rect: Rect, color: [f32; 4]) {
+ PaintCtx::icon(self, name, rect, color);
+ }
+
fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
self.quad(Rect { x, y, width: w, height: h }, color);
}
diff --git a/src/vk/image.rs b/src/vk/image.rs
index 10178be..369b66e 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -131,6 +131,12 @@ pub(crate) struct ImageStage {
staging_idle: u32,
/// Per frame in flight: this frame's quad vertices (6 per ImageQuad).
frame_buffers: Vec<AllocatedBuffer>,
+ /// Whether this table takes the process-wide upload queue. True for a
+ /// window's renderer; false for a renderer that keeps images of its own
+ /// (the context menu's popup), which must not drain the queue — an
+ /// upload meant for the window, queued between the window's frame and
+ /// the popup's, would land in the popup's table and never be drawn.
+ pub(crate) shared_uploads: bool,
}
impl ImageStage {
@@ -325,6 +331,7 @@ impl ImageStage {
staging: None,
staging_idle: 0,
frame_buffers,
+ shared_uploads: true,
}
}
}
@@ -338,6 +345,9 @@ impl ImageStage {
queue: vk::Queue,
command_pool: vk::CommandPool,
) {
+ if !self.shared_uploads {
+ return;
+ }
let pending: Vec<Pending> = take_pending();
if pending.is_empty() {
self.staging_idle = self.staging_idle.saturating_add(1);
@@ -439,6 +449,25 @@ impl ImageStage {
}
#[allow(clippy::too_many_arguments)]
+ /// Upload RGBA8 pixels into THIS table now, rather than queueing them for
+ /// whichever renderer drains the shared queue next — for a renderer with
+ /// images of its own (see [`Self::shared_uploads`]). The id comes from
+ /// the process-wide counter, so it never collides with a queued one.
+ pub(crate) fn upload_now(
+ &mut self,
+ device: &ash::Device,
+ allocator: &mut Allocator,
+ queue: vk::Queue,
+ command_pool: vk::CommandPool,
+ pixels: &[u8],
+ width: u32,
+ height: u32,
+ ) -> u32 {
+ let id = crate::draw::images::next_image_id();
+ self.upload(device, allocator, queue, command_pool, id, pixels, width, height, PixelFormat::Rgba, false);
+ id
+ }
+
fn upload(
&mut self,
device: &ash::Device,
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index df2f2f2..131ec2b 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1686,6 +1686,29 @@ impl VkRenderer {
self.surface != vk::SurfaceKHR::null()
}
+ /// Whether this renderer takes the process-wide image upload queue
+ /// ([`crate::vk::upload_rgba`] and kin) — true by default. A renderer
+ /// that keeps images of its own, uploaded with
+ /// [`upload_rgba_now`](Self::upload_rgba_now), turns it off so it never
+ /// takes an upload the window's renderer was meant to draw.
+ pub fn set_shared_uploads(&mut self, on: bool) {
+ self.image.shared_uploads = on;
+ }
+
+ /// Upload RGBA8 pixels into this renderer's image table now, returning
+ /// the id to draw them by in this renderer's frames.
+ pub fn upload_rgba_now(&mut self, pixels: &[u8], width: u32, height: u32) -> u32 {
+ self.image.upload_now(
+ &self.core.device,
+ self.core.allocator.as_mut().unwrap(),
+ self.core.queue,
+ self.core.command_pool,
+ pixels,
+ width,
+ height,
+ )
+ }
+
/// The extent the next `draw_frame` will render at: the pending size when a
/// swapchain rebuild is queued, otherwise the live one.
pub fn pending_extent(&self) -> vk::Extent2D {
diff --git a/src/widget/container/breadcrumb.rs b/src/widget/container/breadcrumb.rs
index 19448cb..c229780 100644
--- a/src/widget/container/breadcrumb.rs
+++ b/src/widget/container/breadcrumb.rs
@@ -125,6 +125,11 @@ impl Breadcrumb {
(family, size.unwrap_or(BREADCRUMB_FONT_SIZE))
}
+ /// The side of the folded-segments marker glyph at font size `size`.
+ fn marker_side(size: f32) -> f32 {
+ (size * 0.9).round()
+ }
+
/// The segments to actually paint, each with its BUTTON BOX left edge/width and its
/// *logical* index (position in [`virtual_segs`]; `None` marks the leading "…" ellipsis).
/// This is the one source for hit-testing, the hover overlay, the plates, and the text
@@ -143,8 +148,12 @@ impl Breadcrumb {
let avail = (rect.width - 2.0 * Self::SEG_INSET).max(0.0);
let (font, size) = Self::font_and_size();
- let box_w =
- |s: &str| crate::widget::display::measure_text_width(s, &font, size) + 2.0 * SEG_PAD_X;
+ // The "…" marker is drawn as the `more-horizontal` glyph, so its box
+ // is the glyph's width, not the character's.
+ let box_w = |s: &str| {
+ let inner = if s == "…" { Self::marker_side(size) } else { crate::widget::display::measure_text_width(s, &font, size) };
+ inner + 2.0 * SEG_PAD_X
+ };
// Lay a list of (text, logical index) out left-to-right from the widget's left edge,
// one button box per segment.
@@ -440,6 +449,15 @@ impl Paint for Breadcrumb {
let (_, size) = Self::font_and_size();
let last_logical = self.virtual_segs().len().saturating_sub(1);
for vs in segs {
+ if vs.logical.is_none() {
+ // The marker for the segments folded away: the
+ // `more-horizontal` glyph in the dimmed colour, centred in
+ // its box.
+ let side = Self::marker_side(size);
+ let g = Rect { x: vs.x + SEG_PAD_X, y: rect.y + 0.5 * (rect.height - side), width: side, height: side };
+ ctx.icon("more-horizontal", g, [0x88 as f32 / 255.0, 0x88 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0]);
+ continue;
+ }
let color =
if vs.logical == Some(last_logical) { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] };
// `visible_segs` already drops segments behind a "…" to make the
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index 807b4c7..a9fa30d 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -586,6 +586,15 @@ impl Paint for MenuBar {
(srgb[1] * 255.0) as u8,
(srgb[2] * 255.0) as u8,
];
+ // The title's context arrow: the `chevron-down` glyph in the title's
+ // colour, standing where the " ▼" it replaced stood (the title's
+ // width is still measured with it, so nothing moves). A label at
+ // (x, y) sized `fs` puts it there.
+ let arrow_tint = [srgb[0], srgb[1], srgb[2], 1.0];
+ let arrow_at = |ctx: &mut PaintCtx, x: f32, y: f32, fs: f32| {
+ let side = (fs * 0.6).round();
+ ctx.icon("chevron-down", Rect { x, y: y + 0.5 * (fs - side), width: side, height: side }, arrow_tint);
+ };
let padding_x = crate::layout::paginator_tab_padding_x();
if let Some(ref label) = self.label {
@@ -637,6 +646,10 @@ impl Paint for MenuBar {
font_size,
text_color,
) {
+ if l.text == "▼" {
+ arrow_at(ctx, l.x, l.y, l.font_size);
+ continue;
+ }
ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None,
Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
}
@@ -656,6 +669,10 @@ impl Paint for MenuBar {
for (i, c) in self.display_title().chars().enumerate() {
let char_str = c.to_string();
let y_pos = start_y + i as f32 * line_height;
+ if c == '▼' {
+ arrow_at(ctx, x_pos, y_pos, font_size);
+ continue;
+ }
ctx.text_with(char_str, x_pos, y_pos, font_size, text_color, None,
Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
}
@@ -681,8 +698,13 @@ impl Paint for MenuBar {
} else {
rect.x + start_x
};
- ctx.text_with(display_title, x_pos, text_y, font_size, text_color, None,
+ ctx.text_with(self.title.clone(), x_pos, text_y, font_size, text_color, None,
Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
+ if !self.context_options.is_empty() {
+ let lead = format!("{} ", self.title);
+ let ax = x_pos + crate::widget::display::measure_text_width(&lead, &font_fam, font_size);
+ arrow_at(ctx, ax, text_y, font_size);
+ }
}
// Every word this widget draws is bounded by the widget. A menu's
@@ -759,18 +781,20 @@ impl Paint for MenuBar {
let checked = self.menu_dropdown_checked.get(menu_idx)
.and_then(|menu| menu.get(i))
.and_then(|&v| v);
- let prefix = match checked {
- Some(true) => "✓ ",
- Some(false) => " ",
- None => "",
- };
- let text = format!("{}{}", prefix, option);
+ // A checkable item keeps a mark's room before its
+ // text; a checked one wears the `check` glyph in it.
+ const MARK: f32 = 9.0;
+ let indent = if checked.is_some() { MARK + 6.0 } else { 0.0 };
let color = item_color(self.hovered_dropdown_item == Some(i), checked == Some(true));
let iy = crate::layout::align_text_y(dy + i as f32 * DROPDOWN_ITEM_H, DROPDOWN_ITEM_H, 12.0, 0.0);
+ if checked == Some(true) {
+ let my = dy + i as f32 * DROPDOWN_ITEM_H + 0.5 * (DROPDOWN_ITEM_H - MARK);
+ pc.icon("check", Rect { x: dx + 8.0, y: my, width: MARK, height: MARK }, color);
+ }
if let Some(ref f) = font {
- pc.text_with_font_and_bounds(&text, dx + 8.0, iy, 12.0, color, f, bounds);
+ pc.text_with_font_and_bounds(option, dx + 8.0 + indent, iy, 12.0, color, f, bounds);
} else {
- pc.text_with_bounds(&text, dx + 8.0, iy, 12.0, color, bounds);
+ pc.text_with_bounds(option, dx + 8.0 + indent, iy, 12.0, color, bounds);
}
}
}
@@ -1134,8 +1158,10 @@ impl MenuController for MenuBar {
let checked = self.menu_dropdown_checked.get(i)
.and_then(|menu| menu.get(item_idx))
.and_then(|&v| v);
+ // `context_menu`'s mark convention: a checked item
+ // is drawn with the `check` glyph.
let prefix = match checked {
- Some(true) => "✓ ",
+ Some(true) => crate::widget::context_menu::MARK_CHECK,
Some(false) => " ",
None => "",
};
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index 845640b..770cf9f 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -68,6 +68,18 @@ impl Paginator {
}
impl Adapted<Paginator> {
+ /// A cce-icons glyph per page, worn at the top of its sidebar tab — see
+ /// [`ButtonStrip::icons`].
+ pub fn with_icons(mut self, icons: Vec<Option<String>>) -> Self {
+ let p = self.inner_mut();
+ p.sidebar_menu.inner_mut().set_icons(icons);
+ p.sidebar_w = p.sidebar_w();
+ let w = p.sidebar_w;
+ let (x, y, _, h) = p.sidebar_menu.rect();
+ p.sidebar_menu.set_rect(x, y, w, h);
+ self
+ }
+
pub fn with_sidebar_mode(self, _enabled: bool) -> Self {
self
}
@@ -122,26 +134,12 @@ impl PageSelector for Paginator {
let font_size = font_info.1;
let padding = crate::layout::button_padding();
- let mut max_w = 0.0;
- for label in &self.page_labels {
- let trimmed = label.trim();
- let space_idx = trimmed.find(' ');
- let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
- let content_w = if has_icon {
- let space_idx = space_idx.unwrap();
- let (icon, _) = trimmed.split_at(space_idx);
- let icon = icon.trim();
- let icon_font_size = 14.0;
- let est_icon_w = crate::widget::display::measure_text_width(icon, &font_fam, icon_font_size);
- est_icon_w.max(font_size)
- } else {
- font_size
- };
- let w = content_w + 2.0 * padding;
- if w > max_w {
- max_w = w;
- }
- }
+ let _ = font_fam;
+ // A page with a glyph (`with_icons`) needs the glyph's width; one
+ // without, a line of its rotated label.
+ let has_icons = self.sidebar_menu.inner().icons.iter().any(|i| i.is_some());
+ let content_w = if has_icons { ButtonStrip::ICON_SIDE.max(font_size) } else { font_size };
+ let max_w = content_w + 2.0 * padding;
max_w.max(1.0)
}
}
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 8b0f325..1bafbc7 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -3663,7 +3663,7 @@ impl ParamController for ParametersBg {
// in the box's recess, not a bare glyph beside it.
Some(
Dropdown::new(options, 0)
- .with_custom_display_text("\u{25be}")
+ .with_custom_display_text("")
.with_raised(true),
)
}
@@ -3923,7 +3923,8 @@ mod tests {
]);
let arrow = |i: usize| {
let d = p.choices[i].as_ref().unwrap();
- d.own_text_labels().into_iter().find(|l| l.text == "▼").expect("an arrow").x
+ let (x, y, w, h) = d.rect();
+ d.inner().arrow_rect(crate::scene::layout::Rect { x, y, width: w, height: h }).x
};
assert!((arrow(0) - arrow(1)).abs() < 1e-3, "picker {} vs dropdown {}", arrow(0), arrow(1));
}
diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index fc62711..4535ffe 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -516,14 +516,28 @@ impl Paint for Spreadsheet {
continue;
}
let cx = xoff + col_w * i as f32 + 8.0;
- let (label, color) = match self.sort {
+ match self.sort {
Some((col, ascending)) if col == i => {
- let mark = if ascending { '\u{25b2}' } else { '\u{25bc}' };
- (format!("{header} {mark}"), [0xff, 0xff, 0xff])
+ // The sorted column: its name, then the `chevron-up` or
+ // `chevron-down` glyph a cell after it — the room of
+ // the " ▲" it was until 2026-10-05, taken out of the
+ // name's budget.
+ let b = budget.saturating_sub(2);
+ let shown: String = if b < 3 || header.chars().count() <= b {
+ header.clone()
+ } else {
+ let mut out: String = header.chars().take(b - 1).collect();
+ out.push('\u{2026}');
+ out
+ };
+ let tx = cx + shown.chars().count() as f32 * char_w + 0.5 * char_w;
+ ctx.text_with(shown, cx, y + 6.0, 12.0, [0xff, 0xff, 0xff], None, col_bounds(i, y, HEADER_H));
+ const SIDE: f32 = 8.0;
+ let r = Rect { x: tx, y: y + 0.5 * (HEADER_H - SIDE), width: SIDE, height: SIDE };
+ ctx.icon(if ascending { "chevron-up" } else { "chevron-down" }, r, [1.0, 1.0, 1.0, 1.0]);
}
- _ => (header.clone(), [0xdd, 0xdd, 0xee]),
- };
- ctx.text_with(fit(label), cx, y + 6.0, 12.0, color, None, col_bounds(i, y, HEADER_H));
+ _ => ctx.text_with(fit(header.clone()), cx, y + 6.0, 12.0, [0xdd, 0xdd, 0xee], None, col_bounds(i, y, HEADER_H)),
+ }
}
// A row half scrolled under the header or off the bottom draws its
// text CUT at the body band, as its zebra fill is. Until 2026-09-30
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 5d6ef1b..1b365d6 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -1642,7 +1642,7 @@ impl TreeList {
/// Type cells clip at their separators so text can't bleed into the next
/// column; sections span the whole row.
/// The section-row chevron (cce-icons), cached per size by `upload_icon`;
- /// `None` when the icon set is missing (rows fall back to text triangles).
+ /// `None` when the icon set is missing (the slot is left empty).
fn chevron_icon(collapsed: bool) -> Option<(u32, u32, u32)> {
crate::upload_icon(if collapsed { "chevron-right" } else { "chevron-down" }, 32)
}
@@ -1697,17 +1697,14 @@ impl TreeList {
}
match item {
- TreeElement::Section { name, indent, collapsed, .. } => {
+ TreeElement::Section { name, indent, .. } => {
if self.editing_key_idx != Some(i) {
let sx = list_left + 8.0 + *indent as f32 * 12.0;
- // Chevron icons (cce-icons) replace the text triangles
- // when available — paint() draws the image in the slot
- // this leaves open. Text triangles are the fallback.
- let (text, tx) = if Self::chevron_icon(*collapsed).is_some() {
- (name.clone(), sx + tree_font_size + 6.0)
- } else {
- (format!("{} {}", if *collapsed { "▶" } else { "▼" }, name), sx)
- };
+ // The chevron glyph (cce-icons) stands in the slot
+ // this leaves open — paint() draws it. A machine
+ // without the icon set shows the slot empty: no
+ // symbol is drawn as a character.
+ let (text, tx) = (name.clone(), sx + tree_font_size + 6.0);
labels.push((TextLabel {
text,
x: tx,
diff --git a/src/widget/core.rs b/src/widget/core.rs
index a2d998e..09d6bcb 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -507,12 +507,68 @@ pub mod context_menu {
}
}
- /// The mark a row that leads to a PAGE carries at its right end.
+ /// The mark a row that leads to a PAGE carries at its right end. Drawn
+ /// as the `chevron-right` glyph; the character is what a host that lays
+ /// out its own rows (the designer's dialog) may reserve room by.
pub const PAGE_MARK: &str = "›";
/// What a page's back band leads with, before the title of the plate it
- /// goes back to.
+ /// goes back to. Drawn as the `chevron-left` glyph.
pub const BACK_MARK: &str = "‹";
+ /// A row whose label BEGINS with one of these wears the mark as a
+ /// cce-icons glyph at its left, and the label is drawn without it: a
+ /// checked item (`check`), a switch or radio that is on (`circle`), one
+ /// that is off (`circle-outline`). The text stays the row's identity —
+ /// a host matching its own labels still matches `"✓ Show Grid"` — and
+ /// the toolkit owns how a mark looks, so no menu draws one as a
+ /// character in whatever face its font falls back to.
+ pub const MARK_CHECK: &str = "✓ ";
+ /// See [`MARK_CHECK`]: a switch or radio row that is on.
+ pub const MARK_ON: &str = "● ";
+ /// See [`MARK_CHECK`]: a switch or radio row that is off.
+ pub const MARK_OFF: &str = "○ ";
+
+ /// The glyph a label's leading mark names, and the label without it.
+ pub fn split_mark(label: &str) -> (Option<&'static str>, &str) {
+ for (mark, glyph) in [(MARK_CHECK, "check"), (MARK_ON, "circle"), (MARK_OFF, "circle-outline")] {
+ if let Some(rest) = label.strip_prefix(mark) {
+ return (Some(glyph), rest);
+ }
+ }
+ (None, label)
+ }
+
+ /// How wide a row mark is drawn, at menu font size `size`, and the gap
+ /// after it: the glyph box at the font size, so the mark (a disc fills
+ /// three quarters of its box) stands about as tall as a capital.
+ fn mark_size(size: f32) -> f32 {
+ (size * 0.95).round()
+ }
+ const MARK_GAP: f32 = 6.0;
+ /// The page and back chevrons, smaller still: they point, they do not
+ /// label.
+ fn chevron_size(size: f32) -> f32 {
+ (size * 0.8).round()
+ }
+
+ /// A glyph a menu draws: name, top-left, side, colour.
+ #[derive(Debug, Clone)]
+ pub(crate) struct MenuGlyph {
+ pub(crate) name: &'static str,
+ x: f32,
+ y: f32,
+ side: f32,
+ color: [f32; 4],
+ }
+
+ /// Draw `g` (shifted by `dx`, at `alpha`): the glyph tinted to the
+ /// label colour beside it — `PaintCtx::icon`.
+ fn paint_glyph(ctx: &mut crate::scene::paint::PaintCtx, g: &MenuGlyph, dx: f32, alpha: f32) {
+ let r = crate::scene::layout::Rect { x: g.x + dx, y: g.y, width: g.side, height: g.side };
+ let [cr, cg, cb, ca] = g.color;
+ ctx.icon(g.name, r, [cr, cg, cb, ca * alpha]);
+ }
+
/// A page turn the menu has been asked for, drained by the host with
/// [`take_turn`] (a swipe) or read with [`turn_at`] (a press). The menu
/// does not turn by itself: what a row leads to may be another list of
@@ -721,6 +777,8 @@ pub mod context_menu {
.options
.iter()
.map(|s| {
+ let (mark, s) = split_mark(s);
+ let mark_w = if mark.is_some() { mark_size(size) + MARK_GAP } else { 0.0 };
let inked = crate::widget::display::measure_text_width(s, &family, size);
let shaped = crate::geometry_font_system()
.lock()
@@ -731,7 +789,7 @@ pub mod context_menu {
.map(|&(_, total)| total)
})
.unwrap_or(0.0);
- inked.max(shaped)
+ mark_w + inked.max(shaped)
})
.fold(0.0f32, f32::max);
self.w = (widest + 2.0 * PAD).max(120.0);
@@ -759,7 +817,9 @@ pub mod context_menu {
}
let (family, size) = label_font();
let measure = |t: &str| crate::widget::display::measure_text_width(t, &family, size);
- let need = PAD + measure(&self.options[idx]) + SLIDER_GAP + measure(PAGE_MARK) + PAD;
+ let (mark, label) = split_mark(&self.options[idx]);
+ let mark_w = if mark.is_some() { mark_size(size) + MARK_GAP } else { 0.0 };
+ let need = PAD + mark_w + measure(label) + SLIDER_GAP + chevron_size(size) + PAD;
self.w = self.w.max(need);
self.pages[idx] = true;
}
@@ -796,9 +856,8 @@ pub mod context_menu {
dir: if back.is_some() { 1.0 } else { -1.0 },
});
if let Some(title) = back {
- let label = format!("{BACK_MARK} {title}");
let (family, size) = label_font();
- let need = PAD + crate::widget::display::measure_text_width(&label, &family, size) + PAD;
+ let need = PAD + chevron_size(size) + MARK_GAP + crate::widget::display::measure_text_width(title, &family, size) + PAD;
self.w = self.w.max(need);
self.back = Some(title.to_string());
self.content_h += ROW_H;
@@ -1474,10 +1533,15 @@ pub mod context_menu {
let bounds = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
let (lo, hi) = (-t.dir * e * TURN_SLIDE, t.dir * (1.0 - e) * TURN_SLIDE);
let (out, into) = turn_fades(e);
- for (labels, dx, alpha) in [(t.from.labels(), lo, out), (self.labels(), hi, into)] {
- for label in labels {
+ for (rows, dx, alpha) in [(&*t.from, lo, out), (self, hi, into)] {
+ for label in rows.labels() {
ctx.text_faded(label.text, label.x + dx, label.y, label.font_size, label.color, alpha, Some(family.clone()), bounds);
}
+ ctx.clip(rect, |ctx| {
+ for g in rows.glyphs() {
+ paint_glyph(ctx, &g, dx, alpha);
+ }
+ });
}
return;
}
@@ -1496,6 +1560,12 @@ pub mod context_menu {
bounds,
);
}
+ let plate = crate::scene::layout::Rect { x: self.x, y: self.y, width: self.w, height: self.h };
+ ctx.clip(plate, |ctx| {
+ for g in self.glyphs() {
+ paint_glyph(ctx, &g, 0.0, 1.0);
+ }
+ });
}
pub fn text_labels(&self) -> Vec<TextLabel> {
@@ -1506,14 +1576,14 @@ pub mod context_menu {
}
/// The labels as the rows stand, shown or not.
- fn labels(&self) -> Vec<TextLabel> {
+ pub(crate) fn labels(&self) -> Vec<TextLabel> {
let mut labels = Vec::new();
if let Some(title) = &self.back {
let (_, label_size) = label_font();
labels.push(TextLabel {
- text: format!("{BACK_MARK} {title}"),
- x: self.x + PAD,
+ text: title.clone(),
+ x: self.x + PAD + chevron_size(label_size) + MARK_GAP,
y: self.back_band_y() + (ROW_H - label_size) / 2.0,
font_size: label_size,
color: rgb8(if self.back_hovered { crate::color::TEXT_HEADER } else { crate::color::TEXT_DIM }),
@@ -1544,25 +1614,17 @@ pub mod context_menu {
rgb8(crate::color::TEXT_FG)
};
+ // A leading mark is drawn as a glyph (see `glyphs`); the
+ // label follows it.
+ let (mark, text) = split_mark(opt);
+ let mark_w = if mark.is_some() { mark_size(label_size) + MARK_GAP } else { 0.0 };
labels.push(TextLabel {
- text: opt.clone(),
- x: self.x + PAD,
+ text: text.to_string(),
+ x: self.x + PAD + mark_w,
y: iy,
font_size: label_size,
color: text_color,
});
- // A row that leads to a page says so at its right end.
- if self.leads_to_page(idx) {
- let (family, _) = label_font();
- let tw = crate::widget::display::measure_text_width(PAGE_MARK, &family, label_size);
- labels.push(TextLabel {
- text: PAGE_MARK.to_string(),
- x: self.x + self.w - PAD - tw.max(label_size * 0.4),
- y: iy,
- font_size: label_size,
- color: text_color,
- });
- }
// A slider row's readout, right-aligned against its band.
if let Some(s) = self.slider(idx) {
let (family, _) = label_font();
@@ -1579,6 +1641,54 @@ pub mod context_menu {
}
labels
}
+
+ /// The glyphs the rows wear, as they stand: each row's leading mark
+ /// (see [`MARK_CHECK`]), the chevron at the right end of a row that
+ /// leads to a page, and the back band's chevron. Each takes its
+ /// row's text colour.
+ pub(crate) fn glyphs(&self) -> Vec<MenuGlyph> {
+ let mut glyphs = Vec::new();
+ let (_, size) = label_font();
+ let (ms, cs) = (mark_size(size), chevron_size(size));
+ if self.back.is_some() {
+ glyphs.push(MenuGlyph {
+ name: "chevron-left",
+ x: self.x + PAD,
+ y: self.back_band_y() + (ROW_H - cs) / 2.0,
+ side: cs,
+ color: if self.back_hovered { crate::color::TEXT_HEADER } else { crate::color::TEXT_DIM },
+ });
+ }
+ for (idx, opt) in self.options.iter().enumerate() {
+ if opt == "-" {
+ continue;
+ }
+ let top = self.row_y(idx);
+ if top + ROW_H < self.y || top > self.y + self.h {
+ continue;
+ }
+ let color = if idx < self.header_count {
+ crate::color::TEXT_DIM
+ } else if self.hovered_item == Some(idx) {
+ crate::color::TEXT_HEADER
+ } else {
+ crate::color::TEXT_FG
+ };
+ if let (Some(name), _) = split_mark(opt) {
+ glyphs.push(MenuGlyph { name, x: self.x + PAD, y: top + (ROW_H - ms) / 2.0, side: ms, color });
+ }
+ if self.leads_to_page(idx) {
+ glyphs.push(MenuGlyph {
+ name: "chevron-right",
+ x: self.x + self.w - PAD - cs,
+ y: top + (ROW_H - cs) / 2.0,
+ side: cs,
+ color,
+ });
+ }
+ }
+ glyphs
+ }
}
/// The menu PLATE alone, over `rect`: [`Material::menu`] on a rounded
@@ -2180,7 +2290,7 @@ mod context_menu_slider_tests {
#[cfg(test)]
mod context_menu_padding_tests {
- use super::context_menu::{self, ContextMenuState, PAD, ROW_H};
+ use super::context_menu::{self, split_mark, ContextMenuState, PAD, ROW_H};
use crate::widget::WidgetId;
/// The shared menu is a popover for the window-drag question too: a
@@ -2200,6 +2310,77 @@ mod context_menu_padding_tests {
assert!(ctx.drag_allowed_at(110.0, 200.0 + PAD + ROW_H * 0.5), "hidden, it vetoes nothing");
}
+ /// A row's leading mark is a glyph, not a character: the label is drawn
+ /// without it and after the glyph's room, the glyph is the mark's
+ /// (`check`, `circle`, `circle-outline`), and a page row's chevron and a
+ /// page's back chevron are glyphs too — no row draws "✓", "●", "○", "›"
+ /// or "‹" as text.
+ #[test]
+ fn menu_marks_and_chevrons_are_glyphs() {
+ let mut m = ContextMenuState::new();
+ m.show(0.0, 0.0, vec!["✓ Show Grid".into(), "● On".into(), "○ Off".into(), "Plain".into(), "Add Tab".into()], 0, WidgetId(1));
+ m.set_row_page(4);
+ let labels = m.labels();
+ let texts: Vec<&str> = labels.iter().map(|l| l.text.as_str()).collect();
+ assert_eq!(texts, ["Show Grid", "On", "Off", "Plain", "Add Tab"]);
+ assert!(labels[0].x > labels[3].x, "a marked label stands after its mark's room");
+ let names: Vec<&str> = m.glyphs().iter().map(|g| g.name).collect();
+ assert_eq!(names, ["check", "circle", "circle-outline", "chevron-right"]);
+ m.show_page(0.0, 0.0, Some("View"), vec!["○ Wireframe".into()], 0, WidgetId(1));
+ assert_eq!(m.labels()[0].text, "View", "the back band reads its title, its chevron a glyph");
+ assert_eq!(m.glyphs().iter().map(|g| g.name).collect::<Vec<_>>(), ["chevron-left", "circle-outline"]);
+ for l in m.labels() {
+ assert!(!l.text.contains(['✓', '●', '○', '›', '‹']), "{:?} draws a mark as text", l.text);
+ }
+ assert_eq!(split_mark("✓ Collapse controls"), (Some("check"), "Collapse controls"));
+ assert_eq!(split_mark("Collapse controls"), (None, "Collapse controls"));
+ }
+
+ /// Every glyph the toolkit draws by name is in the icon set: a name
+ /// with no file draws NOTHING (a missing icon is not an error at paint),
+ /// so the miss is caught here. Skipped where the icon set is not checked
+ /// out beside the crate.
+ #[test]
+ fn every_glyph_the_toolkit_names_is_in_the_icon_set() {
+ let dir = std::path::Path::new(&crate::icons_dir()).to_path_buf();
+ if !dir.is_dir() {
+ eprintln!("skipped: no icon set at {}", dir.display());
+ return;
+ }
+ let mut names = std::collections::BTreeSet::new();
+ let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+ let mut stack = vec![root];
+ while let Some(d) = stack.pop() {
+ for e in std::fs::read_dir(&d).unwrap().flatten() {
+ let p = e.path();
+ if p.is_dir() {
+ stack.push(p);
+ continue;
+ }
+ if p.extension().is_none_or(|x| x != "rs") {
+ continue;
+ }
+ let src = std::fs::read_to_string(&p).unwrap();
+ for call in [".icon(\"", "upload_icon(\"", "upload_icon_tinted(\"", "with_icon_name(\"", "new_icon(\""] {
+ for (i, _) in src.match_indices(call) {
+ let rest = &src[i + call.len()..];
+ if let Some(end) = rest.find('"') {
+ let n = &rest[..end];
+ if !n.is_empty() && n.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
+ names.insert(n.to_string());
+ }
+ }
+ }
+ }
+ }
+ }
+ for g in ["check", "circle", "circle-outline", "chevron-left", "chevron-right", "chevron-up", "chevron-down"] {
+ names.insert(g.to_string());
+ }
+ let missing: Vec<_> = names.iter().filter(|n| !dir.join(format!("{n}.svg")).is_file()).collect();
+ assert!(missing.is_empty(), "named but not in {}: {missing:?}", dir.display());
+ }
+
/// The plate pads its rows evenly: the height is the rows plus a pad
/// above and below, the labels sit one pad in from the left with the
/// widest one a pad from the right, and the padding is plate — a pointer
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index c1c632f..a831b44 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -153,7 +153,7 @@ impl Button {
}
pub fn new_copy_icon(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
- Button::new_icon("copy", "📋", x, y, w, h)
+ Button::new_icon("copy", "Copy", x, y, w, h)
}
/// A plateless icon button faced with the bundled cce-icons glyph
@@ -258,9 +258,6 @@ impl Button {
/// that holds this lock across a widget call (the flat host, around
/// `prepare_text`) never reaches a Button's measure — keep it that way.
fn label_width(&self, label: &str) -> f32 {
- if label == "📋" {
- return 12.0;
- }
let (family, size) = self.font();
let font = Paint::widget_font(self);
crate::geometry_font_system()
diff --git a/src/widget/input/button_strip.rs b/src/widget/input/button_strip.rs
index a2fc20c..a85d17e 100644
--- a/src/widget/input/button_strip.rs
+++ b/src/widget/input/button_strip.rs
@@ -9,6 +9,12 @@ pub struct ButtonStrip {
w: f32,
h: f32,
pub buttons: Vec<String>,
+ /// A bundled cce-icons glyph per button (`None` for one without): in a
+ /// VERTICAL strip it stands at the top of its button, above the
+ /// rotated label. Until 2026-10-05 the glyph was the label's first
+ /// character when that was a word of its own ("📁 Browse"), drawn as
+ /// text — an emoji or a symbol in whatever face the font fell back to.
+ pub icons: Vec<Option<String>>,
pub selected: Option<usize>,
pub vertical: bool,
pub just_clicked: Option<usize>,
@@ -49,6 +55,7 @@ impl ButtonStrip {
w,
h,
buttons: Vec::new(),
+ icons: Vec::new(),
selected: None,
vertical: false,
just_clicked: None,
@@ -109,6 +116,27 @@ impl ButtonStrip {
self
}
+ /// The glyph each button wears in a vertical strip — see [`Self::icons`].
+ pub fn with_icons(mut self, icons: Vec<Option<String>>) -> Self {
+ self.set_icons(icons);
+ self
+ }
+
+ /// See [`Self::with_icons`].
+ pub fn set_icons(&mut self, icons: Vec<Option<String>>) {
+ self.icons = icons;
+ self.tab_quads_cache.clear();
+ self.generate_rotated_labels();
+ }
+
+ /// Button `i`'s glyph, if it has one.
+ pub fn icon_of(&self, i: usize) -> Option<&str> {
+ self.icons.get(i).and_then(|n| n.as_deref())
+ }
+
+ /// The side of a vertical button's glyph.
+ pub const ICON_SIDE: f32 = 14.0;
+
pub fn with_selected(mut self, selected: Option<usize>) -> Self {
self.selected = selected;
self.generate_rotated_labels();
@@ -180,13 +208,8 @@ impl ButtonStrip {
let hex_color = format!("#{:02X}{:02X}{:02X}", color[0], color[1], color[2]);
let trimmed = page_name.trim();
- let space_idx = trimmed.find(' ');
- let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
- let label_text = if has_icon {
- trimmed.split_at(space_idx.unwrap()).1.trim()
- } else {
- trimmed
- };
+ let has_icon = self.icon_of(i).is_some();
+ let label_text = trimmed;
let r = self.item_rect(i);
let padding_y = crate::layout::button_padding();
@@ -272,13 +295,8 @@ impl ButtonStrip {
let font_size = font_info.1;
let padding = crate::layout::button_padding();
if self.vertical {
- let space_idx = trimmed.find(' ');
- let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
- let label_text = if has_icon {
- trimmed.split_at(space_idx.unwrap()).1.trim()
- } else {
- trimmed
- };
+ let has_icon = self.icon_of(i).is_some();
+ let label_text = trimmed;
let text_w = crate::widget::display::measure_text_width(label_text, &font_fam, font_size);
if has_icon {
(text_w + 12.0 + 3.0 * padding).max(1.0)
@@ -418,12 +436,15 @@ impl crate::widget::Paint for ButtonStrip {
if i < self.tab_text_quads.len() {
let min_y = self.y;
let max_y = self.y + self.h;
- let page_name = &self.buttons[i];
- let trimmed = page_name.trim();
- let space_idx = trimmed.find(' ');
- let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
+ let has_icon = self.icon_of(i).is_some();
let padding_y = crate::layout::button_padding();
let y_offset = if has_icon { padding_y + 12.0 } else { 0.0 };
+ if let Some(name) = self.icon_of(i) {
+ let c = if Some(i) == self.selected { [0xf0, 0xf0, 0xf5] } else { [0xa8, 0xa8, 0xb3] };
+ let side = Self::ICON_SIDE;
+ let g = Rect { x: r.0 + 0.5 * (r.2 - side), y: r.1 + (padding_y - 2.0).max(0.0), width: side, height: side };
+ pc.icon(name, g, [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]);
+ }
for &(qx, qy, qw, qh, qc) in &self.tab_text_quads[i] {
let absolute_x = r.0 + qx;
@@ -440,7 +461,7 @@ impl crate::widget::Paint for ButtonStrip {
}
}
- // Own labels (horizontal button text / vertical icon glyphs).
+ // Own labels (horizontal button text).
for tl in self.own_labels() {
pc.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
}
@@ -582,27 +603,8 @@ impl ButtonStrip {
[0xa8, 0xa8, 0xb3]
};
if self.vertical {
- let trimmed = btn_label.trim();
- let space_idx = trimmed.find(' ');
- let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
- if has_icon {
- let space_idx = space_idx.unwrap();
- let (icon, _) = trimmed.split_at(space_idx);
- let icon = icon.trim();
- if !icon.is_empty() {
- let icon_font_size = 14.0;
- let est_icon_w = crate::widget::display::measure_text_width(icon, &font_fam, icon_font_size);
- let padding_y = crate::layout::button_padding();
- let icon_y = r.1 + (padding_y - 2.0).max(0.0);
- labels.push(TextLabel {
- text: icon.to_string(),
- x: r.0 + (r.2 - est_icon_w) / 2.0,
- y: icon_y,
- font_size: icon_font_size,
- color,
- });
- }
- }
+ // A vertical button's label is the rotated texture and its
+ // glyph an icon, both drawn in `paint`.
} else {
let est_w = crate::widget::display::measure_text_width(btn_label, &font_fam, font_size);
labels.push(TextLabel {
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index d770b50..ae171a7 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -50,7 +50,14 @@ fn monospace_cell_width(font_family: &str, font_size: f32) -> f32 {
((w_m20 - w_m10) / 10.0).max(1.0)
}
-/// Width of the ARROW SLOT, a trigger's right end in which its ▼ is
+/// The side of a trigger's arrow, the `chevron-down` glyph: the size the
+/// "▼" it replaced drew at in the 10px face it was set in.
+pub(crate) const ARROW_SIDE: f32 = 8.0;
+/// The arrow's colour: the grey the "▼" was drawn in (raw sRGB, as a text
+/// colour is).
+const ARROW_COLOR: [f32; 4] = [0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0];
+
+/// Width of the ARROW SLOT, a trigger's right end in which its arrow is
/// centred: `ARROW_SLOT` and a relief wall at this band height. A textpick
/// picker (`ParametersBg`) is exactly this wide with its arrow in its middle,
/// so its arrow lines up with every other trigger's in a column of rows.
@@ -161,7 +168,7 @@ pub struct Dropdown {
/// parameter pane's textpick picker button nested in its TextBox), where
/// the menu should span the whole field, not the button sliver.
pub popover_anchor: Option<Rect>,
- /// The ▼ stands in the MIDDLE of the trigger instead of in the arrow
+ /// The arrow stands in the MIDDLE of the trigger instead of in the arrow
/// slot at its right end — for a trigger that carries nothing else
/// (the textpick picker), whose padding about the arrow should be even.
pub center_arrow: bool,
@@ -203,7 +210,7 @@ pub struct Dropdown {
/// Keyboard focus (FocusIn / FocusOut): lights the trigger plate's rim.
focused: bool,
/// The open menu REPLACES the trigger instead of growing out of it: no
- /// trigger band (display text + ▼) in the open surface, the rows alone,
+ /// trigger band (display text + arrow) in the open surface, the rows alone,
/// with the menu's edge anchored where the trigger's was (its bottom for
/// an upward menu, its top for a downward one) so the rows occupy the
/// trigger's slot. The trigger itself stops painting once the revealed
@@ -296,7 +303,7 @@ impl Dropdown {
/// Horizontal inset added to a measured label to get a dropdown width the label fits inside
/// without tripping `paint_text`'s right-edge fade: an 8px left pad plus the 28px right
- /// reservation (`right_limit = w - 28`, room for the 10px gap and the ▼ arrow) = 36px, plus a
+ /// reservation (`right_limit = w - 28`, room for the 10px gap and the arrow) = 36px, plus a
/// 2px cushion for the small gap between the ink-`measure_text_width` used here and the M-dummy
/// advance the paint pass measures with. Shared by `content_width` (widest option) and
/// `display_width` (collapsed display text) so the two can't drift.
@@ -751,18 +758,23 @@ impl Dropdown {
}
}
- /// Where the ▼ is drawn on a trigger band: centred in the arrow slot
- /// at its right end ([`arrow_slot`]), or in the whole band under
- /// `center_arrow`. Measured at the size the glyph is drawn at — the
- /// configured font's, which the runner reads off the font string.
+ /// Where the arrow is drawn on a trigger band: the `chevron-down` glyph,
+ /// [`ARROW_SIDE`] square, centred in the arrow slot at the band's right
+ /// end ([`arrow_slot`]), or in the whole band under `center_arrow`.
/// Until 2026-10-02 it stood 18 px in from the right end, and the
- /// picker's centred arrow stood two pixels left of every other.
- fn arrow_x(&self, band: Rect, font_family: &str, font_size: f32) -> f32 {
+ /// picker's centred arrow stood two pixels left of every other; until
+ /// 2026-10-05 it was a "▼" in the control font.
+ pub(crate) fn arrow_rect(&self, band: Rect) -> Rect {
let slot = if self.center_arrow { band.width } else { arrow_slot(band.height).min(band.width) };
- band.x + band.width - 0.5 * (slot + text_advance("▼", font_family, font_size))
+ Rect {
+ x: band.x + band.width - 0.5 * (slot + ARROW_SIDE),
+ y: band.y + 0.5 * (band.height - ARROW_SIDE),
+ width: ARROW_SIDE,
+ height: ARROW_SIDE,
+ }
}
- /// Emit the selected-text (per-character fade against the right edge) and the ▼ arrow —
+ /// Emit the selected-text (per-character fade against the right edge) and the arrow glyph —
/// the legacy `text_labels` body minus the control label (the adapter's base-label
/// machinery draws that, with the +4px `detached_label_inset`).
fn paint_text(&self, content: Rect, ctx: &mut PaintCtx) {
@@ -852,16 +864,8 @@ impl Dropdown {
// fight rather than help.
// The glyph is drawn at the configured font's size (the runner
// reads it off the font string), so that is the size it is measured at.
- let arrow_x = self.arrow_x(content, &font_family, font_size);
- ctx.text_with(
- "▼",
- arrow_x,
- crate::layout::center_text_y(content.y, content.height, 10.0),
- 10.0,
- [0x83, 0x83, 0x8a],
- Some(crate::layout::control_label_font_detached()),
- Some([content.x, content.y, content.x + content.width, content.y + content.height]),
- );
+ let _ = (&font_family, font_size);
+ ctx.icon("chevron-down", self.arrow_rect(content), ARROW_COLOR);
}
/// Port of the legacy `keyboard_input` body.
@@ -1132,7 +1136,7 @@ impl Paint for Dropdown {
}
// Trigger content redrawn over its band (the box covers the widget-pass
- // trigger paint) — display text left, ▼ right, the paint_text palette.
+ // trigger paint) — display text left, arrow right, the paint_text palette.
// A menu that replaces the trigger has no band to redraw on.
if !self.menu_replaces_trigger {
let (tx, ty) = (rect.x, rect.y);
@@ -1149,16 +1153,7 @@ impl Paint for Dropdown {
&font,
band_bounds,
);
- let (family, size) = crate::layout::control_label_font_detached_parsed();
- pc.text_with_font_and_bounds(
- "▼",
- self.arrow_x(rect, &family, size),
- crate::layout::center_text_y(ty, th, 10.0),
- 10.0,
- [0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0],
- &font,
- band_bounds,
- );
+ pc.icon("chevron-down", self.arrow_rect(rect), ARROW_COLOR);
}
if let Some(h_idx) = self.hovered_item {
@@ -1456,7 +1451,7 @@ mod tests {
.items
.into_iter()
.filter_map(|item| match item.prim {
- crate::scene::paint::Prim::Text { text, x, .. } if text != "▼" => Some((text, x)),
+ crate::scene::paint::Prim::Text { text, x, .. } => Some((text, x)),
_ => None,
})
.collect();
@@ -1668,11 +1663,11 @@ mod tests {
dd.set_rect(10.0, 10.0, 100.0, 24.0); // very narrow dropdown
let labels = dd.own_text_labels();
- // Labels are individual characters of selected_text, then the ▼ arrow (prim order).
+ // Labels are individual characters of selected_text (the arrow is a glyph, not text).
assert!(labels.len() > 2);
- // The last character label (excluding the arrow) should be faded (i.e. not the default color)
- let last_char_idx = labels.len() - 2;
+ // The last character label should be faded (i.e. not the default color)
+ let last_char_idx = labels.len() - 1;
let first_char = &labels[0];
let last_char = &labels[last_char_idx];
@@ -1694,10 +1689,9 @@ mod tests {
let mut dd = Dropdown::new(vec!["a".to_string()], 0).with_custom_display_text("");
dd.inner_mut().center_arrow = center;
dd.set_rect(10.0, 10.0, 30.0, 24.0);
- dd.own_text_labels().into_iter().find(|l| l.text == "▼").expect("the arrow").x
+ dd.inner().arrow_rect(Rect { x: 10.0, y: 10.0, width: 30.0, height: 24.0 }).x
};
- let (family, size) = crate::layout::control_label_font_detached_parsed();
- let adv = super::text_advance("▼", &family, size);
+ let adv = super::ARROW_SIDE;
let x = arrow_x(true);
assert!((x + 0.5 * adv - 25.0).abs() < 1e-3, "centred on the trigger's middle: {x} + {adv}/2");
let slot = super::arrow_slot(24.0);
diff --git a/src/widget/input/font_selector.rs b/src/widget/input/font_selector.rs
index f9fec27..b5c6fa1 100644
--- a/src/widget/input/font_selector.rs
+++ b/src/widget/input/font_selector.rs
@@ -116,17 +116,6 @@ impl FontSelector {
}
}
- // The picker glyph: "Aa", the font-picker convention, in the dropdown arrow's
- // grey — a text glyph every face has (the emoji this drew rendered as tofu
- // wherever no emoji font was installed).
- labels.push(TextLabel {
- text: "Aa".to_string(),
- x: rect.x + rect.width - 24.0,
- y: crate::layout::align_text_y(rect.y, rect.height, 11.0, 0.0),
- font_size: 11.0,
- color: [0x83, 0x83, 0x8a],
- });
-
labels
}
}
@@ -196,18 +185,20 @@ impl Paint for FontSelector {
}
impl FontSelector {
- /// Labels: family text clipped short of the picker glyph (the legacy per-label
- /// bounds), glyph unclipped.
+ /// Labels: family text clipped short of the picker glyph, then the glyph
+ /// itself — the `font` icon in the dropdown arrow's grey, at the right
+ /// end (it was the text "Aa" until 2026-10-05, and an emoji that
+ /// rendered as tofu before that).
fn paint_labels(&self, rect: Rect, ctx: &mut PaintCtx) {
let font = self.widget_font();
let clip_right = rect.x + rect.width - 24.0;
let bounds = Some([rect.x, rect.y, clip_right, rect.y + rect.height]);
- let labels = self.field_labels(rect);
- let count = labels.len();
- for (idx, l) in labels.into_iter().enumerate() {
- let b = if idx < count - 1 { bounds } else { None };
- ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, font.clone(), b);
+ for l in self.field_labels(rect) {
+ ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, font.clone(), bounds);
}
+ const SIDE: f32 = 12.0;
+ let g = Rect { x: rect.x + rect.width - 24.0 + 0.5 * (24.0 - SIDE) - 2.0, y: rect.y + 0.5 * (rect.height - SIDE), width: SIDE, height: SIDE };
+ ctx.icon("font", g, [0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0]);
}
}
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 874b028..98b86b0 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -477,7 +477,7 @@ impl Paint for Spinbox {
}
}
- // Value, unit, and -/+ glyphs.
+ // Value, unit, and the minus/plus glyphs.
let tc = colors::spinbox_text_color();
let text_color = [(tc[0] * 255.0) as u8, (tc[1] * 255.0) as u8, (tc[2] * 255.0) as u8];
// The value and its unit live in the FIELD, which ends where the -/+
@@ -492,11 +492,14 @@ impl Paint for Spinbox {
if g.btn_w > 0.0 {
let dec_center_x = g.dec_c;
let inc_center_x = g.inc_c;
- let ty = crate::layout::align_text_y(g.y, g.h, 12.0, 0.0);
- let dec_box = Some([g.run_x, g.y, g.seam_x, g.y + g.h]);
- let inc_box = Some([g.seam_x, g.y, g.run_end, g.y + g.h]);
- ctx.text_with("-".to_string(), dec_center_x - 4.0, ty, 12.0, text_color, None, dec_box);
- ctx.text_with("+".to_string(), inc_center_x - 4.0, ty, 12.0, text_color, None, inc_box);
+ // The `minus` and `plus` glyphs, in the value's colour, centred
+ // on their buttons (they were "-" and "+" in the 12px face).
+ const SIDE: f32 = 8.0;
+ let gy = g.y + 0.5 * (g.h - SIDE);
+ let tint = [tc[0], tc[1], tc[2], 1.0];
+ let glyph = |cx: f32| crate::scene::layout::Rect { x: cx - 0.5 * SIDE, y: gy, width: SIDE, height: SIDE };
+ ctx.icon("minus", glyph(dec_center_x), tint);
+ ctx.icon("plus", glyph(inc_center_x), tint);
}
}
}
diff --git a/src/widget/markdown.rs b/src/widget/markdown.rs
index 1746a6f..60f4d2e 100644
--- a/src/widget/markdown.rs
+++ b/src/widget/markdown.rs
@@ -83,13 +83,15 @@ pub enum Draw {
Dot { cx: f32, cy: f32, r: f32, color: [f32; 4] },
/// An embedded image, by its link text; its id is asked for at paint.
Image { target: String, rect: Rect },
+ /// A bundled cce-icons glyph, tinted `color` (sRGB, as a text colour).
+ Icon { name: &'static str, rect: Rect, color: [f32; 4] },
}
impl Draw {
fn top_bottom(&self) -> (f32, f32) {
match self {
Draw::Text { y, size, .. } => (*y, y + size * 1.3),
- Draw::Quad { rect, .. } | Draw::Round { rect, .. } | Draw::Image { rect, .. } => (rect.y, rect.y + rect.height),
+ Draw::Quad { rect, .. } | Draw::Round { rect, .. } | Draw::Image { rect, .. } | Draw::Icon { rect, .. } => (rect.y, rect.y + rect.height),
Draw::Line { y1, y2, width, .. } => (y1.min(*y2) - width, y1.max(*y2) + width),
Draw::Check { cy, r, .. } | Draw::Dot { cy, r, .. } => (cy - r, cy + r),
}
@@ -178,6 +180,9 @@ impl Layout {
Some(img) => pc.image(img.id, rect(r), 1.0),
None => pc.rounded_rect(rect(r), 4.0 * k, (true, true, true, true), CODE_BG),
},
+ Draw::Icon { name, rect: r, color } => {
+ pc.icon(name, rect(r), *color);
+ }
}
}
}
@@ -290,16 +295,27 @@ impl<'a> Layouter<'a> {
y + ih
}
Block::Embed { target, subpath, .. } => {
+ // An embed that cannot be shown inline (a note, a missing
+ // image) is a link to it, led by the `link` glyph in the
+ // link colour (it was "↳ " until 2026-10-05).
let shown = match subpath {
- Some(s) => format!("↳ {target}#{s}"),
- None => format!("↳ {target}"),
+ Some(s) => format!("{target}#{s}"),
+ None => target.clone(),
};
+ let size = self.theme.size;
+ let side = (size * 0.85).round();
+ let gap = (size * 0.4).round();
+ self.out.draws.push(Draw::Icon {
+ name: "link",
+ rect: Rect { x, y: y + 0.5 * (size * 1.3 - side), width: side, height: side },
+ color: LINK,
+ });
let span = Span {
text: shown,
style: Style::default(),
link: Some(SpanLink::Note { target: target.clone(), subpath: subpath.clone() }),
};
- self.inline(std::slice::from_ref(&span), x, w, y, self.plain_style())
+ self.inline(std::slice::from_ref(&span), x + side + gap, (w - side - gap).max(0.0), y, self.plain_style())
}
Block::List { start, items, .. } => self.list(*start, items, x, w, y),
Block::Quote { callout, blocks, .. } => self.quote(callout.as_ref(), blocks, x, w, y),