Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic support for text highlighting #1276

Closed
wants to merge 7 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 192 additions & 1 deletion native/src/widget/text.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
//! Write some text for your users to read.
use unicode_segmentation::UnicodeSegmentation;

use crate::alignment;
use crate::layout;
use crate::renderer;
use crate::text;
use crate::{Color, Element, Layout, Length, Point, Rectangle, Size, Widget};

use std::cmp::Ordering;

/// The background color for part of a [`Text`].
#[derive(Clone, Debug)]
pub struct Highlight {
/// The starting grapheme index of the [`Highlight`].
pub start: usize,
/// The ending grapheme index of the [`Highlight`].
pub end: usize,
/// The color of the [`Highlight`].
pub color: Color,
}

/// A paragraph of text.
///
/// # Example
Expand All @@ -23,6 +38,7 @@ pub struct Text<Renderer: text::Renderer> {
content: String,
size: Option<u16>,
color: Option<Color>,
highlights: Vec<Highlight>,
font: Renderer::Font,
width: Length,
height: Length,
Expand All @@ -37,6 +53,7 @@ impl<Renderer: text::Renderer> Text<Renderer> {
content: label.into(),
size: None,
color: None,
highlights: Default::default(),
font: Default::default(),
width: Length::Shrink,
height: Length::Shrink,
Expand All @@ -57,6 +74,14 @@ impl<Renderer: text::Renderer> Text<Renderer> {
self
}

/// Sets the background [`Color`] of the [`Text`] between the given grapheme indexes.
///
/// Can be called multiple times to highlight multiple parts of the text.
pub fn highlight(mut self, start: usize, end: usize, color: Color) -> Self {
self.highlights.push(Highlight { start, end, color });
self
}

/// Sets the [`Font`] of the [`Text`].
///
/// [`Font`]: Renderer::Font
Expand Down Expand Up @@ -135,6 +160,17 @@ where
_cursor_position: Point,
_viewport: &Rectangle,
) {
draw_highlights(
renderer,
layout,
&self.content,
self.font.clone(),
self.size,
&self.highlights,
self.horizontal_alignment,
self.vertical_alignment,
);

draw(
renderer,
style,
Expand Down Expand Up @@ -186,9 +222,11 @@ pub fn draw<Renderer>(
alignment::Vertical::Bottom => bounds.y + bounds.height,
};

let size = size.unwrap_or(renderer.default_size());

renderer.fill_text(crate::text::Text {
content,
size: f32::from(size.unwrap_or(renderer.default_size())),
size: f32::from(size),
bounds: Rectangle { x, y, ..bounds },
color: color.unwrap_or(style.text_color),
font,
Expand All @@ -197,6 +235,158 @@ pub fn draw<Renderer>(
});
}

/// Draws highlights behind text (But not the text itself) using the same logic as the [`Text`] widget.
///
/// Specifically:
///
/// * If no `size` is provided, the default text size of the `Renderer` will be
/// used.
/// * The alignment attributes do not affect the position of the bounds of the
/// [`Layout`].
pub fn draw_highlights<Renderer>(
renderer: &mut Renderer,
layout: Layout<'_>,
content: &str,
font: Renderer::Font,
size: Option<u16>,
highlights: &[Highlight],
horizontal_alignment: alignment::Horizontal,
vertical_alignment: alignment::Vertical,
) where
Renderer: text::Renderer,
{
let bounds = layout.bounds();

let x = match horizontal_alignment {
alignment::Horizontal::Left => bounds.x,
alignment::Horizontal::Center => bounds.center_x(),
alignment::Horizontal::Right => bounds.x + bounds.width,
};

let y = match vertical_alignment {
alignment::Vertical::Top => bounds.y,
alignment::Vertical::Center => bounds.center_y(),
alignment::Vertical::Bottom => bounds.y + bounds.height,
};

let size = size.unwrap_or_else(|| renderer.default_size());

// Cache byte offsets up to the highest accessed index
let mut byte_offsets = Vec::new();
let mut grapheme_indices = content.grapheme_indices(true).map(|(i, _)| i);
let mut get_byte_offset = |grapheme_index| {
byte_offsets.get(grapheme_index).copied().or_else(|| {
byte_offsets.extend(
grapheme_indices
.by_ref()
.take((grapheme_index - byte_offsets.len()) + 1),
);
byte_offsets.get(grapheme_index).copied()
})
};

for &Highlight { start, end, color } in highlights {
let start_index = if let Some(index) = get_byte_offset(start.min(end)) {
index
} else {
continue;
};

let end_index = if let Some(index) = get_byte_offset(start.max(end)) {
index
} else {
continue;
};

// The content prior to the start of the highlight is relevant for calculating offsets:
// The total height of all the lines of text above the line with the highlight,
// and the width of all the text in the line with the highlight, up until the start of the highlight.
let before_start = &content[..start_index];

// Iced's text layouting treats standalone carriage returns (\r) as line endings, but `str::lines`
// does not, so we must manually search for the relevant line ending* instead.
let r = before_start.rfind('\r');
let n = before_start.rfind('\n');
let before_start_linebreak = r
.zip(n)
// If `zip` returns `Some(_)`, there may be multiple line endings
.map(|(r, n)| match (r + 1).cmp(&n) {
// The rightmost line ending is `\n`
Ordering::Less => (n, n),
// The rightmost line ending is `\r\n`
Ordering::Equal => (r, n),
// The rightmost line ending is `\r`
Ordering::Greater => (r, r),
})
// If `zip` returns `None`, there is either 1 or 0 line endings - if 1, `xor` returns `Some(_)`
.or_else(|| r.xor(n).map(|i| (i, i)));

// *Get the text preceding and following the rightmost line ending
let (above_lines, before_start_line) = match before_start_linebreak {
Some((l, r)) => (&before_start[..l], &before_start[(r + 1)..]),
None => (Default::default(), before_start),
};

// Measure height of lines up until the line that contains the start of the highlight
let (_, mut height_offset) =
renderer.measure(above_lines, size, font.clone(), Size::INFINITY);

// If the highlight crosses over multiple lines, draw a seperate rect on each line
// BUG: This ignores single `\r` but Iced's text layouting does not (See above)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rust only considers \n and \r\n as line endings, but Iced's text layouting also considers a standalone \r as a line ending.

This isn't exactly a hard blocker, but I'm unaware of the consensus of if this behaviour should be kept or not.

// BUG #2: Text wrapping caused by the text not being given wide enough bounds is not handled at all
// (And furthermore it currently _can't_ be handled because there's no way to get information about it)
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If there is a way to handle this, please correct me, but if not, this would be a blocker on ensuring correct behaviour.

let mut lines = content[start_index..end_index].lines();

// Unroll the first iteration of the loop as only the first line needs this offset
let first_line_offset =
renderer.measure_width(before_start_line, size, font.clone());

let (width, height) = renderer.measure(
lines.next().unwrap_or_default(),
size,
font.clone(),
Size::INFINITY,
);

let quad = renderer::Quad {
bounds: Rectangle {
x: x + first_line_offset,
y: y + height_offset,
width,
height,
},
border_radius: 0.0,
border_width: 0.0,
border_color: Color::TRANSPARENT,
};

renderer.fill_quad(quad, color);

height_offset += height;

for line in lines {
let (width, height) =
renderer.measure(line, size, font.clone(), Size::INFINITY);

let quad = renderer::Quad {
bounds: Rectangle {
x,
y: y + height_offset,
width,
height,
},
border_radius: 0.0,
border_width: 0.0,
border_color: Color::TRANSPARENT,
};

renderer.fill_quad(quad, color);

height_offset += height;
}
}
}

impl<'a, Message, Renderer> From<Text<Renderer>>
for Element<'a, Message, Renderer>
where
Expand All @@ -213,6 +403,7 @@ impl<Renderer: text::Renderer> Clone for Text<Renderer> {
content: self.content.clone(),
size: self.size,
color: self.color,
highlights: self.highlights.clone(),
font: self.font.clone(),
width: self.width,
height: self.height,
Expand Down