Compare commits

...

2 Commits

Author SHA1 Message Date
Julien Calixte
50f650fef6 feat(editor): split display into writing column and side panel
Carve the panel into a ~60-col writing column and a right-hand side
panel (per CONTEXT.md screen regions); the divider sits right of the
x=396 driver seam. The mode indicator and pending-command echo move
into the panel, while the ':' command line stays in the bottom strip.

The panel also shows word count and words-this-session (a snapshot
refreshed on a typing pause / non-Insert action, never per keystroke,
so ordinary typing keeps the fast windowed refresh) and a "NO KBD"
flag while the keyboard is dropped.
2026-07-08 21:33:41 +02:00
Julien Calixte
ddd9c387d3 feat(usb): track keyboard connection state
Add a persistent KBD_PRESENT atomic (set on setup, cleared on unplug)
and a keyboard_present() accessor. DEV_GONE is a one-shot detach event
the client loop consumes, not a state the panel can read.
2026-07-08 21:33:32 +02:00
3 changed files with 164 additions and 34 deletions

View File

@@ -20,11 +20,22 @@ 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.
/// FONT_10X20 cell size (writing column) and the grid it tiles 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
/// Writing-column width, in characters. The panel is split into a left
/// **writing column** (this wide) and a right **side panel** for metadata (see
/// CONTEXT.md § Screen regions). 60 cols × 10 px = 600 px of text; the driver's
/// `x = 396` seam runs through it invisibly. The remaining 192 px sit entirely
/// in the master half (right of the seam) and hold the side panel.
const WRITE_COLS: usize = 60;
/// Visible writing rows. 13 × 20 px = 260 px; the bottom 12 px is the transient
/// `:` command line (the only thing left of the old status band).
const ROWS: usize = (epd::HEIGHT / 20) as usize; // 13
/// x of the 1 px rule dividing writing column from side panel, and the left edge
/// of panel text (a small gutter past the rule).
const DIVIDER_X: i32 = WRITE_COLS as i32 * CW; // 600
const PANEL_X: i32 = DIVIDER_X + 8; // 608
/// Tab stop, in spaces. Tabs never enter the buffer — they expand on insert so
/// the buffer stays 1 char = 1 column.
const TAB: &str = " ";
@@ -69,6 +80,16 @@ pub struct Editor {
pending_g: bool,
/// The `:` command line being typed (valid only in `Mode::Command`).
cmdline: String,
/// Word count as of the last stats refresh. The panel shows this snapshot,
/// not a live count, so ordinary typing never repaints the panel row — it is
/// refreshed on a typing pause / non-Insert action via `refresh_stats`.
shown_words: usize,
/// Word count captured at the session start (buffer load / boot), so the
/// panel can show words *added* this session. Empty boot buffer → 0.
session_base_words: usize,
/// Whether a USB keyboard is attached; drives the panel disconnect flag.
/// Fed from `usb_kbd::keyboard_present()` by the main loop.
keyboard_present: bool,
}
/// One wrapped display line: its text and the buffer offset of its first char.
@@ -89,6 +110,9 @@ impl Editor {
pending_obj: None,
pending_g: false,
cmdline: String::new(),
shown_words: 0,
session_base_words: 0,
keyboard_present: false,
}
}
@@ -100,6 +124,23 @@ impl Editor {
self.scroll_top
}
/// Recompute the panel word-count snapshot from the buffer. The main loop
/// calls this on a typing pause and on non-Insert actions, so the panel
/// count stays current without repainting on every keystroke.
pub fn refresh_stats(&mut self) {
self.shown_words = self.word_count();
}
/// Tell the editor whether a keyboard is attached (for the panel flag).
pub fn set_keyboard_present(&mut self, present: bool) {
self.keyboard_present = present;
}
/// Whitespace-delimited word count of the whole buffer.
fn word_count(&self) -> usize {
self.text.split_whitespace().count()
}
/// Dispatch one decoded key event according to the current mode.
pub fn handle(&mut self, key: Key) {
match self.mode {
@@ -724,9 +765,9 @@ impl Editor {
// --- Rendering ---------------------------------------------------------
/// Wrap the buffer into display lines, tracking each line's buffer offset.
/// Soft-wrap at word boundaries: a logical line too long for `COLS` breaks at
/// the last space that fits, so words are never split — except a single word
/// wider than the panel, which hard-breaks at `COLS` as a fallback. Buffer is
/// Soft-wrap at word boundaries: a logical line too long for `WRITE_COLS`
/// breaks at the last space that fits, so words are never split — except a
/// single word wider than the writing column, hard-broken at `WRITE_COLS`. Buffer is
/// ASCII (1 byte = 1 char), so a char index within a line is also a byte
/// offset (matches the rest of `editor.rs`; UTF-8 correctness is v0.2 work).
fn layout(&self) -> Vec<Line> {
@@ -740,12 +781,12 @@ impl Editor {
let mut c = 0usize; // char index within `logical`
while c < chars.len() {
let remaining = chars.len() - c;
let take = if remaining <= COLS {
let take = if remaining <= WRITE_COLS {
remaining
} else {
// Break at the last space within the COLS-wide window;
// include that space on this line. No space → hard break.
let window = c + COLS;
let window = c + WRITE_COLS;
let mut brk = None;
let mut p = window;
while p > c {
@@ -757,7 +798,7 @@ impl Editor {
}
match brk {
Some(sp) if sp > c => sp + 1 - c,
_ => COLS,
_ => WRITE_COLS,
}
};
lines.push(Line {
@@ -844,7 +885,7 @@ impl Editor {
}
if crow >= self.scroll_top && crow < self.scroll_top + ROWS {
let x = ccol.min(COLS - 1) as i32 * CW;
let x = ccol.min(WRITE_COLS - 1) as i32 * CW;
let y = (crow - self.scroll_top) as i32 * CH;
match self.mode {
Mode::Normal => {
@@ -877,46 +918,94 @@ impl Editor {
}
}
self.draw_status(&mut f);
self.draw_panel(&mut f);
self.draw_cmdline(&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) {
// Command mode shows the command line itself, vim-style, instead of a
// mode name.
if self.mode == Mode::Command {
let s = format!(":{}", self.cmdline);
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();
return;
}
/// Draw the side panel: a full-height rule, then the mode and any pending
/// command state, in the small 6×10 font. This is the metadata surface every
/// later panel field (filename, word count, clock, Wi-Fi, publish state)
/// will add to; v0.1 shows the mode plus the transient count/operator echo.
/// Everything here is event-driven (mode/command state), never per-keystroke,
/// so it adds no typing-time ghosting.
fn draw_panel(&self, f: &mut Frame) {
// The rule dividing writing column from panel, full panel height.
Rectangle::new(Point::new(DIVIDER_X, 0), Size::new(1, epd::HEIGHT as u32))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.draw(f)
.unwrap();
let style = MonoTextStyle::new(&FONT_6X10, BinaryColor::On);
let name = match self.mode {
Mode::Normal => "NORMAL",
Mode::Insert => "INSERT",
Mode::View => "VIEW",
Mode::Command => unreachable!(),
Mode::Command => "COMMAND",
};
let mut s = format!(" -- {name} --");
Text::with_baseline(name, Point::new(PANEL_X, 2), style, Baseline::Top)
.draw(f)
.unwrap();
// Pending count / operator / text-object / g, one line below the mode —
// transient feedback while a multi-key command is mid-entry.
let mut pend = String::new();
if self.count > 0 {
s.push_str(&format!(" {}", self.count));
pend.push_str(&self.count.to_string());
}
match self.pending_op {
Some(Op::Delete) => s.push('d'),
Some(Op::Change) => s.push('c'),
Some(Op::Delete) => pend.push('d'),
Some(Op::Change) => pend.push('c'),
None => {}
}
match self.pending_obj {
Some(false) => s.push('i'),
Some(true) => s.push('a'),
Some(false) => pend.push('i'),
Some(true) => pend.push('a'),
None => {}
}
if self.pending_g {
s.push('g');
pend.push('g');
}
if !pend.is_empty() {
Text::with_baseline(&pend, Point::new(PANEL_X, 14), style, Baseline::Top)
.draw(f)
.unwrap();
}
// Word count + words added this session, from the throttled snapshot.
let words = format!("{} words", self.shown_words);
Text::with_baseline(&words, Point::new(PANEL_X, 40), style, Baseline::Top)
.draw(f)
.unwrap();
let session = format!(
"+{} this session",
self.shown_words.saturating_sub(self.session_base_words)
);
Text::with_baseline(&session, Point::new(PANEL_X, 52), style, Baseline::Top)
.draw(f)
.unwrap();
// Keyboard-disconnect flag at the panel foot, shown only while the
// keyboard is dropped. Latin-9 has no ⌨/✗ glyph, so plain text.
if !self.keyboard_present {
Text::with_baseline(
"NO KBD",
Point::new(PANEL_X, epd::HEIGHT as i32 - 22),
style,
Baseline::Top,
)
.draw(f)
.unwrap();
}
}
/// The transient `:` command line, in the bottom 12 px strip below the
/// writing column (vim-style). Shown only while composing a command.
fn draw_cmdline(&self, f: &mut Frame) {
if self.mode != Mode::Command {
return;
}
let s = format!(":{}", self.cmdline);
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)

View File

@@ -66,6 +66,12 @@ fn main() -> anyhow::Result<()> {
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())?;
@@ -79,14 +85,31 @@ fn main() -> anyhow::Result<()> {
keys += 1;
}
// 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 with a silent full-area partial (no flash).
// Normal/View already draw their caret on every action.
// 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;
@@ -99,6 +122,12 @@ fn main() -> anyhow::Result<()> {
}
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;

View File

@@ -71,6 +71,10 @@ const SET_IDLE_INFINITE: [u8; 8] = [0x21, 0x0a, 0x00, 0x00, KBD_INTERFACE, 0x00,
static NEW_DEV_ADDR: AtomicU8 = AtomicU8::new(0);
/// Set when the open device is unplugged.
static DEV_GONE: AtomicBool = AtomicBool::new(false);
/// Whether a keyboard is currently open (attached + set up). Unlike `DEV_GONE`
/// (a one-shot detach event the client loop consumes), this is the persistent
/// connection state the side-panel disconnect flag reads via `keyboard_present`.
static KBD_PRESENT: AtomicBool = AtomicBool::new(false);
/// Control-transfer completion, published by `ctrl_cb` to the setup routine.
static CTRL_DONE: AtomicBool = AtomicBool::new(false);
static CTRL_STATUS: AtomicU32 = AtomicU32::new(0);
@@ -92,6 +96,12 @@ pub fn next_key() -> Option<Key> {
KEY_QUEUE.get()?.lock().unwrap().pop_front()
}
/// Whether a USB keyboard is currently attached and set up. Read by the main
/// loop to drive the side-panel disconnect flag.
pub fn keyboard_present() -> bool {
KBD_PRESENT.load(Ordering::SeqCst)
}
/// Install the USB Host Library and spawn the daemon + client event pumps.
/// Returns once the stack is up; key events then arrive via `next_key()`.
pub fn start() -> Result<(), EspError> {
@@ -154,6 +164,7 @@ fn client_loop() {
Ok((dev, xfer)) => {
open_dev = dev;
report_xfer = xfer;
KBD_PRESENT.store(true, Ordering::SeqCst);
}
Err(e) => log::error!("keyboard setup failed: {e:?}"),
}
@@ -170,6 +181,7 @@ fn client_loop() {
unsafe { usb_host_device_close(client, open_dev) };
open_dev = ptr::null_mut();
*PREV_KEYS.lock().unwrap() = [0; 6];
KBD_PRESENT.store(false, Ordering::SeqCst);
}
}
}