diff --git a/src/bin/main.rs b/src/bin/main.rs index 8223eb4..bd1ca91 100644 --- a/src/bin/main.rs +++ b/src/bin/main.rs @@ -94,7 +94,7 @@ async fn main(spawner: Spawner) { let h = EXAMPLE[HEART]; let e = EXAMPLE[EMPTY]; let b = EXAMPLE[BOX]; - if let Err(e) = lcd.write(&alloc::format!("Custom symbols: {h}{e}{b}")) { + if let Err(e) = lcd.write(&alloc::format!("Custom symbols: {h}{e}{b} And more stuff to wrap and cut off please.")) { log::error!( "Tertiary LCD startup text write failed over shared I2C bus; check the shared bus, wiring, and device responses: {e}" ); diff --git a/src/drivers/tertiary_lcd.rs b/src/drivers/tertiary_lcd.rs index 8e420ce..36213f5 100644 --- a/src/drivers/tertiary_lcd.rs +++ b/src/drivers/tertiary_lcd.rs @@ -36,16 +36,7 @@ impl<'a> TertiaryDisplayPinConfiguration<'a> { #[derive(Debug, Clone)] pub enum TertiaryDisplayWriteError<'a> { Display(TertiaryDisplayError<'a>), - ColumnOverflow { - line: u8, - columns: usize, - }, - LineOverflow, - InvalidChar { - /// Offset in bytes - position: usize, - symbol: char, - }, + Format(LinesError), } impl<'a> From> for TertiaryDisplayWriteError<'a> { @@ -54,39 +45,35 @@ impl<'a> From> for TertiaryDisplayWriteError<'a> { } } +impl From for TertiaryDisplayWriteError<'_> { + fn from(value: LinesError) -> Self { + Self::Format(value) + } +} + impl<'a> core::fmt::Display for TertiaryDisplayWriteError<'a> { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { - TertiaryDisplayWriteError::Display(e) => write!(f, "display error: {e}"), - TertiaryDisplayWriteError::ColumnOverflow { line, columns } => { - write!( - f, - "text too long for line {line}: {columns} > {}", - TertiaryDisplay::DISPLAY_WIDTH - ) - } - TertiaryDisplayWriteError::LineOverflow => write!(f, "text has too many lines"), - TertiaryDisplayWriteError::InvalidChar { position, symbol } => { - write!(f, "Invalid character {symbol} at offset {position}") - } + Self::Display(e) => write!(f, "display error: {e}"), + Self::Format(e) => write!(f, "format error: {e}"), } } } pub struct TertiaryDisplay<'a> { display: TertiaryDisplayRaw<'a>, - charset: CustomCharSet, + charset: CustomCharset, } impl<'a> TertiaryDisplay<'a> { - pub const DISPLAY_WIDTH: usize = 16; - pub const DISPLAY_ROWS: usize = 2; + pub const WIDTH: usize = 16; + pub const ROWS: usize = 2; pub fn inner_mut(&mut self) -> &mut TertiaryDisplayRaw<'a> { &mut self.display } - pub fn load_charset(&mut self, charset: CustomCharSet) -> Result<(), TertiaryDisplayError<'a>> { + pub fn load_charset(&mut self, charset: CustomCharset) -> Result<(), TertiaryDisplayError<'a>> { self.charset = charset; for (index, charmap) in self .charset @@ -110,167 +97,21 @@ impl<'a> TertiaryDisplay<'a> { Ok(()) } - /// Clamp the text's length so that it fits within the character display without overflow. - /// Note: clamp does not work properly yet if line 1 is too long with multiple newlines in string. - pub fn clamp(text: &str) -> &str { - enum NewLines { - None, - One { - offset: usize, - }, - More { - /// Offset for the first 2 newlines. We discard everything from 2nd newline onwards anyway. - offsets: [usize; 2], - }, - } - let pos_of_newlines = |(pos, c)| (c == '\n').then_some(pos); - let mut newlines = NewLines::None; - for pos in text.char_indices().filter_map(pos_of_newlines) { - match newlines { - NewLines::None => { - newlines = NewLines::One { offset: pos }; - } - NewLines::One { offset } => { - newlines = NewLines::More { - offsets: [offset, pos], - }; - break; - } - NewLines::More { offsets } => { - // unreachable because of break in previous arm - newlines = NewLines::More { offsets }; - break; - } - } - } + /// Writes text to the LCD. Normal strings wrap by default but this can be controlled + /// via explicit [`Lines::checked`]. + pub fn write<'b, L: Into>>( + &mut self, + text: L, + ) -> Result<(), TertiaryDisplayWriteError<'a>> { + let lines = text.into(); - let len_no_newline = text.len() - - match newlines { - NewLines::None => 0, - NewLines::One { .. } => 1, - NewLines::More { .. } => 2, - }; - let max = len_no_newline.min(Self::DISPLAY_ROWS * Self::DISPLAY_WIDTH); + // Use original to get correct position in original string + lines + .original + .iter() + .try_for_each(|text| Lines::invalid_chars(text, Some(&self.charset)))?; - match newlines { - NewLines::None => &text[..max], - NewLines::More { - offsets: [offset, ..], - } - | NewLines::One { offset } - if offset > Self::DISPLAY_ROWS * Self::DISPLAY_WIDTH => - { - // first line already fully fills the screen, - // so new line does not fit on screen anyway - &text[..max] - } - NewLines::One { offset } => { - let line2 = text.split_at(offset).1; - let line2_len = line2.len().min(Self::DISPLAY_WIDTH); - // new line isn't rendered so string may be one longer - &text[..max.min(offset + line2_len) + 1] - } - NewLines::More { - offsets: [eol1, eol2], - } => { - let line2 = text[..eol2].split_at(eol1).1; - let line2_len = line2.len().min(Self::DISPLAY_WIDTH); - // new line isn't rendered so string may be one longer - &text[..max.min(eol1 + line2_len) + 1] - } - } - } - - /// Clamp the text's length so that it fits within a single line of the character display without overflow. - pub fn clamp_line(text: &str) -> &str { - let newline = text.chars().position(|c| c == '\n'); - let end_of_line = newline.unwrap_or(text.len()); - let stop_at = end_of_line.min(Self::DISPLAY_WIDTH); - &text[..stop_at] - } - - pub fn write_line(&mut self, row: u8, text: &str) -> Result<(), TertiaryDisplayWriteError<'a>> { - self.invalid_chars(text)?; - let line = Self::limit_line(text)?; - - self.display - .set_cursor(0, row) - .inspect_err(|e| error!("I2C LCD cursor move failed on row {row}: {e}"))?; - - self.display - .print(line) - .inspect_err(|e| error!("I2C LCD print failed on row {row}: {e}"))?; - Ok(()) - } - - fn invalid_chars(&self, text: &str) -> Result<(), TertiaryDisplayWriteError<'a>> { - let is_custom = |c: char| u32::from(c) < self.charset.len as u32; - let is_newline = |c: char| c == '\n'; - let invalid = text - .char_indices() - .find(|&(_, c)| !c.is_ascii() || (c.is_control() && !(is_newline(c) || is_custom(c)))); - match invalid { - Some((position, symbol)) => { - Err(TertiaryDisplayWriteError::InvalidChar { position, symbol }) - } - None => Ok(()), - } - } - - fn limit_line<'b>(text: &'b str) -> Result<&'b str, TertiaryDisplayWriteError<'a>> { - if text.contains('\n') { - return Err(TertiaryDisplayWriteError::LineOverflow); - } - - if text.len() > Self::DISPLAY_WIDTH { - Err(TertiaryDisplayWriteError::ColumnOverflow { - line: 1, - columns: text.len(), - }) - } else { - Ok(text) - } - } - - fn split_lines<'b>(text: &'b str) -> Result<[&'b str; 2], TertiaryDisplayWriteError<'a>> { - let newlines = text.chars().filter(|&c| c == '\n').count(); - match newlines { - 0 => { - if text.len() > Self::DISPLAY_ROWS * Self::DISPLAY_WIDTH { - Err(TertiaryDisplayWriteError::ColumnOverflow { - line: 2, - columns: text.len() - Self::DISPLAY_WIDTH, - }) - } else { - Ok(text - .split_at_checked(Self::DISPLAY_WIDTH) - .unwrap_or((text, "")) - .into()) - } - } - 1 => { - let lines: [_; 2] = text.split_once('\n').unwrap_or((text, "")).into(); - - for (row, line) in lines.into_iter().enumerate() { - if line.len() > Self::DISPLAY_WIDTH { - return Err(TertiaryDisplayWriteError::ColumnOverflow { - line: row as u8 + 1, - columns: line.len(), - }); - } - } - - Ok(lines) - } - _ => Err(TertiaryDisplayWriteError::LineOverflow), - } - } - - /// Writes text to the LCD, wrapping to the next line if it exceeds the display width. - /// Returns an error if the text would overflow. - pub fn write(&mut self, text: &str) -> Result<(), TertiaryDisplayWriteError<'a>> { - self.invalid_chars(text)?; - let lines = Self::split_lines(text)?; + let lines = lines.into_result()?; self.display .clear() @@ -290,14 +131,195 @@ impl<'a> TertiaryDisplay<'a> { } } +#[derive(Debug, PartialEq, Eq, Clone)] +pub enum LinesError { + ColumnOverflow { line: u8, columns: usize }, + LineOverflow, + InvalidChar { position: usize, symbol: char }, +} + +impl core::fmt::Display for LinesError { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + match self { + Self::ColumnOverflow { line, columns } => { + write!( + f, + "text too long for line {line}: {columns} > {}", + TertiaryDisplay::WIDTH + ) + } + Self::LineOverflow => write!(f, "text has too many lines"), + Self::InvalidChar { position, symbol } => { + write!(f, "Invalid character {symbol} at offset {position}") + } + } + } +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct Lines<'a> { + pub lines: Result<[&'a str; TertiaryDisplay::ROWS], LinesError>, + /// Unmodified original string. Only first entry is populated unless constructed with [`by_line`] + pub original: [&'a str; TertiaryDisplay::ROWS], +} + +impl<'a> Lines<'a> { + pub fn into_result(self) -> Result<[&'a str; TertiaryDisplay::ROWS], LinesError> { + self.lines + } + + /// Validates that the text contains only non-control ASCII characters, + /// newlines, and contiguous custom characters up to `charset.len`. + /// If `charset` is `None`, allow any custom char up to and including maximum. + pub fn invalid_chars(text: &str, charset: Option<&CustomCharset>) -> Result<(), LinesError> { + for (pos, c) in text.char_indices() { + let is_standard_valid = c == '\n' || (' '..='~').contains(&c); + + if is_standard_valid { + continue; + } + + let first_invalid_custom_char = + charset.map(|c| c.len).unwrap_or(CUSTOM_CHAR_COUNT.into()); + + if (c as usize) >= first_invalid_custom_char { + return Err(LinesError::InvalidChar { + position: pos, + symbol: c, + }); + } + } + Ok(()) + } + + /// Checks pre-split lines strictly for length and valid characters. + pub fn by_line(lines: [&'a str; TertiaryDisplay::ROWS]) -> Self { + let this = |res| Self { + lines: res, + original: lines, + }; + + for line in lines.iter() { + if let Err(e) = Self::invalid_chars(line, None) { + return this(Err(e)); + } + } + + for (i, line) in lines.iter().enumerate() { + if line.len() > TertiaryDisplay::WIDTH { + return this(Err(LinesError::ColumnOverflow { + line: i as u8, + columns: line.len(), + })); + } + } + + this(Ok(lines)) + } + + /// Strictly checks an input string. + /// Fails if there are invalid chars, too many newlines, or if any segment exceeds WIDTH. + pub fn checked(input: &'a str) -> Self { + let this = |res| Self { + lines: res, + original: [input, ""], + }; + + if let Err(e) = Self::invalid_chars(input, None) { + return this(Err(e)); + } + + let mut out = [""; TertiaryDisplay::ROWS]; + let mut parts = input.split('\n'); + + for (i, out) in out.iter_mut().enumerate() { + if let Some(part) = parts.next() { + if part.len() > TertiaryDisplay::WIDTH { + return this(Err(LinesError::ColumnOverflow { + line: i as u8, + columns: part.len(), + })); + } + *out = part; + } + } + + if parts.next().is_some() { + return this(Err(LinesError::LineOverflow)); + } + + this(Ok(out)) + } + + /// Wraps text automatically at `WIDTH` or at newlines. + /// Discards (clamps) any leftover text that exceeds `ROWS`. + /// Fails immediately if invalid characters are present. + pub fn clamped(input: &'a str) -> Self { + let this = |res| Self { + lines: res, + original: [input, ""], + }; + if let Err(e) = Self::invalid_chars(input, None) { + return this(Err(e)); + } + + let mut out = [""; TertiaryDisplay::ROWS]; + let mut remaining = input; + + for out in out.iter_mut() { + if remaining.is_empty() { + break; + } + + let (segment, rest) = Self::next_segment(remaining, TertiaryDisplay::WIDTH); + *out = segment; + remaining = rest; + } + + this(Ok(out)) + } + + /// Helper function to slice an ASCII string up to `max_width` + /// or until the next newline, whichever comes first. + fn next_segment(s: &str, max_width: usize) -> (&str, &str) { + let nl_pos = s.find('\n'); + + let split_pos = match nl_pos { + Some(pos) if pos <= max_width => pos, + _ => core::cmp::min(s.len(), max_width), + }; + + let segment = &s[..split_pos]; + let mut rest = &s[split_pos..]; + + if let Some(stripped) = rest.strip_prefix('\n') { + rest = stripped; + } + + (segment, rest) + } +} + +impl<'a> From<&'a str> for Lines<'a> { + fn from(value: &'a str) -> Self { + Self::clamped(value) + } +} + +impl<'a> From<&'a alloc::string::String> for Lines<'a> { + fn from(value: &'a alloc::string::String) -> Self { + Self::clamped(value) + } +} + /// 5x8 pixel charmap. Each set bit is a black pixel. pub type CharMap = [u8; 8]; -pub struct CustomCharSet { +pub struct CustomCharset { set: [CharMap; CUSTOM_CHAR_COUNT as usize], len: usize, } -impl core::ops::Index for CustomCharSet { +impl core::ops::Index for CustomCharset { type Output = &'static str; fn index(&self, map: CharMap) -> &Self::Output { @@ -333,7 +355,7 @@ impl core::ops::Index for CustomCharSet { #[macro_export] macro_rules! create_custom_char_set { () => {{ - CustomCharSet { set: [EMPTY; CUSTOM_CHAR_COUNT as usize], len: 0 } + CustomCharset { set: [EMPTY; CUSTOM_CHAR_COUNT as usize], len: 0 } }}; ($($char:expr),+ $(,)?) => {{ // Count how many characters were passed using macro repetition length @@ -358,7 +380,7 @@ macro_rules! create_custom_char_set { buffer }; - CustomCharSet { + CustomCharset { set: SET, len: LEN, } @@ -384,4 +406,5 @@ pub const BOX: CharMap = [ 0b00000, // Cursor line (usually left blank) ]; -pub const EXAMPLE: CustomCharSet = create_custom_char_set!(HEART, BOX, EMPTY); +pub const EXAMPLE: CustomCharset = create_custom_char_set!(HEART, BOX, EMPTY); +