From 463387d6a1ff5946403842dc41b017f10eb3c953 Mon Sep 17 00:00:00 2001 From: "Josh Ricker-Rampersad (jrampersad_pins)" Date: Tue, 31 Mar 2026 12:09:25 -0400 Subject: [PATCH 01/10] Remove 1px horizontal offset on pixel fonts --- src/swash.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/swash.rs b/src/swash.rs index 8ff411a..e046ded 100644 --- a/src/swash.rs +++ b/src/swash.rs @@ -44,7 +44,7 @@ fn swash_image( // in a real renderer let offset = if cache_key.flags.contains(CacheKeyFlags::PIXEL_FONT) { Vector::new( - cache_key.x_bin.as_float().round() + 1.0, + cache_key.x_bin.as_float().round(), cache_key.y_bin.as_float().round(), ) } else { From 19e029a58a95cdb90077d77e2857b880bf91f05a Mon Sep 17 00:00:00 2001 From: Hojjat Date: Tue, 31 Mar 2026 13:25:00 -0600 Subject: [PATCH 02/10] feat: add cursor_position and is_rtl methods to buffer cursor_position returns (x,y) of the cursor. This is needed for clients that do not want to use a fully fledged editor (for example TextInput in Iced). is_rtl is necessary to know the base direction of a paragraph. --- src/buffer.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/buffer.rs b/src/buffer.rs index 9d8b49a..da84a66 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -1234,6 +1234,21 @@ impl Buffer { new_cursor_opt } + /// Returns the visual (x, y) position of a cursor within the buffer. + /// y is the top of the line containing the cursor. + /// This is a convenience wrapper around [`LayoutRun::cursor_position`]. + pub fn cursor_position(&self, cursor: &Cursor) -> Option<(f32, f32)> { + self.layout_runs() + .filter(|run| run.line_i == cursor.line) + .find_map(|run| run.cursor_position(cursor).map(|x| (x, run.line_top))) + } + + /// Returns if the text direction for a given line is RTL + /// Returns `None` if the line doesn't exist or hasn't been shaped yet. + pub fn is_rtl(&self, line: usize) -> Option { + self.lines.get(line)?.shape_opt().map(|shape| shape.rtl) + } + /// Apply a [`Motion`] to a [`Cursor`] pub fn cursor_motion( &mut self, From 158d8ba518d2431d1af26a78278f66e2070a8899 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rasmus=20Kildev=C3=A6ld?= Date: Fri, 27 Mar 2026 14:24:06 +0100 Subject: [PATCH 03/10] Add swash libm feature to the no_std feature list --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index dedb16c..6a33b08 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -46,7 +46,7 @@ features = ["hardcoded-data"] default = ["std", "swash", "fontconfig"] fontconfig = ["fontdb/fontconfig", "std"] monospace_fallback = [] -no_std = ["hashbrown", "dep:libm", "skrifa/libm", "core_maths"] +no_std = ["hashbrown", "dep:libm", "skrifa/libm", "core_maths", "swash?/libm"] peniko = [] shape-run-cache = [] std = [ From 9a5579f5231bc52b3688a2de3643091f1ce32cbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rasmus=20Kildev=C3=A6ld?= Date: Fri, 27 Mar 2026 14:24:45 +0100 Subject: [PATCH 04/10] Import CoreFloat when no_std is enabled --- src/swash.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/swash.rs b/src/swash.rs index e046ded..c644ca1 100644 --- a/src/swash.rs +++ b/src/swash.rs @@ -1,7 +1,10 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 #[cfg(not(feature = "std"))] -use alloc::vec::Vec; +use alloc::boxed::Box; +#[cfg(feature = "no_std")] +use core_maths::CoreFloat; + use core::fmt; use swash::scale::{image::Content, ScaleContext}; use swash::scale::{Render, Source, StrikeWith}; From 9a2ab09f06905e91f41d64ac6eee887726e7fd76 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Thu, 2 Apr 2026 21:47:56 -0600 Subject: [PATCH 05/10] fix: fallback to a default font in basic shaping mode --- src/shape.rs | 87 +++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 8 deletions(-) diff --git a/src/shape.rs b/src/shape.rs index 5e187af..ec98963 100644 --- a/src/shape.rs +++ b/src/shape.rs @@ -5,8 +5,8 @@ use crate::fallback::FontFallbackIter; use crate::{ math, Align, Attrs, AttrsList, CacheKeyFlags, Color, DecorationMetrics, DecorationSpan, - Ellipsize, EllipsizeHeightLimit, Font, FontSystem, GlyphDecorationData, Hinting, LayoutGlyph, - LayoutLine, Metrics, Wrap, + Ellipsize, EllipsizeHeightLimit, Family, Font, FontSystem, GlyphDecorationData, Hinting, + LayoutGlyph, LayoutLine, Metrics, Wrap, }; #[cfg(not(feature = "std"))] use alloc::{format, vec, vec::Vec}; @@ -496,6 +496,78 @@ fn shape_skip( ); let font = font_iter.next().expect("no default font found"); + let glyph_start = glyphs.len(); + + shape_skip_glyphs(glyphs, &font, line, attrs_list, start_run, end_run); + + // If any glyphs are missing and the user has specified a font, + // fall back to a default font (SansSerif or Monospace) + if matches!(attrs.family, Family::Name(_)) + && glyphs[glyph_start..].iter().any(|g| g.glyph_id == 0) + { + let is_mono = font_system + .db() + .face(font.id()) + .is_some_and(|face| face.monospaced); + let fb_family = if is_mono { + Family::Monospace + } else { + Family::SansSerif + }; + let fb_attrs = Attrs::new() + .family(fb_family) + .weight(attrs.weight) + .style(attrs.style) + .stretch(attrs.stretch); + let fb_fonts = font_system.get_font_matches(&fb_attrs); + let fb_families = [&fb_family]; + let mut fb_iter = + FontFallbackIter::new(font_system, &fb_fonts, &fb_families, &[], "", attrs.weight); + + if let Some(fb_font) = fb_iter.next() { + let fb_swash = fb_font.as_swash(); + let fb_charmap = fb_swash.charmap(); + let fb_metrics = fb_swash.metrics(&[]); + let fb_glyph_metrics = fb_swash.glyph_metrics(&[]).scale(1.0); + let fb_scale = f32::from(fb_metrics.units_per_em); + + for glyph in glyphs[glyph_start..].iter_mut() { + if glyph.glyph_id != 0 { + continue; + } + let codepoint = line[glyph.start..glyph.end].chars().next().unwrap_or('\0'); + let glyph_id = fb_charmap.map(codepoint); + if glyph_id != 0 { + let span_attrs = attrs_list.get_span(glyph.start); + glyph.glyph_id = glyph_id; + glyph.font_id = fb_font.id(); + glyph.font_monospace_em_width = fb_font.monospace_em_width(); + glyph.ascent = fb_metrics.ascent / fb_scale; + glyph.descent = fb_metrics.descent / fb_scale; + glyph.x_advance = fb_glyph_metrics.advance_width(glyph_id) + + span_attrs + .letter_spacing_opt + .map_or(0.0, |spacing| spacing.0); + glyph.cache_key_flags = override_fake_italic( + span_attrs.cache_key_flags, + fb_font.as_ref(), + &span_attrs, + ); + } + } + } + } +} + +#[cfg(feature = "swash")] +fn shape_skip_glyphs( + glyphs: &mut Vec, + font: &Font, + line: &str, + attrs_list: &AttrsList, + start_run: usize, + end_run: usize, +) { let font_id = font.id(); let font_monospace_em_width = font.monospace_em_width(); let swash_font = font.as_swash(); @@ -513,7 +585,10 @@ fn shape_skip( .map(|(chr_idx, codepoint)| { let glyph_id = charmap.map(codepoint); let x_advance = glyph_metrics.advance_width(glyph_id) - + attrs.letter_spacing_opt.map_or(0.0, |spacing| spacing.0); + + attrs_list + .get_span(start_run + chr_idx) + .letter_spacing_opt + .map_or(0.0, |spacing| spacing.0); let attrs = attrs_list.get_span(start_run + chr_idx); ShapeGlyph { @@ -531,11 +606,7 @@ fn shape_skip( glyph_id, color_opt: attrs.color_opt, metadata: attrs.metadata, - cache_key_flags: override_fake_italic( - attrs.cache_key_flags, - font.as_ref(), - &attrs, - ), + cache_key_flags: override_fake_italic(attrs.cache_key_flags, font, &attrs), metrics_opt: attrs.metrics_opt.map(Into::into), } }), From 83e8bd237a415fe31a736e28e1e1931d7f9e64bc Mon Sep 17 00:00:00 2001 From: Hojjat Date: Thu, 9 Apr 2026 16:23:39 -0600 Subject: [PATCH 06/10] fix: clamp scroll.line to a valid range --- src/buffer.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/buffer.rs b/src/buffer.rs index da84a66..f8fa5ff 100644 --- a/src/buffer.rs +++ b/src/buffer.rs @@ -573,6 +573,13 @@ impl Buffer { return; } let metrics = self.metrics; + + // Clamp scroll.line to valid range (lines may have been removed by editing) + if self.scroll.line >= self.lines.len() { + self.scroll.line = self.lines.len().saturating_sub(1); + self.scroll.vertical = 0.0; + } + let old_scroll = self.scroll; loop { From 4d74f795cc771fdcc7ea0f9cacba63fcf036fad6 Mon Sep 17 00:00:00 2001 From: Hojjat Date: Thu, 9 Apr 2026 16:41:50 -0600 Subject: [PATCH 07/10] chore: update hashbrown --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 6a33b08..0cf3b0d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ core_maths = { version = "0.1.1", optional = true } cosmic_undo_2 = { version = "0.2.0", optional = true } fontdb = { version = "0.23", default-features = false } harfrust = { version = "0.5.0", default-features = false } -hashbrown = { version = "0.16", optional = true, default-features = false } +hashbrown = { version = "0.17", optional = true, default-features = false } libm = { version = "0.2.16", optional = true } linebender_resource_handle = { version = "0.1.1", default-features = false } log = "0.4.29" From c24886c2471e5606587c46090cd25dbbf209186b Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Wed, 22 Apr 2026 13:17:01 -0600 Subject: [PATCH 08/10] 0.19.0 --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e31a9..c9a462b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,32 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.19.0] - 2026-04-22 + +### Fixed + +- Empty lines use span metrics: https://github.com/pop-os/cosmic-text/pull/479 +- Match variable fonts using wght axis: https://github.com/pop-os/cosmic-text/pull/486 +- Used normalized\_coords for swash scaler: https://github.com/pop-os/cosmic-text/pull/488 +- Fix highlight for editor: https://github.com/pop-os/cosmic-text/pull/491 +- Optimize shape\_until\_scroll with large line count: https://github.com/pop-os/cosmic-text/pull/490 +- Fix shape\_until\_scroll if a buffer\_line is modified: https://github.com/pop-os/cosmic-text/pull/494 +- Remove 1px horizontal offset on pixel fonts: https://github.com/pop-os/cosmic-text/pull/495 +- Make the swash feature compile with no\_std: https://github.com/pop-os/cosmic-text/pull/492 +- Fallback to a default font in basic shaping mode: https://github.com/pop-os/cosmic-text/pull/498 +- Clamp scroll.line to a valid range: https://github.com/pop-os/cosmic-text/pull/501 + +### Added + +- Text decoration: https://github.com/pop-os/cosmic-text/pull/480 +- Implement layout\_runs for BufferLine: https://github.com/pop-os/cosmic-text/pull/484 +- Add cursor\_position and is\_rtl methods to buffer: https://github.com/pop-os/cosmic-text/pull/496 + +### Changed + +- Buffer setter methods are now lazy: https://github.com/pop-os/cosmic-text/pull/483 +- Update hashbrown: https://github.com/pop-os/cosmic-text/pull/502 + ## [0.18.2] - 2026-02-20 ### Fixed diff --git a/Cargo.toml b/Cargo.toml index 0cf3b0d..67e9485 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cosmic-text" description = "Pure Rust multi-line text handling" -version = "0.18.2" +version = "0.19.0" authors = ["Jeremy Soller "] edition = "2021" license = "MIT OR Apache-2.0" From 0e15681adb16cbf6534b489048a442dc46e14ea7 Mon Sep 17 00:00:00 2001 From: leyoda Date: Thu, 23 Apr 2026 22:31:26 +0200 Subject: [PATCH 09/10] Apply upstream PR #503 (fix monospace width rounding) --- src/shape.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/shape.rs b/src/shape.rs index ec98963..9153a62 100644 --- a/src/shape.rs +++ b/src/shape.rs @@ -2903,9 +2903,12 @@ impl ShapeLine { 0.0 }, ); - if let Some(match_em_width) = match_mono_em_width { - // Round to nearest monospace width - x_advance = ((x_advance / match_em_width).round()) * match_em_width; + if let Some(mono_width) = match_mono_width { + // Round to nearest multiple of the monospace cell width + let cells = (x_advance / mono_width).round(); + // Ensure visible glyphs occupy at least one cell + let cells = if x_advance > 0.0 { cells.max(1.0) } else { cells }; + x_advance = cells * mono_width; } if hinting == Hinting::Enabled { x_advance = x_advance.round(); From 63072bbe29a1657d82cd3deb5db45070404ec7a1 Mon Sep 17 00:00:00 2001 From: leyoda Date: Thu, 23 Apr 2026 23:20:40 +0200 Subject: [PATCH 10/10] yoda: snap monospace cell width via Unicode East Asian Width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the previous heuristic (font_monospace_em_width.is_none() ⇒ 2 cells) which was reviewed as unsound: Arabic, dingbats, math symbols and other narrow scripts pulled from non-monospace fallback fonts would all have been forced to 2 cells. It also didn't handle ZWJ emoji clusters or ambiguous-width chars correctly. Proper fix, computed at shape time when `line: &str` is in scope: - new ShapeGlyph.terminal_cells: u8 (0, 1 or 2) - populated via unicode-width crate applied to the cluster text line[start..end] (harfrust path, uses UnicodeWidthStr) or to the single codepoint (no-font fallback path, UnicodeWidthChar) - layout_to_buffer consumes it when match_mono_width is Some: x_advance = cells * mono_width instead of the previous round(x_advance / mono_width) * mono_width which produced variable cell counts for fallback glyphs. Covers: - ASCII + Latin → width 1 (unchanged visual) - CJK + fullwidth → width 2 ✓ - Emoji (incl. ZWJ) → width 2 ✓ (cluster text handles the ZWJ case) - Arabic / Hebrew → width 1 ✓ (was wrongly snapped to 2 before) - Combining marks → width 0 ✓ (zero-advance, matches terminals) - Variation selectors → width 0 ✓ Limitations: ambiguous-width chars (EAW=A) resolve to 1 via unicode-width default; a 'cjk' ambiguous mode (unicode-width::UnicodeWidthChar::width_cjk) could be exposed later as a Buffer flag if needed — not needed for typical terminal use, matching most wcwidth implementations. Based on review feedback from lionel@wopr.io on the initial heuristic patch. --- Cargo.toml | 2 ++ src/shape.rs | 49 ++++++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 46 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 67e9485..227b603 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,8 @@ sys-locale = { version = "0.3.2", optional = true } unicode-linebreak = "0.1.5" unicode-script = "0.5.8" unicode-segmentation = "1.12.0" +# Yoda: EAW-aware cell width for monospace/terminal rendering. +unicode-width = "0.2" [dependencies.swash] version = "0.2.6" diff --git a/src/shape.rs b/src/shape.rs index 9153a62..5e76c73 100644 --- a/src/shape.rs +++ b/src/shape.rs @@ -237,6 +237,8 @@ fn shape_fallback( metadata: attrs.metadata, cache_key_flags: override_fake_italic(attrs.cache_key_flags, font, &attrs), metrics_opt: attrs.metrics_opt.map(Into::into), + // Set by the post-loop pass below once `end` is finalized. + terminal_cells: 1, }); } @@ -265,6 +267,19 @@ fn shape_fallback( } } + // Yoda: compute EAW terminal width per glyph now that `end` is finalized. + // We use the cluster's text (byte range into `line`) rather than just the + // first char so ZWJ emoji sequences collapse to a single cluster width. + // `unicode-width` returns 0 for combining marks / variation selectors, 2 + // for CJK & most emoji, 1 for everything else including Arabic. + { + use unicode_width::UnicodeWidthStr; + for glyph in &mut glyphs[glyph_start..] { + let cluster = line.get(glyph.start..glyph.end).unwrap_or(""); + glyph.terminal_cells = UnicodeWidthStr::width(cluster).min(2) as u8; + } + } + // Restore the buffer to save an allocation. scratch.harfrust_buffer = Some(glyph_buffer.clear()); @@ -590,6 +605,12 @@ fn shape_skip_glyphs( .letter_spacing_opt .map_or(0.0, |spacing| spacing.0); let attrs = attrs_list.get_span(start_run + chr_idx); + // Yoda: EAW width for terminal-aware monospace snap (see + // ShapeGlyph.terminal_cells doc). + let terminal_cells = { + use unicode_width::UnicodeWidthChar; + UnicodeWidthChar::width(codepoint).unwrap_or(1).min(2) as u8 + }; ShapeGlyph { start: chr_idx + start_run, @@ -608,6 +629,7 @@ fn shape_skip_glyphs( metadata: attrs.metadata, cache_key_flags: override_fake_italic(attrs.cache_key_flags, font, &attrs), metrics_opt: attrs.metrics_opt.map(Into::into), + terminal_cells, } }), ); @@ -644,6 +666,14 @@ pub struct ShapeGlyph { pub metadata: usize, pub cache_key_flags: CacheKeyFlags, pub metrics_opt: Option, + /// Yoda: Unicode East Asian Width of the source text covered by this glyph, + /// in terminal cells (0, 1 or 2 per `unicode_width::UnicodeWidthStr::width`). + /// Populated at shape time from `line[start..end]`. Consumed by + /// `layout_to_buffer` when `match_mono_width` is Some — wide chars (emoji, + /// CJK, fullwidth forms) snap to 2 cells instead of rounding based on the + /// fallback font's natural glyph advance, which was the upstream bug + /// breaking terminal column alignment. + pub terminal_cells: u8, } impl ShapeGlyph { @@ -2904,11 +2934,20 @@ impl ShapeLine { }, ); if let Some(mono_width) = match_mono_width { - // Round to nearest multiple of the monospace cell width - let cells = (x_advance / mono_width).round(); - // Ensure visible glyphs occupy at least one cell - let cells = if x_advance > 0.0 { cells.max(1.0) } else { cells }; - x_advance = cells * mono_width; + // Yoda: use Unicode East Asian Width, stored at + // shape time, as the authoritative cell count. + // `terminal_cells` is 0 for combining marks / + // variation selectors (they stay 0-advance), + // 2 for CJK & emoji clusters, 1 for everything + // else (including Arabic, Latin, etc.). + // + // This replaces the previous round-to-nearest + // logic which produced variable cell widths + // for fallback glyphs because it depended on + // the glyph's natural advance in the fallback + // font rather than on the source character's + // terminal width spec. + x_advance = f32::from(glyph.terminal_cells) * mono_width; } if hinting == Hinting::Enabled { x_advance = x_advance.round();