use crate::config::{DisplaySettings, TextEncoding};
use crate::error::{Result, VfdError};
use encoding_rs::{IBM866, WINDOWS_1251};
#[derive(Debug, Clone)]
pub struct EpsonCodec {
display: DisplaySettings,
}
impl EpsonCodec {
pub fn new(display: DisplaySettings) -> Self {
Self { display }
}
pub fn display(&self) -> &DisplaySettings {
&self.display
}
pub fn init(&self) -> Vec<u8> {
let mut out = Vec::with_capacity(5);
if self.display.reset_on_open {
out.extend_from_slice(&[0x1B, 0x40]);
}
if let Some(table) = self.display.code_table {
out.extend_from_slice(&[0x1B, 0x74, table]);
}
out
}
pub fn clear(&self) -> [u8; 1] {
[0x0C]
}
pub fn goto_xy(&self, x: u8, y: u8) -> Result<[u8; 4]> {
self.validate_xy(x, y)?;
Ok([0x1F, 0x24, x, y])
}
pub fn brightness(&self, level: u8) -> Result<[u8; 3]> {
if let Some(range) = &self.display.brightness {
if range.contains(&level) {
return Ok([0x1F, 0x58, level]);
}
return Err(VfdError::UnsupportedBrightness {
level,
min: *range.start(),
max: *range.end(),
});
}
Err(VfdError::UnsupportedBrightness {
level,
min: 0,
max: 0,
})
}
pub fn encode_text(&self, text: &str) -> Vec<u8> {
match self.display.encoding {
TextEncoding::Cp866 => IBM866.encode(text).0.into_owned(),
TextEncoding::Windows1251 => WINDOWS_1251.encode(text).0.into_owned(),
TextEncoding::Ascii => text
.chars()
.map(|ch| if ch.is_ascii() { ch as u8 } else { b'?' })
.collect(),
TextEncoding::Utf8 => text.as_bytes().to_vec(),
}
}
pub fn fit_line(&self, text: &str) -> String {
fit_to_width(&sanitize_text(text), self.display.columns)
}
pub fn clip_from(&self, x: u8, text: &str) -> Result<String> {
self.validate_xy(x, 1)?;
let remaining = self.display.columns - usize::from(x) + 1;
Ok(truncate_chars(&sanitize_text(text), remaining).to_string())
}
pub fn validate_xy(&self, x: u8, y: u8) -> Result<()> {
if x == 0
|| y == 0
|| usize::from(x) > self.display.columns
|| usize::from(y) > self.display.rows
{
return Err(VfdError::InvalidCoordinate {
x,
y,
columns: self.display.columns,
rows: self.display.rows,
});
}
Ok(())
}
pub fn validate_line(&self, line: u8) -> Result<()> {
if line == 0 || usize::from(line) > self.display.rows {
return Err(VfdError::InvalidLine {
line,
rows: self.display.rows,
});
}
Ok(())
}
}
pub fn truncate_chars(s: &str, max_chars: usize) -> &str {
s.char_indices()
.nth(max_chars)
.map_or(s, |(byte_index, _)| &s[..byte_index])
}
pub fn sanitize_text(s: &str) -> String {
s.chars()
.map(|c| match c {
'…' => '.',
'—' | '–' => '-',
'№' => '#',
'\t' => ' ',
'“' | '”' => '"',
'‘' | '’' => '\'',
_ => c,
})
.collect()
}
pub fn sanitize_for_cp866(s: &str) -> String {
sanitize_text(s)
}
pub fn fit_to_width(s: &str, width: usize) -> String {
let mut out = String::with_capacity(width);
let mut len = 0;
for ch in s.chars().take(width) {
out.push(ch);
len += 1;
}
out.extend(std::iter::repeat_n(' ', width.saturating_sub(len)));
out
}
pub(crate) fn changed_runs(current: &str, next: &str) -> Vec<(u8, String)> {
let mut runs = Vec::new();
let mut run_start = None;
let mut run_text = String::new();
for (index, (old, new)) in current.chars().zip(next.chars()).enumerate() {
if old != new {
run_start.get_or_insert((index + 1) as u8);
run_text.push(new);
} else if let Some(start) = run_start.take() {
runs.push((start, std::mem::take(&mut run_text)));
}
}
if let Some(start) = run_start {
runs.push((start, run_text));
}
runs
}
pub(crate) fn replace_cached_range(line: &mut String, x: u8, text: &str, width: usize) {
if x == 0 || usize::from(x) > width || text.is_empty() {
return;
}
let mut chars: Vec<char> = line.chars().take(width).collect();
chars.resize(width, ' ');
let start = usize::from(x) - 1;
for (slot, ch) in chars[start..].iter_mut().zip(text.chars()) {
*slot = ch;
}
line.clear();
line.extend(chars);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Preset, VfdConfig};
#[test]
fn preset_init_matches_legacy_reset_and_cp866_table() {
let cfg = VfdConfig::preset("test", Preset::Epson20x2Cp866).unwrap();
let codec = EpsonCodec::new(cfg.display);
assert_eq!(codec.init(), vec![0x1B, 0x40, 0x1B, 0x74, 6]);
}
#[test]
fn optional_code_table_can_be_omitted() {
let display = DisplaySettings::new(20, 2, TextEncoding::Cp866);
let codec = EpsonCodec::new(display);
assert_eq!(codec.init(), vec![0x1B, 0x40]);
}
#[test]
fn validates_geometry_instead_of_ignoring_invalid_coordinates() {
let codec = EpsonCodec::new(DisplaySettings::new(20, 4, TextEncoding::Cp866));
assert!(codec.goto_xy(1, 4).is_ok());
assert!(matches!(
codec.goto_xy(21, 1),
Err(VfdError::InvalidCoordinate { .. })
));
assert!(matches!(
codec.validate_line(5),
Err(VfdError::InvalidLine { .. })
));
}
#[test]
fn sanitizes_and_fits_unicode_by_characters() {
assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'.");
assert_eq!(fit_to_width("Привет", 4), "Прив");
assert_eq!(fit_to_width("да", 4), "да ");
assert_eq!(fit_to_width("text", 0), "");
assert_eq!(truncate_chars("ёжик", 3), "ёжи");
}
#[test]
fn cache_update_replaces_the_complete_fragment() {
let mut line = "Temp: 00.00C ".to_string();
replace_cached_range(&mut line, 7, "21.50", 20);
assert_eq!(line, "Temp: 21.50C ");
}
#[test]
fn cache_update_handles_unicode_and_clips_at_the_right_edge() {
let mut line = String::new();
replace_cached_range(&mut line, 3, "ёжик", 5);
assert_eq!(line, " ёжи");
}
#[test]
fn diff_groups_adjacent_changes_into_minimal_runs() {
assert_eq!(
changed_runs("abcd efgh", "abXY eZZh"),
vec![(3, "XY".to_string()), (7, "ZZ".to_string())]
);
assert!(changed_runs("без перемен", "без перемен").is_empty());
}
}