Compare commits
3 Commits
e6498cdcb3
...
e92f9c15d3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e92f9c15d3 | ||
|
|
9ea8e74394 | ||
|
|
da23b95cfd |
2
display/.gitignore
vendored
Normal file
2
display/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
2
editor/.gitignore
vendored
Normal file
2
editor/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/Cargo.lock
|
||||
@@ -51,10 +51,27 @@ pub enum Mode {
|
||||
/// Read-only reading: keys scroll the viewport, edits are locked out.
|
||||
View,
|
||||
/// `:` command line — keys accumulate a command shown in the status strip;
|
||||
/// Enter runs it, Esc cancels. Currently just `:fmt`.
|
||||
/// Enter runs it, Esc cancels. Handles `:fmt` (in-core) plus `:w`/`:sync`
|
||||
/// (which ask the host to persist/publish via an [`Effect`]).
|
||||
Command,
|
||||
}
|
||||
|
||||
/// A side effect the host (firmware) must carry out after a `:` command. The
|
||||
/// editor core is pure and does no IO, so persistence and publishing can't
|
||||
/// happen here — they're signalled out through [`Editor::handle`]'s return
|
||||
/// value and actioned by the main loop. `:fmt` is pure text work and stays
|
||||
/// in-core, so it yields [`Effect::None`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// Nothing for the host to do — ordinary keys, `:fmt`, unknown commands.
|
||||
None,
|
||||
/// `:w` (and the `:wq`/`:x` aliases) — persist the buffer to storage.
|
||||
Save,
|
||||
/// `:sync` — publish the buffer (save, then git push). The host saves
|
||||
/// first: publishing an unsaved buffer is meaningless.
|
||||
Publish,
|
||||
}
|
||||
|
||||
/// A pending operator awaiting a motion or text object (`d`elete / `c`hange).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum Op {
|
||||
@@ -139,12 +156,23 @@ impl Editor {
|
||||
self.text.split_whitespace().count()
|
||||
}
|
||||
|
||||
/// Dispatch one decoded key event according to the current mode.
|
||||
pub fn handle(&mut self, key: Key) {
|
||||
/// Dispatch one decoded key event according to the current mode, returning
|
||||
/// any [`Effect`] the host must carry out (only `:` commands produce one;
|
||||
/// every other key yields [`Effect::None`]).
|
||||
pub fn handle(&mut self, key: Key) -> Effect {
|
||||
match self.mode {
|
||||
Mode::Insert => self.insert_key(key),
|
||||
Mode::Normal => self.normal_key(key),
|
||||
Mode::View => self.view_key(key),
|
||||
Mode::Insert => {
|
||||
self.insert_key(key);
|
||||
Effect::None
|
||||
}
|
||||
Mode::Normal => {
|
||||
self.normal_key(key);
|
||||
Effect::None
|
||||
}
|
||||
Mode::View => {
|
||||
self.view_key(key);
|
||||
Effect::None
|
||||
}
|
||||
Mode::Command => self.command_key(key),
|
||||
}
|
||||
}
|
||||
@@ -315,7 +343,7 @@ impl Editor {
|
||||
|
||||
// --- Command mode (`:`) ------------------------------------------------
|
||||
|
||||
fn command_key(&mut self, key: Key) {
|
||||
fn command_key(&mut self, key: Key) -> Effect {
|
||||
match key {
|
||||
Key::Char(c) => self.cmdline.push(c),
|
||||
Key::Backspace => {
|
||||
@@ -325,9 +353,10 @@ impl Editor {
|
||||
}
|
||||
}
|
||||
Key::Enter => {
|
||||
self.execute_command();
|
||||
let effect = self.execute_command();
|
||||
self.cmdline.clear();
|
||||
self.mode = Mode::Normal;
|
||||
return effect;
|
||||
}
|
||||
Key::Escape => {
|
||||
self.cmdline.clear();
|
||||
@@ -336,13 +365,22 @@ impl Editor {
|
||||
// Word/line deletes and Tab aren't meaningful on a short command line.
|
||||
_ => {}
|
||||
}
|
||||
Effect::None
|
||||
}
|
||||
|
||||
/// Run the typed `:` command. Unknown commands are silently ignored.
|
||||
fn execute_command(&mut self) {
|
||||
/// Run the typed `:` command, returning any [`Effect`] the host must carry
|
||||
/// out. Unknown commands are silently ignored. The `:q` quit family is
|
||||
/// deliberately absent — an always-on writing appliance has nothing to
|
||||
/// quit to; `:wq`/`:x` therefore just save (the "quit" half is dropped).
|
||||
fn execute_command(&mut self) -> Effect {
|
||||
match self.cmdline.trim() {
|
||||
"fmt" => self.format_buffer(),
|
||||
_ => {}
|
||||
"fmt" => {
|
||||
self.format_buffer();
|
||||
Effect::None
|
||||
}
|
||||
"w" | "wq" | "x" => Effect::Save,
|
||||
"sync" => Effect::Publish,
|
||||
_ => Effect::None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1242,6 +1280,19 @@ mod tests {
|
||||
e
|
||||
}
|
||||
|
||||
/// From a fresh editor, run `:{cmd}<Enter>`, returning the editor and the
|
||||
/// [`Effect`] the Enter produced.
|
||||
fn command(cmd: &str) -> (Editor, Effect) {
|
||||
let mut e = Editor::new();
|
||||
e.handle(Key::Escape); // Insert -> Normal
|
||||
e.handle(Key::Char(':')); // Normal -> Command
|
||||
for c in cmd.chars() {
|
||||
e.handle(Key::Char(c));
|
||||
}
|
||||
let effect = e.handle(Key::Enter);
|
||||
(e, effect)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_builds_buffer_and_advances_caret() {
|
||||
let e = typed("hello");
|
||||
@@ -1385,4 +1436,34 @@ mod tests {
|
||||
let frame = e.draw(true);
|
||||
assert_eq!(frame.bytes().len(), display::FB_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn w_command_signals_save_and_returns_to_normal() {
|
||||
let (e, eff) = command("w");
|
||||
assert_eq!(eff, Effect::Save);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sync_command_signals_publish() {
|
||||
assert_eq!(command("sync").1, Effect::Publish);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wq_and_x_alias_save_dropping_the_quit() {
|
||||
assert_eq!(command("wq").1, Effect::Save);
|
||||
assert_eq!(command("x").1, Effect::Save);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fmt_stays_in_core_and_asks_the_host_for_nothing() {
|
||||
assert_eq!(command("fmt").1, Effect::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_is_ignored() {
|
||||
let (e, eff) = command("q"); // quit is deliberately unimplemented
|
||||
assert_eq!(eff, Effect::None);
|
||||
assert_eq!(e.mode(), Mode::Normal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,3 +113,107 @@ info:
|
||||
# list USB serial devices
|
||||
ports:
|
||||
@ls /dev/cu.usb* 2>/dev/null || echo "no USB serial device found"
|
||||
|
||||
# Pre-seed an SD card with a full clone of the notes repo, copied from a
|
||||
# computer (the decision in docs/notes/git-sync-images-and-repo-size.md). The
|
||||
# device never cold-clones 566 MB over Wi-Fi + mbedTLS; a laptop copies the
|
||||
# clone onto the card via a reader, and the device only ever takes the `open` +
|
||||
# fast-forward path. Dest on the card is a top-level `repo/`, matching the
|
||||
# on-device /sd/repo the editor opens (roadmap v0.1).
|
||||
#
|
||||
# just seed-sd ~/code/notes # auto-detect the one removable card
|
||||
# just seed-sd ~/code/notes MYSD # or name /Volumes/<name> if >1 card
|
||||
#
|
||||
# Everything the repo's .gitignore ignores is excluded (node_modules is 3.9 GB,
|
||||
# gitignored, and never in .git) — the device needs .git + the checkout
|
||||
# (~720 MB), not the JS deps or any local secrets like firmware/.env. Copying
|
||||
# (not a fresh `git clone` of the local path) preserves origin → GitHub so
|
||||
# on-device fetch/push still work; the recipe warns if origin isn't a remote.
|
||||
sd_repo_dir := "repo"
|
||||
|
||||
seed-sd repo_src sd_volume="":
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# Resolve + validate the source clone.
|
||||
src="{{repo_src}}"
|
||||
src="${src%/}" # strip trailing slash
|
||||
[ -d "$src/.git" ] || { echo "error: '$src' is not a git repo (no .git/)"; exit 1; }
|
||||
|
||||
origin="$(git -C "$src" remote get-url origin 2>/dev/null || true)"
|
||||
case "$origin" in
|
||||
https://*|git@*|ssh://*|git://*)
|
||||
echo "source origin: $origin" ;;
|
||||
"")
|
||||
echo "warning: '$src' has no 'origin' remote — the device can't fetch/push after seeding" ;;
|
||||
*)
|
||||
echo "warning: origin is '$origin' (looks like a local path, not a remote) —"
|
||||
echo " the device fetch/push will fail; set origin to the GitHub URL first" ;;
|
||||
esac
|
||||
# Opportunistic cross-check against TW_REMOTE_URL (loaded from .env if present).
|
||||
if [ -n "${TW_REMOTE_URL:-}" ] && [ -n "$origin" ] && [ "$origin" != "${TW_REMOTE_URL}" ]; then
|
||||
echo "warning: origin ($origin) != TW_REMOTE_URL (${TW_REMOTE_URL})"
|
||||
fi
|
||||
|
||||
# Detect the target card. Prefer an explicit /Volumes/<name>; else auto-detect
|
||||
# exactly one ejectable + external volume (refuse on 0 or >1 — a wrong guess
|
||||
# here means rsync --delete wipes the wrong disk's repo/).
|
||||
want="{{sd_volume}}"
|
||||
if [ -n "$want" ]; then
|
||||
vol="/Volumes/$want"
|
||||
[ -d "$vol" ] || { echo "error: '$vol' is not mounted"; exit 1; }
|
||||
else
|
||||
cands=()
|
||||
for v in /Volumes/*; do
|
||||
[ -d "$v" ] || continue
|
||||
info="$(diskutil info "$v" 2>/dev/null || true)"
|
||||
if echo "$info" | grep -qiE 'Ejectable:[[:space:]]+Yes' \
|
||||
&& echo "$info" | grep -qiE 'Device Location:[[:space:]]+External|Removable Media:[[:space:]]+(Removable|Yes)'; then
|
||||
cands+=("$v")
|
||||
fi
|
||||
done
|
||||
case "${#cands[@]}" in
|
||||
0) echo "error: no removable card detected under /Volumes — insert an SD card (FAT32)"; exit 1 ;;
|
||||
1) vol="${cands[0]}" ;;
|
||||
*) echo "error: multiple removable volumes — name one as the 2nd arg (just seed-sd <src> <name>):"
|
||||
printf ' %s\n' "${cands[@]#/Volumes/}"; exit 1 ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# The device won't mount a non-FAT card (esp_vfs_fat, format_if_mount_failed=false).
|
||||
fs="$(diskutil info "$vol" 2>/dev/null | grep -iE 'File System Personality' | sed 's/.*:[[:space:]]*//' || true)"
|
||||
case "$fs" in
|
||||
*FAT32*|*MS-DOS*) : ;;
|
||||
*) echo "warning: '$vol' is '$fs', not FAT32 — the device may fail to mount it" ;;
|
||||
esac
|
||||
|
||||
dest="$vol/{{sd_repo_dir}}"
|
||||
echo "seeding: $src -> $dest"
|
||||
mkdir -p "$dest"
|
||||
|
||||
# Build the exclude list from git's own ignore resolution (the repo's
|
||||
# .gitignore, plus .git/info/exclude and any global excludes). --directory
|
||||
# collapses a fully-ignored dir (node_modules/, 3.9 GB) to one line, so the
|
||||
# list stays tiny. Driving off git — not rsync's own dir-merge — means we
|
||||
# never list a tracked file or anything under .git/, so a .gitignore pattern
|
||||
# like `logs/` or `*.pack` can't silently corrupt the copied clone.
|
||||
ignore_list="$(mktemp)"
|
||||
trap 'rm -f "$ignore_list"' EXIT
|
||||
git -C "$src" -c core.quotePath=false ls-files \
|
||||
--others --ignored --exclude-standard --directory --no-empty-directory \
|
||||
> "$ignore_list"
|
||||
echo "excluding $(wc -l < "$ignore_list" | tr -d ' ') gitignored path(s) (incl. node_modules, .env)"
|
||||
|
||||
# -rt (no perms/owner/symlinks — meaningless on FAT), --modify-window=1 for
|
||||
# FAT's 2 s timestamp granularity, --delete to mirror (re-runs stay clean),
|
||||
# -P for progress on the ~700 MB copy. Scoped to repo/, never the card root.
|
||||
rsync -rtP --delete --modify-window=1 --exclude-from="$ignore_list" "$src/" "$dest/"
|
||||
|
||||
# Flush and eject so the card can go straight into Typoena.
|
||||
sync
|
||||
echo "seeded ok — ejecting $vol"
|
||||
if diskutil eject "$vol" >/dev/null 2>&1; then
|
||||
echo "✅ card ejected — remove it and insert into Typoena"
|
||||
else
|
||||
echo "⚠️ eject failed (a file may still be open) — eject '$vol' from Finder before removing"
|
||||
fi
|
||||
|
||||
@@ -10,7 +10,7 @@ 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 editor::{Editor, Effect, Mode, CH};
|
||||
use epd::Epd;
|
||||
|
||||
/// Injected by build.rs so serial output identifies the exact build.
|
||||
@@ -79,11 +79,28 @@ fn main() -> anyhow::Result<()> {
|
||||
// Drain all queued keystrokes (type-ahead absorbed during a refresh),
|
||||
// apply them, then do a single refresh for the batch.
|
||||
let mut keys = 0;
|
||||
let mut effect = Effect::None;
|
||||
while let Some(k) = usb_kbd::next_key() {
|
||||
ed.handle(k);
|
||||
// A `:` command (only) yields an Effect; keep the last one in the batch.
|
||||
match ed.handle(k) {
|
||||
Effect::None => {}
|
||||
e => effect = e,
|
||||
}
|
||||
keys += 1;
|
||||
}
|
||||
|
||||
// Carry out any host-side effect a `:` command asked for. The SD write
|
||||
// and git-push paths aren't wired into the main loop yet (v0.1 gate —
|
||||
// see src/bin/git_sync.rs for the persistent-clone push, git_push.rs
|
||||
// for the TLS trust store). Both block for seconds, so the real version
|
||||
// must run off this task (dedicated git thread, as the spike does) and
|
||||
// surface status on the panel; for now we log the intent.
|
||||
match effect {
|
||||
Effect::None => {}
|
||||
Effect::Save => log::info!(":w — save requested (TODO v0.1: write buffer to SD)"),
|
||||
Effect::Publish => log::info!(":sync — publish requested (TODO v0.1: save + git push)"),
|
||||
}
|
||||
|
||||
// Keyboard attach/detach feeds the panel's disconnect flag.
|
||||
let kbd = usb_kbd::keyboard_present();
|
||||
ed.set_keyboard_present(kbd);
|
||||
|
||||
Reference in New Issue
Block a user