From c88389b623af3e3666fd2c4980ebf814d65e697e Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Sun, 5 Jul 2026 01:30:49 +0200 Subject: [PATCH] chore: update package meta data --- firmware/Cargo.toml | 2 +- firmware/src/editor.rs | 568 ++++++++++++++++++++++++++++++++++++++++ firmware/src/main.rs | 162 ++++++------ firmware/src/usb_kbd.rs | 89 ++++++- 4 files changed, 726 insertions(+), 95 deletions(-) create mode 100644 firmware/src/editor.rs diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index 5362469..a34befa 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "firmware" version = "0.1.0" -authors = ["Julien Calixte "] +authors = ["Julien Calixte "] edition = "2024" resolver = "2" rust-version = "1.85" diff --git a/firmware/src/editor.rs b/firmware/src/editor.rs new file mode 100644 index 0000000..e34caee --- /dev/null +++ b/firmware/src/editor.rs @@ -0,0 +1,568 @@ +//! Modal text editor core: a vim-style buffer with Normal / Insert (edit) / +//! View (read-only) modes, rendered onto the e-paper [`Frame`]. +//! +//! The buffer is plain ASCII — the US-QWERTY decoder only ever produces ASCII +//! and Tab expands to spaces on insert — so a byte offset into the `String` is +//! also a character index, and `caret` is that offset. Motions and edits work +//! on the logical (`\n`-delimited) buffer; word-wrapping and scrolling are a +//! render-time concern handled by [`Editor::draw`]. + +use embedded_graphics::mono_font::ascii::{FONT_6X10, FONT_10X20}; +use embedded_graphics::mono_font::MonoTextStyle; +use embedded_graphics::pixelcolor::BinaryColor; +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; + +/// FONT_10X20 cell size and the grid it tiles the panel into. +pub const CW: i32 = 10; +pub const CH: i32 = 20; +const COLS: usize = (epd::WIDTH / 10) as usize; // 79 characters per line +const ROWS: usize = (epd::HEIGHT / 20) as usize; // 13 text rows; bottom 12 px = status +/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so +/// the buffer stays 1 char = 1 column. +const TAB: &str = " "; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// Navigation and commands (hjkl, w/b/e, dd, x, …). + Normal, + /// Text entry — keys insert at the caret. + Insert, + /// Read-only reading: keys scroll the viewport, edits are locked out. + View, +} + +/// The editor state: buffer, caret, mode, viewport, and pending command state. +pub struct Editor { + text: String, + /// Byte offset of the caret (== char index; the buffer is ASCII). Ranges + /// over `0..=text.len()`. + caret: usize, + mode: Mode, + /// Index of the first visible display line. + scroll_top: usize, + /// Pending numeric count prefix (`0` = none), e.g. the `3` in `3j`. + count: usize, + /// `d` operator awaiting a motion (`dd`, `dw`). + pending_d: bool, + /// First `g` of a `gg` awaiting the second. + pending_g: bool, +} + +/// One wrapped display line: its text and the buffer offset of its first char. +struct Line { + start: usize, + text: String, +} + +impl Editor { + pub fn new() -> Self { + Editor { + text: String::new(), + caret: 0, + mode: Mode::Insert, // writing appliance: power-on = ready to type + scroll_top: 0, + count: 0, + pending_d: false, + pending_g: false, + } + } + + pub fn mode(&self) -> Mode { + self.mode + } + + pub fn scroll_top(&self) -> usize { + self.scroll_top + } + + /// Dispatch one decoded key event according to the current mode. + pub fn handle(&mut self, key: Key) { + match self.mode { + Mode::Insert => self.insert_key(key), + Mode::Normal => self.normal_key(key), + Mode::View => self.view_key(key), + } + } + + // --- Insert mode ------------------------------------------------------- + + fn insert_key(&mut self, key: Key) { + match key { + Key::Char('\t') => self.insert_str(TAB), + Key::Char(c) => self.insert_char(c), + Key::Enter => self.insert_char('\n'), + Key::Backspace => self.backspace(), + Key::DeleteWord => self.delete_word_before(), + Key::DeleteLine => self.delete_to_line_start(), + Key::Escape => { + self.mode = Mode::Normal; + // vim drops the caret onto the last inserted char. + if self.caret > self.line_start(self.caret) { + self.caret -= 1; + } + } + } + } + + // --- Normal mode ------------------------------------------------------- + + fn normal_key(&mut self, key: Key) { + let c = match key { + Key::Char(c) => c, + // Esc and non-character events cancel any pending command. + _ => { + self.reset_pending(); + return; + } + }; + + // Count prefix: a leading `0` is the line-start motion, not a digit. + if c.is_ascii_digit() && !(c == '0' && self.count == 0) { + self.count = self.count.saturating_mul(10) + (c as usize - '0' as usize); + return; + } + let n = self.count.max(1); + + if self.pending_d { + self.pending_d = false; + match c { + 'd' => (0..n).for_each(|_| self.delete_current_line()), + 'w' => (0..n).for_each(|_| self.delete_word_forward()), + _ => {} + } + self.count = 0; + return; + } + if self.pending_g { + self.pending_g = false; + if c == 'g' { + self.caret = 0; + } + self.count = 0; + return; + } + + match c { + 'h' => (0..n).for_each(|_| self.move_left()), + 'l' => (0..n).for_each(|_| self.move_right()), + 'j' => (0..n).for_each(|_| self.move_down()), + 'k' => (0..n).for_each(|_| self.move_up()), + 'w' => (0..n).for_each(|_| self.caret = self.word_forward_pos(self.caret)), + 'b' => (0..n).for_each(|_| self.caret = self.word_back_pos(self.caret)), + 'e' => (0..n).for_each(|_| self.caret = self.word_end_pos(self.caret)), + '0' => self.caret = self.line_start(self.caret), + '$' => self.caret = self.line_end(self.caret), + 'G' => self.caret = self.line_start(self.text.len()), + 'g' => { + self.pending_g = true; + return; + } + 'x' => (0..n).for_each(|_| self.delete_at_caret()), + 'd' => { + self.pending_d = true; + return; + } + 'i' => self.mode = Mode::Insert, + 'a' => { + self.move_right_append(); + self.mode = Mode::Insert; + } + 'A' => { + self.caret = self.line_end(self.caret); + self.mode = Mode::Insert; + } + 'I' => { + self.caret = self.line_start(self.caret); + self.mode = Mode::Insert; + } + 'o' => { + self.caret = self.line_end(self.caret); + self.insert_char('\n'); + self.mode = Mode::Insert; + } + 'O' => { + let p = self.line_start(self.caret); + self.text.insert(p, '\n'); + self.caret = p; + self.mode = Mode::Insert; + } + 'v' | 'V' => self.mode = Mode::View, + _ => {} + } + self.count = 0; + } + + fn reset_pending(&mut self) { + self.count = 0; + self.pending_d = false; + self.pending_g = false; + } + + // --- View mode --------------------------------------------------------- + + fn view_key(&mut self, key: Key) { + match key { + Key::Char('j') => self.scroll_top += 1, // clamped in draw() + Key::Char('k') => self.scroll_top = self.scroll_top.saturating_sub(1), + Key::Char(' ') => self.scroll_top += ROWS, + Key::Char('G') => { + let total = self.layout().len(); + self.scroll_top = total.saturating_sub(ROWS); + } + Key::Char('g') => { + if self.pending_g { + self.scroll_top = 0; + self.pending_g = false; + } else { + self.pending_g = true; + } + } + Key::Escape => { + self.mode = Mode::Normal; + self.pending_g = false; + } + _ => {} + } + } + + // --- Motions (all on the logical buffer) ------------------------------- + + /// Offset of the start of the line containing `pos`. + fn line_start(&self, pos: usize) -> usize { + let b = self.text.as_bytes(); + let mut i = pos; + while i > 0 && b[i - 1] != b'\n' { + i -= 1; + } + i + } + + /// Offset of the end of the line containing `pos` (the `\n`, or buffer end). + fn line_end(&self, pos: usize) -> usize { + let b = self.text.as_bytes(); + let mut i = pos; + while i < b.len() && b[i] != b'\n' { + i += 1; + } + i + } + + fn move_left(&mut self) { + if self.caret > self.line_start(self.caret) { + self.caret -= 1; + } + } + + fn move_right(&mut self) { + if self.caret < self.line_end(self.caret) { + self.caret += 1; + } + } + + /// Like `l` but allowed to land one past the last char (for `a`). + fn move_right_append(&mut self) { + if self.caret < self.line_end(self.caret) { + self.caret += 1; + } + } + + fn move_down(&mut self) { + let col = self.caret - self.line_start(self.caret); + let le = self.line_end(self.caret); + if le >= self.text.len() { + return; // already on the last line + } + let next_start = le + 1; + let next_end = self.line_end(next_start); + self.caret = (next_start + col).min(next_end); + } + + fn move_up(&mut self) { + let ls = self.line_start(self.caret); + if ls == 0 { + return; // already on the first line + } + let col = self.caret - ls; + let prev_start = self.line_start(ls - 1); + let prev_end = ls - 1; // the '\n' that ends the previous line + self.caret = (prev_start + col).min(prev_end); + } + + /// Start of the next whitespace-delimited word after `from`. + fn word_forward_pos(&self, from: usize) -> usize { + let b = self.text.as_bytes(); + let n = b.len(); + let mut i = from; + while i < n && !b[i].is_ascii_whitespace() { + i += 1; + } + while i < n && b[i].is_ascii_whitespace() { + i += 1; + } + i + } + + /// Start of the word at or before `from`. + fn word_back_pos(&self, from: usize) -> usize { + let b = self.text.as_bytes(); + let mut i = from; + while i > 0 && b[i - 1].is_ascii_whitespace() { + i -= 1; + } + while i > 0 && !b[i - 1].is_ascii_whitespace() { + i -= 1; + } + i + } + + /// End of the current/next word (lands on its last char). + fn word_end_pos(&self, from: usize) -> usize { + let b = self.text.as_bytes(); + let n = b.len(); + let mut i = from + 1; + if i >= n { + return from; + } + while i < n && b[i].is_ascii_whitespace() { + i += 1; + } + while i < n && !b[i].is_ascii_whitespace() { + i += 1; + } + i.saturating_sub(1) + } + + // --- Edits ------------------------------------------------------------- + + fn insert_char(&mut self, c: char) { + self.text.insert(self.caret, c); + self.caret += c.len_utf8(); + } + + fn insert_str(&mut self, s: &str) { + self.text.insert_str(self.caret, s); + self.caret += s.len(); + } + + fn backspace(&mut self) { + if self.caret > 0 { + self.caret -= 1; + self.text.remove(self.caret); + } + } + + /// `x` — delete the char under the caret (never a newline). + fn delete_at_caret(&mut self) { + let b = self.text.as_bytes(); + if self.caret < b.len() && b[self.caret] != b'\n' { + self.text.remove(self.caret); + // Keep the caret on a char: if it fell off the line end, step back. + if self.caret >= self.line_end(self.caret) && self.caret > self.line_start(self.caret) { + self.caret -= 1; + } + } + } + + /// `dd` — delete the current logical line, including its newline (or the + /// preceding one for the last line, so no blank line is left behind). + fn delete_current_line(&mut self) { + let ls = self.line_start(self.caret); + let le = self.line_end(self.caret); + let (start, end) = if le < self.text.len() { + (ls, le + 1) // eat the trailing newline + } else if ls > 0 { + (ls - 1, le) // last line: eat the preceding newline instead + } else { + (ls, le) // whole buffer + }; + self.text.replace_range(start..end, ""); + self.caret = self.line_start(start.min(self.text.len())); + } + + /// `dw` — delete from the caret to the start of the next word. + fn delete_word_forward(&mut self) { + let target = self.word_forward_pos(self.caret); + self.text.replace_range(self.caret..target, ""); + } + + /// Insert-mode Ctrl+W / Ctrl+Backspace: delete the word before the caret. + fn delete_word_before(&mut self) { + let b = self.text.as_bytes(); + let mut i = self.caret; + while i > 0 && (b[i - 1] == b' ' || b[i - 1] == b'\t') { + i -= 1; + } + while i > 0 && !b[i - 1].is_ascii_whitespace() { + i -= 1; + } + self.text.replace_range(i..self.caret, ""); + self.caret = i; + } + + /// Insert-mode Cmd+Backspace: delete back to the start of the line, or the + /// preceding newline if already there. + fn delete_to_line_start(&mut self) { + let ls = self.line_start(self.caret); + if ls == self.caret { + if self.caret > 0 { + self.caret -= 1; + self.text.remove(self.caret); + } + } else { + self.text.replace_range(ls..self.caret, ""); + self.caret = ls; + } + } + + // --- Rendering --------------------------------------------------------- + + /// Wrap the buffer into display lines, tracking each line's buffer offset. + fn layout(&self) -> Vec { + let mut lines = vec![Line { + start: 0, + text: String::new(), + }]; + let mut idx = 0usize; + for ch in self.text.chars() { + if ch == '\n' { + idx += 1; + lines.push(Line { + start: idx, + text: String::new(), + }); + continue; + } + if lines.last().unwrap().text.chars().count() >= COLS { + lines.push(Line { + start: idx, + text: String::new(), + }); + } + lines.last_mut().unwrap().text.push(ch); + idx += 1; + } + lines + } + + /// Display (row, col) of the caret within `lay`. + fn caret_rc(&self, lay: &[Line]) -> (usize, usize) { + let mut row = 0; + for (i, l) in lay.iter().enumerate() { + if l.start <= self.caret { + row = i; + } else { + break; + } + } + (row, self.caret - lay[row].start) + } + + /// Move the viewport so the caret stays visible (Normal/Insert), or just + /// clamp it to the content (View). + fn adjust_scroll(&mut self, caret_row: usize, total: usize) { + match self.mode { + Mode::View => { + let max = total.saturating_sub(ROWS); + if self.scroll_top > max { + self.scroll_top = max; + } + } + _ => { + if caret_row < self.scroll_top { + self.scroll_top = caret_row; + } else if caret_row >= self.scroll_top + ROWS { + self.scroll_top = caret_row + 1 - ROWS; + } + } + } + } + + /// Render the current state into a frame. `insert_cursor_on` gates the + /// Insert-mode bar caret (suppressed while typing, shown after a pause); + /// Normal draws a block caret and View draws none, regardless. + pub fn draw(&mut self, insert_cursor_on: bool) -> Frame { + let lay = self.layout(); + let (crow, ccol) = self.caret_rc(&lay); + self.adjust_scroll(crow, lay.len()); + + let mut f = Frame::new_white(); + let text_style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On); + let end = (self.scroll_top + ROWS).min(lay.len()); + for (vis, li) in (self.scroll_top..end).enumerate() { + Text::with_baseline( + &lay[li].text, + Point::new(0, vis as i32 * CH), + text_style, + Baseline::Top, + ) + .draw(&mut f) + .unwrap(); + } + + if crow >= self.scroll_top && crow < self.scroll_top + ROWS { + let x = ccol.min(COLS - 1) as i32 * CW; + let y = (crow - self.scroll_top) as i32 * CH; + match self.mode { + Mode::Normal => { + // Block caret: fill the cell, redraw the glyph in white. + Rectangle::new(Point::new(x, y), Size::new(CW as u32, CH as u32)) + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) + .draw(&mut f) + .unwrap(); + if let Some(ch) = lay[crow].text.chars().nth(ccol) { + let mut buf = [0u8; 4]; + let inv = MonoTextStyle::new(&FONT_10X20, BinaryColor::Off); + Text::with_baseline( + ch.encode_utf8(&mut buf), + Point::new(x, y), + inv, + Baseline::Top, + ) + .draw(&mut f) + .unwrap(); + } + } + Mode::Insert if insert_cursor_on => { + // Bar caret at the left edge of the cell. + Rectangle::new(Point::new(x, y), Size::new(2, CH as u32)) + .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) + .draw(&mut f) + .unwrap(); + } + _ => {} + } + } + + self.draw_status(&mut f); + f + } + + /// Draw the mode indicator (and any pending count/operator) in the bottom + /// strip, in the small 6×10 font so it fits below the 13 text rows. + fn draw_status(&self, f: &mut Frame) { + let name = match self.mode { + Mode::Normal => "NORMAL", + Mode::Insert => "INSERT", + Mode::View => "VIEW", + }; + let mut s = format!(" -- {name} --"); + if self.count > 0 { + s.push_str(&format!(" {}", self.count)); + } + if self.pending_d { + s.push('d'); + } + if self.pending_g { + s.push('g'); + } + let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On); + Text::with_baseline(&s, Point::new(2, ROWS as i32 * CH + 1), style, Baseline::Top) + .draw(f) + .unwrap(); + } +} diff --git a/firmware/src/main.rs b/firmware/src/main.rs index a360d3d..7ce7863 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -1,14 +1,9 @@ +mod editor; mod epd; mod usb_kbd; use std::time::Instant; -use embedded_graphics::mono_font::ascii::FONT_10X20; -use embedded_graphics::mono_font::MonoTextStyle; -use embedded_graphics::pixelcolor::BinaryColor; -use embedded_graphics::prelude::*; -use embedded_graphics::primitives::{PrimitiveStyle, Rectangle}; -use embedded_graphics::text::{Baseline, Text}; use esp_idf_svc::hal::delay::FreeRtos; use esp_idf_svc::hal::gpio::{AnyIOPin, PinDriver, Pull}; use esp_idf_svc::hal::peripherals::Peripherals; @@ -16,28 +11,28 @@ 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, 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")); -/// FONT_10X20 laid out on the 792×272 panel: 10 px wide, 20 px tall. -const CW: i32 = 10; -const CH: i32 = 20; -const COLS: usize = (epd::WIDTH / 10) as usize; // 79 characters per line -const ROWS: usize = (epd::HEIGHT / 20) as usize; // 13 lines - /// 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 Spike 5 — partial refresh + keyboard, {BUILD_TAG}"); + log::info!("Typoena — modal editor (vim modes), {BUILD_TAG}"); let peripherals = Peripherals::take()?; let pins = peripherals.pins; @@ -66,48 +61,91 @@ fn main() -> anyhow::Result<()> { // 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(); + // First render is full (establishes the on-screen baseline for partials). - let mut text = String::new(); - let mut shown = render_frame(&text); + let mut shown = ed.draw(true); epd.display_frame(shown.bytes())?; - let mut updates: u32 = 0; 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; while let Some(k) = usb_kbd::next_key() { - apply_key(&mut text, k); + ed.handle(k); keys += 1; } + if keys == 0 { - FreeRtos::delay_ms(8); + // Debounced caret, Insert mode only: once typing pauses, bring the + // bar caret back with a silent full-area partial (no flash). + // Normal/View already draw their caret on every action. + if ed.mode() == Mode::Insert + && !cursor_shown + && last_activity.elapsed().as_millis() >= CURSOR_DEBOUNCE_MS + { + 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; } - let frame = render_frame(&text); - // Only the rows that changed since the last shown frame need updating — - // usually just the edited line, which the partial refresh windows to. + last_activity = Instant::now(); + // 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; - continue; // no visible change (e.g. backspace at start of buffer) + 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; - let full = updates % FULL_REFRESH_EVERY == 0; + // 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(); - if full { + let refresh = if periodic { epd.display_frame(frame.bytes())?; - } else { + "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 #{updates}: {ms} ms (rows {y0}..={y1}, {keys} key(s), {} chars)", - if full { "FULL" } else { "partial" }, - text.chars().count(), + "{refresh} refresh #{updates} [{:?}]: {ms} ms (rows {y0}..={y1}, {keys} key(s))", + ed.mode() ); shown = frame; + cursor_shown = ed.mode() != Mode::Insert; } } @@ -127,61 +165,17 @@ fn changed_rows(a: &[u8], b: &[u8]) -> Option<(u16, u16)> { first.map(|f| (f, last)) } -/// Apply a key event to the text buffer. -fn apply_key(text: &mut String, key: usb_kbd::Key) { - match key { - usb_kbd::Key::Char(c) => text.push(c), - usb_kbd::Key::Enter => text.push('\n'), - usb_kbd::Key::Backspace => { - text.pop(); +/// 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; } } -} - -/// Render the text buffer into a frame: word-wrapped at the panel width, -/// scrolled to the last `ROWS` lines, with an underline caret at the end. -fn render_frame(text: &str) -> epd::Frame { - let mut frame = epd::Frame::new_white(); - let style = MonoTextStyle::new(&FONT_10X20, BinaryColor::On); // black ink - - // Break into display lines on '\n' and at the column limit; tabs expand - // to 4 spaces. - let mut lines: Vec = vec![String::new()]; - for ch in text.chars() { - if ch == '\n' { - lines.push(String::new()); - continue; - } - let (glyph, count) = if ch == '\t' { (' ', 4) } else { (ch, 1) }; - for _ in 0..count { - if lines.last().unwrap().chars().count() >= COLS { - lines.push(String::new()); - } - lines.last_mut().unwrap().push(glyph); - } - } - - // Scroll: show only the last ROWS lines. - let start = lines.len().saturating_sub(ROWS); - let shown = &lines[start..]; - for (row, line) in shown.iter().enumerate() { - Text::with_baseline(line, Point::new(0, row as i32 * CH), style, Baseline::Top) - .draw(&mut frame) - .unwrap(); - } - - // Underline caret at the end of the last visible line. - if let Some(last) = shown.last() { - let col = last.chars().count().min(COLS - 1) as i32; - let row = shown.len() as i32 - 1; - Rectangle::new( - Point::new(col * CW, row * CH + CH - 2), - Size::new(CW as u32, 2), - ) - .into_styled(PrimitiveStyle::with_fill(BinaryColor::On)) - .draw(&mut frame) - .unwrap(); - } - - frame + true } diff --git a/firmware/src/usb_kbd.rs b/firmware/src/usb_kbd.rs index 3cadbeb..992134a 100644 --- a/firmware/src/usb_kbd.rs +++ b/firmware/src/usb_kbd.rs @@ -38,12 +38,21 @@ use esp_idf_svc::sys::{ usb_transfer_t, EspError, ESP_INTR_FLAG_LEVEL1, }; -/// A decoded key-down event. +/// A decoded key-down event. Beyond plain characters, the decoder recognises a +/// few editing combos (resolved here so the main loop only sees intents) and a +/// dual-role Caps Lock: held it acts as Ctrl, tapped it emits `Escape`. #[derive(Debug, Clone, Copy)] pub enum Key { Char(char), Enter, Backspace, + /// Ctrl+Backspace or Ctrl+W — delete the word before the caret. + DeleteWord, + /// Cmd/GUI+Backspace — delete back to the start of the current line. + DeleteLine, + /// Caps Lock tapped on its own. A no-op for now; groundwork for a future + /// vim-style normal mode. + Escape, } /// Boot-keyboard parameters, confirmed by Spike 4's enumeration. @@ -73,6 +82,10 @@ static KEY_QUEUE: OnceLock>> = OnceLock::new(); /// Keycodes held in the previous report, for key-down edge detection. Only /// ever touched from the single client thread's `report_cb`. static PREV_KEYS: Mutex<[u8; 6]> = Mutex::new([0; 6]); +/// Caps Lock dual-role tracking: set while Caps is held once any other key is +/// pressed, so releasing Caps only emits `Escape` on a clean tap. Only touched +/// from the client thread's `report_cb`. +static CAPS_USED: AtomicBool = AtomicBool::new(false); /// Pop the next decoded key-down event, if any. pub fn next_key() -> Option { @@ -205,28 +218,61 @@ unsafe extern "C" fn report_cb(transfer: *mut usb_transfer_t) { } } +/// Log and enqueue a decoded key event for the main thread to drain. +fn enqueue(key: Key) { + log::info!("key: {key:?}"); + if let Some(q) = KEY_QUEUE.get() { + q.lock().unwrap().push_back(key); + } +} + +/// Caps Lock usage ID — repurposed as a dual-role Ctrl/Escape key. +const CAPS: u8 = 0x39; + /// Edge-detect key-downs in an 8-byte boot report and enqueue translated keys. /// Layout: [modifiers, reserved, key1..key6]; 0 means "no key". fn handle_report(report: &[u8]) { if report.len() < 3 { return; } - let shift = report[0] & 0x22 != 0; // LShift (0x02) | RShift (0x20) + let mods = report[0]; + let shift = mods & 0x22 != 0; // LShift 0x02 | RShift 0x20 + let cmd = mods & 0x88 != 0; // LGUI 0x08 | RGUI 0x80 let current = &report[2..]; let mut prev = PREV_KEYS.lock().unwrap(); + + // Caps Lock is a normal key in the boot report (not a modifier bit), so we + // track its down/up edges here. Held, it acts as Ctrl; tapped alone, it + // emits Escape. + let caps_now = current.contains(&CAPS); + let caps_before = prev.contains(&CAPS); + let ctrl = mods & 0x11 != 0 || caps_now; // LCtrl 0x01 | RCtrl 0x10, or Caps + // Any other key down while Caps is held means it was used as Ctrl — so its + // release must not fire Escape. + if caps_now && current.iter().any(|&k| k != 0 && k != CAPS) { + CAPS_USED.store(true, Ordering::SeqCst); + } + for &k in current { - if k == 0 || prev.contains(&k) { - continue; // not pressed, or already held last report + if k == 0 || k == CAPS || prev.contains(&k) { + continue; // empty slot, the Caps key itself, or already held } - if let Some(key) = translate(k, shift) { - log::info!("key: {key:?}"); - if let Some(q) = KEY_QUEUE.get() { - q.lock().unwrap().push_back(key); - } + if let Some(key) = translate(k, shift, ctrl, cmd) { + enqueue(key); } } + // Caps released as a clean tap (nothing else pressed while it was down) → + // Escape. Reset the used-flag on both the press and release edges. + if caps_before && !caps_now { + if !CAPS_USED.swap(false, Ordering::SeqCst) { + enqueue(Key::Escape); + } + } else if caps_now && !caps_before { + CAPS_USED.store(false, Ordering::SeqCst); + } + let mut next = [0u8; 6]; for (slot, &k) in next.iter_mut().zip(current.iter()) { *slot = k; @@ -235,7 +281,30 @@ fn handle_report(report: &[u8]) { } /// Translate a HID keyboard usage ID to a key event using a US QWERTY layout. -fn translate(usage: u8, shift: bool) -> Option { +/// Editing combos (Ctrl/Cmd chords) resolve to intents here and take priority +/// over character insertion; other keys with Ctrl or Cmd held are swallowed. +fn translate(usage: u8, shift: bool, ctrl: bool, cmd: bool) -> Option { + match usage { + 0x2a => { + // Backspace: Cmd = delete line, Ctrl = delete word, else one char. + return Some(if cmd { + Key::DeleteLine + } else if ctrl { + Key::DeleteWord + } else { + Key::Backspace + }); + } + 0x1a if ctrl => return Some(Key::DeleteWord), // Ctrl+W, readline-style + _ => {} + } + + // With Ctrl or Cmd held and no combo matched above, insert nothing — so + // Caps+J or Cmd+S don't type a stray character. + if ctrl || cmd { + return None; + } + let key = match usage { 0x04..=0x1d => { let base = b'a' + (usage - 0x04);