1
0
mirror of https://github.com/emilk/egui.git synced 2026-08-30 05:10:03 -04:00

New text layout (#682)

This PR introduces a completely rewritten text layout engine which is simpler and more powerful. It allows mixing different text styles (heading, body, etc) and formats (color, underlining, strikethrough, …) in the same layout pass, and baked into the same `Galley`.

This opens up the door to having a syntax-highlighed code editor, or a WYSIWYG markdown editor.

One major change is the color is now baked in at layout time. However, many widgets changes text color on hovered. But we need to do the text layout before we know if it is hovered. Therefor the painter has an option to override the text color of a galley.


## Performance
Text layout alone is about 20% slower, but a lot of that is because more tessellation is done upfront. Text tessellation is now a lot faster, but text layout + tessellation still lands at a net loss of 5-10% in performance. There are however a few tricks to speed it up (like using `smallvec`) which I am saving for later. Text layout is also cached, meaning that in most cases (when all text isn't changing each frame) text tessellation is actually more important (and that's more than 2x faster!).

Sadly, the actual text cache lookup is significantly slower (300ns -> 600ns). That's because the `TextLayoutJob` is a lot bigger (it has more options, like underlining, fonts etc), so it is slower to hash and compare. I have an idea how to speed this up, but I need to do some other work before I can implement that.

All in all, the performance impact on `demo_with_tesselate__realistic` is about 5-6% in the red. Not great; not terrible. The benefits are worth it, but I also think with some work I can get that down significantly, hopefully down to the old levels.
This commit is contained in:
Emil Ernerfeldt
2021-09-03 18:18:00 +02:00
committed by GitHub
parent 36cffd7b84
commit de1a1ba9b2
43 changed files with 2204 additions and 1295 deletions

View File

@@ -34,20 +34,39 @@ impl std::ops::IndexMut<usize> for Color32 {
}
impl Color32 {
// Mostly follows CSS names:
pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0);
pub const BLACK: Color32 = Color32::from_rgb(0, 0, 0);
pub const LIGHT_GRAY: Color32 = Color32::from_rgb(220, 220, 220);
pub const DARK_GRAY: Color32 = Color32::from_rgb(96, 96, 96);
pub const GRAY: Color32 = Color32::from_rgb(160, 160, 160);
pub const LIGHT_GRAY: Color32 = Color32::from_rgb(220, 220, 220);
pub const WHITE: Color32 = Color32::from_rgb(255, 255, 255);
pub const BROWN: Color32 = Color32::from_rgb(165, 42, 42);
pub const DARK_RED: Color32 = Color32::from_rgb(0x8B, 0, 0);
pub const RED: Color32 = Color32::from_rgb(255, 0, 0);
pub const LIGHT_RED: Color32 = Color32::from_rgb(255, 128, 128);
pub const YELLOW: Color32 = Color32::from_rgb(255, 255, 0);
pub const LIGHT_YELLOW: Color32 = Color32::from_rgb(255, 255, 0xE0);
pub const KHAKI: Color32 = Color32::from_rgb(240, 230, 140);
pub const DARK_GREEN: Color32 = Color32::from_rgb(0, 0x64, 0);
pub const GREEN: Color32 = Color32::from_rgb(0, 255, 0);
pub const LIGHT_GREEN: Color32 = Color32::from_rgb(0x90, 0xEE, 0x90);
pub const DARK_BLUE: Color32 = Color32::from_rgb(0, 0, 0x8B);
pub const BLUE: Color32 = Color32::from_rgb(0, 0, 255);
pub const LIGHT_BLUE: Color32 = Color32::from_rgb(140, 160, 255);
pub const LIGHT_BLUE: Color32 = Color32::from_rgb(0xAD, 0xD8, 0xE6);
pub const GOLD: Color32 = Color32::from_rgb(255, 215, 0);
pub const DEBUG_COLOR: Color32 = Color32::from_rgba_premultiplied(0, 200, 0, 128);
/// An ugly color that is planned to be replaced before making it to the screen.
pub const TEMPORARY_COLOR: Color32 = Color32::from_rgb(64, 254, 0);
#[inline(always)]
pub const fn from_rgb(r: u8, g: u8, b: u8) -> Self {
Self([r, g, b, 255])

View File

@@ -175,3 +175,26 @@ macro_rules! epaint_assert {
}
}
}
// ----------------------------------------------------------------------------
#[inline(always)]
pub(crate) fn f32_hash<H: std::hash::Hasher>(state: &mut H, f: f32) {
if f == 0.0 {
state.write_u8(0)
} else if f.is_nan() {
state.write_u8(1)
} else {
use std::hash::Hash;
f.to_bits().hash(state)
}
}
#[inline(always)]
pub(crate) fn f32_eq(a: f32, b: f32) -> bool {
if a.is_nan() && b.is_nan() {
true
} else {
a == b
}
}

View File

@@ -35,6 +35,7 @@ pub struct Mesh {
/// The texture to use when drawing these triangles.
pub texture_id: TextureId,
// TODO: bounding rectangle
}
impl Mesh {
@@ -72,6 +73,15 @@ impl Mesh {
self.indices.is_empty() && self.vertices.is_empty()
}
/// Calculate a bounding rectangle.
pub fn calc_bounds(&self) -> Rect {
let mut bounds = Rect::NOTHING;
for v in &self.vertices {
bounds.extend_with(v.pos);
}
bounds
}
/// Append all the indices and vertices of `other` to `self`.
pub fn append(&mut self, other: Mesh) {
crate::epaint_assert!(other.is_valid());
@@ -85,9 +95,8 @@ impl Mesh {
);
let index_offset = self.vertices.len() as u32;
for index in &other.indices {
self.indices.push(index_offset + index);
}
self.indices
.extend(other.indices.iter().map(|index| index + index_offset));
self.vertices.extend(other.vertices.iter());
}
}

View File

@@ -43,12 +43,18 @@ pub enum Shape {
Text {
/// Top left corner of the first character..
pos: Pos2,
/// The layed out text.
galley: std::sync::Arc<Galley>,
/// Text color (foreground).
color: Color32,
/// If true, tilt the letters for a hacky italics effect.
fake_italics: bool,
/// Add this underline to the whole text.
/// You can also set an underline when creating the galley.
underline: Stroke,
/// If set, the text color in the galley will be ignored and replaced
/// with the given color.
/// This will NOT replace background color nor strikethrough/underline color.
override_text_color: Option<Color32>,
},
Mesh(Mesh),
}
@@ -169,13 +175,17 @@ impl Shape {
text_style: TextStyle,
color: Color32,
) -> Self {
let galley = fonts.layout_multiline(text_style, text.to_string(), f32::INFINITY);
let galley = fonts.layout_no_wrap(text.to_string(), text_style, color);
let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size));
Self::galley(rect.min, galley)
}
pub fn galley(pos: Pos2, galley: std::sync::Arc<Galley>) -> Self {
Self::Text {
pos: rect.min,
pos,
galley,
color,
fake_italics: false,
override_text_color: None,
underline: Stroke::none(),
}
}
}

View File

