Compare commits

...

10 commits

Author SHA1 Message Date
63072bbe29 yoda: snap monospace cell width via Unicode East Asian Width
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.
2026-04-23 23:20:40 +02:00
0e15681adb Apply upstream PR #503 (fix monospace width rounding) 2026-04-23 22:31:26 +02:00
Jeremy Soller
c24886c247 0.19.0
Some checks failed
Pages / pages (push) Has been cancelled
Rust / cargo-deny (push) Has been cancelled
Rust / build (push) Has been cancelled
2026-04-22 13:17:01 -06:00
Hojjat
4d74f795cc chore: update hashbrown 2026-04-09 17:59:26 -06:00
Hojjat
83e8bd237a fix: clamp scroll.line to a valid range 2026-04-09 16:41:52 -06:00
Hojjat
9a2ab09f06 fix: fallback to a default font in basic shaping mode 2026-04-04 07:00:53 -06:00
Rasmus Kildevæld
9a5579f523 Import CoreFloat when no_std is enabled 2026-04-01 10:25:56 -06:00
Rasmus Kildevæld
158d8ba518 Add swash libm feature to the no_std feature list 2026-04-01 10:25:56 -06:00
Hojjat
19e029a58a 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.
2026-04-01 10:25:30 -06:00
Josh Ricker-Rampersad (jrampersad_pins)
463387d6a1 Remove 1px horizontal offset on pixel fonts 2026-03-31 15:33:13 -06:00
5 changed files with 182 additions and 16 deletions

View file

@ -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

View file

@ -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 <jeremy@system76.com>"]
edition = "2021"
license = "MIT OR Apache-2.0"
@ -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"
@ -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"
@ -46,7 +48,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 = [

View file

@ -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 {
@ -1234,6 +1241,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<bool> {
self.lines.get(line)?.shape_opt().map(|shape| shape.rtl)
}
/// Apply a [`Motion`] to a [`Cursor`]
pub fn cursor_motion(
&mut self,

View file

@ -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};
@ -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());
@ -496,6 +511,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<ShapeGlyph>,
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,8 +600,17 @@ 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);
// 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,
@ -531,12 +627,9 @@ 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),
terminal_cells,
}
}),
);
@ -573,6 +666,14 @@ pub struct ShapeGlyph {
pub metadata: usize,
pub cache_key_flags: CacheKeyFlags,
pub metrics_opt: Option<Metrics>,
/// 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 {
@ -2832,9 +2933,21 @@ 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 {
// 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();

View file

@ -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};
@ -44,7 +47,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 {