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.
674 lines
20 KiB
674 lines
20 KiB
use heapless::Vec;
|
|
|
|
use crate::ui::{
|
|
component::{Component, Event, EventCtx, Never, Paginate},
|
|
display::toif::Icon,
|
|
geometry::{Alignment, Insets, LinearPlacement, Offset, Point, Rect, TOP_LEFT},
|
|
};
|
|
|
|
use super::layout::{LayoutFit, TextLayout, TextStyle};
|
|
|
|
/// Used as an upper bound of number of different styles we may render on single
|
|
/// page.
|
|
pub const MAX_LINES: usize = 10;
|
|
/// Maximum space between paragraphs. Actual result may be smaller (even 0) if
|
|
/// it would make paragraphs overflow the bounding box.
|
|
pub const DEFAULT_SPACING: i16 = 0;
|
|
/// Offset of paragraph text from the top of the paragraph bounding box. Tweak
|
|
/// these values to get nice alignment of baselines relative to the surrounding
|
|
/// components.
|
|
pub const PARAGRAPH_TOP_SPACE: i16 = -1;
|
|
/// Offset of paragraph bounding box bottom relative to bottom of its text.
|
|
pub const PARAGRAPH_BOTTOM_SPACE: i16 = 5;
|
|
|
|
pub type ParagraphVecLong<T> = Vec<Paragraph<T>, 32>;
|
|
pub type ParagraphVecShort<T> = Vec<Paragraph<T>, 8>;
|
|
|
|
/// Trait for internal representation of strings, which need to support
|
|
/// converting to short-lived &str reference as well as creating a new string by
|
|
/// skipping some number of bytes. Exists so that we can support `StrBuffer` as
|
|
/// well as `&'static str`.
|
|
///
|
|
/// NOTE: do not implement this trait for `&'static str` in firmware. We always
|
|
/// use StrBuffer because using multiple internal representations results in
|
|
/// multiple copies of the code in flash memory.
|
|
pub trait ParagraphStrType: AsRef<str> {
|
|
fn skip_prefix(&self, bytes: usize) -> Self;
|
|
}
|
|
|
|
#[cfg(feature = "bootloader")]
|
|
impl ParagraphStrType for &str {
|
|
fn skip_prefix(&self, chars: usize) -> Self {
|
|
&self[chars..]
|
|
}
|
|
}
|
|
|
|
pub trait ParagraphSource {
|
|
/// Determines the output type produced.
|
|
type StrType: ParagraphStrType;
|
|
|
|
/// Return text and associated style for given paragraph index and character
|
|
/// offset within the paragraph.
|
|
fn at(&self, index: usize, offset: usize) -> Paragraph<Self::StrType>;
|
|
|
|
/// Number of paragraphs.
|
|
fn size(&self) -> usize;
|
|
|
|
fn into_paragraphs(self) -> Paragraphs<Self>
|
|
where
|
|
Self: Sized,
|
|
{
|
|
Paragraphs::new(self)
|
|
}
|
|
}
|
|
|
|
pub struct Paragraphs<T> {
|
|
area: Rect,
|
|
placement: LinearPlacement,
|
|
offset: PageOffset,
|
|
visible: Vec<TextLayout, MAX_LINES>,
|
|
source: T,
|
|
}
|
|
|
|
impl<T> Paragraphs<T>
|
|
where
|
|
T: ParagraphSource,
|
|
{
|
|
pub fn new(source: T) -> Self {
|
|
Self {
|
|
area: Rect::zero(),
|
|
placement: LinearPlacement::vertical()
|
|
.align_at_center()
|
|
.with_spacing(DEFAULT_SPACING),
|
|
offset: PageOffset::default(),
|
|
visible: Vec::new(),
|
|
source,
|
|
}
|
|
}
|
|
|
|
pub fn with_placement(mut self, placement: LinearPlacement) -> Self {
|
|
self.placement = placement;
|
|
self
|
|
}
|
|
|
|
pub fn with_spacing(mut self, spacing: i16) -> Self {
|
|
self.placement = self.placement.with_spacing(spacing);
|
|
self
|
|
}
|
|
|
|
pub fn inner(&self) -> &T {
|
|
&self.source
|
|
}
|
|
|
|
pub fn inner_mut(&mut self) -> &mut T {
|
|
&mut self.source
|
|
}
|
|
|
|
/// Update bounding boxes of paragraphs on the current page. First determine
|
|
/// the number of visible paragraphs and their sizes. These are then
|
|
/// arranged according to the layout.
|
|
fn change_offset(&mut self, offset: PageOffset) {
|
|
self.offset = offset;
|
|
Self::dyn_change_offset(self.area, offset, &self.source, self.visible.as_mut());
|
|
self.placement.arrange(self.area, &mut self.visible);
|
|
}
|
|
|
|
/// Helper for `change_offset` which should not get monomorphized as it
|
|
/// doesn't refer to T or Self.
|
|
fn dyn_change_offset<S: ParagraphStrType>(
|
|
mut area: Rect,
|
|
mut offset: PageOffset,
|
|
source: &dyn ParagraphSource<StrType = S>,
|
|
visible: &mut Vec<TextLayout, MAX_LINES>,
|
|
) {
|
|
visible.clear();
|
|
let full_height = area.height();
|
|
|
|
while offset.par < source.size() {
|
|
let (next_offset, remaining_area, layout) = offset.advance(area, source, full_height);
|
|
if let Some(layout) = layout {
|
|
unwrap!(visible.push(layout));
|
|
}
|
|
if let Some(remaining_area) = remaining_area {
|
|
#[cfg(feature = "ui_debug")]
|
|
assert_eq!(next_offset.par, offset.par + 1);
|
|
area = remaining_area;
|
|
offset = next_offset;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
fn break_pages(&self) -> PageBreakIterator<T> {
|
|
PageBreakIterator {
|
|
paragraphs: self,
|
|
current: None,
|
|
}
|
|
}
|
|
|
|
/// Iterate over visible layouts (bounding box, style) together
|
|
/// with corresponding string content. Should not get monomorphized.
|
|
fn foreach_visible<'a, 'b, S: ParagraphStrType>(
|
|
source: &'a dyn ParagraphSource<StrType = S>,
|
|
visible: &'a [TextLayout],
|
|
offset: PageOffset,
|
|
func: &'b mut dyn FnMut(&TextLayout, &str),
|
|
) {
|
|
let mut vis_iter = visible.iter();
|
|
let mut chr = offset.chr;
|
|
|
|
for par in offset.par..source.size() {
|
|
let s = source.at(par, chr).content;
|
|
if s.as_ref().is_empty() {
|
|
chr = 0;
|
|
continue;
|
|
}
|
|
if let Some(layout) = vis_iter.next() {
|
|
func(layout, s.as_ref());
|
|
} else {
|
|
break;
|
|
}
|
|
chr = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T> Component for Paragraphs<T>
|
|
where
|
|
T: ParagraphSource,
|
|
{
|
|
type Msg = Never;
|
|
|
|
fn place(&mut self, bounds: Rect) -> Rect {
|
|
self.area = bounds;
|
|
self.change_offset(self.offset);
|
|
self.area
|
|
}
|
|
|
|
fn event(&mut self, _ctx: &mut EventCtx, _event: Event) -> Option<Self::Msg> {
|
|
None
|
|
}
|
|
|
|
fn paint(&mut self) {
|
|
Self::foreach_visible(
|
|
&self.source,
|
|
&self.visible,
|
|
self.offset,
|
|
&mut |layout, content| {
|
|
layout.render_text(content);
|
|
},
|
|
)
|
|
}
|
|
|
|
#[cfg(feature = "ui_bounds")]
|
|
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
|
|
sink(self.area);
|
|
for layout in &self.visible {
|
|
sink(layout.bounds)
|
|
}
|
|
}
|
|
}
|
|
|
|
impl<T> Paginate for Paragraphs<T>
|
|
where
|
|
T: ParagraphSource,
|
|
{
|
|
fn page_count(&mut self) -> usize {
|
|
// There's always at least one page.
|
|
self.break_pages().count().max(1)
|
|
}
|
|
|
|
fn change_page(&mut self, to_page: usize) {
|
|
if let Some(offset) = self.break_pages().nth(to_page) {
|
|
self.change_offset(offset)
|
|
} else {
|
|
// Should not happen, set index to first paragraph and render empty page.
|
|
self.offset = PageOffset::default();
|
|
self.visible.clear()
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "ui_debug")]
|
|
pub mod trace {
|
|
use crate::ui::component::text::layout::trace::TraceSink;
|
|
|
|
use super::*;
|
|
|
|
impl<T: ParagraphSource> crate::trace::Trace for Paragraphs<T> {
|
|
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
|
t.open("Paragraphs");
|
|
Self::foreach_visible(
|
|
&self.source,
|
|
&self.visible,
|
|
self.offset,
|
|
&mut |layout, content| {
|
|
layout.layout_text(content, &mut layout.initial_cursor(), &mut TraceSink(t));
|
|
t.string("\n");
|
|
},
|
|
);
|
|
t.close();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub struct Paragraph<T> {
|
|
/// Paragraph text.
|
|
content: T,
|
|
/// Paragraph style.
|
|
style: &'static TextStyle,
|
|
/// Paragraph alignment.
|
|
align: Alignment,
|
|
/// Place next paragraph on new page.
|
|
break_after: bool,
|
|
/// Try to keep this and the next paragraph on the same page. NOTE: doesn't
|
|
/// work if two or more subsequent paragraphs have this flag.
|
|
no_break: bool,
|
|
padding_top: i16,
|
|
padding_bottom: i16,
|
|
}
|
|
|
|
impl<T> Paragraph<T> {
|
|
pub const fn new(style: &'static TextStyle, content: T) -> Self {
|
|
Self {
|
|
content,
|
|
style,
|
|
align: Alignment::Start,
|
|
break_after: false,
|
|
no_break: false,
|
|
padding_top: PARAGRAPH_TOP_SPACE,
|
|
padding_bottom: PARAGRAPH_BOTTOM_SPACE,
|
|
}
|
|
}
|
|
|
|
pub const fn centered(mut self) -> Self {
|
|
self.align = Alignment::Center;
|
|
self
|
|
}
|
|
|
|
pub const fn break_after(mut self) -> Self {
|
|
self.break_after = true;
|
|
self
|
|
}
|
|
|
|
pub const fn no_break(mut self) -> Self {
|
|
self.no_break = true;
|
|
self
|
|
}
|
|
|
|
pub const fn with_top_padding(mut self, padding: i16) -> Self {
|
|
self.padding_top = padding;
|
|
self
|
|
}
|
|
|
|
pub const fn with_bottom_padding(mut self, padding: i16) -> Self {
|
|
self.padding_bottom = padding;
|
|
self
|
|
}
|
|
|
|
pub fn content(&self) -> &T {
|
|
&self.content
|
|
}
|
|
|
|
pub fn update(&mut self, content: T) {
|
|
self.content = content
|
|
}
|
|
|
|
/// Copy style and replace content.
|
|
pub fn map<U>(&self, func: impl FnOnce(&T) -> U) -> Paragraph<U> {
|
|
Paragraph {
|
|
content: func(&self.content),
|
|
style: self.style,
|
|
align: self.align,
|
|
break_after: self.break_after,
|
|
no_break: self.no_break,
|
|
padding_top: self.padding_top,
|
|
padding_bottom: self.padding_bottom,
|
|
}
|
|
}
|
|
|
|
fn layout(&self, area: Rect) -> TextLayout {
|
|
TextLayout {
|
|
padding_top: self.padding_top,
|
|
padding_bottom: self.padding_bottom,
|
|
..TextLayout::new(*self.style)
|
|
.with_align(self.align)
|
|
.with_bounds(area)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
|
struct PageOffset {
|
|
/// Index of paragraph.
|
|
par: usize,
|
|
|
|
/// Index of (the first byte of) the character in the paragraph.
|
|
chr: usize,
|
|
}
|
|
|
|
impl PageOffset {
|
|
/// Given an `PageOffset` and a `Rect` area, returns:
|
|
///
|
|
/// - The next offset.
|
|
/// - Part of `area` that remains free after the current offset is rendered
|
|
/// into it, or `None` if we've reached the end of the page.
|
|
/// - The `TextLayout` for the current offset, or `None` if `area` is too
|
|
/// small to render any text.
|
|
///
|
|
/// If the returned remaining area is not None then it holds that
|
|
/// `next_offset.par == self.par + 1`.
|
|
fn advance<S: ParagraphStrType>(
|
|
mut self,
|
|
area: Rect,
|
|
source: &dyn ParagraphSource<StrType = S>,
|
|
full_height: i16,
|
|
) -> (PageOffset, Option<Rect>, Option<TextLayout>) {
|
|
let paragraph = source.at(self.par, self.chr);
|
|
|
|
// Skip empty paragraphs.
|
|
if paragraph.content.as_ref().is_empty() {
|
|
self.par += 1;
|
|
self.chr = 0;
|
|
return (self, Some(area), None);
|
|
}
|
|
|
|
// Handle the `no_break` flag used to keep key-value pair on the same page.
|
|
if paragraph.no_break && self.chr == 0 {
|
|
if let Some(next_paragraph) =
|
|
(self.par + 1 < source.size()).then(|| source.at(self.par + 1, 0))
|
|
{
|
|
if Self::should_place_pair_on_next_page(
|
|
¶graph,
|
|
&next_paragraph,
|
|
area,
|
|
full_height,
|
|
) {
|
|
return (self, None, None);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
let page_full: bool;
|
|
match fit {
|
|
LayoutFit::Fitting { .. } => {
|
|
// Continue with start of next paragraph.
|
|
self.par += 1;
|
|
self.chr = 0;
|
|
// Handle hard break if requested for this paragraph.
|
|
page_full = paragraph.break_after;
|
|
}
|
|
LayoutFit::OutOfBounds {
|
|
processed_chars, ..
|
|
} => {
|
|
// Reached end of the page and not all content fits.
|
|
self.chr += processed_chars;
|
|
// Do not render more paragraphs.
|
|
page_full = true;
|
|
}
|
|
}
|
|
|
|
(
|
|
self,
|
|
Some(remaining_area).filter(|_| !page_full),
|
|
Some(layout).filter(|_| fit.height() > 0),
|
|
)
|
|
}
|
|
|
|
fn should_place_pair_on_next_page<S: ParagraphStrType>(
|
|
this_paragraph: &Paragraph<S>,
|
|
next_paragraph: &Paragraph<S>,
|
|
area: Rect,
|
|
full_height: i16,
|
|
) -> bool {
|
|
// Never break if we're at the beginning of the page.
|
|
let remaining_height = area.height();
|
|
if remaining_height >= full_height {
|
|
return false;
|
|
}
|
|
|
|
let full_area = area.with_height(full_height);
|
|
let key_height = this_paragraph
|
|
.layout(full_area)
|
|
.fit_text(this_paragraph.content.as_ref())
|
|
.height();
|
|
let val_height = next_paragraph
|
|
.layout(full_area)
|
|
.fit_text(next_paragraph.content.as_ref())
|
|
.height();
|
|
let screen_full_threshold = this_paragraph.style.text_font.line_height()
|
|
+ next_paragraph.style.text_font.line_height();
|
|
|
|
if key_height + val_height > remaining_height {
|
|
return
|
|
// There are only ~2 remaining lines, don't try to fit and put everything on the
|
|
// next page.
|
|
(remaining_height <= screen_full_threshold)
|
|
// More than 2 remaining lines so try to fit something -- but won't
|
|
// fit at least one line of value.
|
|
|| (val_height > 0 && key_height > remaining_height)
|
|
// Whole property won't fit to the page, but it will fit on a page
|
|
// by itself.
|
|
|| (key_height + val_height <= full_height);
|
|
}
|
|
|
|
// None of the above, continue fitting on the same page.
|
|
false
|
|
}
|
|
}
|
|
|
|
struct PageBreakIterator<'a, T> {
|
|
/// Reference to paragraph vector.
|
|
paragraphs: &'a Paragraphs<T>,
|
|
|
|
/// Current offset, or `None` before first `next()` call.
|
|
current: Option<PageOffset>,
|
|
}
|
|
|
|
impl<T: ParagraphSource> PageBreakIterator<'_, T> {
|
|
fn dyn_next<S: ParagraphStrType>(
|
|
mut area: Rect,
|
|
paragraphs: &dyn ParagraphSource<StrType = S>,
|
|
mut offset: PageOffset,
|
|
) -> Option<PageOffset> {
|
|
let full_height = area.height();
|
|
|
|
while offset.par < paragraphs.size() {
|
|
let (next_offset, remaining_area, _layout) =
|
|
offset.advance(area, paragraphs, full_height);
|
|
if next_offset.par >= paragraphs.size() {
|
|
// Last page.
|
|
return None;
|
|
} else if let Some(remaining_area) = remaining_area {
|
|
#[cfg(feature = "ui_debug")]
|
|
assert_eq!(next_offset.par, offset.par + 1);
|
|
area = remaining_area;
|
|
offset = next_offset;
|
|
} else {
|
|
return Some(next_offset);
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
}
|
|
|
|
/// Yields indices to beginnings of successive pages. First value is always
|
|
/// `PageOffset { 0, 0 }` even if the paragraph vector is empty.
|
|
impl<T: ParagraphSource> Iterator for PageBreakIterator<'_, T> {
|
|
/// `PageOffset` denotes the first paragraph that is rendered and a
|
|
/// character offset in that paragraph.
|
|
type Item = PageOffset;
|
|
|
|
fn next(&mut self) -> Option<Self::Item> {
|
|
let first = self.current.is_none();
|
|
let current = self.current.get_or_insert_with(PageOffset::default);
|
|
if first {
|
|
return self.current;
|
|
}
|
|
|
|
let next = Self::dyn_next(self.paragraphs.area, &self.paragraphs.source, *current);
|
|
if next.is_some() {
|
|
// Better panic than infinite loop.
|
|
assert_ne!(next, self.current);
|
|
self.current = next;
|
|
}
|
|
next
|
|
}
|
|
}
|
|
|
|
pub struct Checklist<T> {
|
|
area: Rect,
|
|
paragraphs: Paragraphs<T>,
|
|
current: usize,
|
|
icon_current: Icon,
|
|
icon_done: Icon,
|
|
}
|
|
|
|
impl<T> Checklist<T> {
|
|
const CHECK_WIDTH: i16 = 16;
|
|
const DONE_OFFSET: Offset = Offset::new(-2, 6);
|
|
const CURRENT_OFFSET: Offset = Offset::new(2, 3);
|
|
|
|
pub fn from_paragraphs(
|
|
icon_current: Icon,
|
|
icon_done: Icon,
|
|
current: usize,
|
|
paragraphs: Paragraphs<T>,
|
|
) -> Self {
|
|
Self {
|
|
area: Rect::zero(),
|
|
paragraphs,
|
|
current,
|
|
icon_current,
|
|
icon_done,
|
|
}
|
|
}
|
|
|
|
fn paint_icon(&self, layout: &TextLayout, icon: Icon, offset: Offset) {
|
|
let top_left = Point::new(self.area.x0, layout.bounds.y0);
|
|
icon.draw(
|
|
top_left + offset,
|
|
TOP_LEFT,
|
|
layout.style.text_color,
|
|
layout.style.background_color,
|
|
);
|
|
}
|
|
}
|
|
|
|
impl<T> Component for Checklist<T>
|
|
where
|
|
T: ParagraphSource,
|
|
{
|
|
type Msg = Never;
|
|
|
|
fn place(&mut self, bounds: Rect) -> Rect {
|
|
self.area = bounds;
|
|
let para_area = bounds.inset(Insets::left(Self::CHECK_WIDTH));
|
|
self.paragraphs.place(para_area);
|
|
self.area
|
|
}
|
|
|
|
fn event(&mut self, ctx: &mut EventCtx, event: Event) -> Option<Self::Msg> {
|
|
self.paragraphs.event(ctx, event)
|
|
}
|
|
|
|
fn paint(&mut self) {
|
|
self.paragraphs.paint();
|
|
|
|
let current_visible = self.current.saturating_sub(self.paragraphs.offset.par);
|
|
for layout in self.paragraphs.visible.iter().take(current_visible) {
|
|
self.paint_icon(layout, self.icon_done, Self::DONE_OFFSET);
|
|
}
|
|
if let Some(layout) = self.paragraphs.visible.iter().nth(current_visible) {
|
|
self.paint_icon(layout, self.icon_current, Self::CURRENT_OFFSET);
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "ui_bounds")]
|
|
fn bounds(&self, sink: &mut dyn FnMut(Rect)) {
|
|
sink(self.area);
|
|
self.paragraphs.bounds(sink);
|
|
}
|
|
}
|
|
|
|
#[cfg(feature = "ui_debug")]
|
|
impl<T: ParagraphSource> crate::trace::Trace for Checklist<T> {
|
|
fn trace(&self, t: &mut dyn crate::trace::Tracer) {
|
|
t.open("Checklist");
|
|
t.field("current", &self.current);
|
|
t.field("items", &self.paragraphs);
|
|
t.close();
|
|
}
|
|
}
|
|
|
|
pub trait VecExt<T> {
|
|
fn add(&mut self, paragraph: Paragraph<T>) -> &mut Self;
|
|
}
|
|
|
|
impl<T, const N: usize> VecExt<T> for Vec<Paragraph<T>, N>
|
|
where
|
|
T: AsRef<str>,
|
|
{
|
|
fn add(&mut self, paragraph: Paragraph<T>) -> &mut Self {
|
|
if paragraph.content.as_ref().is_empty() {
|
|
return self;
|
|
}
|
|
if self.push(paragraph).is_err() {
|
|
#[cfg(feature = "ui_debug")]
|
|
panic!("paragraph list is full");
|
|
}
|
|
self
|
|
}
|
|
}
|
|
|
|
impl<T: ParagraphStrType, const N: usize> ParagraphSource for Vec<Paragraph<T>, N> {
|
|
type StrType = T;
|
|
|
|
fn at(&self, index: usize, offset: usize) -> Paragraph<Self::StrType> {
|
|
let para = &self[index];
|
|
para.map(|content| content.skip_prefix(offset))
|
|
}
|
|
|
|
fn size(&self) -> usize {
|
|
self.len()
|
|
}
|
|
}
|
|
|
|
impl<T: ParagraphStrType, const N: usize> ParagraphSource for [Paragraph<T>; N] {
|
|
type StrType = T;
|
|
|
|
fn at(&self, index: usize, offset: usize) -> Paragraph<Self::StrType> {
|
|
let para = &self[index];
|
|
para.map(|content| content.skip_prefix(offset))
|
|
}
|
|
|
|
fn size(&self) -> usize {
|
|
self.len()
|
|
}
|
|
}
|
|
|
|
impl<T: ParagraphStrType> ParagraphSource for Paragraph<T> {
|
|
type StrType = T;
|
|
|
|
fn at(&self, index: usize, offset: usize) -> Paragraph<Self::StrType> {
|
|
assert_eq!(index, 0);
|
|
self.map(|content| content.skip_prefix(offset))
|
|
}
|
|
|
|
fn size(&self) -> usize {
|
|
1
|
|
}
|
|
}
|