@@ -24,8 +24,23 @@ pub fn adjust_colors(shape: &mut Shape, adjust_color: &impl Fn(&mut Color32)) {
adjust_color(fill);
adjust_color(&mut stroke.color);
}
Shape::Text { color, .. } => {
adjust_color(color);
Shape::Text {
galley,
override_text_color,
..
} => {
if let Some(override_text_color) = override_text_color {
adjust_color(override_text_color);
}
if !galley.is_empty() {
let galley = std::sync::Arc::make_mut(galley);
for row in &mut galley.rows {
for vertex in &mut row.visuals.mesh.vertices {
adjust_color(&mut vertex.color);
}
}
}
}
Shape::Mesh(mesh) => {
for v in &mut mesh.vertices {

View File

@@ -85,13 +85,13 @@ impl AllocInfo {
// }
pub fn from_galley(galley: &Galley) -> Self {
Self::from_slice(galley.text.as_bytes())
Self::from_slice(galley.text().as_bytes())
+ Self::from_slice(&galley.rows)
+ galley.rows.iter().map(Self::from_galley_row).sum()
}
fn from_galley_row(row: &crate::text::Row) -> Self {
Self::from_slice(&row.x_offsets) + Self::from_slice(&row.uv_rects)
Self::from_mesh(&row.visuals.mesh) + Self::from_slice(&row.glyphs)
}
pub fn from_mesh(mesh: &Mesh) -> Self {

View File

@@ -3,7 +3,7 @@ use super::*;
/// Describes the width and color of a line.
///
/// The default stroke is the same as [`Stroke::none`].
#[derive(Clone, Copy, Debug, Default, PartialEq)]
#[derive(Clone, Copy, Debug, Default)]
#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))]
pub struct Stroke {
pub width: f32,
@@ -12,10 +12,12 @@ pub struct Stroke {
impl Stroke {
/// Same as [`Stroke::default`].
#[inline(always)]
pub fn none() -> Self {
Self::new(0.0, Color32::TRANSPARENT)
}
#[inline]
pub fn new(width: impl Into<f32>, color: impl Into<Color32>) -> Self {
Self {
width: width.into(),
@@ -28,7 +30,26 @@ impl<Color> From<(f32, Color)> for Stroke
where
Color: Into<Color32>,
{
#[inline(always)]
fn from((width, color): (f32, Color)) -> Stroke {
Stroke::new(width, color)
}
}
impl std::hash::Hash for Stroke {
#[inline(always)]
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
let Self { width, color } = *self;
crate::f32_hash(state, width);
color.hash(state);
}
}
impl PartialEq for Stroke {
#[inline(always)]
fn eq(&self, other: &Self) -> bool {
self.color == other.color && crate::f32_eq(self.width, other.width)
}
}
impl std::cmp::Eq for Stroke {}

View File

@@ -12,7 +12,7 @@ use std::f32::consts::TAU;
// ----------------------------------------------------------------------------
#[derive(Clone, Debug, Default)]
pub struct PathPoint {
struct PathPoint {
pos: Pos2,
/// For filled paths the normal is used for anti-aliasing (both strokes and filled areas).
@@ -31,7 +31,7 @@ pub struct PathPoint {
/// to either to a stroke (with thickness) or a filled convex area.
/// Used as a scratch-pad during tessellation.
#[derive(Clone, Debug, Default)]
struct Path(Vec<PathPoint>);
pub struct Path(Vec<PathPoint>);
impl Path {
#[inline(always)]
@@ -150,6 +150,31 @@ impl Path {
n0 = n1;
}
}
/// Open-ended.
pub fn stroke_open(&self, stroke: Stroke, options: TessellationOptions, out: &mut Mesh) {
stroke_path(&self.0, PathType::Open, stroke, options, out)
}
/// A closed path (returning to the first point).
pub fn stroke_closed(&self, stroke: Stroke, options: TessellationOptions, out: &mut Mesh) {
stroke_path(&self.0, PathType::Closed, stroke, options, out)
}
pub fn stroke(
&self,
path_type: PathType,
stroke: Stroke,
options: TessellationOptions,
out: &mut Mesh,
) {
stroke_path(&self.0, path_type, stroke, options, out)
}
/// The path is taken to be closed (i.e. returning to the start again).
pub fn fill(&self, color: Color32, options: TessellationOptions, out: &mut Mesh) {
fill_closed_path(&self.0, color, options, out)
}
}
pub mod path {
@@ -226,7 +251,6 @@ pub enum PathType {
Open,
Closed,
}
use self::PathType::{Closed, Open};
/// Tessellation quality options
#[derive(Clone, Copy, Debug, PartialEq)]
@@ -265,6 +289,16 @@ impl Default for TessellationOptions {
}
}
impl TessellationOptions {
pub fn from_pixels_per_point(pixels_per_point: f32) -> Self {
Self {
pixels_per_point,
aa_size: 1.0 / pixels_per_point,
..Default::default()
}
}
}
impl TessellationOptions {
#[inline(always)]
pub fn round_to_pixel(&self, point: f32) -> f32 {
@@ -420,7 +454,11 @@ fn stroke_path(
out.reserve_triangles(2 * n as usize);
out.reserve_vertices(2 * n as usize);
let last_index = if path_type == Closed { n } else { n - 1 };
let last_index = if path_type == PathType::Closed {
n
} else {
n - 1
};
for i in 0..last_index {
out.add_triangle(
idx + (2 * i + 0) % (2 * n),
@@ -519,11 +557,10 @@ impl Tessellator {
return;
}
let path = &mut self.scratchpad_path;
path.clear();
path.add_circle(center, radius);
fill_closed_path(&path.0, fill, options, out);
stroke_path(&path.0, Closed, stroke, options, out);
self.scratchpad_path.clear();
self.scratchpad_path.add_circle(center, radius);
self.scratchpad_path.fill(fill, options, out);
self.scratchpad_path.stroke_closed(stroke, options, out);
}
Shape::Mesh(mesh) => {
if mesh.is_valid() {
@@ -533,10 +570,9 @@ impl Tessellator {
}
}
Shape::LineSegment { points, stroke } => {
let path = &mut self.scratchpad_path;
path.clear();
path.add_line_segment(points);
stroke_path(&path.0, Open, stroke, options, out);
self.scratchpad_path.clear();
self.scratchpad_path.add_line_segment(points);
self.scratchpad_path.stroke_open(stroke, options, out);
}
Shape::Path {
points,
@@ -545,12 +581,11 @@ impl Tessellator {
stroke,
} => {
if points.len() >= 2 {
let path = &mut self.scratchpad_path;
path.clear();
self.scratchpad_path.clear();
if closed {
path.add_line_loop(&points);
self.scratchpad_path.add_line_loop(&points);
} else {
path.add_open_points(&points);
self.scratchpad_path.add_open_points(&points);
}
if fill != Color32::TRANSPARENT {
@@ -558,10 +593,14 @@ impl Tessellator {
closed,
"You asked to fill a path that is not closed. That makes no sense."
);
fill_closed_path(&path.0, fill, options, out);
self.scratchpad_path.fill(fill, options, out);
}
let typ = if closed { Closed } else { Open };
stroke_path(&path.0, typ, stroke, options, out);
let typ = if closed {
PathType::Closed
} else {
PathType::Open
};
self.scratchpad_path.stroke(typ, stroke, options, out);
}
}
Shape::Rect {
@@ -581,8 +620,8 @@ impl Tessellator {
Shape::Text {
pos,
galley,
color,
fake_italics,
underline,
override_text_color,
} => {
if options.debug_paint_text_rects {
self.tessellate_rect(
@@ -590,12 +629,12 @@ impl Tessellator {
rect: Rect::from_min_size(pos, galley.size).expand(0.5),
corner_radius: 2.0,
fill: Default::default(),
stroke: (0.5, color).into(),
stroke: (0.5, Color32::GREEN).into(),
},
out,
);
}
self.tessellate_text(tex_size, pos, &galley, color, fake_italics, out);
self.tessellate_text(tex_size, pos, &galley, underline, override_text_color, out);
}
}
}
@@ -626,107 +665,87 @@ impl Tessellator {
path.clear();
path::rounded_rectangle(&mut self.scratchpad_points, rect, corner_radius);
path.add_line_loop(&self.scratchpad_points);
fill_closed_path(&path.0, fill, self.options, out);
stroke_path(&path.0, Closed, stroke, self.options, out);
path.fill(fill, self.options, out);
path.stroke_closed(stroke, self.options, out);
}
pub fn tessellate_text(
&mut self,
tex_size: [usize; 2],
pos: Pos2,
galley_pos: Pos2,
galley: &super::Galley,
color: Color32,
fake_italics: bool,
underline: Stroke,
override_text_color: Option<Color32>,
out: &mut Mesh,
) {
if color == Color32::TRANSPARENT || galley.is_empty() {
if galley.is_empty() {
return;
}
if cfg!(any(
feature = "extra_asserts",
all(feature = "extra_debug_asserts", debug_assertions),
)) {
galley.sanity_check();
}
out.vertices.reserve(galley.num_vertices);
out.indices.reserve(galley.num_indices);
// The contents of the galley is already snapped to pixel coordinates,
// but we need to make sure the galley ends up on the start of a physical pixel:
let pos = pos2(
self.options.round_to_pixel(pos.x),
self.options.round_to_pixel(pos.y),
let galley_pos = pos2(
self.options.round_to_pixel(galley_pos.x),
self.options.round_to_pixel(galley_pos.y),
);
let num_chars = galley.char_count_excluding_newlines();
out.reserve_triangles(num_chars * 2);
out.reserve_vertices(num_chars * 4);
let inv_tex_w = 1.0 / tex_size[0] as f32;
let inv_tex_h = 1.0 / tex_size[1] as f32;
let clip_slack = 2.0; // Some fudge to handle letters that are slightly larger than expected.
let clip_rect_min_y = self.clip_rect.min.y - clip_slack;
let clip_rect_max_y = self.clip_rect.max.y + clip_slack;
let uv_normalizer = vec2(1.0 / tex_size[0] as f32, 1.0 / tex_size[1] as f32);
for row in &galley.rows {
let row_min_y = pos.y + row.y_min;
let row_max_y = pos.y + row.y_max;
let is_line_visible = clip_rect_min_y <= row_max_y && row_min_y <= clip_rect_max_y;
if row.visuals.mesh.is_empty() {
continue;
}
if self.options.coarse_tessellation_culling && !is_line_visible {
let row_rect = row.visuals.mesh_bounds.translate(galley_pos.to_vec2());
if self.options.coarse_tessellation_culling && !self.clip_rect.intersects(row_rect) {
// culling individual lines of text is important, since a single `Shape::Text`
// can span hundreds of lines.
continue;
}
for (x_offset, uv_rect) in row.x_offsets.iter().zip(&row.uv_rects) {
if let Some(glyph) = uv_rect {
let mut left_top = pos + glyph.offset + vec2(*x_offset, row.y_min);
left_top.x = self.options.round_to_pixel(left_top.x); // Pixel-perfection.
left_top.y = self.options.round_to_pixel(left_top.y); // Pixel-perfection.
let index_offset = out.vertices.len() as u32;
let rect = Rect::from_min_max(left_top, left_top + glyph.size);
let uv = Rect::from_min_max(
pos2(
glyph.min.0 as f32 * inv_tex_w,
glyph.min.1 as f32 * inv_tex_h,
),
pos2(
glyph.max.0 as f32 * inv_tex_w,
glyph.max.1 as f32 * inv_tex_h,
),
);
out.indices.extend(
row.visuals
.mesh
.indices
.iter()
.map(|index| index + index_offset),
);
if fake_italics {
let idx = out.vertices.len() as u32;
out.add_triangle(idx, idx + 1, idx + 2);
out.add_triangle(idx + 2, idx + 1, idx + 3);
out.vertices.extend(
row.visuals
.mesh
.vertices
.iter()
.enumerate()
.map(|(i, vertex)| {
let mut color = vertex.color;
let top_offset = rect.height() * 0.25 * Vec2::X;
if let Some(override_text_color) = override_text_color {
if row.visuals.glyph_vertex_range.contains(&i) {
color = override_text_color;
}
}
out.vertices.push(Vertex {
pos: rect.left_top() + top_offset,
uv: uv.left_top(),
Vertex {
pos: galley_pos + vertex.pos.to_vec2(),
uv: (vertex.uv.to_vec2() * uv_normalizer).to_pos2(),
color,
});
out.vertices.push(Vertex {
pos: rect.right_top() + top_offset,
uv: uv.right_top(),
color,
});
out.vertices.push(Vertex {
pos: rect.left_bottom(),
uv: uv.left_bottom(),
color,
});
out.vertices.push(Vertex {
pos: rect.right_bottom(),
uv: uv.right_bottom(),
color,
});
} else {
out.add_rect_with_uv(rect, uv, color);
}
}
}
}),
);
if underline != Stroke::none() {
self.scratchpad_path.clear();
self.scratchpad_path
.add_line_segment([row_rect.left_bottom(), row_rect.right_bottom()]);
self.scratchpad_path
.stroke_open(underline, self.options, out);
}
}
}

View File

@@ -1,9 +1,6 @@
use crate::{
mutex::{Mutex, RwLock},
text::{
galley::{Galley, Row},
TextStyle,
},
text::TextStyle,
TextureAtlas,
};
use ahash::AHashMap;
@@ -13,28 +10,37 @@ use std::sync::Arc;
// ----------------------------------------------------------------------------
#[derive(Clone, Copy, Debug, PartialEq)]
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct UvRect {
/// X/Y offset for nice rendering (unit: points).
pub offset: Vec2,
/// Screen size (in points) of this glyph.
/// Note that the height is different from the font height.
pub size: Vec2,
/// Top left corner UV in texture.
pub min: (u16, u16),
pub min: [u16; 2],
/// Bottom right corner (exclusive).
pub max: (u16, u16),
pub max: [u16; 2],
}
impl UvRect {
pub fn is_nothing(&self) -> bool {
self.min == self.max
}
}
#[derive(Clone, Copy, Debug)]
pub struct GlyphInfo {
id: ab_glyph::GlyphId,
pub(crate) id: ab_glyph::GlyphId,
/// Unit: points.
pub advance_width: f32,
/// Texture coordinates. None for space.
pub uv_rect: Option<UvRect>,
pub uv_rect: UvRect,
}
impl Default for GlyphInfo {
@@ -42,7 +48,7 @@ impl Default for GlyphInfo {
Self {
id: ab_glyph::GlyphId(0),
advance_width: 0.0,
uv_rect: None,
uv_rect: Default::default(),
}
}
}
@@ -161,6 +167,7 @@ impl FontImpl {
}
}
#[inline]
pub fn pair_kerning(
&self,
last_glyph_id: ab_glyph::GlyphId,
@@ -281,11 +288,12 @@ impl Font {
self.row_height
}
pub fn uv_rect(&self, c: char) -> Option<UvRect> {
pub fn uv_rect(&self, c: char) -> UvRect {
self.glyph_info_cache
.read()
.get(&c)
.and_then(|gi| gi.1.uv_rect)
.map(|gi| gi.1.uv_rect)
.unwrap_or_default()
}
/// Width of this character in points.
@@ -309,6 +317,13 @@ impl Font {
font_index_glyph_info
}
#[inline]
pub(crate) fn glyph_info_and_font_impl(&self, c: char) -> (&FontImpl, GlyphInfo) {
let (font_index, glyph_info) = self.glyph_info(c);
let font_impl = &self.fonts[font_index];
(font_impl, glyph_info)
}
fn glyph_info_no_cache_or_fallback(&self, c: char) -> Option<(FontIndex, GlyphInfo)> {
for (font_index, font_impl) in self.fonts.iter().enumerate() {
if let Some(glyph_info) = font_impl.glyph_info(c) {
@@ -320,325 +335,6 @@ impl Font {
}
None
}
/// Typeset the given text onto one row.
/// Assumes there are no `\n` in the text.
/// Return `x_offsets`, one longer than the number of characters in the text.
fn layout_single_row_fragment(&self, text: &str) -> Vec<f32> {
let mut x_offsets = Vec::with_capacity(text.chars().count() + 1);
x_offsets.push(0.0);
let mut cursor_x_in_points = 0.0f32;
let mut last_glyph_id = None;
for c in text.chars() {
if !self.fonts.is_empty() {
let (font_index, glyph_info) = self.glyph_info(c);
let font_impl = &self.fonts[font_index];
if let Some(last_glyph_id) = last_glyph_id {
cursor_x_in_points += font_impl.pair_kerning(last_glyph_id, glyph_info.id)
}
cursor_x_in_points += glyph_info.advance_width;
cursor_x_in_points = self.round_to_pixel(cursor_x_in_points);
last_glyph_id = Some(glyph_info.id);
}
x_offsets.push(cursor_x_in_points);
}
x_offsets
}
/// Typeset the given text onto one row.
/// Any `\n` will show up as the replacement character.
/// Always returns exactly one `Row` in the `Galley`.
///
/// Most often you probably want `\n` to produce a new row,
/// and so [`Self::layout_no_wrap`] may be a better choice.
pub fn layout_single_line(&self, text: String) -> Galley {
let x_offsets = self.layout_single_row_fragment(&text);
let row = Row {
x_offsets,
uv_rects: vec![], // will be filled in later
y_min: 0.0,
y_max: self.row_height(),
ends_with_newline: false,
};
let width = row.max_x();
let size = vec2(width, self.row_height());
let galley = Galley {
text_style: self.text_style,
text,
rows: vec![row],
size,
};
self.finalize_galley(galley)
}
/// Will line break at `\n`.
///
/// Always returns at least one row.
pub fn layout_no_wrap(&self, text: String) -> Galley {
self.layout_multiline(text, f32::INFINITY)
}
/// Will wrap text at the given width and line break at `\n`.
///
/// Always returns at least one row.
pub fn layout_multiline(&self, text: String, max_width_in_points: f32) -> Galley {
self.layout_multiline_with_indentation_and_max_width(text, 0.0, max_width_in_points)
}
/// * `first_row_indentation`: extra space before the very first character (in points).
/// * `max_width_in_points`: wrapping width.
///
/// Always returns at least one row.
pub fn layout_multiline_with_indentation_and_max_width(
&self,
text: String,
first_row_indentation: f32,
max_width_in_points: f32,
) -> Galley {
let row_height = self.row_height();
let mut cursor_y = 0.0;
let mut rows = Vec::new();
let mut paragraph_start = 0;
while paragraph_start < text.len() {
let next_newline = text[paragraph_start..].find('\n');
let paragraph_end = next_newline
.map(|newline| paragraph_start + newline)
.unwrap_or_else(|| text.len());
assert!(paragraph_start <= paragraph_end);
let paragraph_text = &text[paragraph_start..paragraph_end];
let line_indentation = if rows.is_empty() {
first_row_indentation
} else {
0.0
};
let mut paragraph_rows = self.layout_paragraph_max_width(
paragraph_text,
line_indentation,
max_width_in_points,
);
assert!(!paragraph_rows.is_empty());
paragraph_rows.last_mut().unwrap().ends_with_newline = next_newline.is_some();
for row in &mut paragraph_rows {
row.y_min += cursor_y;
row.y_max += cursor_y;
}
cursor_y = paragraph_rows.last().unwrap().y_max;
// cursor_y += row_height * 0.2; // Extra spacing between paragraphs.
rows.append(&mut paragraph_rows);
paragraph_start = paragraph_end + 1;
}
if text.is_empty() {
rows.push(Row {
x_offsets: vec![first_row_indentation],
uv_rects: vec![],
y_min: cursor_y,
y_max: cursor_y + row_height,
ends_with_newline: false,
});
} else if text.ends_with('\n') {
rows.push(Row {
x_offsets: vec![0.0],
uv_rects: vec![],
y_min: cursor_y,
y_max: cursor_y + row_height,
ends_with_newline: false,
});
}
let mut widest_row = 0.0;
for row in &rows {
widest_row = row.max_x().max(widest_row);
}
let size = vec2(widest_row, rows.last().unwrap().y_max);
let text_style = self.text_style;
let galley = Galley {
text_style,
text,
rows,
size,
};
self.finalize_galley(galley)
}
/// A paragraph is text with no line break character in it.
/// The text will be wrapped by the given `max_width_in_points`.
/// Always returns at least one row.
fn layout_paragraph_max_width(
&self,
text: &str,
mut first_row_indentation: f32,
max_width_in_points: f32,
) -> Vec<Row> {
if text.is_empty() {
return vec![Row {
x_offsets: vec![first_row_indentation],
uv_rects: vec![],
y_min: 0.0,
y_max: self.row_height(),
ends_with_newline: false,
}];
}
let full_x_offsets = self.layout_single_row_fragment(text);
let mut row_start_x = 0.0; // NOTE: BEFORE the `first_row_indentation`.
let mut cursor_y = 0.0;
let mut row_start_idx = 0;
// Keeps track of good places to insert row break if we exceed `max_width_in_points`.
let mut row_break_candidates = RowBreakCandidates::default();
let mut out_rows = vec![];
for (i, (x, chr)) in full_x_offsets.iter().skip(1).zip(text.chars()).enumerate() {
crate::epaint_assert!(chr != '\n');
let potential_row_width = first_row_indentation + x - row_start_x;
if potential_row_width > max_width_in_points {
let is_first_row = out_rows.is_empty();
if is_first_row
&& first_row_indentation > 0.0
&& !row_break_candidates.has_word_boundary()
{
// Allow the first row to be completely empty, because we know there will be more space on the next row:
assert_eq!(row_start_idx, 0);
out_rows.push(Row {
x_offsets: vec![first_row_indentation],
uv_rects: vec![],
y_min: cursor_y,
y_max: cursor_y + self.row_height(),
ends_with_newline: false,
});
cursor_y = self.round_to_pixel(cursor_y + self.row_height());
first_row_indentation = 0.0; // Continue all other rows as if there is no indentation
} else if let Some(last_kept_index) = row_break_candidates.get() {
out_rows.push(Row {
x_offsets: full_x_offsets[row_start_idx..=last_kept_index + 1]
.iter()
.map(|x| first_row_indentation + x - row_start_x)
.collect(),
uv_rects: vec![], // Will be filled in later!
y_min: cursor_y,
y_max: cursor_y + self.row_height(),
ends_with_newline: false,
});
row_start_idx = last_kept_index + 1;
row_start_x = first_row_indentation + full_x_offsets[row_start_idx];
row_break_candidates = Default::default();
cursor_y = self.round_to_pixel(cursor_y + self.row_height());
}
}
row_break_candidates.add(i, chr);
}
if row_start_idx + 1 < full_x_offsets.len() {
out_rows.push(Row {
x_offsets: full_x_offsets[row_start_idx..]
.iter()
.map(|x| first_row_indentation + x - row_start_x)
.collect(),
uv_rects: vec![], // Will be filled in later!
y_min: cursor_y,
y_max: cursor_y + self.row_height(),
ends_with_newline: false,
});
}
out_rows
}
fn finalize_galley(&self, mut galley: Galley) -> Galley {
let mut chars = galley.text.chars();
for row in &mut galley.rows {
row.uv_rects.clear();
row.uv_rects.reserve(row.char_count_excluding_newline());
for _ in 0..row.char_count_excluding_newline() {
let c = chars.next().unwrap();
row.uv_rects.push(self.uv_rect(c));
}
if row.ends_with_newline {
let newline = chars.next().unwrap();
assert_eq!(newline, '\n');
}
}
assert_eq!(chars.next(), None);
galley.sanity_check();
galley
}
}
/// Keeps track of good places to break a long row of text.
/// Will focus primarily on spaces, secondarily on things like `-`
#[derive(Clone, Copy, Default)]
struct RowBreakCandidates {
/// Breaking at ` ` or other whitespace
/// is always the primary candidate.
space: Option<usize>,
/// Logogram (single character representing a whole word) are good candidates for line break.
logogram: Option<usize>,
/// Breaking at a dash is super-
/// good idea.
dash: Option<usize>,
/// This is nicer for things like URLs, e.g. www.
/// example.com.
punctuation: Option<usize>,
/// Breaking after just random character is some
/// times necessary.
any: Option<usize>,
}
impl RowBreakCandidates {
fn add(&mut self, index: usize, chr: char) {
const NON_BREAKING_SPACE: char = '\u{A0}';
if chr.is_whitespace() && chr != NON_BREAKING_SPACE {
self.space = Some(index);
} else if is_chinese(chr) {
self.logogram = Some(index);
} else if chr == '-' {
self.dash = Some(index);
} else if chr.is_ascii_punctuation() {
self.punctuation = Some(index);
} else {
self.any = Some(index);
}
}
fn has_word_boundary(&self) -> bool {
self.space.is_some() || self.logogram.is_some()
}
fn get(&self) -> Option<usize> {
self.space
.or(self.logogram)
.or(self.dash)
.or(self.punctuation)
.or(self.any)
}
}
#[inline]
fn is_chinese(c: char) -> bool {
('\u{4E00}' <= c && c <= '\u{9FFF}')
|| ('\u{3400}' <= c && c <= '\u{4DBF}')
|| ('\u{2B740}' <= c && c <= '\u{2B81F}')
}
#[inline]
@@ -663,12 +359,12 @@ fn allocate_glyph(
let glyph =
glyph_id.with_scale_and_position(scale_in_pixels, ab_glyph::Point { x: 0.0, y: 0.0 });
let uv_rect = font.outline_glyph(glyph).and_then(|glyph| {
let uv_rect = font.outline_glyph(glyph).map(|glyph| {
let bb = glyph.px_bounds();
let glyph_width = bb.width() as usize;
let glyph_height = bb.height() as usize;
if glyph_width == 0 || glyph_height == 0 {
None
UvRect::default()
} else {
let glyph_pos = atlas.allocate((glyph_width, glyph_height));
@@ -683,17 +379,18 @@ fn allocate_glyph(
let offset_in_pixels = vec2(bb.min.x as f32, scale_in_pixels as f32 + bb.min.y as f32);
let offset = offset_in_pixels / pixels_per_point + y_offset * Vec2::Y;
Some(UvRect {
UvRect {
offset,
size: vec2(glyph_width as f32, glyph_height as f32) / pixels_per_point,
min: (glyph_pos.0 as u16, glyph_pos.1 as u16),
max: (
min: [glyph_pos.0 as u16, glyph_pos.1 as u16],
max: [
(glyph_pos.0 + glyph_width) as u16,
(glyph_pos.1 + glyph_height) as u16,
),
})
],
}
}
});
let uv_rect = uv_rect.unwrap_or_default();
let advance_width_in_points =
font.as_scaled(scale_in_pixels).h_advance(glyph_id) / pixels_per_point;

View File

@@ -10,7 +10,7 @@ use crate::{
mutex::Mutex,
text::{
font::{Font, FontImpl},
Galley,
Galley, LayoutJob,
},
Texture, TextureAtlas,
};
@@ -315,69 +315,58 @@ impl Fonts {
self.fonts[&text_style].row_height()
}
/// Will line break at `\n`.
/// Layout some text.
/// This is the most advanced layout function.
/// See also [`Self::layout`], [`Self::layout_no_wrap`] and
/// [`Self::layout_delayed_color`].
///
/// Always returns at least one row.
pub fn layout_no_wrap(&self, text_style: TextStyle, text: String) -> Arc<Galley> {
self.layout_multiline(text_style, text, f32::INFINITY)
}
/// Typeset the given text onto one row.
/// Any `\n` will show up as the replacement character.
/// Always returns exactly one `Row` in the `Galley`.
///
/// Most often you probably want `\n` to produce a new row,
/// and so [`Self::layout_no_wrap`] may be a better choice.
pub fn layout_single_line(&self, text_style: TextStyle, text: String) -> Arc<Galley> {
self.galley_cache.lock().layout(
&self.fonts,
LayoutJob {
text_style,
text,
layout_params: LayoutParams::SingleLine,
},
)
/// The implementation uses memoization so repeated calls are cheap.
pub fn layout_job(&self, job: impl Into<Arc<LayoutJob>>) -> Arc<Galley> {
self.galley_cache.lock().layout(self, job.into())
}
/// Will wrap text at the given width and line break at `\n`.
///
/// Always returns at least one row.
pub fn layout_multiline(
/// The implementation uses memoization so repeated calls are cheap.
pub fn layout(
&self,
text_style: TextStyle,
text: String,
max_width_in_points: f32,
text_style: TextStyle,
color: crate::Color32,
wrap_width: f32,
) -> Arc<Galley> {
self.layout_multiline_with_indentation_and_max_width(
text_style,
text,
0.0,
max_width_in_points,
)
let job = LayoutJob::simple(text, text_style, color, wrap_width);
self.layout_job(job)
}
/// * `first_row_indentation`: extra space before the very first character (in points).
/// * `max_width_in_points`: wrapping width.
/// Will line break at `\n`.
///
/// Always returns at least one row.
pub fn layout_multiline_with_indentation_and_max_width(
/// The implementation uses memoization so repeated calls are cheap.
pub fn layout_no_wrap(
&self,
text_style: TextStyle,
text: String,
first_row_indentation: f32,
max_width_in_points: f32,
text_style: TextStyle,
color: crate::Color32,
) -> Arc<Galley> {
self.galley_cache.lock().layout(
&self.fonts,
LayoutJob {
text_style,
text,
layout_params: LayoutParams::Multiline {
first_row_indentation: first_row_indentation.into(),
max_width_in_points: max_width_in_points.into(),
},
},
)
let job = LayoutJob::simple(text, text_style, color, f32::INFINITY);
self.layout_job(job)
}
/// Like [`Self::layout`], made for when you want to pick a color for the text later.
///
/// The implementation uses memoization so repeated calls are cheap.
pub fn layout_delayed_color(
&self,
text: String,
text_style: TextStyle,
wrap_width: f32,
) -> Arc<Galley> {
self.layout_job(LayoutJob::simple(
text,
text_style,
crate::Color32::TEMPORARY_COLOR,
wrap_width,
))
}
pub fn num_galleys_in_cache(&self) -> usize {
@@ -386,7 +375,7 @@ impl Fonts {
/// Must be called once per frame to clear the [`Galley`] cache.
pub fn end_frame(&self) {
self.galley_cache.lock().end_frame()
self.galley_cache.lock().end_frame();
}
}
@@ -401,22 +390,6 @@ impl std::ops::Index<TextStyle> for Fonts {
// ----------------------------------------------------------------------------
#[derive(Clone, Copy, Eq, PartialEq, Hash)]
enum LayoutParams {
SingleLine,
Multiline {
first_row_indentation: ordered_float::OrderedFloat<f32>,
max_width_in_points: ordered_float::OrderedFloat<f32>,
},
}
#[derive(Clone, Eq, PartialEq, Hash)]
struct LayoutJob {
text_style: TextStyle,
layout_params: LayoutParams,
text: String,
}
struct CachedGalley {
/// When it was last used
last_used: u32,
@@ -427,41 +400,26 @@ struct CachedGalley {
struct GalleyCache {
/// Frame counter used to do garbage collection on the cache
generation: u32,
cache: AHashMap<LayoutJob, CachedGalley>,
cache: AHashMap<Arc<LayoutJob>, CachedGalley>,
}
impl GalleyCache {
fn layout(&mut self, fonts: &BTreeMap<TextStyle, Font>, job: LayoutJob) -> Arc<Galley> {
if let Some(cached) = self.cache.get_mut(&job) {
cached.last_used = self.generation;
cached.galley.clone()
} else {
let LayoutJob {
text_style,
layout_params,
text,
} = job.clone();
let font = &fonts[&text_style];
let galley = match layout_params {
LayoutParams::SingleLine => font.layout_single_line(text),
LayoutParams::Multiline {
first_row_indentation,
max_width_in_points,
} => font.layout_multiline_with_indentation_and_max_width(
text,
first_row_indentation.into_inner(),
max_width_in_points.into_inner(),
),
};
let galley = Arc::new(galley);
self.cache.insert(
job,
CachedGalley {
fn layout(&mut self, fonts: &Fonts, job: Arc<LayoutJob>) -> Arc<Galley> {
match self.cache.entry(job.clone()) {
std::collections::hash_map::Entry::Occupied(entry) => {
let cached = entry.into_mut();
cached.last_used = self.generation;
cached.galley.clone()
}
std::collections::hash_map::Entry::Vacant(entry) => {
let galley = super::layout(fonts, job);
let galley = Arc::new(galley);
entry.insert(CachedGalley {
last_used: self.generation,
galley: galley.clone(),
},
);
galley
});
galley
}
}
}

View File

@@ -3,14 +3,16 @@
pub mod cursor;
mod font;
mod fonts;
mod galley;
mod text_layout;
mod text_layout_types;
/// One `\t` character is this many spaces wide.
pub const TAB_SIZE: usize = 4;
pub use {
fonts::{FontDefinitions, FontFamily, Fonts, TextStyle},
galley::{Galley, Row},
text_layout::layout,
text_layout_types::*,
};
/// Suggested character to use to replace those in password text fields.

View File

@@ -0,0 +1,538 @@
use std::ops::RangeInclusive;
use std::sync::Arc;
use super::{Fonts, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals};
use crate::{Color32, Mesh, Stroke, Vertex};
use emath::*;
/// Temporary storage before line-wrapping.
#[derive(Default, Clone)]
struct Paragraph {
/// Start of the next glyph to be added.
pub cursor_x: f32,
pub glyphs: Vec<Glyph>,
/// In case of an empty paragraph ("\n"), use this as height.
pub empty_paragraph_height: f32,
}
/// Layout text into a [`Galley`].
///
/// In most cases you should use [`Fonts::layout_job`] instead
/// since that memoizes the input, making subsequent layouting of the same text much faster.
pub fn layout(fonts: &Fonts, job: Arc<LayoutJob>) -> Galley {
let mut paragraphs = vec![Paragraph::default()];
for (section_index, section) in job.sections.iter().enumerate() {
layout_section(fonts, &job, section_index as u32, section, &mut paragraphs);
}
let rows = rows_from_paragraphs(paragraphs, job.wrap_width);
galley_from_rows(fonts, job, rows)
}
fn layout_section(
fonts: &Fonts,
job: &LayoutJob,
section_index: u32,
section: &LayoutSection,
out_paragraphs: &mut Vec<Paragraph>,
) {
let LayoutSection {
leading_space,
byte_range,
format,
} = section;
let font = &fonts[format.style];
let font_height = font.row_height();
let mut paragraph = out_paragraphs.last_mut().unwrap();
if paragraph.glyphs.is_empty() {
paragraph.empty_paragraph_height = font_height; // TODO: replace this hack with actually including `\n` in the glyphs?
}
paragraph.cursor_x += leading_space;
let mut last_glyph_id = None;
for chr in job.text[byte_range.clone()].chars() {
if job.break_on_newline && chr == '\n' {
out_paragraphs.push(Paragraph::default());
paragraph = out_paragraphs.last_mut().unwrap();
paragraph.empty_paragraph_height = font_height; // TODO: replace this hack with actually including `\n` in the glyphs?
} else {
let (font_impl, glyph_info) = font.glyph_info_and_font_impl(chr);
if let Some(last_glyph_id) = last_glyph_id {
paragraph.cursor_x += font_impl.pair_kerning(last_glyph_id, glyph_info.id)
}
paragraph.glyphs.push(Glyph {
chr,
pos: pos2(paragraph.cursor_x, f32::NAN),
size: vec2(glyph_info.advance_width, font_height),
uv_rect: glyph_info.uv_rect,
section_index,
});
paragraph.cursor_x += glyph_info.advance_width;
paragraph.cursor_x = font.round_to_pixel(paragraph.cursor_x);
last_glyph_id = Some(glyph_info.id);
}
}
}
/// We ignore y at this stage
fn rect_from_x_range(x_range: RangeInclusive<f32>) -> Rect {
Rect::from_x_y_ranges(x_range, 0.0..=0.0)
}
fn rows_from_paragraphs(paragraphs: Vec<Paragraph>, wrap_width: f32) -> Vec<Row> {
let num_paragraphs = paragraphs.len();
let mut rows = vec![];
for (i, paragraph) in paragraphs.into_iter().enumerate() {
let is_last_paragraph = (i + 1) == num_paragraphs;
if paragraph.glyphs.is_empty() {
rows.push(Row {
glyphs: vec![],
visuals: Default::default(),
rect: Rect::from_min_size(
pos2(paragraph.cursor_x, 0.0),
vec2(0.0, paragraph.empty_paragraph_height),
),
ends_with_newline: !is_last_paragraph,
});
} else {
let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x();
if paragraph_max_x <= wrap_width {
// early-out optimization
let paragraph_min_x = paragraph.glyphs[0].pos.x;
rows.push(Row {
glyphs: paragraph.glyphs,
visuals: Default::default(),
rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x),
ends_with_newline: !is_last_paragraph,
});
} else {
line_break(&paragraph, wrap_width, &mut rows);
rows.last_mut().unwrap().ends_with_newline = !is_last_paragraph;
}
}
}
rows
}
fn line_break(paragraph: &Paragraph, wrap_width: f32, out_rows: &mut Vec<Row>) {
// Keeps track of good places to insert row break if we exceed `wrap_width`.
let mut row_break_candidates = RowBreakCandidates::default();
let mut first_row_indentation = paragraph.glyphs[0].pos.x;
let mut row_start_x = 0.0;
let mut row_start_idx = 0;
for (i, glyph) in paragraph.glyphs.iter().enumerate() {
let potential_row_width = glyph.pos.x - row_start_x;
if potential_row_width > wrap_width {
if first_row_indentation > 0.0 && !row_break_candidates.has_word_boundary() {
// Allow the first row to be completely empty, because we know there will be more space on the next row:
// TODO: this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height.
out_rows.push(Row {
glyphs: vec![],
visuals: Default::default(),
rect: rect_from_x_range(first_row_indentation..=first_row_indentation),
ends_with_newline: false,
});
row_start_x += first_row_indentation;
first_row_indentation = 0.0;
} else if let Some(last_kept_index) = row_break_candidates.get() {
let glyphs: Vec<Glyph> = paragraph.glyphs[row_start_idx..=last_kept_index]
.iter()
.copied()
.map(|mut glyph| {
glyph.pos.x -= row_start_x;
glyph
})
.collect();
let paragraph_min_x = glyphs[0].pos.x;
let paragraph_max_x = glyphs.last().unwrap().max_x();
out_rows.push(Row {
glyphs,
visuals: Default::default(),
rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x),
ends_with_newline: false,
});
row_start_idx = last_kept_index + 1;
row_start_x = paragraph.glyphs[row_start_idx].pos.x;
row_break_candidates = Default::default();
} else {
// Found no place to break, so we have to overrun wrap_width.
}
}
row_break_candidates.add(i, glyph.chr);
}
if row_start_idx < paragraph.glyphs.len() {
let glyphs: Vec<Glyph> = paragraph.glyphs[row_start_idx..]
.iter()
.copied()
.map(|mut glyph| {
glyph.pos.x -= row_start_x;
glyph
})
.collect();
let paragraph_min_x = glyphs[0].pos.x;
let paragraph_max_x = glyphs.last().unwrap().max_x();
out_rows.push(Row {
glyphs,
visuals: Default::default(),
rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x),
ends_with_newline: false,
});
}
}
/// Calculate the Y positions and tessellate the text.
fn galley_from_rows(fonts: &Fonts, job: Arc<LayoutJob>, mut rows: Vec<Row>) -> Galley {
let mut first_row_min_height = job.first_row_min_height;
let mut cursor_y = 0.0;
let mut max_x: f32 = 0.0;
for row in &mut rows {
let mut row_height = first_row_min_height.max(row.rect.height());
first_row_min_height = 0.0;
for glyph in &row.glyphs {
row_height = row_height.max(glyph.size.y);
}
row_height = fonts.round_to_pixel(row_height);
// Now positions each glyph:
for glyph in &mut row.glyphs {
let format = &job.sections[glyph.section_index as usize].format;
glyph.pos.y = cursor_y + format.valign.to_factor() * (row_height - glyph.size.y);
glyph.pos.y = fonts.round_to_pixel(glyph.pos.y);
}
row.rect.min.y = cursor_y;
row.rect.max.y = cursor_y + row_height;
max_x = max_x.max(row.rect.right());
cursor_y += row_height;
cursor_y = fonts.round_to_pixel(cursor_y);
}
let format_summary = format_summary(&job);
let mut num_vertices = 0;
let mut num_indices = 0;
for row in &mut rows {
row.visuals = tessellate_row(fonts, &job, &format_summary, row);
num_vertices += row.visuals.mesh.vertices.len();
num_indices += row.visuals.mesh.indices.len();
}
let size = vec2(max_x, cursor_y);
Galley {
job,
rows,
size,
num_vertices,
num_indices,
}
}
#[derive(Default)]
struct FormatSummary {
any_background: bool,
any_underline: bool,
any_strikethrough: bool,
}
fn format_summary(job: &LayoutJob) -> FormatSummary {
let mut format_summary = FormatSummary::default();
for section in &job.sections {
format_summary.any_background |= section.format.background != Color32::TRANSPARENT;
format_summary.any_underline |= section.format.underline != Stroke::none();
format_summary.any_strikethrough |= section.format.strikethrough != Stroke::none();
}
format_summary
}
fn tessellate_row(
fonts: &Fonts,
job: &LayoutJob,
format_summary: &FormatSummary,
row: &mut Row,
) -> RowVisuals {
if row.glyphs.is_empty() {
return Default::default();
}
let mut mesh = Mesh::default();
mesh.reserve_triangles(row.glyphs.len() * 2);
mesh.reserve_vertices(row.glyphs.len() * 4);
if format_summary.any_background {
add_row_backgrounds(job, row, &mut mesh);
}
let glyph_vertex_start = mesh.vertices.len();
tessellate_glyphs(fonts, job, row, &mut mesh);
let glyph_vertex_end = mesh.vertices.len();
if format_summary.any_underline {
add_row_hline(fonts, row, &mut mesh, |glyph| {
let format = &job.sections[glyph.section_index as usize].format;
let stroke = format.underline;
let y = glyph.logical_rect().bottom();
(stroke, y)
});
}
if format_summary.any_strikethrough {
add_row_hline(fonts, row, &mut mesh, |glyph| {
let format = &job.sections[glyph.section_index as usize].format;
let stroke = format.strikethrough;
let y = glyph.logical_rect().center().y;
(stroke, y)
});
}
let mesh_bounds = mesh.calc_bounds();
RowVisuals {
mesh,
mesh_bounds,
glyph_vertex_range: glyph_vertex_start..glyph_vertex_end,
}
}
/// Create background for glyphs that have them.
/// Creates as few rectangular regions as possible.
fn add_row_backgrounds(job: &LayoutJob, row: &Row, mesh: &mut Mesh) {
if row.glyphs.is_empty() {
return;
}
let mut end_run = |start: Option<(Color32, Rect)>, stop_x: f32| {
if let Some((color, start_rect)) = start {
let rect = Rect::from_min_max(start_rect.left_top(), pos2(stop_x, start_rect.bottom()));
let rect = rect.expand(1.0); // looks better
mesh.add_colored_rect(rect, color);
}
};
let mut run_start = None;
let mut last_rect = Rect::NAN;
for glyph in &row.glyphs {
let format = &job.sections[glyph.section_index as usize].format;
let color = format.background;
let rect = glyph.logical_rect();
if color == Color32::TRANSPARENT {
end_run(run_start.take(), last_rect.right());
} else if let Some((existing_color, start)) = run_start {
if existing_color == color
&& start.top() == rect.top()
&& start.bottom() == rect.bottom()
{
// continue the same background rectangle
} else {
end_run(run_start.take(), last_rect.right());
run_start = Some((color, rect));
}
} else {
run_start = Some((color, rect));
}
last_rect = rect;
}
end_run(run_start.take(), last_rect.right());
}
fn tessellate_glyphs(fonts: &Fonts, job: &LayoutJob, row: &Row, mesh: &mut Mesh) {
for glyph in &row.glyphs {
let uv_rect = glyph.uv_rect;
if !uv_rect.is_nothing() {
let mut left_top = glyph.pos + uv_rect.offset;
left_top.x = fonts.round_to_pixel(left_top.x);
left_top.y = fonts.round_to_pixel(left_top.y);
let rect = Rect::from_min_max(left_top, left_top + uv_rect.size);
let uv = Rect::from_min_max(
pos2(uv_rect.min[0] as f32, uv_rect.min[1] as f32),
pos2(uv_rect.max[0] as f32, uv_rect.max[1] as f32),
);
let format = &job.sections[glyph.section_index as usize].format;
let color = format.color;
if format.italics {
let idx = mesh.vertices.len() as u32;
mesh.add_triangle(idx, idx + 1, idx + 2);
mesh.add_triangle(idx + 2, idx + 1, idx + 3);
let top_offset = rect.height() * 0.25 * Vec2::X;
mesh.vertices.push(Vertex {
pos: rect.left_top() + top_offset,
uv: uv.left_top(),
color,
});
mesh.vertices.push(Vertex {
pos: rect.right_top() + top_offset,
uv: uv.right_top(),
color,
});
mesh.vertices.push(Vertex {
pos: rect.left_bottom(),
uv: uv.left_bottom(),
color,
});
mesh.vertices.push(Vertex {
pos: rect.right_bottom(),
uv: uv.right_bottom(),
color,
});
} else {
mesh.add_rect_with_uv(rect, uv, color);
}
}
}
}
/// Add a horizontal line over a row of glyphs with a stroke and y decided by a callback.
fn add_row_hline(
fonts: &Fonts,
row: &Row,
mesh: &mut Mesh,
stroke_and_y: impl Fn(&Glyph) -> (Stroke, f32),
) {
let mut end_line = |start: Option<(Stroke, Pos2)>, stop_x: f32| {
if let Some((stroke, start)) = start {
add_hline(fonts, [start, pos2(stop_x, start.y)], stroke, mesh);
}
};
let mut line_start = None;
let mut last_right_x = f32::NAN;
for glyph in &row.glyphs {
let (stroke, y) = stroke_and_y(glyph);
if stroke == Stroke::none() {
end_line(line_start.take(), last_right_x);
} else if let Some((existing_stroke, start)) = line_start {
if existing_stroke == stroke && start.y == y {
// continue the same line
} else {
end_line(line_start.take(), last_right_x);
line_start = Some((stroke, pos2(glyph.pos.x, y)));
}
} else {
line_start = Some((stroke, pos2(glyph.pos.x, y)));
}
last_right_x = glyph.max_x();
}
end_line(line_start.take(), last_right_x);
}
fn add_hline(fonts: &Fonts, [start, stop]: [Pos2; 2], stroke: Stroke, mesh: &mut Mesh) {
let antialiased = true;
if antialiased {
let mut path = crate::tessellator::Path::default(); // TODO: reuse this to avoid re-allocations.
path.add_line_segment([start, stop]);
let options = crate::tessellator::TessellationOptions::from_pixels_per_point(
fonts.pixels_per_point(),
);
path.stroke_open(stroke, options, mesh);
} else {
// Thin lines often lost, so this is a bad idea
assert_eq!(start.y, stop.y);
let min_y = fonts.round_to_pixel(start.y - 0.5 * stroke.width);
let max_y = fonts.round_to_pixel(min_y + stroke.width);
let rect = Rect::from_min_max(
pos2(fonts.round_to_pixel(start.x), min_y),
pos2(fonts.round_to_pixel(stop.x), max_y),
);
mesh.add_colored_rect(rect, stroke.color);
}
}
// ----------------------------------------------------------------------------
/// Keeps track of good places to break a long row of text.
/// Will focus primarily on spaces, secondarily on things like `-`
#[derive(Clone, Copy, Default)]
struct RowBreakCandidates {
/// Breaking at ` ` or other whitespace
/// is always the primary candidate.
space: Option<usize>,
/// Logograms (single character representing a whole word) are good candidates for line break.
logogram: Option<usize>,
/// Breaking at a dash is a super-
/// good idea.
dash: Option<usize>,
/// This is nicer for things like URLs, e.g. www.
/// example.com.
punctuation: Option<usize>,
/// Breaking after just random character is some
/// times necessary.
any: Option<usize>,
}
impl RowBreakCandidates {
fn add(&mut self, index: usize, chr: char) {
const NON_BREAKING_SPACE: char = '\u{A0}';
if chr.is_whitespace() && chr != NON_BREAKING_SPACE {
self.space = Some(index);
} else if is_chinese(chr) {
self.logogram = Some(index);
} else if chr == '-' {
self.dash = Some(index);
} else if chr.is_ascii_punctuation() {
self.punctuation = Some(index);
} else {
self.any = Some(index);
}
}
fn has_word_boundary(&self) -> bool {
self.space.is_some() || self.logogram.is_some()
}
fn get(&self) -> Option<usize> {
self.space
.or(self.logogram)
.or(self.dash)
.or(self.punctuation)
.or(self.any)
}
}
#[inline]
fn is_chinese(c: char) -> bool {
('\u{4E00}' <= c && c <= '\u{9FFF}')
|| ('\u{3400}' <= c && c <= '\u{4DBF}')
|| ('\u{2B740}' <= c && c <= '\u{2B81F}')
}

View File

@@ -1,35 +1,224 @@
//! A [`Galley`] is a piece of text after layout, i.e. where each character has been assigned a position.
//!
//! ## How it works
//! This is going to get complicated.
//!
//! To avoid confusion, we never use the word "line".
//! The `\n` character demarcates the split of text into "paragraphs".
//! Each paragraph is wrapped at some width onto one or more "rows".
//!
//! If this cursors sits right at the border of a wrapped row break (NOT paragraph break)
//! do we prefer the next row?
//! For instance, consider this single paragraph, word wrapped:
//! ``` text
//! Hello_
//! world!
//! ```
//!
//! The offset `6` is both the end of the first row
//! and the start of the second row.
//! [`CCursor::prefer_next_row`] etc selects which.
use std::ops::Range;
use std::sync::Arc;
use super::{cursor::*, font::UvRect};
use emath::{pos2, NumExt, Rect, Vec2};
use crate::{Color32, Mesh, Stroke, TextStyle};
use emath::*;
/// Describes the task of laying out text.
///
/// This supports mixing different fonts, color and formats (underline etc).
///
/// Pass this to [`Fonts::layout_job]` or [`crate::text::layout`].
#[derive(Clone, Debug)]
pub struct LayoutJob {
/// The complete text of this job, referenced by `LayoutSection`.
pub text: String, // TODO: Cow<'static, str>
/// The different section, which can have different fonts, colors, etc.
pub sections: Vec<LayoutSection>,
/// Try to break text so that no row is wider than this.
/// Set to [`f32::INFINITY`] to turn off wrapping.
/// Note that `\n` always produces a new line.
pub wrap_width: f32,
/// The first row must be at least this high.
/// This is in case we lay out text that is the continuation
/// of some earlier text (sharing the same row),
/// in which case this will be the height of the earlier text.
/// In other cases, set this to `0.0`.
pub first_row_min_height: f32,
/// If `false`, all newlines characters will be ignored
/// and show up as the replacement character.
/// Default: `true`.
pub break_on_newline: bool,
// TODO: option to show whitespace characters
}
impl Default for LayoutJob {
#[inline]
fn default() -> Self {
Self {
text: Default::default(),
sections: Default::default(),
wrap_width: f32::INFINITY,
first_row_min_height: 0.0,
break_on_newline: true,
}
}
}
impl LayoutJob {
/// Break on `\n` and at the given wrap width.
#[inline]
pub fn simple(text: String, text_style: TextStyle, color: Color32, wrap_width: f32) -> Self {
Self {
sections: vec![LayoutSection {
leading_space: 0.0,
byte_range: 0..text.len(),
format: TextFormat::simple(text_style, color),
}],
text,
wrap_width,
break_on_newline: true,
..Default::default()
}
}
/// Does not break on `\n`, but shows the replacement character instead.
#[inline]
pub fn simple_singleline(text: String, text_style: TextStyle, color: Color32) -> Self {
Self {
sections: vec![LayoutSection {
leading_space: 0.0,
byte_range: 0..text.len(),
format: TextFormat::simple(text_style, color),
}],
text,
wrap_width: f32::INFINITY,
break_on_newline: false,
..Default::default()
}
}
#[inline(always)]
pub fn is_empty(&self) -> bool {
self.sections.is_empty()
}
/// Helper for adding a new section when building a `LayoutJob`.
pub fn append(&mut self, text: &str, leading_space: f32, format: TextFormat) {
let start = self.text.len();
self.text += text;
let byte_range = start..self.text.len();
self.sections.push(LayoutSection {
leading_space,
byte_range,
format,
});
}
}
impl std::hash::Hash for LayoutJob {
#[inline]
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
let Self {
text,
sections,
wrap_width,
first_row_min_height,
break_on_newline,
} = self;
text.hash(state);
sections.hash(state);
crate::f32_hash(state, *wrap_width);
crate::f32_hash(state, *first_row_min_height);
break_on_newline.hash(state);
}
}
impl PartialEq for LayoutJob {
#[inline(always)]
fn eq(&self, other: &Self) -> bool {
self.text == other.text
&& self.sections == other.sections
&& crate::f32_eq(self.wrap_width, other.wrap_width)
&& crate::f32_eq(self.first_row_min_height, other.first_row_min_height)
&& self.break_on_newline == other.break_on_newline
}
}
impl std::cmp::Eq for LayoutJob {}
// ----------------------------------------------------------------------------
#[derive(Clone, Debug)]
pub struct LayoutSection {
/// Can be used for first row indentation.
pub leading_space: f32,
/// Range into the galley text
pub byte_range: Range<usize>,
pub format: TextFormat,
}
impl std::hash::Hash for LayoutSection {
#[inline]
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
let Self {
leading_space,
byte_range,
format,
} = self;
crate::f32_hash(state, *leading_space);
byte_range.hash(state);
format.hash(state);
}
}
impl PartialEq for LayoutSection {
#[inline(always)]
fn eq(&self, other: &Self) -> bool {
crate::f32_eq(self.leading_space, other.leading_space)
&& self.byte_range == other.byte_range
&& self.format == other.format
}
}
impl std::cmp::Eq for LayoutSection {}
// ----------------------------------------------------------------------------
#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)]
pub struct TextFormat {
pub style: TextStyle,
/// Text color
pub color: Color32,
pub background: Color32,
pub italics: bool,
pub underline: Stroke,
pub strikethrough: Stroke,
/// If you use a small font and [`Align::TOP`] you
/// can get the effect of raised text.
pub valign: Align,
// TODO: lowered
}
impl Default for TextFormat {
#[inline]
fn default() -> Self {
Self {
style: TextStyle::Body,
color: Color32::GRAY,
background: Color32::TRANSPARENT,
italics: false,
underline: Stroke::none(),
strikethrough: Stroke::none(),
valign: Align::BOTTOM,
}
}
}
impl TextFormat {
#[inline]
pub fn simple(style: TextStyle, color: Color32) -> Self {
Self {
style,
color,
..Default::default()
}
}
}
// ----------------------------------------------------------------------------
/// A collection of text locked into place.
#[derive(Clone, Debug, PartialEq)]
pub struct Galley {
/// The [`crate::TextStyle`] (font) used.
pub text_style: crate::TextStyle,
/// The full text, including any an all `\n`.
pub text: String,
/// The job that this galley is the result of.
/// Contains the original string and style sections.
pub job: Arc<LayoutJob>,
/// Rows of text, from top to bottom.
/// The number of chars in all rows sum up to text.chars().count().
@@ -37,88 +226,123 @@ pub struct Galley {
/// can be split up into multiple rows.
pub rows: Vec<Row>,
// Optimization: calculated once and reused.
/// Bounding size (min is always `[0,0]`)
pub size: Vec2,
/// Total number of vertices in all the row meshes.
pub num_vertices: usize,
/// Total number of indices in all the row meshes.
pub num_indices: usize,
}
/// A typeset piece of text on a single row.
#[derive(Clone, Debug, PartialEq)]
pub struct Row {
/// The start of each character, probably starting at zero.
/// The last element is the end of the last character.
/// This is never empty.
/// Unit: points.
///
/// `x_offsets.len() + (ends_with_newline as usize) == text.chars().count() + 1`
pub x_offsets: Vec<f32>,
/// One for each `char`.
pub glyphs: Vec<Glyph>,
/// Per-character. Used when rendering.
pub uv_rects: Vec<Option<UvRect>>,
/// Logical bounding rectangle based on font heights etc.
/// Use this when drawing a selection or similar!
/// Includes leading and trailing whitespace.
pub rect: Rect,
/// Top of the row, offset within the Galley.
/// Unit: points.
pub y_min: f32,
/// Bottom of the row, offset within the Galley.
/// Unit: points.
pub y_max: f32,
/// The mesh, ready to be rendered.
pub visuals: RowVisuals,
/// If true, this `Row` came from a paragraph ending with a `\n`.
/// The `\n` itself is omitted from `x_offsets`.
/// The `\n` itself is omitted from [`Self::glyphs`].
/// A `\n` in the input text always creates a new `Row` below it,
/// so that text that ends with `\n` has an empty `Row` last.
/// This also implies that the last `Row` in a `Galley` always has `ends_with_newline == false`.
pub ends_with_newline: bool,
}
impl Row {
#[inline]
pub fn sanity_check(&self) {
assert!(!self.x_offsets.is_empty());
assert!(self.x_offsets.len() == self.uv_rects.len() + 1);
/// The tessellated output of a row.
#[derive(Clone, Debug, PartialEq)]
pub struct RowVisuals {
/// The tessellated text, using non-normalized (texel) UV coordinates.
/// That is, you need to divide the uv coordinates by the texture size.
pub mesh: Mesh,
/// Bounds of the mesh, and can be used for culling.
/// Does NOT include leading or trailing whitespace glyphs!!
pub mesh_bounds: Rect,
/// The range of vertices in the mesh the contain glyphs.
/// Before comes backgrounds (if any), and after any underlines and strikethrough.
pub glyph_vertex_range: Range<usize>,
}
impl Default for RowVisuals {
fn default() -> Self {
Self {
mesh: Default::default(),
mesh_bounds: Rect::NOTHING,
glyph_vertex_range: 0..0,
}
}
}
#[derive(Copy, Clone, Debug, PartialEq)]
pub struct Glyph {
pub chr: char,
/// Relative to the galley position.
/// Logical position: pos.y is the same for all chars of the same [`TextFormat`].
pub pos: Pos2,
/// Advance width and font row height.
pub size: Vec2,
/// Position of the glyph in the font texture.
pub uv_rect: UvRect,
/// Index into [`LayoutJob::sections`]. Decides color etc.
pub section_index: u32,
}
impl Glyph {
pub fn max_x(&self) -> f32 {
self.pos.x + self.size.x
}
/// Same y range for all characters with the same [`TextFormat`].
#[inline]
pub fn logical_rect(&self) -> Rect {
Rect::from_min_size(self.pos, self.size)
}
}
// ----------------------------------------------------------------------------
impl Row {
/// Excludes the implicit `\n` after the `Row`, if any.
#[inline]
pub fn char_count_excluding_newline(&self) -> usize {
assert!(!self.x_offsets.is_empty());
self.x_offsets.len() - 1
self.glyphs.len()
}
/// Includes the implicit `\n` after the `Row`, if any.
#[inline]
pub fn char_count_including_newline(&self) -> usize {
self.char_count_excluding_newline() + (self.ends_with_newline as usize)
self.glyphs.len() + (self.ends_with_newline as usize)
}
#[inline]
pub fn min_x(&self) -> f32 {
*self.x_offsets.first().unwrap()
pub fn min_y(&self) -> f32 {
self.rect.top()
}
#[inline]
pub fn max_x(&self) -> f32 {
*self.x_offsets.last().unwrap()
pub fn max_y(&self) -> f32 {
self.rect.bottom()
}
#[inline]
pub fn height(&self) -> f32 {
self.y_max - self.y_min
}
pub fn rect(&self) -> Rect {
Rect::from_min_max(
pos2(self.min_x(), self.y_min),
pos2(self.max_x(), self.y_max),
)
self.rect.height()
}
/// Closest char at the desired x coordinate.
/// Returns something in the range `[0, char_count_excluding_newline()]`.
pub fn char_at(&self, desired_x: f32) -> usize {
for (i, char_x_bounds) in self.x_offsets.windows(2).enumerate() {
let char_center_x = 0.5 * (char_x_bounds[0] + char_x_bounds[1]);
if desired_x < char_center_x {
for (i, glyph) in self.glyphs.iter().enumerate() {
if desired_x < glyph.logical_rect().center().x {
return i;
}
}
@@ -126,56 +350,35 @@ impl Row {
}
pub fn x_offset(&self, column: usize) -> f32 {
self.x_offsets[column.min(self.x_offsets.len() - 1)]
}
// Move down this much
#[inline(always)]
pub fn translate_y(&mut self, dy: f32) {
self.y_min += dy;
self.y_max += dy;
if let Some(glyph) = self.glyphs.get(column) {
glyph.pos.x
} else {
self.rect.right()
}
}
}
impl Galley {
#[inline(always)]
pub fn is_empty(&self) -> bool {
self.text.is_empty()
self.job.is_empty()
}
#[inline(always)]
pub(crate) fn char_count_excluding_newlines(&self) -> usize {
let mut char_count = 0;
for row in &self.rows {
char_count += row.char_count_excluding_newline();
}
char_count
}
pub fn sanity_check(&self) {
let mut char_count = 0;
for row in &self.rows {
row.sanity_check();
char_count += row.char_count_including_newline();
}
crate::epaint_assert!(char_count == self.text.chars().count());
if let Some(last_row) = self.rows.last() {
crate::epaint_assert!(
!last_row.ends_with_newline,
"If the text ends with '\\n', there would be an empty row last.\n\
Galley: {:#?}",
self
);
}
pub fn text(&self) -> &str {
&self.job.text
}
}
// ----------------------------------------------------------------------------
/// ## Physical positions
impl Galley {
/// Zero-width rect past the last character.
fn end_pos(&self) -> Rect {
if let Some(row) = self.rows.last() {
let x = row.max_x();
Rect::from_min_max(pos2(x, row.y_min), pos2(x, row.y_max))
let x = row.rect.right();
Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y()))
} else {
// Empty galley
Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 0.0))
@@ -201,7 +404,7 @@ impl Galley {
&& column >= row.char_count_excluding_newline();
if !select_next_row_instead {
let x = row.x_offset(column);
return Rect::from_min_max(pos2(x, row.y_min), pos2(x, row.y_max));
return Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y()));
}
}
}
@@ -219,7 +422,7 @@ impl Galley {
/// Returns a 0-width Rect.
pub fn pos_from_cursor(&self, cursor: &Cursor) -> Rect {
self.pos_from_pcursor(cursor.pcursor) // The one TextEdit stores
self.pos_from_pcursor(cursor.pcursor) // pcursor is what TextEdit stores
}
/// Cursor at the given position within the galley
@@ -231,8 +434,8 @@ impl Galley {
let mut pcursor_it = PCursor::default();
for (row_nr, row) in self.rows.iter().enumerate() {
let is_pos_within_row = pos.y >= row.y_min && pos.y <= row.y_max;
let y_dist = (row.y_min - pos.y).abs().min((row.y_max - pos.y).abs());
let is_pos_within_row = pos.y >= row.min_y() && pos.y <= row.max_y();
let y_dist = (row.min_y() - pos.y).abs().min((row.max_y() - pos.y).abs());
if is_pos_within_row || y_dist < best_y_dist {
best_y_dist = y_dist;
let column = row.char_at(pos.x);
@@ -515,7 +718,7 @@ impl Galley {
} else {
// keep same X coord
let x = self.pos_from_cursor(cursor).center().x;
let column = if x > self.rows[new_row].max_x() {
let column = if x > self.rows[new_row].rect.right() {
// beyond the end of this row - keep same colum
cursor.rcursor.column
} else {
@@ -546,7 +749,7 @@ impl Galley {
} else {
// keep same X coord
let x = self.pos_from_cursor(cursor).center().x;
let column = if x > self.rows[new_row].max_x() {
let column = if x > self.rows[new_row].rect.right() {
// beyond the end of the next row - keep same column
cursor.rcursor.column
} else {
@@ -578,244 +781,3 @@ impl Galley {
})
}
}
// ----------------------------------------------------------------------------
#[test]
fn test_text_layout() {
impl PartialEq for Cursor {
fn eq(&self, other: &Cursor) -> bool {
(self.ccursor, self.rcursor, self.pcursor)
== (other.ccursor, other.rcursor, other.pcursor)
}
}
use crate::*;
let pixels_per_point = 1.0;
let fonts = text::Fonts::from_definitions(pixels_per_point, text::FontDefinitions::default());
let font = &fonts[TextStyle::Monospace];
let galley = font.layout_multiline("".to_owned(), 1024.0);
assert_eq!(galley.rows.len(), 1);
assert!(!galley.rows[0].ends_with_newline);
assert_eq!(galley.rows[0].x_offsets, vec![0.0]);
let galley = font.layout_multiline("\n".to_owned(), 1024.0);
assert_eq!(galley.rows.len(), 2);
assert!(galley.rows[0].ends_with_newline);
assert!(!galley.rows[1].ends_with_newline);
assert_eq!(galley.rows[1].x_offsets, vec![0.0]);
let galley = font.layout_multiline("\n\n".to_owned(), 1024.0);
assert_eq!(galley.rows.len(), 3);
assert!(galley.rows[0].ends_with_newline);
assert!(galley.rows[1].ends_with_newline);
assert!(!galley.rows[2].ends_with_newline);
assert_eq!(galley.rows[2].x_offsets, vec![0.0]);
let galley = font.layout_multiline(" ".to_owned(), 1024.0);
assert_eq!(galley.rows.len(), 1);
assert!(!galley.rows[0].ends_with_newline);
let galley = font.layout_multiline("One row!".to_owned(), 1024.0);
assert_eq!(galley.rows.len(), 1);
assert!(!galley.rows[0].ends_with_newline);
let galley = font.layout_multiline("First row!\n".to_owned(), 1024.0);
assert_eq!(galley.rows.len(), 2);
assert!(galley.rows[0].ends_with_newline);
assert!(!galley.rows[1].ends_with_newline);
assert_eq!(galley.rows[1].x_offsets, vec![0.0]);
let galley = font.layout_multiline("line\nbreak".to_owned(), 40.0);
assert_eq!(galley.rows.len(), 2);
assert!(galley.rows[0].ends_with_newline);
assert!(!galley.rows[1].ends_with_newline);
// Test wrapping:
let galley = font.layout_multiline("word wrap".to_owned(), 40.0);
assert_eq!(galley.rows.len(), 2);
assert!(!galley.rows[0].ends_with_newline);
assert!(!galley.rows[1].ends_with_newline);
{
// Test wrapping:
let galley = font.layout_multiline("word wrap.\nNew para.".to_owned(), 40.0);
assert_eq!(galley.rows.len(), 4);
assert!(!galley.rows[0].ends_with_newline);
assert_eq!(galley.rows[0].char_count_excluding_newline(), "word ".len());
assert_eq!(galley.rows[0].char_count_including_newline(), "word ".len());
assert!(galley.rows[1].ends_with_newline);
assert_eq!(galley.rows[1].char_count_excluding_newline(), "wrap.".len());
assert_eq!(
galley.rows[1].char_count_including_newline(),
"wrap.\n".len()
);
assert_eq!(galley.rows[2].char_count_excluding_newline(), "New ".len());
assert_eq!(galley.rows[3].char_count_excluding_newline(), "para.".len());
assert!(!galley.rows[2].ends_with_newline);
assert!(!galley.rows[3].ends_with_newline);
let cursor = Cursor::default();
assert_eq!(cursor, galley.from_ccursor(cursor.ccursor));
assert_eq!(cursor, galley.from_rcursor(cursor.rcursor));
assert_eq!(cursor, galley.from_pcursor(cursor.pcursor));
let cursor = galley.end();
assert_eq!(cursor, galley.from_ccursor(cursor.ccursor));
assert_eq!(cursor, galley.from_rcursor(cursor.rcursor));
assert_eq!(cursor, galley.from_pcursor(cursor.pcursor));
assert_eq!(
cursor,
Cursor {
ccursor: CCursor::new(20),
rcursor: RCursor { row: 3, column: 5 },
pcursor: PCursor {
paragraph: 1,
offset: 9,
prefer_next_row: false,
}
}
);
let cursor = galley.from_ccursor(CCursor::new(1));
assert_eq!(cursor.rcursor, RCursor { row: 0, column: 1 });
assert_eq!(
cursor.pcursor,
PCursor {
paragraph: 0,
offset: 1,
prefer_next_row: false,
}
);
assert_eq!(cursor, galley.from_ccursor(cursor.ccursor));
assert_eq!(cursor, galley.from_rcursor(cursor.rcursor));
assert_eq!(cursor, galley.from_pcursor(cursor.pcursor));
let cursor = galley.from_pcursor(PCursor {
paragraph: 1,
offset: 2,
prefer_next_row: false,
});
assert_eq!(cursor.rcursor, RCursor { row: 2, column: 2 });
assert_eq!(cursor, galley.from_ccursor(cursor.ccursor));
assert_eq!(cursor, galley.from_rcursor(cursor.rcursor));
assert_eq!(cursor, galley.from_pcursor(cursor.pcursor));
let cursor = galley.from_pcursor(PCursor {
paragraph: 1,
offset: 6,
prefer_next_row: false,
});
assert_eq!(cursor.rcursor, RCursor { row: 3, column: 2 });
assert_eq!(cursor, galley.from_ccursor(cursor.ccursor));
assert_eq!(cursor, galley.from_rcursor(cursor.rcursor));
assert_eq!(cursor, galley.from_pcursor(cursor.pcursor));
// On the border between two rows within the same paragraph:
let cursor = galley.from_rcursor(RCursor { row: 0, column: 5 });
assert_eq!(
cursor,
Cursor {
ccursor: CCursor::new(5),
rcursor: RCursor { row: 0, column: 5 },
pcursor: PCursor {
paragraph: 0,
offset: 5,
prefer_next_row: false,
}
}
);
assert_eq!(cursor, galley.from_rcursor(cursor.rcursor));
let cursor = galley.from_rcursor(RCursor { row: 1, column: 0 });
assert_eq!(
cursor,
Cursor {
ccursor: CCursor::new(5),
rcursor: RCursor { row: 1, column: 0 },
pcursor: PCursor {
paragraph: 0,
offset: 5,
prefer_next_row: false,
}
}
);
assert_eq!(cursor, galley.from_rcursor(cursor.rcursor));
}
{
// Test cursor movement:
let galley = font.layout_multiline("word wrap.\nNew para.".to_owned(), 40.0);
assert_eq!(galley.rows.len(), 4);
assert!(!galley.rows[0].ends_with_newline);
assert!(galley.rows[1].ends_with_newline);
assert!(!galley.rows[2].ends_with_newline);
assert!(!galley.rows[3].ends_with_newline);
let cursor = Cursor::default();
assert_eq!(galley.cursor_up_one_row(&cursor), cursor);
assert_eq!(galley.cursor_begin_of_row(&cursor), cursor);
assert_eq!(
galley.cursor_end_of_row(&cursor),
Cursor {
ccursor: CCursor::new(5),
rcursor: RCursor { row: 0, column: 5 },
pcursor: PCursor {
paragraph: 0,
offset: 5,
prefer_next_row: false,
}
}
);
assert_eq!(
galley.cursor_down_one_row(&cursor),
Cursor {
ccursor: CCursor::new(5),
rcursor: RCursor { row: 1, column: 0 },
pcursor: PCursor {
paragraph: 0,
offset: 5,
prefer_next_row: false,
}
}
);
let cursor = Cursor::default();
assert_eq!(
galley.cursor_down_one_row(&galley.cursor_down_one_row(&cursor)),
Cursor {
ccursor: CCursor::new(11),
rcursor: RCursor { row: 2, column: 0 },
pcursor: PCursor {
paragraph: 1,
offset: 0,
prefer_next_row: false,
}
}
);
let cursor = galley.end();
assert_eq!(galley.cursor_down_one_row(&cursor), cursor);
let cursor = galley.end();
assert!(galley.cursor_up_one_row(&galley.end()) != cursor);
assert_eq!(
galley.cursor_up_one_row(&galley.end()),
Cursor {
ccursor: CCursor::new(15),
rcursor: RCursor { row: 2, column: 5 },
pcursor: PCursor {
paragraph: 1,
offset: 4,
prefer_next_row: false,
}
}
);
}
}