diff --git a/builtin/commands/default.lua b/builtin/commands/default.lua index 2a13c21..48d6384 100644 --- a/builtin/commands/default.lua +++ b/builtin/commands/default.lua @@ -1259,6 +1259,21 @@ local function show_help_text(text) pmacs.window.switch_buffer(buf) end +--- Internal seam (journey Stage 1b-3): render `text` into the shared +--- `*help*` buffer. Exposed under the underscore convention so +--- `runtime/welcome.lua`'s `M-x help` renders through THIS mechanism +--- rather than growing a second help surface — `commands/default.lua` +--- loads before the runtime chunks, so the seam is present by then. +--- +--- Inherits this mechanism's two known gaps, recorded rather than +--- papered over: it writes with `delete`/`insert` instead of +--- `set_generated_contents` (so the buffer stays ordinarily editable and +--- keeps its undo history), and it finds `*help*` BY NAME, so a foreign +--- buffer of that name would be cleared. +function pmacs.editor._show_help(text) + show_help_text(text) +end + cmd { name = "editor.describe-command", description = "Prompt for a command name and render its description in *help*.", fn = function() diff --git a/builtin/runtime/welcome.lua b/builtin/runtime/welcome.lua new file mode 100644 index 0000000..fa55b3c --- /dev/null +++ b/builtin/runtime/welcome.lua @@ -0,0 +1,102 @@ +-- welcome.lua --- journey step 4: say something when the editor opens. +-- Framing: docs/journey-stage1b3-welcome-framing.md. +-- +-- `COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, +-- no tutorial, no cheat sheet reachable from inside the editor. The sole +-- discovery affordance was knowing to press `M-x`. +-- +-- Split of responsibility with Rust: this file owns WHAT is said (the +-- entries and their rendering) and the `help` command; the Rust seam +-- `EditorState::finalize_local_launch` owns WHEN and WHERE — it alone +-- decides that this is a local, no-target launch whose `*scratch*` is +-- still untouched, and it clears the modified flag afterwards (there is +-- no Lua API for that, deliberately). + +pmacs.welcome = pmacs.welcome or {} + +--- The keys the welcome advertises, in display order. +--- +--- `keys` is EXACTLY what `pmacs.keymap.lookup` accepts, which is the +--- whole point of the shape: the acceptance suite checks every entry +--- resolves, so the welcome can never advertise a binding a later stage +--- removed. Scraping the rendered prose instead would be ambiguous — +--- `C-c c` is two chords and nothing in the text marks the boundary. +--- +--- Public so a user who rebinds can rebuild it from `init.lua`. +pmacs.welcome.entries = { + { keys = "C-x C-f", label = "open a file" }, + { keys = "C-c t", label = "terminal" }, + { keys = "C-c c", label = "build" }, + { keys = "C-x b", label = "switch buffer" }, +} + +-- Two entries per line, padded so the labels align. Kept deliberately +-- small: three lines total, because the greeting a user must delete +-- before typing should not be chrome. +local function entry_columns() + local width = 0 + for _, e in ipairs(pmacs.welcome.entries) do + if #e.keys > width then width = #e.keys end + end + local lines, pending = {}, nil + for _, e in ipairs(pmacs.welcome.entries) do + local cell = string.format("%-" .. width .. "s %s", e.keys, e.label) + if pending then + lines[#lines + 1] = " " .. string.format("%-24s", pending) .. cell + pending = nil + else + pending = cell + end + end + if pending then lines[#lines + 1] = " " .. pending end + return lines +end + +--- The welcome text, as written into an untouched `*scratch*`. +--- +--- `M-x` and `M-x help` are prose rather than entries: `M-x` is the +--- palette itself and `help` is a command name, so neither is a keymap +--- lookup. The acceptance checks the command exists instead. +function pmacs.welcome.text() + local lines = { "Welcome to pmacs. M-x runs any command; M-x help lists the keys." } + for _, line in ipairs(entry_columns()) do + lines[#lines + 1] = line + end + return table.concat(lines, "\n") .. "\n" +end + +-- --------------------------------------------------------------------- +-- M-x help +-- --------------------------------------------------------------------- +-- +-- The smallest version of §18's second item, included because the +-- welcome would otherwise point at nothing. It is the ROOT of the +-- eventual family: when the discovery arc adds `help.keys` and friends, +-- `help` stays the index they are reached from, so no rename is owed. +-- +-- Renders through `editor.describe-command`'s existing `*help*` +-- mechanism rather than growing a second help surface. +local function help_text() + local lines = { + "pmacs help", + "", + " M-x run a command by name", + } + for _, e in ipairs(pmacs.welcome.entries) do + lines[#lines + 1] = string.format(" %-18s %s", e.keys, e.label) + end + lines[#lines + 1] = "" + lines[#lines + 1] = " M-x editor.describe-command what a command does" + lines[#lines + 1] = " M-x editor.list-buffers every open buffer" + lines[#lines + 1] = "" + lines[#lines + 1] = "The full keymap reference is docs/keybindings.md." + return table.concat(lines, "\n") .. "\n" +end + +pmacs.command.define { + name = "help", + description = "Show the pmacs key and command cheat sheet.", + fn = function() + pmacs.editor._show_help(help_text()) + end, +} diff --git a/src/editor.rs b/src/editor.rs index 4be27be..12e755b 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -705,6 +705,17 @@ impl EditorState { include_str!("../builtin/runtime/compile.lua"), ) .expect("load compile builtin chunk"); + // Journey Stage 1b-3: the welcome text and `M-x help`. Loaded + // after `commands/default.lua` (which `attach_editor` above ran) + // so `pmacs.editor._show_help` exists, and after the runtime + // chunks whose keys it advertises, so a binding it names is + // already registered when the acceptance suite checks them. + lua_host + .eval( + Some("@pmacs/builtin/runtime/welcome.lua"), + include_str!("../builtin/runtime/welcome.lua"), + ) + .expect("load welcome builtin chunk"); // T M7.11 bundled-package bootstrap. Through M7.10 the REPL // was loaded directly via `eval(include_str!(...))`; the // M7.11 deliverable migrates it to the package system so it @@ -878,6 +889,73 @@ impl EditorState { } } + /// Final step of a **local, no-target** launch: greet an untouched + /// `*scratch*` (journey step 4, `COHERENCE.md` §18). + /// + /// Called from [`prepare_startup`] after config has run, after + /// attach dispatch resolved to local, and after + /// [`Self::restore_desktop_if_armed`]. None of the constructors is + /// the right hook: `EditorState::open` calls `new` *before* + /// resolving its target, the daemon constructs one too, and + /// `init.lua` runs inside `new` — so a greeting written there would + /// reach a daemon session, precede the file argument that replaces + /// the buffer, and outrun anything config or a restored desktop puts + /// in `*scratch*`. + /// + /// Greets only when all four hold; each excludes one of those cases: + /// + /// 1. `had_file` is false — a positional argument means "open this". + /// 2. the session is local — guaranteed by the call site. + /// 3. `*scratch*` is the active buffer — restore may have moved it. + /// 4. `*scratch*` is empty — never overwrite config or a restore. + /// + /// Leaves the buffer **unmodified**: the greeting must not look like + /// unsaved work. It is deliberately *not* written through + /// `set_generated_contents`, which would lift read-only, discard + /// history and mark the buffer generated — all wrong for a buffer + /// journey step 5 requires the user to type into immediately. + pub fn finalize_local_launch(&mut self, had_file: bool) { + if had_file { + return; + } + let buffer_id = self.core.borrow().active_buffer_id(); + { + let registry = self.core.borrow().registry.clone(); + let reg = registry.borrow(); + let Ok(buf) = reg.get(buffer_id) else { + return; + }; + if buf.name() != "*scratch*" || !buf.is_empty() { + return; + } + } + let text: String = match self + .lua_host + .lua() + .load("return pmacs.welcome.text()") + .eval() + { + Ok(text) => text, + // A user who replaced `pmacs.welcome` with something broken + // gets no greeting, not a failed launch. + Err(_) => return, + }; + let registry = self.core.borrow().registry.clone(); + let mut reg = registry.borrow_mut(); + let Ok(buf) = reg.get_mut(buffer_id) else { + return; + }; + if buf + .apply_edit(crate::buffer::EditOp::Insert { + pos: 0, + bytes: text.as_bytes(), + }) + .is_ok() + { + buf.mark_clean(); + } + } + /// Restore the session saved under this desktop's key, if armed /// (`pmacs.session.desktop_mode(true)` called it) and no positional /// file arg was given (Q#DS7). Called from the `RunLocal` arm of @@ -3590,6 +3668,68 @@ impl Default for EditorState { // Run loop // --------------------------------------------------------------------------- +/// Outcome of [`prepare_startup`]: either a session ready for the local +/// TUI loop, or a hand-off the caller must perform. +pub enum Startup { + /// Ready to enter the local loop — desktop restored, launch + /// finalized. + Local(Box), + /// The init-time attach request resolved to something other than + /// local. Performing it takes over the terminal, so it stays out of + /// [`prepare_startup`] and is the caller's job. + HandOff(crate::attach_dispatch::AttachDispatch), +} + +/// Everything [`run`] does **before** it touches the terminal: +/// construct from the target, install state dirs, dispatch the +/// init-time attach request, and — on the local path — restore the +/// desktop and finalize the launch. +/// +/// Extracted so the local-startup sequence is testable: `run` adds only +/// `Frontend::new()` and the event loop, which is where terminal +/// takeover genuinely lives. Without this split, deleting the +/// [`EditorState::finalize_local_launch`] call would leave every +/// direct-call test green while shipping no welcome at all. +/// +/// `pub` rather than `pub(crate)` because the journey acceptance suite +/// is a separate integration crate — and because the rest of this +/// sequence (`run`, `EditorState::new`, `EditorState::open`, +/// `install_state_dirs`, `restore_desktop_if_armed`) is already public, +/// so this completes that surface rather than widening it. +pub fn prepare_startup(file: Option) -> io::Result { + // Capture before the `match` consumes `file`: a positional file arg + // means "open this", not "restore my desktop" (Q#DS7). + let had_file = file.is_some(); + let mut state = match file { + Some(path) => EditorState::open(path)?, + None => EditorState::new(), + }; + // Real session: wire up on-disk persistence (history + pmacs.state). + state.install_state_dirs(); + // Post-init dispatch: read whatever init.lua left in the + // RequestedAttach slot and decide whether to run local or hand off + // to attach mode. `take_requested_attach` consumes the slot. + let requested = state.lua_host.take_requested_attach(); + match crate::attach_dispatch::dispatch_attach(requested) { + crate::attach_dispatch::AttachDispatch::RunLocal => { + // Committed to local mode: restore the desktop if armed and + // no file arg was given (Q#DS7). Done here, not right after + // construction, so a hand-off never populates an + // EditorState it's about to drop. + state.restore_desktop_if_armed(had_file); + // Journey step 4: the last thing before the loop, so config + // and any restored desktop have already had their say. + state.finalize_local_launch(had_file); + Ok(Startup::Local(Box::new(state))) + } + // The EditorState is dropped by the caller before it takes over + // the terminal: attach mode constructs its own Frontend, and a + // locally-built one would leak its alternate-screen / raw-mode + // setup if held across the call. + other => Ok(Startup::HandOff(other)), + } +} + /// Main run loop. Opens the file (if any), takes over the terminal, /// renders, dispatches keys, until the user quits. /// @@ -3611,54 +3751,22 @@ impl Default for EditorState { /// local-TUI for the terminal. pub fn run(file: Option) -> io::Result<()> { install_panic_hook(); - // Capture before the `match` consumes `file`: a positional file arg - // means "open this", not "restore my desktop" (Q#DS7). - let had_file = file.is_some(); - let mut state = match file { - Some(path) => EditorState::open(path)?, - None => EditorState::new(), - }; - // Real session: wire up on-disk persistence (history + pmacs.state). - state.install_state_dirs(); - - // Post-init dispatch: read whatever init.lua left in the - // RequestedAttach slot and decide whether to run local or hand - // off to attach mode. `take_requested_attach` consumes the slot - // — even on the hand-off path the local EditorState is dropped - // before attach::run_attach takes over the terminal, so the - // request is consumed exactly once. - let requested = state.lua_host.take_requested_attach(); - match crate::attach_dispatch::dispatch_attach(requested) { - crate::attach_dispatch::AttachDispatch::RunLocal => { - // Committed to local mode: restore the desktop if armed and - // no file arg was given (Q#DS7). Done here, not right after - // construction, so a hand-off to attach mode (above) never - // populates an EditorState it's about to drop. - state.restore_desktop_if_armed(had_file); - // Fall through to the local TUI loop below. - } - crate::attach_dispatch::AttachDispatch::RunAttachLocalSocket(socket) => { - // Drop the local EditorState before taking over the - // terminal: attach mode constructs its own Frontend, and - // the locally-built one would leak its alternate-screen - // / raw-mode setup if held across the call. - drop(state); + let mut state = match prepare_startup(file)? { + Startup::Local(state) => *state, + Startup::HandOff(crate::attach_dispatch::AttachDispatch::RunAttachLocalSocket(socket)) => { return crate::attach::run_attach(socket).map_err(|e| io::Error::other(format!("{e}"))); } - crate::attach_dispatch::AttachDispatch::RunAttachSsh(target) => { - // Same EditorState-drop reasoning as the local-socket - // path: SSH attach takes over the terminal. - drop(state); + Startup::HandOff(crate::attach_dispatch::AttachDispatch::RunAttachSsh(target)) => { return crate::attach::run_attach_ssh(target) .map_err(|e| io::Error::other(format!("{e}"))); } - dispatch @ crate::attach_dispatch::AttachDispatch::DeferredInV01 { .. } => { + Startup::HandOff(dispatch) => { let msg = dispatch .deferred_message() - .expect("DeferredInV01 always has a message"); + .unwrap_or_else(|| "unsupported attach dispatch".to_owned()); return Err(io::Error::other(msg)); } - } + }; let mut frontend = Frontend::new()?; let mut render_state = crate::instance_render::RenderState::new(frontend.size()); diff --git a/tests/journey_acceptance.rs b/tests/journey_acceptance.rs index 74cd265..42c25dd 100644 --- a/tests/journey_acceptance.rs +++ b/tests/journey_acceptance.rs @@ -1742,3 +1742,299 @@ fn journey_step9_the_compile_directory_is_detection_canonical() { "the header must name the directory detection resolved to;\n{text}" ); } + +// --------------------------------------------------------------------------- +// Step 4 — understand the visible interface (Journey Stage 1b-3) +// +// `COHERENCE.md` §18 graded onboarding "missing entirely": no welcome, +// no cheat sheet reachable from inside the editor, and `M-x` the only +// door in — which a new user has no way to learn about. +// +// These rows drive `prepare_startup`, the production call `run()` makes. +// Calling `finalize_local_launch` by hand instead would leave the wiring +// unpinned: deleting the one call inside `prepare_startup` would keep +// every other assertion here green while shipping no welcome at all. +// --------------------------------------------------------------------------- + +/// The `*scratch*` buffer's text, wherever it currently sits. +fn scratch_text(s: &EditorState) -> String { + eval( + s, + r#" + for _, id in ipairs(pmacs.buffer.list()) do + if pmacs.describe.buffer(id).name == "*scratch*" then + return id:slice(0, id:len()) + end + end + return "" + "#, + ) +} + +fn welcome_entries(s: &EditorState) -> Vec<(String, String)> { + let raw: Vec = eval( + s, + "local out = {} + for _, e in ipairs(pmacs.welcome.entries) do + out[#out + 1] = e.keys .. '\\1' .. e.label + end + return out", + ); + raw.into_iter() + .map(|row| { + let (keys, label) = row + .split_once('\u{1}') + .expect("entry encodes keys and label"); + (keys.to_owned(), label.to_owned()) + }) + .collect() +} + +/// Drive the production startup path with no target, as `pmacs` does. +fn start_local() -> EditorState { + match pmacs::editor::prepare_startup(None).expect("startup must not fail") { + pmacs::editor::Startup::Local(state) => *state, + pmacs::editor::Startup::HandOff(_) => panic!("no init.lua attach request in a test"), + } +} + +/// **N** — journey step 4: a no-target local launch greets. +/// +/// Falsified by deleting the `finalize_local_launch` call inside +/// `prepare_startup` — the mutation every by-hand pin would survive. +#[test] +fn journey_step4_a_no_target_launch_greets_in_scratch() { + let s = start_local(); + + // Preconditions asserted, not assumed (framing §3.2b): a developer + // whose real init.lua arms desktop mode would otherwise get a + // restored scratch and a silently different result. + assert!( + eval::( + &s, + "return pmacs.session == nil or pmacs.session.desktop_armed ~= true" + ), + "precondition: desktop restore must be unarmed for this pin to mean anything" + ); + assert_eq!(active_name(&s), "*scratch*"); + + let text = active_text(&s); + assert!( + !text.is_empty(), + "an unconfigured launch must say something" + ); + assert!( + text.contains("M-x"), + "and must name the one key that opens everything; got {text:?}" + ); +} + +/// **N** — every entry the welcome names is actually bound. +/// +/// A property over the structured list, not a scrape of prose: `C-c c` +/// is two chords and nothing in the rendered text marks the boundary. +#[test] +fn journey_step4_every_advertised_key_is_bound() { + let s = start_local(); + let entries = welcome_entries(&s); + assert!( + !entries.is_empty(), + "precondition: the entry list must be non-empty or this loop is vacuous" + ); + for (keys, label) in entries { + let bound: Option = eval( + &s, + &format!("local b = pmacs.keymap.lookup({keys:?}) return b and b.command"), + ); + assert!( + bound.is_some(), + "the welcome advertises {keys:?} ({label}) but nothing is bound to it" + ); + } +} + +/// **N** — the rendered text contains every entry. +/// +/// Pin 2 alone would pass if rendering silently dropped one. +#[test] +fn journey_step4_the_rendered_welcome_contains_every_entry() { + let s = start_local(); + let text = active_text(&s); + for (keys, label) in welcome_entries(&s) { + assert!( + text.contains(&keys), + "welcome text omits the key {keys:?}; got {text:?}" + ); + assert!( + text.contains(&label), + "welcome text omits the label {label:?}; got {text:?}" + ); + } +} + +/// **N** — `M-x help` renders the cheat sheet, reached the way a user +/// reaches it. +/// +/// `pmacs.command.invoke` is the *programmatic* API; M-x is +/// `editor.execute-command`, a minibuffer with the `commands` completion +/// source that calls `invoke_interactive` only on accept. +/// +/// The selection is asserted **before** RET: a selected candidate +/// shadows typed text, and `Minibuffer::accept` does `session.take()`, +/// so nothing about the accepted value survives afterwards. +#[test] +fn journey_step4_m_x_help_renders_the_cheat_sheet() { + let mut s = start_local(); + + s.dispatch_key( + FrontendId::LOCAL, + key(KeyCode::Char('x'), KeyModifiers::ALT), + ); + assert!( + eval::(&s, "return pmacs.minibuffer.is_active()"), + "M-x must open the command palette" + ); + for ch in "help".chars() { + type_char(&mut s, ch); + } + assert_eq!( + eval::>(&s, "return pmacs.minibuffer.selected()").as_deref(), + Some("help"), + "the completion source must have `help` selected; a different \ + candidate would accept a different command" + ); + + press(&mut s, KeyCode::Enter); + pump(&mut s); + + let help = named_text(&s, "*help*"); + for (keys, _) in welcome_entries(&s) { + assert!( + help.contains(&keys), + "the cheat sheet omits {keys:?}; got:\n{help}" + ); + } +} + +/// **P** — the greeted buffer is editable and unmodified. +/// +/// Targeted mutation: rendering through `set_generated_contents`, which +/// would lift read-only, discard history, and fail the insert. +#[test] +fn journey_step4_preservation_the_greeted_scratch_is_editable_and_clean() { + let mut s = start_local(); + assert!( + eval::( + &s, + "return pmacs.describe.buffer(pmacs.window.buffer()).modified == false" + ), + "a greeting must not look like unsaved work" + ); + + let before = active_text(&s).len(); + type_char(&mut s, 'X'); + assert!( + active_text(&s).len() > before, + "step 5 must still work from the first frame: typing inserts" + ); +} + +/// **P** — a file target does not greet. +#[test] +fn journey_step4_preservation_a_file_target_does_not_greet() { + let td = project(); + let path = td.path().join("alpha.txt"); + let s = match pmacs::editor::prepare_startup(Some(path.clone())).expect("startup") { + pmacs::editor::Startup::Local(state) => *state, + pmacs::editor::Startup::HandOff(_) => panic!("no attach request"), + }; + assert_eq!(active_name(&s), path.display().to_string()); + assert_eq!( + scratch_text(&s), + "", + "a positional argument means \"open this\", not \"greet me\"" + ); +} + +/// **P** — a directory target does not greet either, so Stage 1a's +/// dired listing is what the user sees. +/// +/// Separate from the file pin because the directory path reaches +/// `*scratch*` differently: the bootstrap replaces the window's buffer +/// and `replace_active_buffer` removes nothing, so the scratch buffer +/// still exists to be wrongly greeted. +#[test] +fn journey_step4_preservation_a_directory_target_does_not_greet() { + let td = project(); + let mut s = + match pmacs::editor::prepare_startup(Some(td.path().to_path_buf())).expect("startup") { + pmacs::editor::Startup::Local(state) => *state, + pmacs::editor::Startup::HandOff(_) => panic!("no attach request"), + }; + pump(&mut s); + assert!(active_name(&s).starts_with("*dired:")); + assert_eq!(scratch_text(&s), "", "the dired listing is the greeting"); +} + +/// **P** — a non-empty `*scratch*` is never overwritten. +#[test] +fn journey_step4_preservation_existing_scratch_content_survives() { + let mut s = EditorState::new(); + exec(&s, "pmacs.window.buffer():insert(0, 'user content')"); + s.finalize_local_launch(false); + assert_eq!( + active_text(&s), + "user content", + "config or a restored desktop owns whatever is already there" + ); +} + +/// **P** — a non-active `*scratch*` is not greeted. Stands in for a +/// desktop restore having put something else in front. +#[test] +fn journey_step4_preservation_a_backgrounded_scratch_is_not_greeted() { + let td = project(); + let mut s = EditorState::new(); + exec( + &s, + &format!( + "pmacs.window.display_file({:?})", + td.path().join("alpha.txt").display().to_string() + ), + ); + pump(&mut s); + s.finalize_local_launch(false); + assert_eq!( + scratch_text(&s), + "", + "only the buffer actually greeting the user is written to" + ); +} + +/// **P** — the constructors greet nothing on their own. +/// +/// This is what makes the seam the only writer, and would catch a +/// greeting smuggled back into a constructor — including the daemon's. +/// Necessary but not sufficient: pin 1 is what proves the seam is +/// reached in production. +#[test] +fn journey_step4_preservation_constructors_never_greet() { + let bare = EditorState::new(); + assert_eq!(scratch_text(&bare), "", "EditorState::new must not greet"); + + let td = project(); + let file = EditorState::open(td.path().join("alpha.txt")).expect("open file"); + assert_eq!( + scratch_text(&file), + "", + "EditorState::open(file) must not greet" + ); + + let mut dir = EditorState::open(td.path().to_path_buf()).expect("open dir"); + pump(&mut dir); + assert_eq!( + scratch_text(&dir), + "", + "EditorState::open(dir) must not greet" + ); +}