You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
trezor-firmware/core/embed/rust/src/ui/component/text/layout.rs

748 lines
24 KiB

use super::iter::GlyphMetrics;
use crate::ui::{
display,
display::{toif::Icon, Color, Font},
geometry::{Alignment, Dimensions, Offset, Point, Rect, BOTTOM_LEFT},
};
const ELLIPSIS: &str = "...";
#[derive(Copy, Clone)]
pub enum LineBreaking {
/// Break line only at whitespace, if possible. If we don't find any
/// whitespace, break words.
BreakAtWhitespace,
/// Break words, adding a hyphen before the line-break. Does not use any
/// smart algorithm, just char-by-char.
BreakWordsAndInsertHyphen,
/// Break words char-by-char, don't insert hyphens.
BreakWordsNoHyphen,
}
#[derive(Copy, Clone)]
pub enum PageBreaking {
/// Stop after hitting the bottom-right edge of the bounds.
Cut,
/// Before stopping at the bottom-right edge, insert ellipsis to signify
/// more content is available, but only if no hyphen has been inserted yet.
CutAndInsertEllipsis,
/// Same as `CutAndInsertEllipsis` but also insert an ellipsis/icon at the
/// beginning of the next page.
CutAndInsertEllipsisBoth,
}
/// Visual instructions for laying out a formatted block of text.
#[derive(Copy, Clone)]
pub struct TextLayout {
/// Bounding box restricting the layout dimensions.
pub bounds: Rect,
/// Additional space before beginning of text, can be negative to shift text
/// upwards.
pub padding_top: i16,
/// Additional space between end of text and bottom of bounding box, can be
/// negative.
pub padding_bottom: i16,
/// Fonts, colors, line/page breaking behavior.
pub style: TextStyle,
/// Horizontal alignment.
pub align: Alignment,
/// Whether to draw "..." (or an icon) at the beginning.
pub continues_from_prev_page: bool,
}
#[derive(Copy, Clone)]
pub struct TextStyle {
/// Text font ID. Can be overridden by `Op::Font`.
pub text_font: Font,
/// Text color. Can be overridden by `Op::Color`.
pub text_color: Color,
/// Background color.
pub background_color: Color,
/// Foreground color used for drawing the hyphen.
pub hyphen_color: Color,
/// Foreground color used for drawing the ellipsis.
pub ellipsis_color: Color,
/// Optional icon shown as ellipsis.
pub ellipsis_icon: Option<Icon>,
/// Optional icon to signal content continues from previous page.
pub prev_page_ellipsis_icon: Option<Icon>,
/// Specifies which line-breaking strategy to use.
pub line_breaking: LineBreaking,
/// Specifies what to do at the end of the page.
pub page_breaking: PageBreaking,
}
impl TextStyle {
pub const fn new(
text_font: Font,
text_color: Color,
background_color: Color,
hyphen_color: Color,
ellipsis_color: Color,
) -> Self {
TextStyle {
text_font,
text_color,
background_color,
hyphen_color,
ellipsis_color,
ellipsis_icon: None,
prev_page_ellipsis_icon: None,
line_breaking: LineBreaking::BreakAtWhitespace,
page_breaking: PageBreaking::CutAndInsertEllipsis,
}
}
pub const fn with_line_breaking(mut self, line_breaking: LineBreaking) -> Self {
self.line_breaking = line_breaking;
self
}
pub const fn with_page_breaking(mut self, page_breaking: PageBreaking) -> Self {
self.page_breaking = page_breaking;
self
}
/// Adding optional icon shown instead of "..." ellipsis.
pub const fn with_ellipsis_icon(mut self, icon: Icon) -> Self {
self.ellipsis_icon = Some(icon);
self
}
/// Adding optional icon signalling content continues from previous page.
pub const fn with_prev_page_icon(mut self, icon: Icon) -> Self {
self.prev_page_ellipsis_icon = Some(icon);
self
}
fn ellipsis_width(&self) -> i16 {
if let Some(icon) = self.ellipsis_icon {
icon.toif.width()
} else {
self.text_font.text_width(ELLIPSIS)
}
}
fn prev_page_ellipsis_width(&self) -> i16 {
if let Some(icon) = self.prev_page_ellipsis_icon {
icon.toif.width()
} else {
self.text_font.text_width(ELLIPSIS)
}
}
}
impl TextLayout {
/// Create a new text layout, with empty size and default text parameters
/// filled from `T`.
pub fn new(style: TextStyle) -> Self {
Self {
bounds: Rect::zero(),
padding_top: 0,
padding_bottom: 0,
style,
align: Alignment::Start,
continues_from_prev_page: false,
}
}
pub fn with_bounds(mut self, bounds: Rect) -> Self {
self.bounds = bounds;
self
}
pub fn with_align(mut self, align: Alignment) -> Self {
self.align = align;
self
}
pub fn initial_cursor(&self) -> Point {
let font = &self.style.text_font;
self.bounds.top_left()
+ Offset::y(font.text_max_height() - font.text_baseline() + self.padding_top)
}
pub fn fit_text(&self, text: &str) -> LayoutFit {
self.layout_text(text, &mut self.initial_cursor(), &mut TextNoOp)
}
pub fn render_text(&self, text: &str) {
self.layout_text(text, &mut self.initial_cursor(), &mut TextRenderer);
}
pub fn layout_ops(
mut self,
ops: &mut dyn Iterator<Item = Op<'_>>,
cursor: &mut Point,
sink: &mut dyn LayoutSink,
) -> LayoutFit {
let init_cursor = *cursor;
let mut total_processed_chars = 0;
for op in ops {
match op {
Op::Color(color) => {
self.style.text_color = color;
}
Op::Font(font) => {
self.style.text_font = font;
}
Op::Text(text) => match self.layout_text(text, cursor, sink) {
LayoutFit::Fitting {
processed_chars, ..
} => {
total_processed_chars += processed_chars;
}
LayoutFit::OutOfBounds {
processed_chars, ..
} => {
total_processed_chars += processed_chars;
return LayoutFit::OutOfBounds {
processed_chars: total_processed_chars,
height: self.layout_height(init_cursor, *cursor),
};
}
},
}
}
LayoutFit::Fitting {
processed_chars: total_processed_chars,
height: self.layout_height(init_cursor, *cursor),
}
}
pub fn layout_text(
&self,
text: &str,
cursor: &mut Point,
sink: &mut dyn LayoutSink,
) -> LayoutFit {
let init_cursor = *cursor;
let bottom = (self.bounds.y1 - self.padding_bottom).max(self.bounds.y0);
let mut remaining_text = text;
// Check if bounding box is high enough for at least one line.
if cursor.y > bottom {
sink.out_of_bounds();
return LayoutFit::OutOfBounds {
processed_chars: 0,
height: 0,
};
}
// Draw the arrow icon if we are in the middle of a string
if matches!(
self.style.page_breaking,
PageBreaking::CutAndInsertEllipsisBoth
) && self.continues_from_prev_page
{
sink.prev_page_ellipsis(*cursor, self);
cursor.x += self.style.prev_page_ellipsis_width();
}
while !remaining_text.is_empty() {
let is_last_line = cursor.y + self.style.text_font.line_height() > self.bottom_y();
let line_ending_space = if is_last_line {
self.style.ellipsis_width()
} else {
0
};
let remaining_width = self.bounds.x1 - cursor.x;
let span = Span::fit_horizontally(
remaining_text,
remaining_width,
self.style.text_font,
self.style.line_breaking,
line_ending_space,
);
cursor.x += match self.align {
Alignment::Start => 0,
Alignment::Center => {
let start_bearing_x = self.style.text_font.start_x_bearing(&remaining_text[..span.length]);
let mut visible_width = self.style.text_font.visible_text_width(&remaining_text[..span.length]);
if span.advance.y> 0 && span.insert_hyphen_before_line_break {
let hyphen_width = self.style.text_font.visible_text_width("-");
let hyphen_bearing_x = self.style.text_font.start_x_bearing("-");
visible_width = span.advance.x - start_bearing_x + hyphen_width + hyphen_bearing_x;
}
if cursor.x != init_cursor.x {
}
((remaining_width - visible_width) / 2) - start_bearing_x
},
Alignment::End => remaining_width - span.advance.x,
};
// Report the span at the cursor position.
sink.text(*cursor, self, &remaining_text[..span.length]);
// Continue with the rest of the remaining_text.
remaining_text = &remaining_text[span.length + span.skip_next_chars..];
// Advance the cursor horizontally.
cursor.x += span.advance.x;
if span.advance.y > 0 {
// We're advancing to the next line.
// Check if we should be appending a hyphen at this point.
if span.insert_hyphen_before_line_break {
sink.hyphen(*cursor, self);
}
// Check the amount of vertical space we have left.
if cursor.y + span.advance.y > bottom {
if !remaining_text.is_empty() {
// Append ellipsis to indicate more content is available, but only if we
// haven't already appended a hyphen.
let should_append_ellipsis = matches!(
self.style.page_breaking,
PageBreaking::CutAndInsertEllipsis
| PageBreaking::CutAndInsertEllipsisBoth
) && !span.insert_hyphen_before_line_break;
if should_append_ellipsis {
sink.ellipsis(*cursor, self);
}
// TODO: This does not work in case we are the last
// fitting text token on the line, with more text tokens
// following and `text.is_empty() == true`.
}
// Report we are out of bounds and quit.
sink.out_of_bounds();
return LayoutFit::OutOfBounds {
processed_chars: text.len() - remaining_text.len(),
height: self.layout_height(init_cursor, *cursor),
};
} else {
// Advance the cursor to the beginning of the next line.
cursor.x = self.bounds.x0;
cursor.y += span.advance.y;
// Report a line break. While rendering works using the cursor coordinates, we
// use explicit line-break reporting in the `Trace` impl.
sink.line_break(*cursor);
}
}
}
LayoutFit::Fitting {
processed_chars: text.len(),
height: self.layout_height(init_cursor, *cursor),
}
}
fn layout_height(&self, init_cursor: Point, end_cursor: Point) -> i16 {
self.padding_top
+ self.style.text_font.text_height()
+ (end_cursor.y - init_cursor.y)
+ self.padding_bottom
}
fn bottom_y(&self) -> i16 {
(self.bounds.y1 - self.padding_bottom).max(self.bounds.y0)
}
}
impl Dimensions for TextLayout {
fn fit(&mut self, area: Rect) {
self.bounds = area;
}
fn area(&self) -> Rect {
self.bounds
}
}
pub enum LayoutFit {
/// Entire content fits. Vertical size is returned in `height`.
Fitting { processed_chars: usize, height: i16 },
/// Content fits partially or not at all.
OutOfBounds { processed_chars: usize, height: i16 },
}
impl LayoutFit {
pub fn height(&self) -> i16 {
match self {
LayoutFit::Fitting { height, .. } => *height,
LayoutFit::OutOfBounds { height, .. } => *height,
}
}
}
/// Visitor for text segment operations.
pub trait LayoutSink {
fn text(&mut self, _cursor: Point, _layout: &TextLayout, _text: &str) {}
fn hyphen(&mut self, _cursor: Point, _layout: &TextLayout) {}
fn ellipsis(&mut self, _cursor: Point, _layout: &TextLayout) {}
fn prev_page_ellipsis(&mut self, _cursor: Point, _layout: &TextLayout) {}
fn line_break(&mut self, _cursor: Point) {}
fn out_of_bounds(&mut self) {}
}
pub struct TextNoOp;
impl LayoutSink for TextNoOp {}
pub struct TextRenderer;
impl LayoutSink for TextRenderer {
fn text(&mut self, cursor: Point, layout: &TextLayout, text: &str) {
display::text(
cursor,
text,
layout.style.text_font,
layout.style.text_color,
layout.style.background_color,
);
}
fn hyphen(&mut self, cursor: Point, layout: &TextLayout) {
display::text(
cursor,
"-",
layout.style.text_font,
layout.style.hyphen_color,
layout.style.background_color,
);
}
fn ellipsis(&mut self, cursor: Point, layout: &TextLayout) {
if let Some(icon) = layout.style.ellipsis_icon {
icon.draw(
cursor,
BOTTOM_LEFT,
layout.style.ellipsis_color,
layout.style.background_color,
);
} else {
display::text(
cursor,
ELLIPSIS,
layout.style.text_font,
layout.style.ellipsis_color,
layout.style.background_color,
);
}
}
fn prev_page_ellipsis(&mut self, cursor: Point, layout: &TextLayout) {
if let Some(icon) = layout.style.prev_page_ellipsis_icon {
icon.draw(
cursor,
BOTTOM_LEFT,
layout.style.ellipsis_color,
layout.style.background_color,
);
} else {
display::text(
cursor,
ELLIPSIS,
layout.style.text_font,
layout.style.ellipsis_color,
layout.style.background_color,
);
}
}
}
#[cfg(feature = "ui_debug")]
pub mod trace {
use crate::ui::geometry::Point;
use super::*;
pub struct TraceSink<'a>(pub &'a mut dyn crate::trace::Tracer);
impl<'a> LayoutSink for TraceSink<'a> {
fn text(&mut self, _cursor: Point, _layout: &TextLayout, text: &str) {
self.0.string(text);
}
fn hyphen(&mut self, _cursor: Point, _layout: &TextLayout) {
self.0.string("-");
}
fn ellipsis(&mut self, _cursor: Point, _layout: &TextLayout) {
self.0.string("...");
}
fn prev_page_ellipsis(&mut self, _cursor: Point, _layout: &TextLayout) {
self.0.string("...");
}
fn line_break(&mut self, _cursor: Point) {
self.0.string("\n");
}
}
}
#[derive(Copy, Clone, PartialEq, Eq)]
pub enum Op<'a> {
/// Render text with current color and font.
Text(&'a str),
/// Set current text color.
Color(Color),
/// Set currently used font.
Font(Font),
}
impl<'a> Op<'a> {
pub fn skip_n_text_bytes(
ops: impl Iterator<Item = Op<'a>>,
skip_bytes: usize,
) -> impl Iterator<Item = Op<'a>> {
let mut skipped = 0;
ops.filter_map(move |op| match op {
Op::Text(text) if skipped < skip_bytes => {
skipped = skipped.saturating_add(text.len());
if skipped > skip_bytes {
let leave_bytes = skipped - skip_bytes;
Some(Op::Text(&text[text.len() - leave_bytes..]))
} else {
None
}
}
op_to_pass_through => Some(op_to_pass_through),
})
}
}
#[derive(Debug, PartialEq, Eq)]
struct Span {
/// How many characters from the input text this span is laying out.
length: usize,
/// How many chars from the input text should we skip before fitting the
/// next span?
skip_next_chars: usize,
/// By how much to offset the cursor after this span. If the vertical offset
/// is bigger than zero, it means we are breaking the line.
advance: Offset,
/// If we are breaking the line, should we insert a hyphen right after this
/// span to indicate a word-break?
insert_hyphen_before_line_break: bool,
}
impl Span {
fn fit_horizontally(
text: &str,
max_width: i16,
text_font: impl GlyphMetrics,
breaking: LineBreaking,
line_ending_space: i16,
) -> Self {
const ASCII_LF: char = '\n';
const ASCII_CR: char = '\r';
const ASCII_SPACE: char = ' ';
const ASCII_HYPHEN: char = '-';
fn is_whitespace(ch: char) -> bool {
ch == ASCII_SPACE || ch == ASCII_LF || ch == ASCII_CR
}
// Checking if the trimmed text fits the line - the whitespace is not being
// drawn, so we don't need to account for it.
let fits_completely = text_font.text_width(text.trim_end()) <= max_width;
let mut use_hyphens = !matches!(breaking, LineBreaking::BreakWordsNoHyphen);
// How much space we need to left unused at the end of the line
// (e.g. for the line-ending hyphen or page-ending ellipsis).
// Differs for incomplete and complete words (incomplete need
// to account for a possible hyphen).
let incomplete_word_end_width = if fits_completely {
use_hyphens = false;
0
} else if line_ending_space > 0 {
use_hyphens = false;
line_ending_space
} else if use_hyphens {
text_font.char_width(ASCII_HYPHEN)
} else {
0
};
let complete_word_end_width = if fits_completely {
0
} else {
line_ending_space
};
// The span we return in case the line has to break. We mutate it in the
// possible break points, and its initial value is returned in case no text
// at all is fitting the constraints: zero length, zero width, full line
// break.
let mut line = Self {
length: 0,
advance: Offset::y(text_font.line_height()),
insert_hyphen_before_line_break: false,
skip_next_chars: 0,
};
let mut span_width = 0;
let mut found_any_whitespace = false;
let mut char_indices_iter = text.char_indices().peekable();
// Iterating manually because we need a reference to the iterator inside the
// loop.
while let Some((i, ch)) = char_indices_iter.next() {
let char_width = text_font.char_width(ch);
// Consider if we could be breaking the line at this position.
if is_whitespace(ch) && span_width + complete_word_end_width <= max_width {
// Break before the whitespace, without hyphen.
line.length = i;
line.advance.x = span_width;
line.insert_hyphen_before_line_break = false;
line.skip_next_chars = 1;
if ch == ASCII_CR {
// We'll be breaking the line, but advancing the cursor only by a half of the
// regular line height.
line.advance.y = text_font.line_height() / 2;
}
if ch == ASCII_LF || ch == ASCII_CR {
// End of line, break immediately.
return line;
}
found_any_whitespace = true;
} else if span_width + char_width > max_width {
// Return the last breakpoint.
return line;
} else {
let have_space_for_break =
span_width + char_width + incomplete_word_end_width <= max_width;
let can_break_word =
!matches!(breaking, LineBreaking::BreakAtWhitespace) || !found_any_whitespace;
if have_space_for_break && can_break_word {
// Break after this character, append hyphen.
line.length = match char_indices_iter.peek() {
Some((idx, _)) => *idx,
None => text.len(),
};
line.advance.x = span_width + char_width;
line.insert_hyphen_before_line_break = use_hyphens;
line.skip_next_chars = 0;
}
}
span_width += char_width;
}
// The whole text is fitting.
Self {
length: text.len(),
advance: Offset::x(span_width),
insert_hyphen_before_line_break: false,
skip_next_chars: 0,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
pub struct Fixed {
pub width: i16,
pub height: i16,
}
impl GlyphMetrics for Fixed {
fn char_width(&self, _ch: char) -> i16 {
self.width
}
fn line_height(&self) -> i16 {
self.height
}
fn text_width(&self, text: &str) -> i16 {
self.width * text.len() as i16
}
}
const FIXED_FONT: Fixed = Fixed {
width: 1,
height: 1,
};
#[test]
fn test_span() {
assert_eq!(spans_from("hello", 5), vec![("hello", false)]);
assert_eq!(spans_from("", 5), vec![("", false)]);
assert_eq!(
spans_from("hello world", 5),
vec![("hello", false), ("world", false)]
);
assert_eq!(
spans_from("hello\nworld", 5),
vec![("hello", false), ("world", false)]
);
}
#[test]
#[ignore]
fn test_leading_trailing() {
assert_eq!(
spans_from("\nhello\nworld\n", 5),
vec![("", false), ("hello", false), ("world", false), ("", false)]
);
}
#[test]
fn test_long_word() {
assert_eq!(
spans_from("Down with the establishment!", 5),
vec![
("Down", false),
("with", false),
("the", false),
("esta", true),
("blis", true),
("hmen", true),
("t!", false),
]
);
}
#[test]
fn test_char_boundary() {
assert_eq!(
spans_from("+ěščřžýáíé", 5),
vec![("+ěšč", true), ("řžýá", true), ("íé", false)]
);
}
fn spans_from(text: &str, max_width: i16) -> Vec<(&str, bool)> {
let mut spans = vec![];
let mut remaining_text = text;
loop {
let span = Span::fit_horizontally(
remaining_text,
max_width,
FIXED_FONT,
LineBreaking::BreakAtWhitespace,
0,
);
spans.push((
&remaining_text[..span.length],
span.insert_hyphen_before_line_break,
));
remaining_text = &remaining_text[span.length + span.skip_next_chars..];
if remaining_text.is_empty() {
break;
}
}
spans
}
}