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

commit840bc4e9c05ef2de86b099bcc69e2af8b2f35bf5
parent7b299d30a0
authorLucas Galante <[email protected]>
date2026-08-29 23:07
fix: ASCII text in a monospaced face shapes Basic — no ligatures

cosmic-text's Advanced shaping applies the font's default-on OpenType
features, and in a monospaced face the ligatures among them break the
grid: Chivo Mono's liga substitutes f+i with a single-advance fi glyph,
so the bar's window titles drew two letters squeezed into one cell
("file" with a cramped fi). cosmic-text 0.12 exposes no per-feature
control, so the lever is the shaping mode.

shaping_for (exported via engine) picks Basic exactly when the text is
pure ASCII and the resolved face is monospaced per fontdb — mono faces
carry no kerning to lose, and ligatures are the only thing Advanced
adds them on ASCII. Proportional faces keep Advanced (kerning and
ligatures are wanted there — a font preview must not misrepresent the
face), and non-ASCII text keeps real shaping everywhere.

Both the measure path (StyledLabel) and the draw path (Prim::Text)
funnel through get_text_buffer_attrs, so widths and pixels stay in
agreement.

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

 src/backend/window_runner.rs | 50 +++++++++++++++++++++++++++++++++++++++++++-
 src/engine.rs                |  2 +-
 2 files changed, 50 insertions(+), 2 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 9fc2b2d..f35670b 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -130,6 +130,53 @@ fn find_cased_family(fs: &FontSystem, name: &str) -> Option<String> {
     None
 }
 
+thread_local! {
+    /// Family name → is-monospaced, resolved once per family from fontdb's
+    /// face metadata (the post table's isFixedPitch, as fontdb records it).
+    static MONO_FAMILY_CACHE: std::cell::RefCell<std::collections::HashMap<String, bool>> =
+        std::cell::RefCell::new(std::collections::HashMap::new());
+}
+
+fn family_is_monospaced(fs: &FontSystem, name: &str) -> bool {
+    MONO_FAMILY_CACHE.with(|cache| {
+        if let Some(&mono) = cache.borrow().get(name) {
+            return mono;
+        }
+        let lower = name.to_lowercase();
+        let mono = fs
+            .db()
+            .faces()
+            .find(|face| face.families.iter().any(|(f, _)| f.to_lowercase() == lower))
+            .map(|face| face.monospaced)
+            .unwrap_or(false);
+        cache.borrow_mut().insert(name.to_string(), mono);
+        mono
+    })
+}
+
+/// The shaping mode for one text run: ASCII-only text in a MONOSPACED face
+/// shapes `Basic`, everything else `Advanced`.
+///
+/// `Basic` bypasses OpenType substitution and positioning, and for ASCII in a
+/// mono face that is exactly right: a mono font's ligatures are the one thing
+/// `Advanced` adds there, and they break the grid — Chivo Mono's `liga`
+/// squeezes f+i into a single-advance fi glyph, which is why the bar's window
+/// titles rendered "file" with a cramped fi — while mono faces carry no
+/// kerning to lose. Proportional faces keep `Advanced` (their kerning and
+/// ligatures are wanted — a font preview must not misrepresent the face), and
+/// any non-ASCII text keeps real shaping (combining marks, emoji, complex
+/// scripts) whatever the face.
+pub fn shaping_for(fs: &FontSystem, text: &str, family: &cosmic_text::Family) -> cosmic_text::Shaping {
+    if text.is_ascii() {
+        if let cosmic_text::Family::Name(name) = family {
+            if family_is_monospaced(fs, name) {
+                return cosmic_text::Shaping::Basic;
+            }
+        }
+    }
+    cosmic_text::Shaping::Advanced
+}
+
 pub fn get_text_buffer(fs: &mut FontSystem, text: &str, size: f32, font: Option<&str>) -> Buffer {
     get_text_buffer_attrs(fs, text, size, font, crate::scene::paint::TextAttrs::default())
 }
@@ -259,7 +306,8 @@ pub fn get_text_buffer_attrs(
     if let Some(w) = text_attrs.weight {
         attrs = attrs.weight(cosmic_text::Weight(w));
     }
-    buf.set_text(fs, text, attrs, cosmic_text::Shaping::Advanced);
+    let shaping = shaping_for(fs, text, &family);
+    buf.set_text(fs, text, attrs, shaping);
     buf.shape_until_scroll(fs, true);
 
     BUFFER_CACHE.with(|cache| {
diff --git a/src/engine.rs b/src/engine.rs
index 2c5ecf5..106fe9d 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -10,5 +10,5 @@ pub use crate::backend::window_runner::{
     extra_quad_vertices, push_extra_quad_vertices, extra_quad_vertices_clipped,
     push_extra_quad_vertices_clipped, circle_vertices, circle_border_vertices,
     arc_background_vertices, push_arc_background_vertices, push_plate_solid_border_vertices,
-    get_text_buffer_laid_out, shaped_cluster_offsets,
+    get_text_buffer_laid_out, shaped_cluster_offsets, shaping_for,
 };