-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
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
Closed
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
8606c9c
Add support for text highlighting
BigWingBeat b62971b
Improve doc comments
BigWingBeat 1ddbf08
Index by graphemes rather than bytes
BigWingBeat ef0841e
Cache iterator results
BigWingBeat e27f278
Ignore invalid highlights over panicking
BigWingBeat d401b46
Add partial multiline support
BigWingBeat b19d9f0
Split highlight rendering into seperate function
BigWingBeat File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
|
@@ -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, | ||
|
@@ -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, | ||
|
@@ -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 | ||
|
@@ -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, | ||
|
@@ -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, | ||
|
@@ -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) | ||
// 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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
@@ -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, | ||
|
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.