From e8063a2b13bdca8365d3d019d5f0350cd63a9759 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sat, 11 Jul 2026 00:28:37 +0200 Subject: [PATCH] refactor(editor): extract editor + display into host-testable crates Move the editor core out of the firmware crate (pinned to the xtensa target, so it can't run `cargo test`) into a standalone `editor` crate, with the panel framebuffer + geometry split into a `display` crate. Both depend only on embedded-graphics + keymap, so the editor is now host-buildable and unit-tested (8 characterization tests). Firmware links them and re-exports the geometry from epd.rs; behaviour is unchanged. The xtensa firmware build was not verified in this environment. --- display/Cargo.toml | 10 +++ display/src/lib.rs | 66 ++++++++++++++ editor/Cargo.toml | 10 +++ firmware/src/editor.rs => editor/src/lib.rs | 99 +++++++++++++++++++-- firmware/Cargo.toml | 5 ++ firmware/src/epd.rs | 63 ++----------- firmware/src/main.rs | 1 - 7 files changed, 189 insertions(+), 65 deletions(-) create mode 100644 display/Cargo.toml create mode 100644 display/src/lib.rs create mode 100644 editor/Cargo.toml rename firmware/src/editor.rs => editor/src/lib.rs (94%) diff --git a/display/Cargo.toml b/display/Cargo.toml new file mode 100644 index 0000000..5fb7c2d --- /dev/null +++ b/display/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "display" +version = "0.1.0" +edition = "2021" +description = "The GDEY0579T93 panel's geometry plus an in-memory drawable Frame (an embedded-graphics DrawTarget), split out of the hardware driver so the driver and the host-testable editor crate share one framebuffer definition without pulling in esp-idf." + +# Pure embedded-graphics — no esp/std hardware deps — so the editor crate that +# renders onto Frame stays host-buildable and testable off the xtensa target. +[dependencies] +embedded-graphics = "0.8" diff --git a/display/src/lib.rs b/display/src/lib.rs new file mode 100644 index 0000000..1b1ab3b --- /dev/null +++ b/display/src/lib.rs @@ -0,0 +1,66 @@ +//! The e-paper panel's geometry and an in-memory drawable frame. +//! +//! Split out of the hardware driver (`firmware/src/epd.rs`) so the driver and +//! the host-testable `editor` crate share one framebuffer definition. `Frame` +//! is a pure `embedded-graphics` [`DrawTarget`]; the `Epd` driver in firmware +//! consumes its raw bytes via [`Frame::bytes`] and never names the type, so +//! nothing here depends on esp-idf and the whole crate builds on the host. + +use embedded_graphics::pixelcolor::BinaryColor; +use embedded_graphics::prelude::*; + +pub const WIDTH: u16 = 792; +pub const HEIGHT: u16 = 272; + +/// Full-frame 1-bit framebuffer: 792 px = 99 bytes per row, MSB-first, +/// 1 = white, 0 = black (SSD16xx convention). +pub const FB_BYTES_W: usize = (WIDTH / 8) as usize; // 99 +pub const FB_BYTES: usize = FB_BYTES_W * HEIGHT as usize; // 26928 + +/// In-memory 792×272 1-bit frame, drawable via `embedded-graphics`. +/// `BinaryColor::On` = black ink, `Off` = white paper. +pub struct Frame { + buf: Vec, +} + +impl Frame { + pub fn new_white() -> Self { + Self { buf: vec![0xFF; FB_BYTES] } + } + + pub fn new_black() -> Self { + Self { buf: vec![0x00; FB_BYTES] } + } + + pub fn bytes(&self) -> &[u8] { + &self.buf + } +} + +impl OriginDimensions for Frame { + fn size(&self) -> Size { + Size::new(WIDTH as u32, HEIGHT as u32) + } +} + +impl DrawTarget for Frame { + type Color = BinaryColor; + type Error = core::convert::Infallible; + + fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + for Pixel(p, color) in pixels { + if (0..WIDTH as i32).contains(&p.x) && (0..HEIGHT as i32).contains(&p.y) { + let idx = p.y as usize * FB_BYTES_W + p.x as usize / 8; + let bit = 0x80u8 >> (p.x % 8); + match color { + BinaryColor::On => self.buf[idx] &= !bit, // black ink + BinaryColor::Off => self.buf[idx] |= bit, // white paper + } + } + } + Ok(()) + } +} diff --git a/editor/Cargo.toml b/editor/Cargo.toml new file mode 100644 index 0000000..726b088 --- /dev/null +++ b/editor/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "editor" +version = "0.1.0" +edition = "2021" +description = "Modal (vim-style) text-editor core: the buffer, motions, edits, and e-paper layout/render. Depends only on embedded-graphics plus the display and keymap crates, so it builds and is tested on the host off the xtensa target (unlike the firmware crate it used to live in)." + +[dependencies] +embedded-graphics = "0.8" +display = { path = "../display" } +keymap = { path = "../keymap" } diff --git a/firmware/src/editor.rs b/editor/src/lib.rs similarity index 94% rename from firmware/src/editor.rs rename to editor/src/lib.rs index 7deb521..ce36348 100644 --- a/firmware/src/editor.rs +++ b/editor/src/lib.rs @@ -17,8 +17,8 @@ use embedded_graphics::prelude::*; use embedded_graphics::primitives::{PrimitiveStyle, Rectangle}; use embedded_graphics::text::{Baseline, Text}; -use crate::epd::{self, Frame}; -use crate::usb_kbd::Key; +use display::{Frame, HEIGHT}; +use keymap::Key; /// FONT_10X20 cell size (writing column) and the grid it tiles into. pub const CW: i32 = 10; @@ -31,7 +31,7 @@ pub const CH: i32 = 20; const WRITE_COLS: usize = 60; /// Visible writing rows. 13 × 20 px = 260 px; the bottom 12 px is the transient /// `:` command line (the only thing left of the old status band). -const ROWS: usize = (epd::HEIGHT / 20) as usize; // 13 +const ROWS: usize = (HEIGHT / 20) as usize; // 13 /// x of the 1 px rule dividing writing column from side panel, and the left edge /// of panel text (a small gutter past the rule). const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 600 @@ -928,7 +928,7 @@ impl Editor { /// repaints per keystroke. fn draw_panel(&self, f: &mut Frame) { // The rule dividing writing column from panel, full panel height. - Rectangle::new(Point::new(DIVIDER_X, 0), Size::new(1, epd::HEIGHT as u32)) + Rectangle::new(Point::new(DIVIDER_X, 0), Size::new(1, HEIGHT as u32)) .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) .draw(f) .unwrap(); @@ -946,7 +946,7 @@ impl Editor { if !self.keyboard_present { Text::with_baseline( "NO KBD", - Point::new(PANEL_X, epd::HEIGHT as i32 - 24), + Point::new(PANEL_X, HEIGHT as i32 - 24), style, Baseline::Top, ) @@ -983,7 +983,7 @@ impl Editor { } Text::with_baseline( &s, - Point::new(PANEL_X, epd::HEIGHT as i32 - 12), + Point::new(PANEL_X, HEIGHT as i32 - 12), style, Baseline::Top, ) @@ -1188,3 +1188,90 @@ fn pad_cell(cell: &str, w: usize, align: Align) -> String { _ => format!("{cell}{}", " ".repeat(pad)), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Type a run of characters in Insert mode (the power-on mode). + fn typed(s: &str) -> Editor { + let mut e = Editor::new(); + for c in s.chars() { + e.handle(Key::Char(c)); + } + e + } + + #[test] + fn insert_builds_buffer_and_advances_caret() { + let e = typed("hello"); + assert_eq!(e.text, "hello"); + assert_eq!(e.caret, 5); + assert_eq!(e.mode(), Mode::Insert); + } + + #[test] + fn backspace_deletes_previous_char() { + let mut e = typed("hello"); + e.handle(Key::Backspace); + assert_eq!(e.text, "hell"); + assert_eq!(e.caret, 4); + } + + #[test] + fn enter_splits_the_line() { + let mut e = typed("ab"); + e.handle(Key::Enter); + e.handle(Key::Char('c')); + assert_eq!(e.text, "ab\nc"); + assert_eq!(e.caret, 4); + } + + #[test] + fn escape_enters_normal_and_steps_onto_last_char() { + let mut e = typed("abc"); + e.handle(Key::Escape); + assert_eq!(e.mode(), Mode::Normal); + assert_eq!(e.caret, 2); // vim: caret drops onto the last inserted char + } + + #[test] + fn normal_h_and_l_step_one_char() { + let mut e = typed("abc"); + e.handle(Key::Escape); // Normal, caret = 2 + e.handle(Key::Char('h')); + assert_eq!(e.caret, 1); + e.handle(Key::Char('h')); + assert_eq!(e.caret, 0); + e.handle(Key::Char('l')); + assert_eq!(e.caret, 1); + } + + #[test] + fn normal_x_deletes_char_under_caret() { + let mut e = typed("abc"); + e.handle(Key::Escape); // caret on 'c' + e.handle(Key::Char('h')); // caret on 'b' + e.handle(Key::Char('x')); + assert_eq!(e.text, "ac"); + } + + #[test] + fn word_forward_lands_on_next_word_start() { + let mut e = typed("foo bar"); + e.handle(Key::Escape); // Normal + e.handle(Key::Char('0')); // line start + e.handle(Key::Char('w')); + assert_eq!(e.caret, 4); // 'b' of "bar" + } + + /// The buffer round-trips and `draw()` runs for a plain-ASCII buffer — the + /// current, byte==char world. UTF-8 (accented-input) correctness is the next + /// change; when it lands, add the accented-motion cases here. + #[test] + fn draw_produces_a_full_frame_for_ascii() { + let mut e = typed("hello world"); + let frame = e.draw(true); + assert_eq!(frame.bytes().len(), display::FB_BYTES); + } +} diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 24cce99..3516372 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -88,6 +88,11 @@ log = "0.4" # Pure HID decode (Key type + edge-detecting Decoder), split out so it is # host-testable off the xtensa target. See ../keymap and MEMORY_AUDIT.md. keymap = { path = "../keymap" } +# Editor core (buffer, motions, edits, layout/render) and the panel framebuffer, +# extracted from src/editor.rs and src/epd.rs so `cargo test` can exercise them +# off-device. The firmware links them; the host tests live in each crate. +editor = { path = "../editor" } +display = { path = "../display" } git2 = { version = "0.20", default-features = false, optional = true } esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-time-driver", "embassy-sync"] } # Remove `generic-queue-8` if you plan to use `embassy-time` WITH `embassy-executor` diff --git a/firmware/src/epd.rs b/firmware/src/epd.rs index 9f0c656..05b0d0d 100644 --- a/firmware/src/epd.rs +++ b/firmware/src/epd.rs @@ -12,75 +12,22 @@ //! `embedded-graphics` `DrawTarget` (`Frame`), full refresh (`display_frame`), //! and partial refresh (`display_frame_partial`) — Spikes 2 and 5. -use embedded_graphics::pixelcolor::BinaryColor; -use embedded_graphics::prelude::*; use esp_idf_svc::hal::delay::FreeRtos; use esp_idf_svc::hal::gpio::{Input, Output, PinDriver}; use esp_idf_svc::hal::spi::{SpiBusDriver, SpiDriver}; use esp_idf_svc::sys::EspError; -pub const WIDTH: u16 = 792; -pub const HEIGHT: u16 = 272; +// Panel geometry and the drawable `Frame` now live in the `display` crate, so +// the editor can render onto them off the xtensa target. Re-exported here so +// `epd::HEIGHT`, `epd::FB_BYTES`, etc. keep resolving for main.rs and the driver +// code below, and so the driver need not know they were relocated. +pub use display::{FB_BYTES, FB_BYTES_W, HEIGHT, WIDTH}; /// Each controller drives one half. SSD1683 X is byte-addressed; 396 px /// rounds up to 50 bytes (400 px) of RAM width, full panel height (272 rows). const CTRL_BYTES_W: usize = 50; const CTRL_BYTES: usize = CTRL_BYTES_W * HEIGHT as usize; // 50 * 272 = 13600 -/// Full-frame 1-bit framebuffer: 792 px = 99 bytes per row, MSB-first, -/// 1 = white, 0 = black (SSD16xx convention). -pub const FB_BYTES_W: usize = (WIDTH / 8) as usize; // 99 -pub const FB_BYTES: usize = FB_BYTES_W * HEIGHT as usize; // 26928 - -/// In-memory 792×272 1-bit frame, drawable via `embedded-graphics`. -/// `BinaryColor::On` = black ink, `Off` = white paper. -pub struct Frame { - buf: Vec, -} - -impl Frame { - pub fn new_white() -> Self { - Self { buf: vec![0xFF; FB_BYTES] } - } - - #[allow(dead_code)] // symmetric with new_white; kept as part of the API - pub fn new_black() -> Self { - Self { buf: vec![0x00; FB_BYTES] } - } - - pub fn bytes(&self) -> &[u8] { - &self.buf - } -} - -impl OriginDimensions for Frame { - fn size(&self) -> Size { - Size::new(WIDTH as u32, HEIGHT as u32) - } -} - -impl DrawTarget for Frame { - type Color = BinaryColor; - type Error = core::convert::Infallible; - - fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> - where - I: IntoIterator>, - { - for Pixel(p, color) in pixels { - if (0..WIDTH as i32).contains(&p.x) && (0..HEIGHT as i32).contains(&p.y) { - let idx = p.y as usize * FB_BYTES_W + p.x as usize / 8; - let bit = 0x80u8 >> (p.x % 8); - match color { - BinaryColor::On => self.buf[idx] &= !bit, // black ink - BinaryColor::Off => self.buf[idx] |= bit, // white paper - } - } - } - Ok(()) - } -} - /// Max bytes per SPI transfer; matches the DMA size configured in `main`. const SPI_CHUNK: usize = 4096; diff --git a/firmware/src/main.rs b/firmware/src/main.rs index ed88ad1..a703858 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -1,4 +1,3 @@ -mod editor; mod epd; mod usb_kbd;