diff --git a/core/assets/page-next.png b/core/assets/page-next.png new file mode 100644 index 000000000..1c8b55513 Binary files /dev/null and b/core/assets/page-next.png differ diff --git a/core/assets/page-prev.png b/core/assets/page-prev.png new file mode 100644 index 000000000..a0bdda1d8 Binary files /dev/null and b/core/assets/page-prev.png differ diff --git a/core/embed/rust/src/ui/component/text/iter.rs b/core/embed/rust/src/ui/component/text/iter.rs index 29273f490..6834b11f1 100644 --- a/core/embed/rust/src/ui/component/text/iter.rs +++ b/core/embed/rust/src/ui/component/text/iter.rs @@ -160,6 +160,7 @@ fn select_line_breaks( pub trait GlyphMetrics { fn char_width(&self, ch: char) -> i16; + fn text_width(&self, text: &str) -> i16; fn line_height(&self) -> i16; } @@ -168,6 +169,10 @@ impl GlyphMetrics for Font { Font::char_width(*self, ch) } + fn text_width(&self, text: &str) -> i16 { + Font::text_width(*self, text) + } + fn line_height(&self) -> i16 { Font::line_height(*self) } @@ -217,6 +222,10 @@ mod tests { fn line_height(&self) -> i16 { self.height } + + fn text_width(&self, text: &str) -> i16 { + self.width * text.len() as i16 + } } fn break_text(s: &str, w: i16) -> Vec { diff --git a/core/embed/rust/src/ui/component/text/layout.rs b/core/embed/rust/src/ui/component/text/layout.rs index c2b40448c..e1409d693 100644 --- a/core/embed/rust/src/ui/component/text/layout.rs +++ b/core/embed/rust/src/ui/component/text/layout.rs @@ -1,10 +1,12 @@ use super::iter::GlyphMetrics; use crate::ui::{ display, - display::{Color, Font}, - geometry::{Alignment, Dimensions, Offset, Point, Rect}, + 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 @@ -24,6 +26,9 @@ pub enum PageBreaking { /// 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. @@ -43,6 +48,9 @@ pub struct TextLayout { 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)] @@ -58,6 +66,10 @@ pub struct TextStyle { pub hyphen_color: Color, /// Foreground color used for drawing the ellipsis. pub ellipsis_color: Color, + /// Optional icon shown as ellipsis. + pub ellipsis_icon: Option, + /// Optional icon to signal content continues from previous page. + pub prev_page_ellipsis_icon: Option, /// Specifies which line-breaking strategy to use. pub line_breaking: LineBreaking, @@ -79,6 +91,8 @@ impl TextStyle { background_color, hyphen_color, ellipsis_color, + ellipsis_icon: None, + prev_page_ellipsis_icon: None, line_breaking: LineBreaking::BreakAtWhitespace, page_breaking: PageBreaking::CutAndInsertEllipsis, } @@ -93,6 +107,34 @@ impl TextStyle { 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 { @@ -105,6 +147,7 @@ impl TextLayout { padding_bottom: 0, style, align: Alignment::Start, + continues_from_prev_page: false, } } @@ -192,13 +235,31 @@ impl TextLayout { }; } + // 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 { @@ -228,9 +289,11 @@ impl TextLayout { 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) - && !span.insert_hyphen_before_line_break; + 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); } @@ -270,6 +333,10 @@ impl TextLayout { + (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 { @@ -303,6 +370,7 @@ 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) {} } @@ -335,13 +403,41 @@ impl LayoutSink for TextRenderer { } fn ellipsis(&mut self, cursor: Point, layout: &TextLayout) { - display::text( - cursor, - "...", - layout.style.text_font, - layout.style.ellipsis_color, - layout.style.background_color, - ); + 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, + ); + } } } @@ -366,6 +462,10 @@ pub mod trace { 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"); } @@ -425,6 +525,7 @@ impl Span { max_width: i16, text_font: impl GlyphMetrics, breaking: LineBreaking, + line_ending_space: i16, ) -> Self { const ASCII_LF: char = '\n'; const ASCII_CR: char = '\r'; @@ -435,12 +536,31 @@ impl Span { ch == ASCII_SPACE || ch == ASCII_LF || ch == ASCII_CR } - let use_hyphens = !matches!(breaking, LineBreaking::BreakWordsNoHyphen); - let hyphen_width = if use_hyphens { + // 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 @@ -463,7 +583,7 @@ impl Span { let char_width = text_font.char_width(ch); // Consider if we could be breaking the line at this position. - if is_whitespace(ch) { + 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; @@ -483,7 +603,8 @@ impl Span { // Return the last breakpoint. return line; } else { - let have_space_for_break = span_width + char_width + hyphen_width <= max_width; + 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 { @@ -528,6 +649,10 @@ mod tests { 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 { @@ -591,6 +716,7 @@ mod tests { max_width, FIXED_FONT, LineBreaking::BreakAtWhitespace, + 0, ); spans.push(( &remaining_text[..span.length], diff --git a/core/embed/rust/src/ui/component/text/paragraphs.rs b/core/embed/rust/src/ui/component/text/paragraphs.rs index 75bb54f8d..61788756f 100644 --- a/core/embed/rust/src/ui/component/text/paragraphs.rs +++ b/core/embed/rust/src/ui/component/text/paragraphs.rs @@ -392,6 +392,9 @@ impl PageOffset { // Find out the dimensions of the paragraph at given char offset. let mut layout = paragraph.layout(area); + if self.chr > 0 { + layout.continues_from_prev_page = true; + } let fit = layout.fit_text(paragraph.content.as_ref()); let (used, remaining_area) = area.split_top(fit.height()); layout.bounds = used; diff --git a/core/embed/rust/src/ui/display/toif.rs b/core/embed/rust/src/ui/display/toif.rs index 2da23f0b9..6bf34bc90 100644 --- a/core/embed/rust/src/ui/display/toif.rs +++ b/core/embed/rust/src/ui/display/toif.rs @@ -114,9 +114,12 @@ pub struct Icon { } impl Icon { - pub fn new(data: &'static [u8]) -> Self { - let toif = unwrap!(Toif::new(data)); - assert!(toif.format() == ToifFormat::GrayScaleEH); + pub const fn new(data: &'static [u8]) -> Self { + let toif = match Toif::new(data) { + Some(t) => t, + None => panic!("Invalid image."), + }; + assert!(matches!(toif.format(), ToifFormat::GrayScaleEH)); Self { toif } } diff --git a/core/embed/rust/src/ui/model_tt/component/page.rs b/core/embed/rust/src/ui/model_tt/component/page.rs index d7d709048..1779b2e96 100644 --- a/core/embed/rust/src/ui/model_tt/component/page.rs +++ b/core/embed/rust/src/ui/model_tt/component/page.rs @@ -497,8 +497,8 @@ mod tests { ); page.place(SCREEN); - let expected1 = " buttons: > >"; - let expected2 = " buttons: > >"; + let expected1 = " buttons: > >"; + let expected2 = " buttons: > >"; assert_eq!(trace(&page), expected1); swipe_down(&mut page); @@ -533,9 +533,9 @@ mod tests { ); page.place(SCREEN); - let expected1 = " buttons: > >"; - let expected2 = " buttons: > >"; - let expected3 = " buttons: > >"; + let expected1 = " buttons: > >"; + let expected2 = " buttons: > >"; + let expected3 = " buttons: > >"; assert_eq!(trace(&page), expected1); swipe_down(&mut page); diff --git a/core/embed/rust/src/ui/model_tt/res/page-next.toif b/core/embed/rust/src/ui/model_tt/res/page-next.toif new file mode 100644 index 000000000..7c2088f70 Binary files /dev/null and b/core/embed/rust/src/ui/model_tt/res/page-next.toif differ diff --git a/core/embed/rust/src/ui/model_tt/res/page-prev.toif b/core/embed/rust/src/ui/model_tt/res/page-prev.toif new file mode 100644 index 000000000..b5b524427 Binary files /dev/null and b/core/embed/rust/src/ui/model_tt/res/page-prev.toif differ diff --git a/core/embed/rust/src/ui/model_tt/theme.rs b/core/embed/rust/src/ui/model_tt/theme.rs index bb18afa82..32e5b4f53 100644 --- a/core/embed/rust/src/ui/model_tt/theme.rs +++ b/core/embed/rust/src/ui/model_tt/theme.rs @@ -5,7 +5,7 @@ use crate::{ text::{formatted::FormattedFonts, LineBreaking, PageBreaking, TextStyle}, FixedHeightBar, }, - display::{Color, Font}, + display::{Color, Font, Icon}, geometry::Insets, }, }; @@ -68,6 +68,8 @@ pub const ICON_LOCK: &[u8] = include_res!("model_tt/res/lock.toif"); pub const ICON_LOGO: &[u8] = include_res!("model_tt/res/logo.toif"); pub const ICON_SUCCESS_SMALL: &[u8] = include_res!("model_tt/res/success_bld.toif"); pub const ICON_WARN_SMALL: &[u8] = include_res!("model_tt/res/warn_bld.toif"); +pub const ICON_PAGE_NEXT: &[u8] = include_res!("model_tt/res/page-next.toif"); +pub const ICON_PAGE_PREV: &[u8] = include_res!("model_tt/res/page-prev.toif"); // Large, three-color icons. pub const WARN_COLOR: Color = YELLOW; @@ -389,7 +391,9 @@ pub const TEXT_DEMIBOLD: TextStyle = TextStyle::new(Font::DEMIBOLD, FG, BG, GREY pub const TEXT_BOLD: TextStyle = TextStyle::new(Font::BOLD, FG, BG, GREY_LIGHT, GREY_LIGHT); pub const TEXT_MONO: TextStyle = TextStyle::new(Font::MONO, FG, BG, GREY_LIGHT, GREY_LIGHT) .with_line_breaking(LineBreaking::BreakWordsNoHyphen) - .with_page_breaking(PageBreaking::Cut); + .with_page_breaking(PageBreaking::CutAndInsertEllipsisBoth) + .with_ellipsis_icon(Icon::new(ICON_PAGE_NEXT)) + .with_prev_page_icon(Icon::new(ICON_PAGE_PREV)); /// Convert Python-side numeric id to a `TextStyle`. pub fn textstyle_number(num: i32) -> &'static TextStyle {