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.
This commit is contained in:
Julien Calixte
2026-07-11 00:28:37 +02:00
parent 875658a661
commit e8063a2b13
7 changed files with 189 additions and 65 deletions

10
display/Cargo.toml Normal file
View File

@@ -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"

66
display/src/lib.rs Normal file
View File

@@ -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<u8>,
}
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<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
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(())
}
}

10
editor/Cargo.toml Normal file
View File

@@ -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" }

View File

@@ -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);
}
}

View File

@@ -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`

View File

@@ -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<u8>,
}
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<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
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;

View File

@@ -1,4 +1,3 @@
mod editor;
mod epd;
mod usb_kbd;