рефакторинг библиотеки.
This commit is contained in:
@@ -1,2 +1,6 @@
|
||||
//! Библиотека управления двухстрочными VFD-дисплеями в Epson-совместимом режиме.
|
||||
|
||||
/// Низкоуровневая работа с serial-портом, кодировкой и командами дисплея.
|
||||
pub mod vfd;
|
||||
/// Фоновый поток и потокобезопасный интерфейс для обновления дисплея.
|
||||
pub mod worker;
|
||||
|
||||
+82
-23
@@ -6,15 +6,21 @@ use std::time::Duration;
|
||||
|
||||
const TABLE_CYR: u8 = 6; // PD-2600: your Cyrillic table (ESC t 6)
|
||||
const FIXED_BAUD: u32 = 9600;
|
||||
const MAX_WIDTH: usize = u8::MAX as usize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Настройки serial-подключения и геометрии VFD-дисплея.
|
||||
pub struct VfdConfig {
|
||||
/// Имя serial-порта, например `/dev/cu.usbmodem101`.
|
||||
pub port_name: String,
|
||||
/// Число символов в строке; ограничивается диапазоном `1..=255`.
|
||||
pub width: usize,
|
||||
/// Тайм-аут операций чтения и записи serial-порта.
|
||||
pub timeout: Duration,
|
||||
}
|
||||
|
||||
impl VfdConfig {
|
||||
/// Создаёт конфигурацию с шириной 20 символов и тайм-аутом 100 мс.
|
||||
pub fn new(port_name: impl Into<String>) -> Self {
|
||||
Self {
|
||||
port_name: port_name.into(),
|
||||
@@ -23,25 +29,27 @@ impl VfdConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Устанавливает ширину строки с учётом диапазона координат протокола.
|
||||
pub fn with_width(mut self, width: usize) -> Self {
|
||||
self.width = width.max(1);
|
||||
self.width = width.clamp(1, MAX_WIDTH);
|
||||
self
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Устанавливает тайм-аут операций serial-порта.
|
||||
pub fn with_timeout(mut self, timeout: Duration) -> Self {
|
||||
self.timeout = timeout;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Низкоуровневое соединение с дисплеем, владеющее serial-портом.
|
||||
pub struct Vfd {
|
||||
port: Box<dyn SerialPort>,
|
||||
pub width: usize,
|
||||
blank_line: String,
|
||||
}
|
||||
|
||||
impl Vfd {
|
||||
/// Открывает serial-порт и инициализирует дисплей с таблицей CP866.
|
||||
pub fn open(cfg: VfdConfig) -> Result<Self> {
|
||||
let mut port = serialport::new(cfg.port_name, FIXED_BAUD)
|
||||
.timeout(cfg.timeout)
|
||||
@@ -55,61 +63,69 @@ impl Vfd {
|
||||
Ok(Self {
|
||||
port,
|
||||
width: cfg.width,
|
||||
blank_line: " ".repeat(cfg.width),
|
||||
})
|
||||
}
|
||||
|
||||
/// Очищает обе строки дисплея стандартной командой очистки.
|
||||
pub fn clear(&mut self) -> std::io::Result<()> {
|
||||
self.port.write_all(&[0x0C])
|
||||
}
|
||||
|
||||
/// Полностью перезаписывает первую или вторую строку текстом фиксированной ширины.
|
||||
pub fn print_line(&mut self, line: u8, text: &str) -> std::io::Result<()> {
|
||||
if !(1..=2).contains(&line) {
|
||||
return Ok(());
|
||||
}
|
||||
// overwrite the whole line to avoid leftovers
|
||||
self.goto_xy(1, line)?;
|
||||
|
||||
// FIX: take owned copy so self isn't immutably borrowed during &mut self call
|
||||
let blank = self.blank_line.clone();
|
||||
self.write_cp866(&blank)?;
|
||||
|
||||
// Кадр фиксированной ширины сам перезаписывает остаток предыдущей строки.
|
||||
self.goto_xy(1, line)?;
|
||||
let fitted = fit_to_width(&sanitize_for_cp866(text), self.width);
|
||||
self.write_cp866(&fitted)
|
||||
}
|
||||
|
||||
/// Print already-prepared fixed-width frame at (1, line) without clearing.
|
||||
/// Use this for marquee to avoid double-writes.
|
||||
/// Выводит подготовленный кадр с начала строки без предварительной очистки.
|
||||
pub fn print_frame(&mut self, line: u8, frame: &str) -> std::io::Result<()> {
|
||||
if !(1..=2).contains(&line) {
|
||||
// silently ignore invalid lines (or return an error if you want)
|
||||
// Некорректный номер строки игнорируется так же, как в остальных методах печати.
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self.goto_xy(1, line)?;
|
||||
|
||||
// frame should already be width-sized; if not, fit it
|
||||
// На случай короткого или длинного кадра приводим его к ширине дисплея.
|
||||
let fitted = fit_to_width(frame, self.width);
|
||||
self.write_cp866(&fitted)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Печатает текст с координаты `(x, y)`, выполняя санацию и обрезку по правому краю.
|
||||
pub fn print_at(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> {
|
||||
self.goto_xy(x, y)?;
|
||||
self.write_cp866(&sanitize_for_cp866(text))
|
||||
let text = sanitize_for_cp866(text);
|
||||
self.print_at_prepared(x, y, &text)
|
||||
}
|
||||
|
||||
/// Печатает уже подготовленный текст без повторной санации.
|
||||
pub(crate) fn print_at_prepared(&mut self, x: u8, y: u8, text: &str) -> std::io::Result<()> {
|
||||
if x == 0 || usize::from(x) > self.width || !(1..=2).contains(&y) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remaining = self.width - usize::from(x) + 1;
|
||||
let text = truncate_chars(text, remaining);
|
||||
self.goto_xy(x, y)?;
|
||||
self.write_cp866(text)
|
||||
}
|
||||
|
||||
/// Перемещает аппаратный курсор в координаты дисплея, заданные от единицы.
|
||||
fn goto_xy(&mut self, x: u8, y: u8) -> std::io::Result<()> {
|
||||
// US $ x y
|
||||
self.port.write_all(&[0x1F, 0x24, x, y])
|
||||
}
|
||||
|
||||
/// Кодирует строку в CP866 и целиком записывает байты в serial-порт.
|
||||
fn write_cp866(&mut self, s: &str) -> std::io::Result<()> {
|
||||
let (bytes, _, _) = IBM866.encode(s);
|
||||
self.port.write_all(&bytes)
|
||||
}
|
||||
/// Epson customer display: US X n (brightness), n=1..4
|
||||
/// Устанавливает яркость; значение ограничивается диапазоном `1..=4`.
|
||||
pub fn set_brightness(&mut self, n: u8) -> Result<()> {
|
||||
let n = n.clamp(1, 4);
|
||||
let cmd = [0x1F, 0x58, n]; // US 'X' n
|
||||
@@ -120,6 +136,14 @@ impl Vfd {
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает срез не длиннее заданного числа символов, не разрывая UTF-8.
|
||||
fn truncate_chars(s: &str, max_chars: usize) -> &str {
|
||||
s.char_indices()
|
||||
.nth(max_chars)
|
||||
.map_or(s, |(byte_index, _)| &s[..byte_index])
|
||||
}
|
||||
|
||||
/// Заменяет типографские символы на безопасные аналоги, представимые в CP866.
|
||||
pub fn sanitize_for_cp866(s: &str) -> String {
|
||||
s.chars()
|
||||
.map(|c| match c {
|
||||
@@ -134,11 +158,46 @@ pub fn sanitize_for_cp866(s: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Обрезает строку по числу символов и дополняет пробелами до заданной ширины.
|
||||
pub fn fit_to_width(s: &str, width: usize) -> String {
|
||||
let mut out: String = s.chars().take(width).collect();
|
||||
let len = out.chars().count();
|
||||
if len < width {
|
||||
out.push_str(&" ".repeat(width - len));
|
||||
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 - len));
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_width_stays_within_protocol_coordinate_range() {
|
||||
assert_eq!(VfdConfig::new("test").with_width(0).width, 1);
|
||||
assert_eq!(VfdConfig::new("test").with_width(20).width, 20);
|
||||
assert_eq!(VfdConfig::new("test").with_width(usize::MAX).width, 255);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sanitizes_typographic_characters_without_changing_regular_text() {
|
||||
assert_eq!(sanitize_for_cp866("№1\t“тест”—‘да’…"), "#1 \"тест\"-'да'.");
|
||||
assert_eq!(sanitize_for_cp866("обычный text"), "обычный text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fits_unicode_by_characters_and_pads_short_input() {
|
||||
assert_eq!(fit_to_width("Привет", 4), "Прив");
|
||||
assert_eq!(fit_to_width("да", 4), "да ");
|
||||
assert_eq!(fit_to_width("text", 0), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_without_splitting_utf8_characters() {
|
||||
assert_eq!(truncate_chars("ёжик", 3), "ёжи");
|
||||
assert_eq!(truncate_chars("ёжик", 10), "ёжик");
|
||||
assert_eq!(truncate_chars("ёжик", 0), "");
|
||||
}
|
||||
}
|
||||
|
||||
+118
-56
@@ -9,6 +9,7 @@ use std::thread::{self, JoinHandle};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Команда, передаваемая фоновому потоку дисплея.
|
||||
pub enum Cmd {
|
||||
Clear,
|
||||
PrintLine {
|
||||
@@ -49,21 +50,24 @@ pub enum Cmd {
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
/// Клонируемый потокобезопасный интерфейс отправки команд дисплею.
|
||||
pub struct VfdHandle {
|
||||
tx: Sender<Cmd>,
|
||||
stop: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl VfdHandle {
|
||||
/// Ставит в очередь очистку дисплея.
|
||||
pub fn clear(&self) {
|
||||
let _ = self.tx.send(Cmd::Clear);
|
||||
}
|
||||
|
||||
/// Ставит в очередь изменение яркости.
|
||||
pub fn set_brightness(&self, level: u8) {
|
||||
let _ = self.tx.send(Cmd::SetBrightness { level });
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
/// Ставит в очередь полную перезапись строки.
|
||||
pub fn print_line(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> {
|
||||
self.tx.send(Cmd::PrintLine {
|
||||
line,
|
||||
@@ -72,6 +76,7 @@ impl VfdHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Обновляет только изменившиеся диапазоны строки и уменьшает число serial-команд.
|
||||
pub fn print_line_diff(&self, line: u8, text: impl Into<String>) -> anyhow::Result<()> {
|
||||
self.tx.send(Cmd::PrintLineDiff {
|
||||
line,
|
||||
@@ -80,6 +85,7 @@ impl VfdHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Ставит в очередь печать текста с координаты `(x, y)`.
|
||||
pub fn print_at(&self, x: u8, y: u8, text: impl Into<String>) -> anyhow::Result<()> {
|
||||
self.tx.send(Cmd::PrintAt {
|
||||
x,
|
||||
@@ -89,10 +95,12 @@ impl VfdHandle {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Заменяет текст, используемый бегущей строкой.
|
||||
pub fn set_marquee_text(&self, text: impl Into<String>) {
|
||||
let _ = self.tx.send(Cmd::SetMarqueeText { text: text.into() });
|
||||
}
|
||||
|
||||
/// Запускает бегущую строку на выбранной линии с заданной скоростью и паузой.
|
||||
pub fn start_marquee(&self, line: u8, cps: u32, end_pause: Duration) {
|
||||
let _ = self.tx.send(Cmd::StartMarquee {
|
||||
line,
|
||||
@@ -101,23 +109,28 @@ impl VfdHandle {
|
||||
});
|
||||
}
|
||||
|
||||
/// Останавливает активную бегущую строку.
|
||||
pub fn stop_marquee(&self) {
|
||||
let _ = self.tx.send(Cmd::StopMarquee);
|
||||
}
|
||||
|
||||
/// Сигнализирует фоновому потоку о завершении работы.
|
||||
pub fn shutdown(&self) {
|
||||
self.stop.store(true, Ordering::Relaxed);
|
||||
let _ = self.tx.send(Cmd::Shutdown);
|
||||
}
|
||||
}
|
||||
|
||||
/// Владеет фоновым потоком записи и дожидается его завершения при удалении.
|
||||
pub struct VfdWorker {
|
||||
handle: VfdHandle,
|
||||
join: Option<JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl VfdWorker {
|
||||
/// Открывает дисплей и запускает поток последовательной обработки команд.
|
||||
pub fn start(cfg: VfdConfig) -> Result<Self> {
|
||||
let vfd = Vfd::open(cfg)?;
|
||||
let (tx, rx) = mpsc::channel::<Cmd>();
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
|
||||
@@ -127,7 +140,7 @@ impl VfdWorker {
|
||||
};
|
||||
|
||||
let join = thread::spawn(move || {
|
||||
if let Err(e) = writer_loop(cfg, rx, stop) {
|
||||
if let Err(e) = writer_loop(vfd, rx, stop) {
|
||||
eprintln!("[vfd] writer loop error: {e:#}");
|
||||
}
|
||||
});
|
||||
@@ -138,14 +151,16 @@ impl VfdWorker {
|
||||
})
|
||||
}
|
||||
|
||||
/// Возвращает новый клон интерфейса отправки команд.
|
||||
pub fn handle(&self) -> VfdHandle {
|
||||
self.handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for VfdWorker {
|
||||
/// Корректно останавливает фоновый поток при выходе владельца из области видимости.
|
||||
fn drop(&mut self) {
|
||||
// try to stop gracefully
|
||||
// Сначала посылаем сигнал остановки, затем дожидаемся завершения потока.
|
||||
self.handle.shutdown();
|
||||
if let Some(j) = self.join.take() {
|
||||
let _ = j.join();
|
||||
@@ -169,6 +184,7 @@ struct MarqueeState {
|
||||
}
|
||||
|
||||
impl MarqueeState {
|
||||
/// Создаёт неактивное состояние бегущей строки со значениями по умолчанию.
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
active: false,
|
||||
@@ -183,25 +199,67 @@ impl MarqueeState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Перестраивает поток символов с пустыми полями до и после текста.
|
||||
fn rebuild_stream(&mut self, width: usize) {
|
||||
let text = sanitize_for_cp866(&self.text);
|
||||
self.stream.clear();
|
||||
self.stream.extend(std::iter::repeat(' ').take(width));
|
||||
self.stream.reserve(width * 2 + text.chars().count());
|
||||
self.stream.extend(std::iter::repeat_n(' ', width));
|
||||
self.stream.extend(text.chars());
|
||||
self.stream.extend(std::iter::repeat(' ').take(width));
|
||||
self.stream.extend(std::iter::repeat_n(' ', width));
|
||||
self.offset = 0;
|
||||
self.paused_until = None;
|
||||
self.last_step = Instant::now();
|
||||
}
|
||||
|
||||
/// Рассчитывает ненулевой интервал между сдвигами из скорости в символах в секунду.
|
||||
fn step_interval(&self) -> Duration {
|
||||
let cps = self.cps.max(1);
|
||||
Duration::from_millis((1000 / cps) as u64)
|
||||
let cps = u64::from(self.cps.max(1));
|
||||
Duration::from_nanos((1_000_000_000 / cps).max(1))
|
||||
}
|
||||
}
|
||||
|
||||
fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Result<()> {
|
||||
let mut vfd = Vfd::open(cfg)?;
|
||||
/// Группирует соседние изменившиеся символы в минимальное число диапазонов записи.
|
||||
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
|
||||
}
|
||||
|
||||
/// Обновляет фрагмент кэшированной строки с учётом Unicode и правой границы.
|
||||
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);
|
||||
}
|
||||
|
||||
/// Последовательно обрабатывает команды и по таймеру формирует кадры бегущей строки.
|
||||
fn writer_loop(mut vfd: Vfd, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Result<()> {
|
||||
// optional initial clear:
|
||||
let _ = vfd.clear();
|
||||
|
||||
@@ -258,24 +316,15 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Resu
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut buf = [0u8; 4];
|
||||
|
||||
for (i, (a, b)) in last_lines[idx].chars().zip(next.chars()).enumerate() {
|
||||
if i >= width {
|
||||
break;
|
||||
}
|
||||
if a != b {
|
||||
let x = (i as u8) + 1;
|
||||
let s = b.encode_utf8(&mut buf);
|
||||
let _ = vfd.print_at(x, line, s);
|
||||
}
|
||||
for (x, text) in changed_runs(&last_lines[idx], &next) {
|
||||
let _ = vfd.print_at_prepared(x, line, &text);
|
||||
}
|
||||
|
||||
last_lines[idx] = next;
|
||||
}
|
||||
Cmd::PrintAt { x, y, text } => {
|
||||
// базовая валидация координат
|
||||
if !(1..=width as u8).contains(&x) {
|
||||
if x == 0 || usize::from(x) > width {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -288,39 +337,13 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Resu
|
||||
continue;
|
||||
}
|
||||
|
||||
let s = sanitize_for_cp866(&text);
|
||||
let _ = vfd.print_at(x, y, &s);
|
||||
let remaining = width - usize::from(x) + 1;
|
||||
let text: String =
|
||||
sanitize_for_cp866(&text).chars().take(remaining).collect();
|
||||
let _ = vfd.print_at_prepared(x, y, &text);
|
||||
|
||||
// Обновляем кеш после изменения
|
||||
if (1..=2).contains(&y) {
|
||||
let idx = (y - 1) as usize;
|
||||
|
||||
// гарантируем fixed-width в кэше
|
||||
if last_lines[idx].is_empty() {
|
||||
last_lines[idx] = " ".repeat(width);
|
||||
}
|
||||
|
||||
// заменяем символ в позиции x (1-based) на первый символ s (после sanitize)
|
||||
// (если строка пустая — просто игнор)
|
||||
if let Some(ch) = s.chars().next() {
|
||||
let pos = (x - 1) as usize;
|
||||
let mut new_line = String::with_capacity(width);
|
||||
|
||||
for (i, cur) in last_lines[idx].chars().enumerate() {
|
||||
if i == pos {
|
||||
new_line.push(ch);
|
||||
} else {
|
||||
new_line.push(cur);
|
||||
}
|
||||
if new_line.chars().count() >= width {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// если вдруг кэш был короче width — добиваем пробелами
|
||||
last_lines[idx] = crate::vfd::fit_to_width(&new_line, width);
|
||||
}
|
||||
}
|
||||
let idx = (y - 1) as usize;
|
||||
replace_cached_range(&mut last_lines[idx], x, &text, width);
|
||||
}
|
||||
Cmd::SetMarqueeText { text } => {
|
||||
marquee.text = text;
|
||||
@@ -381,11 +404,10 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Resu
|
||||
let end = (start + width).min(marquee.stream.len());
|
||||
|
||||
let frame: String = marquee.stream[start..end].iter().collect();
|
||||
let _ = vfd.print_frame(marquee.line, &frame);
|
||||
let _ = vfd.print_at_prepared(1, marquee.line, &frame);
|
||||
|
||||
if (1..=2).contains(&marquee.line) {
|
||||
last_lines[(marquee.line - 1) as usize] =
|
||||
crate::vfd::fit_to_width(&frame, width);
|
||||
last_lines[(marquee.line - 1) as usize] = frame;
|
||||
}
|
||||
|
||||
if marquee.offset >= max_off {
|
||||
@@ -403,3 +425,43 @@ fn writer_loop(cfg: VfdConfig, rx: Receiver<Cmd>, stop: Arc<AtomicBool>) -> Resu
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[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());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marquee_interval_never_collapses_to_zero() {
|
||||
let mut marquee = MarqueeState::new();
|
||||
marquee.cps = u32::MAX;
|
||||
|
||||
assert!(!marquee.step_interval().is_zero());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user