diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua new file mode 100644 index 0000000..4be4faa --- /dev/null +++ b/builtin/runtime/listview.lua @@ -0,0 +1,169 @@ +-- listview.lua --- reusable read-only list panels (Arc 1b, Q#P1). +-- +-- Generalizes the *buffer-list* idiom (builtin/commands/default.lua) +-- into `pmacs.listview.open{...}`: a persistent named buffer, +-- wholesale re-render, buffer-local RET/n/p/g/q keymap, a +-- line->item map, previous-buffer capture + `q` restore, and the two +-- disciplines the hand-rolled original lacks --- a read-only +-- intercept (Q#P3; the panel's own renders write with +-- bypass_intercept) and the Q#P6 round-trip-input mark, so a +-- semantic frontend's RET dispatches into the visit binding instead +-- of optimistically inserting a newline. +-- +-- Panels are buffers, so both frontends render them with zero +-- protocol change (Q#P2: switch-in-place; the GPU cannot show +-- splits). Framing: docs/lsp-panels-framing.md. +-- +-- pmacs.listview.open { +-- name = "*references*", +-- header = "12 references RET visit n/p move g refresh q quit", +-- rows = { { text = "src/foo.rs:12:4", item = }, ... }, +-- on_visit = function(item) ... end, -- RET/SPC (optional) +-- on_refresh = function() return rows end, -- g (optional) +-- } + +pmacs.listview = pmacs.listview or {} + +-- name -> { buffer, prev, header, line_to_item, on_visit, on_refresh } +local panels = {} + +local function find_buffer_by_name(name) + for _, id in ipairs(pmacs.buffer.list()) do + local ok, d = pcall(pmacs.describe.buffer, id) + if ok and d and d.name == name then return id end + end + return nil +end + +-- The panel record whose buffer the active window shows, or nil. +local function panel_for_current_buffer() + local buf = pmacs.window.buffer() + if not buf then return nil end + local ok, d = pcall(pmacs.describe.buffer, buf) + if not (ok and d) then return nil end + return panels[d.name] +end + +-- Wholesale re-render: header + one line per row, rebuilding the +-- line->item map (data lines are 1-based; the header is line 0). +-- Panel writes bypass the read-only intercept. +local function render(p, rows) + local lines = { p.header } + p.line_to_item = {} + for _, row in ipairs(rows) do + lines[#lines + 1] = row.text + p.line_to_item[#lines - 1] = row.item + end + local body = table.concat(lines, "\n") + local buf = p.buffer + local len = buf:len() + if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end + if #body > 0 then buf:insert(0, body, { bypass_intercept = true }) end +end + +-- Re-seat the cursor on data line `line` (1-based, clamped). +-- `switch_active_buffer` zeroes the window cursor, so a fresh switch +-- puts us on the header; walk down from there. +local function seat_cursor(p, line) + local count = #p.line_to_item + if count == 0 then return end + local target = math.max(1, math.min(line or 1, count)) + for _ = 1, target do + pmacs.editor.move_down() + end +end + +local function bind_local_keymap(buf) + local function bind(seq, command) + pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = seq, command = command } + end + bind("RET", "listview.visit") + bind("SPC", "listview.visit") + bind("n", "cursor.down") + bind("", "cursor.down") + bind("p", "cursor.up") + bind("", "cursor.up") + bind("g", "listview.refresh") + bind("q", "listview.quit") +end + +-- Build (or adopt) the persistent panel record for `name`. Handles a +-- user-killed panel buffer by recreating it. +local function ensure_panel(name) + local p = panels[name] + if p and p.buffer:is_valid() then return p end + local buf = find_buffer_by_name(name) or pmacs.buffer.create(name) + p = { buffer = buf, line_to_item = {} } + panels[name] = p + -- Read-only (Q#P3): every non-bypass edit is rejected. The + -- intercept lives as long as the buffer; no teardown (the + -- buffer-list precedent for its keymap). + pmacs.buffer.add_intercept(buf, function() + error(name .. " is read-only") + end) + -- Q#P6: semantic frontends must round-trip keys while this panel + -- is focused (RET = visit, not an optimistic newline). + pmacs.buffer.set_round_trip_input(buf, true) + bind_local_keymap(buf) + return p +end + +function pmacs.listview.open(spec) + assert(type(spec) == "table" and type(spec.name) == "string", + "listview.open: spec.name (string) required") + local p = ensure_panel(spec.name) + p.header = spec.header or spec.name + p.on_visit = spec.on_visit + p.on_refresh = spec.on_refresh + -- Remember where to return on `q` --- but never another panel + -- (chained panels would trap `q` in a loop; restore targets the + -- last real buffer). + local active = pmacs.window.buffer() + if active and not panel_for_current_buffer() then + p.prev = active + end + render(p, spec.rows or {}) + pmacs.window.switch_buffer(p.buffer) + seat_cursor(p, 1) +end + +pmacs.command.define { + name = "listview.visit", + description = "Visit the list-panel item under the cursor.", + fn = function() + local p = panel_for_current_buffer() + if not p then return end + local item = p.line_to_item[pmacs.editor.cursor_line()] + if item ~= nil and p.on_visit then p.on_visit(item) end + end, +} + +pmacs.command.define { + name = "listview.refresh", + description = "Re-run the list panel's data source and re-render.", + fn = function() + local p = panel_for_current_buffer() + if not (p and p.on_refresh) then return end + local saved = pmacs.editor.cursor_line() + local rows = p.on_refresh() or {} + render(p, rows) + -- The wholesale rewrite leaves the window cursor at a stale byte + -- offset; re-enter the buffer to reset, then re-seat. + pmacs.window.switch_buffer(p.buffer) + seat_cursor(p, saved) + end, +} + +pmacs.command.define { + name = "listview.quit", + description = "Leave the list panel, restoring the previous buffer.", + fn = function() + local p = panel_for_current_buffer() + if not p then return end + local target = p.prev + if not (target and target:is_valid()) then + target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*") + end + pmacs.window.switch_buffer(target) + end, +} diff --git a/builtin/runtime/lsp.lua b/builtin/runtime/lsp.lua index cf36fa8..2a11595 100644 --- a/builtin/runtime/lsp.lua +++ b/builtin/runtime/lsp.lua @@ -1214,6 +1214,41 @@ function pmacs.lsp.go_to_definition() end) end +-- Visit one LSP location (Arc 1b): the SP-4 cross-file template --- +-- jump ring, find-or-open, cursor walk. Same-buffer hits skip the +-- open. Shared by the references panel (and the outline in phase 2). +local function visit_location(loc) + local path = pmacs.lsp.path_for_uri(loc.uri) + if not path then + pmacs.editor.set_status("LSP: cannot decode target uri " .. tostring(loc.uri)) + return + end + pmacs.editor.push_jump() + local ok, err = pcall(pmacs.buffer.find_or_open, path) + if not ok then + -- Open failed: drop the origin we just pushed so M-, isn't left + -- pointing at a jump that never happened. + pmacs.editor.jump_back() + pmacs.editor.set_status("LSP: failed to open " .. path .. ": " .. tostring(err)) + return + end + move_active_cursor_to(loc.line, loc.col) +end + +-- Shorten `path` against the project root of `relative_to` (a path in +-- the same project) for panel display; falls back to the full path. +local function display_path(path, relative_to) + local ok, proj = pcall(pmacs.project.detect, relative_to or path) + if ok and proj and proj.root then + local root = proj.root + if root:sub(-1) ~= "/" then root = root .. "/" end + if path:sub(1, #root) == root then + return path:sub(#root + 1) + end + end + return path +end + function pmacs.lsp.find_references() local rec = attached_for_active() if not rec then @@ -1236,13 +1271,27 @@ function pmacs.lsp.find_references() pmacs.editor.set_status("LSP: no references found") return end - -- v1 surfaces a modeline summary (count + first hit); a - -- references list buffer is future UX work, like the hover panel. - local first = locs[1] + -- Arc 1b: a browsable *references* panel. RET visits (jump ring + -- included, so M-, returns); q restores this buffer. + local here = pmacs.lsp.path_for_uri(rec.uri) + local rows = {} + for _, loc in ipairs(locs) do + local path = pmacs.lsp.path_for_uri(loc.uri) or loc.uri + rows[#rows + 1] = { + text = string.format("%s:%d:%d", display_path(path, here), loc.line + 1, loc.col + 1), + item = loc, + } + end + pmacs.listview.open { + name = "*references*", + header = string.format( + "%d reference%s RET visit n/p move q quit", + #locs, (#locs == 1 and "" or "s")), + rows = rows, + on_visit = visit_location, + } pmacs.editor.set_status(string.format( - "LSP: %d reference%s; first at %s:%d:%d", - #locs, (#locs == 1 and "" or "s"), - first.uri, first.line + 1, first.col + 1)) + "LSP: %d reference%s", #locs, (#locs == 1 and "" or "s"))) end) end diff --git a/src/editor.rs b/src/editor.rs index 6fb720e..5df243d 100644 --- a/src/editor.rs +++ b/src/editor.rs @@ -276,6 +276,15 @@ impl EditorState { // auto-attach buffer hooks, key-bound commands. Loaded last so // every dependency table (`pmacs.lsp`, `pmacs.parse`, // `pmacs.window`, etc.) already exists. + // Arc 1b: the reusable list-panel module. Loaded before + // lsp.lua, whose panel commands (references, outline) call + // `pmacs.listview.open`. + lua_host + .eval( + Some("@pmacs/builtin/runtime/listview.lua"), + include_str!("../builtin/runtime/listview.lua"), + ) + .expect("load listview builtin chunk"); lua_host .eval( Some("@pmacs/builtin/runtime/lsp.lua"), @@ -508,8 +517,15 @@ impl EditorState { let core = self.core.borrow(); // A live context menu shadows the keymap too (Q#CM1): keys must // round-trip so the daemon's `dispatch_menu_key` drives the menu - // rather than the frontend self-inserting. - !core.minibuffer.is_active() && !core.search_active() && !core.menu_is_open() + // rather than the frontend self-inserting. A round-trip buffer + // (Arc 1b Q#P6 — a focused panel) is the buffer-shaped member of + // the same family: RET must reach its buffer-local bindings and + // typing must reach its read-only intercept, neither of which an + // optimistic local edit would do. + !core.minibuffer.is_active() + && !core.search_active() + && !core.menu_is_open() + && !core.active_buffer_round_trips() } /// `frontend_id` records which frontend produced the event. v0.1 diff --git a/src/editor_core.rs b/src/editor_core.rs index a394222..d1c4c59 100644 --- a/src/editor_core.rs +++ b/src/editor_core.rs @@ -193,6 +193,16 @@ pub struct EditorCore { /// same state the dispatch path navigates and the Lua driver /// publishes into — the completion twin of `menu`. pub completion_popup: crate::completion::SharedCompletionPopup, + /// Buffers whose input must round-trip (Arc 1b, Q#P6). While one + /// of these is the active buffer, + /// [`crate::editor::EditorState::dispatch_idle`] reports `false`, + /// so semantic frontends' optimistic-apply stays off: RET reaches + /// buffer-local bindings (a panel's visit) instead of locally + /// inserting `\n`, and plain typing dispatches into the edit path + /// where a read-only intercept can reject it — a CRDT-import + /// write would bypass the intercept chain entirely. Marked from + /// Lua via `pmacs.buffer.set_round_trip_input`; pruned on kill. + round_trip_buffers: std::collections::HashSet, } impl EditorCore { @@ -233,6 +243,7 @@ impl EditorCore { pending_clipboard: None, menu: crate::menu::make_shared_menu(), completion_popup: crate::completion::make_shared_popup(), + round_trip_buffers: std::collections::HashSet::new(), } } @@ -1980,6 +1991,25 @@ impl EditorCore { true } + // ---- round-trip input buffers (Arc 1b, Q#P6) ---------------------------- + + /// Mark (or unmark) `buffer_id` as requiring round-trip input. + /// See the field doc on `round_trip_buffers` for the semantics. + pub fn set_round_trip_input(&mut self, buffer_id: BufferId, on: bool) { + if on { + self.round_trip_buffers.insert(buffer_id); + } else { + self.round_trip_buffers.remove(&buffer_id); + } + } + + /// True while the active buffer requires round-trip input (a + /// panel or other buffer-local-keymap surface is focused). + #[must_use] + pub fn active_buffer_round_trips(&self) -> bool { + self.round_trip_buffers.contains(&self.active_buffer_id()) + } + /// Ensure the active window carries a /// [`crate::completion::CompletionView`] overlay (deduped by kind). /// The view reads the shared popup, so one instance suffices; it @@ -2012,6 +2042,7 @@ impl EditorCore { return Err("cannot kill the last remaining buffer".into()); } } + self.round_trip_buffers.remove(&buffer_id); let fallback = { let mut reg = self.registry.borrow_mut(); match reg.find_by_name("*scratch*") { diff --git a/src/lua_bindings/mod.rs b/src/lua_bindings/mod.rs index 0961a9e..0cd09de 100644 --- a/src/lua_bindings/mod.rs +++ b/src/lua_bindings/mod.rs @@ -2366,6 +2366,25 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result() { + core.borrow_mut().set_round_trip_input(id.0, on); + } + Ok(()) + })?, + )?; + } + { // T M4.5 L1: find-or-open. If a buffer is already bound to // `path`, switch to it (preserving unsaved edits — no diff --git a/tests/listview_acceptance.rs b/tests/listview_acceptance.rs new file mode 100644 index 0000000..9bb0a45 --- /dev/null +++ b/tests/listview_acceptance.rs @@ -0,0 +1,138 @@ +//! List-panel acceptance (Arc 1b phase 1) --- the `pmacs.listview` +//! module end-to-end through `dispatch_key`: open/navigate/visit, +//! `q` restore, the Q#P3 read-only intercept, the Q#P6 round-trip +//! gate (`dispatch_idle` false while a panel is focused), and +//! refresh. The references panel itself needs a live LSP and is +//! validated manually / via the m4 harness; these tests drive the +//! substrate hermetically. +//! +//! Framing: docs/lsp-panels-framing.md. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers}; +use pmacs::editor::EditorState; +use pmacs::protocol::FrontendId; + +fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent { + KeyEvent { + code, + modifiers: mods, + kind: KeyEventKind::Press, + state: KeyEventState::empty(), + } +} + +fn press(s: &mut EditorState, code: KeyCode) { + s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE)); +} + +/// Open a three-row test panel whose visits record into `_G.VISITED`. +fn open_test_panel(s: &mut EditorState) { + s.lua_host + .lua() + .load( + r#" + _G.VISITED = nil + pmacs.listview.open { + name = "*test-panel*", + header = "3 items RET visit q quit", + rows = { + { text = "alpha", item = "A" }, + { text = "beta", item = "B" }, + { text = "gamma", item = "C" }, + }, + on_visit = function(item) _G.VISITED = item end, + on_refresh = function() + return { { text = "delta", item = "D" } } + end, + } + "#, + ) + .exec() + .expect("open test panel"); +} + +/// `(active buffer name, buffer text, cursor line, visited)` probed +/// through the Lua surface. +fn probe(s: &EditorState) -> (String, String, i64, Option) { + s.lua_host + .lua() + .load( + r" + local b = pmacs.window.buffer() + local d = pmacs.describe.buffer(b) + return d.name, b:slice(0, b:len()), pmacs.editor.cursor_line(), _G.VISITED + ", + ) + .eval() + .expect("probe panel state") +} + +#[test] +fn open_seats_cursor_and_ret_visits_the_row() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + let (name, text, line, _) = probe(&s); + assert_eq!(name, "*test-panel*"); + assert!(text.starts_with("3 items"), "header renders first"); + assert_eq!(line, 1, "the cursor opens on the first data row"); + + press(&mut s, KeyCode::Char('n')); // buffer-local: cursor.down + press(&mut s, KeyCode::Enter); + let (_, _, _, visited) = probe(&s); + assert_eq!(visited.as_deref(), Some("B"), "RET visits the second row"); +} + +#[test] +fn header_row_is_not_visitable() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + press(&mut s, KeyCode::Char('p')); // up onto the header + press(&mut s, KeyCode::Enter); + let (_, _, _, visited) = probe(&s); + assert_eq!(visited, None, "the header maps to no item"); +} + +#[test] +fn q_restores_the_previous_buffer() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + press(&mut s, KeyCode::Char('q')); + let (name, _, _, _) = probe(&s); + assert_eq!(name, "*scratch*", "q returns to the buffer we came from"); +} + +#[test] +fn panel_rejects_typing() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + let (_, before, _, _) = probe(&s); + press(&mut s, KeyCode::Char('z')); // unbound printable → self-insert → intercept rejects + let (_, after, _, _) = probe(&s); + assert_eq!(before, after, "the read-only intercept rejects self-insert"); +} + +#[test] +fn dispatch_idle_is_false_while_a_panel_is_focused() { + // Q#P6: while the panel is the active buffer, semantic frontends + // must round-trip every key (RET = visit, not an optimistic \n). + let mut s = EditorState::new(); + assert!(s.dispatch_idle(), "scratch buffer: idle"); + open_test_panel(&mut s); + assert!(!s.dispatch_idle(), "panel focused: keys must round-trip"); + press(&mut s, KeyCode::Char('q')); + assert!(s.dispatch_idle(), "restored buffer: idle again"); +} + +#[test] +fn refresh_reruns_the_source_and_reseats() { + let mut s = EditorState::new(); + open_test_panel(&mut s); + press(&mut s, KeyCode::Char('g')); + let (_, text, line, _) = probe(&s); + assert!(text.contains("delta"), "g re-renders from on_refresh"); + assert!(!text.contains("alpha"), "old rows are gone"); + assert_eq!(line, 1, "cursor re-seats on a data row after refresh"); + press(&mut s, KeyCode::Enter); + let (_, _, _, visited) = probe(&s); + assert_eq!(visited.as_deref(), Some("D"), "the refreshed row visits"); +}