The pure, no-IO editor core can't persist or push, so `handle()` now returns an `Effect` (None/Save/Publish) that the firmware actions. `:w` signals Save, `:sync` signals Publish (save then git push); `:wq`/`:x` alias Save with vim's "quit" half dropped, and the `:q` family stays absent — an always-on appliance has nothing to quit to. `:fmt` remains in-core and yields no effect. The main loop keeps the batch's last effect and matches on it. Actual SD-write and git-push are the v0.1 gate and still stubbed as log lines.
227 lines
9.1 KiB
Rust
227 lines
9.1 KiB
Rust
mod epd;
|
|
mod usb_kbd;
|
|
|
|
use std::time::Instant;
|
|
|
|
use esp_idf_svc::hal::delay::FreeRtos;
|
|
use esp_idf_svc::hal::gpio::{AnyIOPin, PinDriver, Pull};
|
|
use esp_idf_svc::hal::peripherals::Peripherals;
|
|
use esp_idf_svc::hal::spi::config::{Config, DriverConfig};
|
|
use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver};
|
|
use esp_idf_svc::hal::units::FromValueType;
|
|
|
|
use editor::{Editor, Effect, Mode, CH};
|
|
use epd::Epd;
|
|
|
|
/// Injected by build.rs so serial output identifies the exact build.
|
|
const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT"));
|
|
|
|
/// Occasional full refresh, mainly for panel longevity — partial updates on
|
|
/// this panel stay visually clean far longer, so this is deliberately rare.
|
|
const FULL_REFRESH_EVERY: u32 = 64;
|
|
|
|
/// How long typing must pause before the Insert-mode caret is shown. There is no
|
|
/// caret while actively typing (it would ghost under windowed refresh); it
|
|
/// reappears once you settle. Normal/View draw their own caret every action.
|
|
const CURSOR_DEBOUNCE_MS: u128 = 750;
|
|
|
|
fn main() -> anyhow::Result<()> {
|
|
// Required once before any esp-idf-svc call; some runtime patches
|
|
// only link if this symbol is referenced. See esp-idf-template#71.
|
|
esp_idf_svc::sys::link_patches();
|
|
esp_idf_svc::log::EspLogger::initialize_default();
|
|
|
|
log::info!("Typoena — modal editor (vim modes), {BUILD_TAG}");
|
|
|
|
let peripherals = Peripherals::take()?;
|
|
let pins = peripherals.pins;
|
|
|
|
// GDEY0579T93 on S3-safe GPIOs (Spike 2 wiring):
|
|
// SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 · RST 5 · BUSY 4
|
|
let spi = SpiDriver::new(
|
|
peripherals.spi2,
|
|
pins.gpio12,
|
|
pins.gpio11,
|
|
None::<AnyIOPin>,
|
|
&DriverConfig::new().dma(Dma::Auto(4096)),
|
|
)?;
|
|
let bus = SpiBusDriver::new(spi, &Config::new().baudrate(4.MHz().into()))?;
|
|
let cs = PinDriver::output(pins.gpio7)?;
|
|
let dc = PinDriver::output(pins.gpio6)?;
|
|
let rst = PinDriver::output(pins.gpio5)?;
|
|
let busy = PinDriver::input(pins.gpio4, Pull::Down)?;
|
|
let mut epd = Epd::new(bus, dc, rst, cs, busy);
|
|
|
|
log::info!("EPD reset + init…");
|
|
epd.reset()?;
|
|
epd.init()?;
|
|
epd.clear_screen(0xFF)?; // white baseline; establishes the previous bank
|
|
|
|
// Bring up the USB keyboard in the background; keys arrive via next_key().
|
|
usb_kbd::start()?;
|
|
|
|
let mut ed = Editor::new();
|
|
let mut updates: u32 = 0;
|
|
let mut cursor_shown = true; // the initial render includes the caret
|
|
let mut last_activity = Instant::now();
|
|
|
|
// Keyboard attach/detach state drives the panel's disconnect flag; seed it
|
|
// (and the word-count snapshot) before the first render.
|
|
let mut last_kbd = usb_kbd::keyboard_present();
|
|
ed.set_keyboard_present(last_kbd);
|
|
ed.refresh_stats();
|
|
|
|
// First render is full (establishes the on-screen baseline for partials).
|
|
let mut shown = ed.draw(true);
|
|
epd.display_frame(shown.bytes())?;
|
|
|
|
loop {
|
|
// Drain all queued keystrokes (type-ahead absorbed during a refresh),
|
|
// apply them, then do a single refresh for the batch.
|
|
let mut keys = 0;
|
|
let mut effect = Effect::None;
|
|
while let Some(k) = usb_kbd::next_key() {
|
|
// A `:` command (only) yields an Effect; keep the last one in the batch.
|
|
match ed.handle(k) {
|
|
Effect::None => {}
|
|
e => effect = e,
|
|
}
|
|
keys += 1;
|
|
}
|
|
|
|
// Carry out any host-side effect a `:` command asked for. The SD write
|
|
// and git-push paths aren't wired into the main loop yet (v0.1 gate —
|
|
// see src/bin/git_sync.rs for the persistent-clone push, git_push.rs
|
|
// for the TLS trust store). Both block for seconds, so the real version
|
|
// must run off this task (dedicated git thread, as the spike does) and
|
|
// surface status on the panel; for now we log the intent.
|
|
match effect {
|
|
Effect::None => {}
|
|
Effect::Save => log::info!(":w — save requested (TODO v0.1: write buffer to SD)"),
|
|
Effect::Publish => log::info!(":sync — publish requested (TODO v0.1: save + git push)"),
|
|
}
|
|
|
|
// Keyboard attach/detach feeds the panel's disconnect flag.
|
|
let kbd = usb_kbd::keyboard_present();
|
|
ed.set_keyboard_present(kbd);
|
|
let kbd_changed = kbd != last_kbd;
|
|
last_kbd = kbd;
|
|
|
|
if keys == 0 {
|
|
// A connect/disconnect while idle must still repaint the panel flag —
|
|
// no keystroke will arrive to trigger it otherwise.
|
|
if kbd_changed {
|
|
let f = ed.draw(true);
|
|
epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT)?;
|
|
shown = f;
|
|
cursor_shown = true;
|
|
log::info!("keyboard {}", if kbd { "connected" } else { "disconnected" });
|
|
continue;
|
|
}
|
|
// Debounced caret, Insert mode only: once typing pauses, bring the
|
|
// bar caret back and refresh the panel word count with a silent
|
|
// full-area partial (no flash). Normal/View draw their caret on action.
|
|
if ed.mode() == Mode::Insert
|
|
&& !cursor_shown
|
|
&& last_activity.elapsed().as_millis() >= CURSOR_DEBOUNCE_MS
|
|
{
|
|
ed.refresh_stats();
|
|
let f = ed.draw(true);
|
|
epd.display_frame_partial_window(f.bytes(), 0, epd::HEIGHT)?;
|
|
shown = f;
|
|
cursor_shown = true;
|
|
log::info!("caret shown");
|
|
} else {
|
|
FreeRtos::delay_ms(8);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
last_activity = Instant::now();
|
|
// Non-Insert actions (Normal edits, mode switches) aren't rapid typing,
|
|
// so the panel word count can refresh immediately; in Insert the snapshot
|
|
// stays frozen until the typing-pause path above refreshes it.
|
|
if ed.mode() != Mode::Insert {
|
|
ed.refresh_stats();
|
|
}
|
|
// Suppress the Insert bar caret while typing (fast, no ghost); Normal
|
|
// and View render their caret regardless of this flag.
|
|
let insert_cursor_on = ed.mode() != Mode::Insert;
|
|
let prev_scroll = ed.scroll_top();
|
|
let frame = ed.draw(insert_cursor_on);
|
|
let scrolled = ed.scroll_top() != prev_scroll;
|
|
|
|
// Only the rows that changed since the last shown frame need updating.
|
|
let Some((y0, y1)) = changed_rows(shown.bytes(), frame.bytes()) else {
|
|
shown = frame;
|
|
cursor_shown = ed.mode() != Mode::Insert;
|
|
continue; // no visible change
|
|
};
|
|
// Snap the band to whole text lines so a partial-window boundary never
|
|
// lands mid-glyph — otherwise the boundary gate crops tall characters.
|
|
let ch = CH as u16;
|
|
let y0 = y0 / ch * ch;
|
|
let y1 = (y1 / ch * ch + ch - 1).min(epd::HEIGHT - 1);
|
|
|
|
updates += 1;
|
|
// A purely additive Insert edit (no cursor, no scroll) uses the fast
|
|
// windowed partial; anything else — deletes, caret moves, scrolling,
|
|
// mode switches — uses a clean full-area partial, with a periodic full
|
|
// refresh for panel longevity.
|
|
let periodic = updates % FULL_REFRESH_EVERY == 0;
|
|
let additive = ed.mode() == Mode::Insert
|
|
&& !scrolled
|
|
&& only_adds_ink(shown.bytes(), frame.bytes(), y0, y1);
|
|
|
|
let t0 = Instant::now();
|
|
let refresh = if periodic {
|
|
epd.display_frame(frame.bytes())?;
|
|
"FULL"
|
|
} else if additive {
|
|
epd.display_frame_partial_window(frame.bytes(), y0, y1 - y0 + 1)?;
|
|
"windowed"
|
|
} else {
|
|
epd.display_frame_partial_window(frame.bytes(), 0, epd::HEIGHT)?;
|
|
"full-area"
|
|
};
|
|
let ms = t0.elapsed().as_millis();
|
|
log::info!(
|
|
"{refresh} refresh #{updates} [{:?}]: {ms} ms (rows {y0}..={y1}, {keys} key(s))",
|
|
ed.mode()
|
|
);
|
|
shown = frame;
|
|
cursor_shown = ed.mode() != Mode::Insert;
|
|
}
|
|
}
|
|
|
|
/// First and last (inclusive) framebuffer rows that differ between two frames,
|
|
/// or `None` if identical. Lets the partial refresh target just the band a
|
|
/// keystroke touched instead of all 272 rows.
|
|
fn changed_rows(a: &[u8], b: &[u8]) -> Option<(u16, u16)> {
|
|
let w = epd::FB_BYTES_W;
|
|
let mut first: Option<u16> = None;
|
|
let mut last = 0u16;
|
|
for y in 0..epd::HEIGHT as usize {
|
|
if a[y * w..(y + 1) * w] != b[y * w..(y + 1) * w] {
|
|
first.get_or_insert(y as u16);
|
|
last = y as u16;
|
|
}
|
|
}
|
|
first.map(|f| (f, last))
|
|
}
|
|
|
|
/// True if going from frame `a` to `b` only *adds* ink within rows `y0..=y1`
|
|
/// (no black pixel becomes white). Windowed partial refresh renders added ink
|
|
/// cleanly but leaves ghosts where ink is erased, so erasing edits fall back to
|
|
/// a clean full-area partial. Bit convention: 1 = white, 0 = black ink.
|
|
fn only_adds_ink(a: &[u8], b: &[u8], y0: u16, y1: u16) -> bool {
|
|
let w = epd::FB_BYTES_W;
|
|
for i in y0 as usize * w..(y1 as usize + 1) * w {
|
|
// A bit set in b but clear in a went black→white — an erase.
|
|
if b[i] & !a[i] != 0 {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
}
|