chore: update package meta data

This commit is contained in:
Julien Calixte
2026-07-05 01:30:49 +02:00
parent 00b57bac55
commit c88389b623
4 changed files with 726 additions and 95 deletions

View File

@@ -1,7 +1,7 @@
[package]
name = "firmware"
version = "0.1.0"
authors = ["Julien Calixte <julien.calixte@theodo.com>"]
authors = ["Julien Calixte <juliencalixte@gmail.com>"]
edition = "2024"
resolver = "2"
rust-version = "1.85"

568
firmware/src/editor.rs Normal file
View File

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

View File

@@ -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<String> = 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
}

View File

@@ -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<Mutex<VecDeque<Key>>> = 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<Key> {
@@ -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<Key> {
/// 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<Key> {
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);