From aa32b6545185a6b78f98a77f9480715fcf947e7e Mon Sep 17 00:00:00 2001 From: ede1998 Date: Tue, 25 Aug 2026 17:38:19 +0200 Subject: [PATCH] wip --- src/bin/main.rs | 2 +- src/drivers/tertiary_lcd.rs | 539 +++++++++++++++++++++++------------- 2 files changed, 348 insertions(+), 193 deletions(-) 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..001d345 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,13 @@ 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; - } - } - } - - 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); - - 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)?; + /// 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().into_result()?; self.display .clear() @@ -290,14 +123,180 @@ 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>, +} + +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`. + pub fn invalid_chars(text: &str, charset: &CustomCharset) -> Result<(), LinesError> { + for (pos, c) in text.char_indices() { + let is_standard_valid = c == '\n' || (' '..='~').contains(&c); + + if !is_standard_valid { + // If it's not standard ASCII, check if it falls within the allowed custom range. + // We cast `c` to usize to compare against length. + if (c as usize) >= charset.len { + 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 { + for line in lines.iter() { + if let Err(e) = Self::invalid_chars(line, &create_custom_char_set!()) { + return Self { lines: Err(e) }; + } + } + + for (i, line) in lines.iter().enumerate() { + if line.len() > TertiaryDisplay::WIDTH { + return Self { + lines: Err(LinesError::ColumnOverflow { + line: i as u8, + columns: line.len(), + }), + }; + } + } + Self { lines: 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 { + if let Err(e) = Self::invalid_chars(input, &create_custom_char_set!()) { + return Self { lines: 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 Self { + lines: Err(LinesError::ColumnOverflow { + line: i as u8, + columns: part.len(), + }), + }; + } + *out = part; + } + } + + if parts.next().is_some() { + return Self { + lines: Err(LinesError::LineOverflow), + }; + } + + Self { lines: 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 { + if let Err(e) = Self::invalid_chars(input, &create_custom_char_set!()) { + return Self { lines: 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; + } + + Self { lines: 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 +332,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 +357,7 @@ macro_rules! create_custom_char_set { buffer }; - CustomCharSet { + CustomCharset { set: SET, len: LEN, } @@ -384,4 +383,160 @@ 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); + +#[cfg(test)] +mod tests { + use super::*; + + const EMPTY_CHAR: CustomCharset = CustomCharset::EMPTY; + // Allows custom chars \x00, \x01, \x02 + const CUSTOM_CHAR: CustomCharset = EXAMPLE; + + #[test] + fn test_invalid_chars() { + // Valid standard ASCII + assert_eq!( + Lines::invalid_chars("Hello World! 123", &EMPTY_CHAR), + Ok(()) + ); + assert_eq!(Lines::invalid_chars("Line1\nLine2", &EMPTY_CHAR), Ok(())); + + // Invalid control characters (e.g., \t, \r) + assert_eq!( + Lines::invalid_chars("Hello\tWorld", &EMPTY_CHAR), + Err(LinesError::InvalidChar { + position: 5, + symbol: '\t' + }) + ); + assert_eq!( + Lines::invalid_chars("Hello\r\nWorld", &EMPTY_CHAR), + Err(LinesError::InvalidChar { + position: 5, + symbol: '\r' + }) + ); + + // Valid custom characters + assert_eq!(Lines::invalid_chars("Temp: 20\x00C", &CUSTOM_CHAR), Ok(())); + assert_eq!(Lines::invalid_chars("\x00\x01\x02", &CUSTOM_CHAR), Ok(())); + + // Out of bounds custom character (\x03 is >= len 3) + assert_eq!( + Lines::invalid_chars("Temp: 20\x03C", &CUSTOM_CHAR), + Err(LinesError::InvalidChar { + position: 8, + symbol: '\x03' + }) + ); + + // Non-ASCII characters + assert_eq!( + Lines::invalid_chars("Hellö", &EMPTY_CHAR), + Err(LinesError::InvalidChar { + position: 4, + symbol: 'ö' + }) + ); + } + + #[test] + fn test_by_line() { + // Valid lines + let valid = Lines::by_line(["Short", "Text"], &EMPTY_CHAR).into_result(); + assert_eq!(valid, Ok(["Short", "Text"])); + + // Column overflow + let overflow = Lines::by_line(["12345678901234567", "Ok"], &EMPTY_CHAR).into_result(); + assert_eq!( + overflow, + Err(LinesError::ColumnOverflow { + line: 0, + columns: 17 + }) + ); + + // Invalid character in pre-split line + let invalid = Lines::by_line(["Good", "B\x05ad"], &EMPTY_CHAR).into_result(); + assert_eq!( + invalid, + Err(LinesError::InvalidChar { + position: 1, + symbol: '\x05' + }) + ); + } + + #[test] + fn test_checked() { + // Valid single line + let single = Lines::checked("Single", &EMPTY_CHAR).into_result(); + assert_eq!(single, Ok(["Single", ""])); + + // Valid exactly two lines + let two_lines = Lines::checked("Line1\nLine2", &EMPTY_CHAR).into_result(); + assert_eq!(two_lines, Ok(["Line1", "Line2"])); + + // Column overflow + let col_overflow = Lines::checked("Line1\n12345678901234567", &EMPTY_CHAR).into_result(); + assert_eq!( + col_overflow, + Err(LinesError::ColumnOverflow { + line: 1, + columns: 17 + }) + ); + + // Lines overflow + let lines_overflow = Lines::checked("One\nTwo\nThree", &EMPTY_CHAR).into_result(); + assert_eq!(lines_overflow, Err(LinesError::LineOverflow)); + + // Invalid character detection + let bad_char = Lines::checked("One\nTwö", &EMPTY_CHAR).into_result(); + assert_eq!( + bad_char, + Err(LinesError::InvalidChar { + position: 6, + symbol: 'ö' + }) + ); + } + + #[test] + fn test_clamped() { + // Simple string fits on one line + let short = Lines::clamped("Hello", &EMPTY_CHAR).into_result(); + assert_eq!(short, Ok(["Hello", ""])); + + // Exactly width (16) + let exact = Lines::clamped("1234567890123456", &EMPTY_CHAR).into_result(); + assert_eq!(exact, Ok(["1234567890123456", ""])); + + // Wraps perfectly on 16 characters + let wrapped = Lines::clamped("1234567890123456789", &EMPTY_CHAR).into_result(); + assert_eq!(wrapped, Ok(["1234567890123456", "789"])); + + // Manual newlines + let newlines = Lines::clamped("A\nB", &EMPTY_CHAR).into_result(); + assert_eq!(newlines, Ok(["A", "B"])); + + // Exact match with a manual newline + let wrap_with_newline = Lines::clamped("1234567890123456\nNext", &EMPTY_CHAR).into_result(); + assert_eq!(wrap_with_newline, Ok(["1234567890123456", "Next"])); + + // Clamps excess lines silently (truncates after row 2) + let clamped = Lines::clamped("A\nB\nC", &EMPTY_CHAR).into_result(); + assert_eq!(clamped, Ok(["A", "B"])); // "C" is discarded + + // Invalid char returns error immediately + let invalid = Lines::clamped("1234\t5", &EMPTY_CHAR).into_result(); + assert_eq!( + invalid, + Err(LinesError::InvalidChar { + position: 4, + symbol: '\t' + }) + ); + } +}