chore: update package meta data
This commit is contained in:
@@ -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
568
firmware/src/editor.rs
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user