rework tertiary display API again #42

Merged
ede1998 merged 1 commits from tertiary_display_api into main 2026-08-26 20:16:24 +02:00
2 changed files with 214 additions and 191 deletions
+1 -1
View File
@@ -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}"
);
+213 -190
View File
@@ -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<TertiaryDisplayError<'a>> for TertiaryDisplayWriteError<'a> {
@@ -54,39 +45,35 @@ impl<'a> From<TertiaryDisplayError<'a>> for TertiaryDisplayWriteError<'a> {
}
}
impl From<LinesError> 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<Lines<'b>>>(
&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<CharMap> for CustomCharSet {
impl core::ops::Index<CharMap> for CustomCharset {
type Output = &'static str;
fn index(&self, map: CharMap) -> &Self::Output {
@@ -333,7 +355,7 @@ impl core::ops::Index<CharMap> 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);