6248 lines
240 KiB
Rust
6248 lines
240 KiB
Rust
// editor.rs --- Editor session: core + Lua + dispatcher, plus the run loop.
|
||
|
||
//! The editor session.
|
||
//!
|
||
//! [`EditorState`] holds three things: the world state ([`EditorCore`],
|
||
//! shared via `Rc<RefCell<...>>` so Lua-bound primitives can mutate it),
|
||
//! the [`LuaHost`] (the Lua VM and its registries), and the
|
||
//! [`KeyDispatcher`] state machine that maps chord sequences onto
|
||
//! command names.
|
||
//!
|
||
//! [`run`] takes over the terminal, renders, reads key events, feeds
|
||
//! them through the dispatcher, and invokes the resulting Lua commands
|
||
//! until the user quits.
|
||
|
||
use std::cell::RefCell;
|
||
use std::io;
|
||
use std::path::PathBuf;
|
||
use std::rc::Rc;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use crossterm::event::{KeyCode, KeyModifiers};
|
||
|
||
use crate::async_runtime::SharedAsyncRuntime;
|
||
use crate::cell::CellCoord;
|
||
use crate::editor_core::EditorCore;
|
||
use crate::file_io::load_file;
|
||
use crate::frontend::{Event, Frontend, KeyEvent, KeyEventKind, MouseEvent, install_panic_hook};
|
||
use crate::key::{Chord, display_sequence};
|
||
use crate::keymap_stack::{Action, KeyDispatcher};
|
||
use crate::lua::LuaHost;
|
||
use crate::lua_bindings::SharedCore;
|
||
use crate::minibuffer::Minibuffer;
|
||
use crate::protocol::FrontendId;
|
||
use crate::view::{View, Viewport};
|
||
use crate::window::{Rect, WindowId};
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// EditorState
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// One editor session.
|
||
pub struct EditorState {
|
||
/// World state, mutated by [`pmacs.editor.*`] primitives invoked
|
||
/// from inside command bodies.
|
||
pub core: SharedCore,
|
||
/// The embedded Lua VM and its command/keymap registries.
|
||
pub lua_host: LuaHost,
|
||
/// Multi-key prefix state machine. Driven by the run loop.
|
||
pub dispatcher: KeyDispatcher,
|
||
/// Main-thread async runtime (T M3.3). Owns the worker pool and
|
||
/// the message bus pair; [`Self::tick_async`] drives one
|
||
/// drain-and-resume pass per run-loop iteration.
|
||
pub async_runtime: SharedAsyncRuntime,
|
||
/// Tree-sitter syntax registry (T M4.1). Maps language names to
|
||
/// grammars and tracks per-buffer parse-view handles. Empty by
|
||
/// default --- M4.2 wires the actual `tree-sitter-rust` and
|
||
/// `tree-sitter-lua` registrations at startup.
|
||
pub syntax_registry: crate::syntax::SharedSyntaxRegistry,
|
||
/// Process supervisor (T M4.4). Owns every child process the
|
||
/// editor has spawned (LSP servers from M4.5; REPLs from M5).
|
||
/// Drop-time `shutdown` enforces SIGTERM-then-SIGKILL so editor
|
||
/// exit cannot leave zombies.
|
||
pub process_supervisor: crate::lua_bindings::SharedProcessSupervisor,
|
||
/// LSP manager (T M4.5). Holds one [`crate::lsp::LspClient`] per
|
||
/// language server; rides on top of [`Self::process_supervisor`]
|
||
/// for spawn / I/O / restart. Constructed empty; user code
|
||
/// (`pmacs.lsp.spawn`) populates it. The manager itself never
|
||
/// blocks the main thread --- pipe reads happen on the
|
||
/// supervisor's reader threads, which means a runaway server's
|
||
/// log-flood doesn't stall the editor.
|
||
pub lsp_manager: crate::lsp::SharedLspManager,
|
||
/// MCP manager (T M9.1). Holds one [`crate::mcp::McpClient`] per
|
||
/// MCP server; rides on top of [`Self::process_supervisor`] for
|
||
/// spawn / I/O / restart, sharing the supervisor with the LSP
|
||
/// manager. The two managers are siblings — the protocol-uniformity
|
||
/// claim from spec §sec:concurrency holds because the dispatch
|
||
/// machinery (supervisor → bytes → parser → state machine →
|
||
/// events) is identical; only the per-protocol parser differs.
|
||
pub mcp_manager: crate::mcp::SharedMcpManager,
|
||
/// Workspace / project model (T M4.9). Owns one
|
||
/// [`crate::project::Project`] per open project root and tracks
|
||
/// which one is active for project-scoped commands. Sits next to
|
||
/// the LSP manager so `pmacs.project.lsp_for` can drive the
|
||
/// "one server per `(root, language_id)`" invariant.
|
||
pub workspace: crate::lua_bindings::SharedWorkspace,
|
||
/// Project index registry (T M4.10). One
|
||
/// [`crate::project_index::ProjectIndex`] per known root,
|
||
/// holding aggregated symbols from LSP, tree-sitter, and the
|
||
/// heuristic / raw extractors. Reachable from Lua as
|
||
/// `pmacs.index.*`.
|
||
pub project_indexer: crate::lua_bindings::SharedProjectIndexer,
|
||
/// Completion framework (T M4.11). Owns the registry of
|
||
/// completion providers (LSP, snippets, project symbols,
|
||
/// dabbrev, plus any Lua-defined custom sources) and the
|
||
/// snippet store. Reachable from Lua as `pmacs.completion.*`.
|
||
pub completion_registry: crate::completion_framework::SharedCompletionRegistry,
|
||
/// Snippet store (T M4.11). Co-owned with the snippet
|
||
/// provider closure inside [`Self::completion_registry`].
|
||
pub snippets: crate::completion_framework::SharedSnippetRegistry,
|
||
/// Last left-button down event, used to synthesize terminal double
|
||
/// clicks from crossterm's plain Down/Up mouse event stream.
|
||
mouse_click: Option<MouseClickState>,
|
||
}
|
||
|
||
#[derive(Copy, Clone)]
|
||
struct MouseClickState {
|
||
frontend_id: FrontendId,
|
||
window_id: WindowId,
|
||
cell: CellCoord,
|
||
at: Instant,
|
||
}
|
||
|
||
const DOUBLE_CLICK_MAX_DELAY: Duration = Duration::from_millis(500);
|
||
|
||
impl EditorState {
|
||
/// Construct a fresh editor for an unnamed scratch buffer.
|
||
///
|
||
/// Panics only if Lua initialization or the builtin command/keymap
|
||
/// chunks fail to load --- both indicate broken builds.
|
||
#[must_use]
|
||
#[allow(
|
||
clippy::too_many_lines,
|
||
reason = "linear bootstrap sequence: registry → core → LuaHost → \
|
||
per-builtin module installs (async/syntax/process/lsp/index/...). \
|
||
Splitting into helpers fragments the wiring without removing \
|
||
any single decision the reader needs to follow."
|
||
)]
|
||
pub fn new() -> Self {
|
||
// Build the buffer registry first so EditorCore and LuaHost
|
||
// share the same `Rc`. Both reach buffers through this handle;
|
||
// multi-window dispatch (T M2.8) requires that ids resolve to
|
||
// the same Buffer regardless of who created it.
|
||
let registry: crate::lua_bindings::SharedRegistry =
|
||
Rc::new(RefCell::new(crate::buffer_registry::BufferRegistry::new()));
|
||
let core = Rc::new(RefCell::new(EditorCore::new(registry.clone())));
|
||
let mut lua_host = LuaHost::with_registry(registry).expect("Lua runtime initialization");
|
||
lua_host
|
||
.attach_editor(&core)
|
||
.expect("editor bindings + builtin chunks");
|
||
// Resolve the on-disk history directory before user config
|
||
// runs (so the user could in principle override it from
|
||
// `init.lua`). Skipped in test mode for the same reason as
|
||
// user config: don't touch the developer's real state dir.
|
||
#[cfg(not(test))]
|
||
{
|
||
if let Some(dir) = crate::minibuffer::user_history_dir() {
|
||
core.borrow_mut().minibuffer.history_dir = Some(dir);
|
||
}
|
||
}
|
||
// The async runtime: install pmacs._async raw helpers, then
|
||
// load the friendly Lua surface (`pmacs.async`, Handle class,
|
||
// `pmacs.workers.*`). Both must run before user config so a
|
||
// user's `init.lua` can call `pmacs.async(...)` itself.
|
||
let async_runtime =
|
||
crate::lua_bindings::make_async_runtime(lua_host.lua(), Some(lua_host.registry()))
|
||
.expect("install pmacs._async raw helpers");
|
||
lua_host
|
||
.eval(
|
||
Some("@pmacs/builtin/runtime/async.lua"),
|
||
include_str!("../builtin/runtime/async.lua"),
|
||
)
|
||
.expect("load async builtin chunk");
|
||
// T M8.1 filesystem worker primitives. Sits on top of the
|
||
// raw `pmacs._async._dispatch_fs_*` bindings installed by
|
||
// `make_async_runtime` and reuses the Handle factory
|
||
// exposed at the end of async.lua. Loaded immediately after
|
||
// async.lua so `pmacs.fs.*` is available to every later
|
||
// builtin and to user init.lua.
|
||
lua_host
|
||
.eval(
|
||
Some("@pmacs/builtin/runtime/fs.lua"),
|
||
include_str!("../builtin/runtime/fs.lua"),
|
||
)
|
||
.expect("load fs builtin chunk");
|
||
// T M4.1 tree-sitter Lua surface; M4.2 layers the Lua-side
|
||
// auto-attach hook on top. The registry is empty at startup;
|
||
// `pmacs.parse.language` lazy-loads from `BUILTIN_LANGUAGES`
|
||
// on first use (T M4.2 acceptance: "load grammar lazily").
|
||
let syntax_registry = crate::lua_bindings::make_syntax_registry(
|
||
lua_host.lua(),
|
||
&async_runtime,
|
||
lua_host.registry(),
|
||
)
|
||
.expect("install pmacs.parse");
|
||
lua_host
|
||
.eval(
|
||
Some("@pmacs/builtin/runtime/syntax.lua"),
|
||
include_str!("../builtin/runtime/syntax.lua"),
|
||
)
|
||
.expect("load syntax builtin chunk");
|
||
// T M4.4 process supervisor. Constructed empty; user code
|
||
// spawns children through `pmacs.process.spawn`. Drop-time
|
||
// shutdown enforces no-zombie cleanup at editor exit.
|
||
let process_supervisor = crate::lua_bindings::make_process_supervisor(lua_host.lua())
|
||
.expect("install pmacs.process");
|
||
// T M4.5 LSP manager. Wires onto the same supervisor so its
|
||
// spawn/restart/I/O machinery is shared with `pmacs.process.*`.
|
||
// The manager itself is reachable from Lua as `pmacs.lsp.*`.
|
||
let lsp_manager = crate::lua_bindings::make_lsp_manager(
|
||
lua_host.lua(),
|
||
process_supervisor.clone(),
|
||
async_runtime.clone(),
|
||
&syntax_registry,
|
||
)
|
||
.expect("install pmacs.lsp");
|
||
// T M9.1 MCP manager. Wires onto the same supervisor that LSP
|
||
// and `pmacs.process.*` use; the protocol-uniformity claim is
|
||
// that this share is sufficient (no parallel dispatch path).
|
||
// `pmacs.mcp.*` is the Lua surface; the manager itself is a
|
||
// sibling of `lsp_manager`.
|
||
let mcp_manager = crate::lua_bindings::make_mcp_manager(
|
||
lua_host.lua(),
|
||
process_supervisor.clone(),
|
||
async_runtime.clone(),
|
||
)
|
||
.expect("install pmacs.mcp");
|
||
// builtin/runtime/mcp.lua overrides `pmacs.mcp.send_request`
|
||
// with the Handle-returning friendly wrapper. Loaded after
|
||
// both async.lua (provides `pmacs.workers._new_handle`) and
|
||
// make_mcp_manager (provides `pmacs.mcp._send_request_raw`).
|
||
lua_host
|
||
.eval(
|
||
Some("@pmacs/builtin/runtime/mcp.lua"),
|
||
include_str!("../builtin/runtime/mcp.lua"),
|
||
)
|
||
.expect("load mcp builtin chunk");
|
||
// T M4.9 project / workspace surface. Built atop the LSP
|
||
// manager so `pmacs.project.lsp_for` can hand back a
|
||
// server scoped to (project_root, language_id).
|
||
let workspace = crate::lua_bindings::make_workspace(lua_host.lua(), &lsp_manager)
|
||
.expect("install pmacs.project");
|
||
// T M4.10 project index registry. Independent of the
|
||
// workspace: callers can index any root, not just opened
|
||
// ones, so the indexer maintains its own canonical-root
|
||
// map. `pmacs.index.*` is always available.
|
||
let project_indexer =
|
||
crate::lua_bindings::make_project_indexer(lua_host.lua()).expect("install pmacs.index");
|
||
// T M4.11 completion framework. Wires up the four built-in
|
||
// providers (LSP, snippets, project symbols, dabbrev) at
|
||
// sensible default priorities, and exposes
|
||
// `pmacs.completion.*` so `init.lua` can register custom
|
||
// sources, tweak priorities, or define snippets.
|
||
let (completion_registry, snippets) = crate::lua_bindings::make_completion_framework(
|
||
lua_host.lua(),
|
||
&lsp_manager,
|
||
&project_indexer,
|
||
)
|
||
.expect("install pmacs.completion");
|
||
// T M4.12 default LSP integration: declarative server config,
|
||
// auto-attach buffer hooks, key-bound commands. Loaded last so
|
||
// every dependency table (`pmacs.lsp`, `pmacs.parse`,
|
||
// `pmacs.window`, etc.) already exists.
|
||
lua_host
|
||
.eval(
|
||
Some("@pmacs/builtin/runtime/lsp.lua"),
|
||
include_str!("../builtin/runtime/lsp.lua"),
|
||
)
|
||
.expect("load lsp 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
|
||
// goes through the same manifest, exports, and per-package
|
||
// `_ENV` machinery a third-party package would. The
|
||
// sequence is:
|
||
//
|
||
// 1. Materialize each bundled package (currently just
|
||
// `repl`) to a process-stable directory under the OS
|
||
// temp dir. See `crate::builtin_packages` for the
|
||
// design rationale.
|
||
// 2. Push the resulting `InstalledPackage` records onto
|
||
// the `InstalledPackages` roster slot held in the
|
||
// Lua VM's app-data, so the M7.7 searcher finds them.
|
||
// 3. Drive the load via `pmacs.packages.load("repl")` so
|
||
// the load goes through the boundary `pmacs.packages`
|
||
// function (which catches load-time errors and routes
|
||
// them to *errors*) rather than a bare `require`.
|
||
//
|
||
// Depends on `pmacs.buffer.add_intercept` (T M6.4 Stage 1)
|
||
// and `pmacs.ansi.parser()` (T M6.4 Stage 2), both available
|
||
// by the time `attach_editor` returns above.
|
||
let bundled_root = crate::builtin_packages::bundled_runtime_dir();
|
||
let bundled_packages = crate::builtin_packages::materialize_all(&bundled_root)
|
||
.expect("materialize bundled packages");
|
||
{
|
||
let slot = lua_host
|
||
.lua()
|
||
.app_data_ref::<crate::lua_bindings::InstalledPackages>()
|
||
.expect("InstalledPackages slot installed by attach_editor");
|
||
for pkg in &bundled_packages {
|
||
slot.record(pkg.clone());
|
||
}
|
||
}
|
||
for pkg in &bundled_packages {
|
||
let basename = pkg.install_basename().to_string();
|
||
let script = format!(
|
||
"if not pmacs.packages.load({basename:?}) then \
|
||
error('bundled package failed to load: ' .. {basename:?}) end"
|
||
);
|
||
lua_host
|
||
.eval(Some("@pmacs/bundled-load"), &script)
|
||
.unwrap_or_else(|e| {
|
||
panic!("bundled package `{basename}` failed to load: {e}");
|
||
});
|
||
}
|
||
// User config is loaded after the builtins so it can override
|
||
// them. Failures inside `init.lua` are captured into the
|
||
// `*errors*` buffer; the editor still starts.
|
||
//
|
||
// Skipped under `cfg(test)` so the lib's own test suite doesn't
|
||
// pick up the developer's real `~/.config/pmacs/init.lua` and
|
||
// turn into a flaky environment-dependent run. Tests that need
|
||
// to exercise config loading do so explicitly via
|
||
// [`crate::config::load_user_config_at`].
|
||
//
|
||
// The init-complete flip happens here too so lifecycle-gated
|
||
// Lua APIs (e.g. `pmacs.attach`, M5.6d+) become inert after
|
||
// user config returns. Tests that need post-init semantics flip
|
||
// the flag explicitly via [`crate::lua::LuaHost::set_init_complete`];
|
||
// see the option-(A) discussion in the M5.6c survey.
|
||
#[cfg(not(test))]
|
||
{
|
||
crate::config::load_user_config(&mut lua_host);
|
||
lua_host.set_init_complete();
|
||
}
|
||
Self {
|
||
core,
|
||
lua_host,
|
||
dispatcher: KeyDispatcher::new(),
|
||
async_runtime,
|
||
syntax_registry,
|
||
process_supervisor,
|
||
lsp_manager,
|
||
mcp_manager,
|
||
workspace,
|
||
project_indexer,
|
||
completion_registry,
|
||
snippets,
|
||
mouse_click: None,
|
||
}
|
||
}
|
||
|
||
/// One pass of the process supervisor: drain pending I/O / exit
|
||
/// events and apply restart policies. Mirrors
|
||
/// [`Self::tick_async`]; the run loop calls both per iteration.
|
||
///
|
||
/// Fires the `process.after-tick` hook (T M6.5) after the supervisor
|
||
/// tick releases its borrow. Lua subscribers typically own a
|
||
/// `{[process_id] = handle}` registry and drain events via
|
||
/// `pmacs.process.events_take(id)`; the REPL package
|
||
/// (`builtin/packages/repl/init.lua`) is the first such consumer.
|
||
pub fn tick_processes(&mut self) {
|
||
self.process_supervisor.borrow_mut().tick();
|
||
self.lua_host
|
||
.run_hook("process.after-tick", mlua::MultiValue::new());
|
||
}
|
||
|
||
/// One pass of the LSP manager: drain process events into per-
|
||
/// server stdout buffers, parse JSON-RPC frames, dispatch to the
|
||
/// per-server state machine, apply LSP-layer restart policy.
|
||
/// `tick_processes` must be called first (or shortly after) so
|
||
/// the supervisor surface fresh exit/I/O events; the run loop
|
||
/// calls both in order.
|
||
pub fn tick_lsp(&mut self) {
|
||
self.lsp_manager.borrow_mut().tick();
|
||
}
|
||
|
||
/// One pass of the MCP manager (T M9.1): same shape as
|
||
/// [`Self::tick_lsp`], applied to MCP servers. The supervisor
|
||
/// is shared, so [`Self::tick_processes`] feeding LSP and MCP is
|
||
/// a single call; only the per-manager parse-and-dispatch step
|
||
/// is per-protocol. Order is `tick_processes` → `tick_lsp` →
|
||
/// `tick_mcp` so any in-the-same-batch I/O lands deterministically.
|
||
pub fn tick_mcp(&mut self) {
|
||
self.mcp_manager.borrow_mut().tick();
|
||
}
|
||
|
||
/// One pass of the main-thread async runtime: drain the worker
|
||
/// reply bus, fire `on_complete` callbacks, resume coroutines
|
||
/// parked on settled handles. Called every iteration of the run
|
||
/// loop --- and from tests that drive async flows synchronously.
|
||
///
|
||
/// Errors raised inside `pmacs._async.tick` are reported through
|
||
/// the same `*errors*` capture path as other Lua failures.
|
||
pub fn tick_async(&mut self) {
|
||
let _ = self
|
||
.lua_host
|
||
.eval(Some("@pmacs/runtime/async.lua:tick"), "pmacs._async.tick()");
|
||
}
|
||
|
||
/// Construct an editor for a path. Empty buffer with `[new file]`
|
||
/// status if the path does not exist; loaded contents otherwise.
|
||
pub fn open(path: PathBuf) -> io::Result<Self> {
|
||
let display_name = path.display().to_string();
|
||
let state = Self::new();
|
||
let mut fire_after_load = false;
|
||
match load_file(&path) {
|
||
Ok((bytes, meta)) => {
|
||
let new_id = state
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes(display_name, &bytes);
|
||
state.replace_active_buffer(new_id);
|
||
let mut core = state.core.borrow_mut();
|
||
core.set_buffer_path(new_id, Some(path));
|
||
core.set_buffer_meta(new_id, Some(meta));
|
||
fire_after_load = true;
|
||
Ok(())
|
||
}
|
||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||
let new_id = state.lua_host.registry().borrow_mut().create(display_name);
|
||
state.replace_active_buffer(new_id);
|
||
let mut core = state.core.borrow_mut();
|
||
core.set_buffer_path(new_id, Some(path));
|
||
core.status = "[new file]".into();
|
||
Ok(())
|
||
}
|
||
Err(e) => Err(e),
|
||
}?;
|
||
let mut state = state;
|
||
if fire_after_load {
|
||
// Fire the hook *after* the borrow on `core` is released
|
||
// (block above ends). Listeners may legitimately re-enter
|
||
// pmacs.editor.* primitives that re-borrow the core.
|
||
state
|
||
.lua_host
|
||
.run_hook("buffer.after-load", mlua::MultiValue::new());
|
||
}
|
||
Ok(state)
|
||
}
|
||
|
||
/// Switch the active window to `buffer_id`, dropping any old
|
||
/// scratch buffer if the active window's previous buffer has no
|
||
/// other windows referencing it. Returns silently on a stale id.
|
||
fn replace_active_buffer(&self, buffer_id: crate::buffer::BufferId) {
|
||
let mut core = self.core.borrow_mut();
|
||
let _ = core.switch_active_buffer(buffer_id);
|
||
}
|
||
|
||
/// Translate a key event into a chord, run it through the
|
||
/// dispatcher, and invoke the resolved command (or the
|
||
/// self-insert fallback for an unbound printable chord).
|
||
///
|
||
/// Whether the daemon's key-dispatch path is currently "idle" in
|
||
/// the sense that the *next* key event would self-insert into the
|
||
/// active buffer rather than being intercepted.
|
||
///
|
||
/// `false` when either:
|
||
///
|
||
/// - the dispatcher holds a pending multi-key prefix (e.g. the
|
||
/// user has typed `C-x` and the daemon is waiting for the next
|
||
/// chord), or
|
||
/// - a minibuffer prompt is active and absorbing keys.
|
||
///
|
||
/// Used by the daemon to drive the `InstanceMessage::DispatchIdle`
|
||
/// wire signal that gates `crdt_replica` frontends' optimistic-apply
|
||
/// path. Without this signal the optimistic layer would Insert a
|
||
/// plain-char keystroke into the active document while the
|
||
/// daemon's actual intent is to route the keystroke into the
|
||
/// minibuffer prompt — the M10.10 "documented limitation" that
|
||
/// surfaced during session-5 manual validation.
|
||
#[must_use]
|
||
pub fn dispatch_idle(&self) -> bool {
|
||
if !self.dispatcher.pending().is_empty() {
|
||
return false;
|
||
}
|
||
!self.core.borrow().minibuffer.is_active()
|
||
}
|
||
|
||
/// `frontend_id` records which frontend produced the event. v0.1
|
||
/// uses [`FrontendId::LOCAL`] uniformly; the parameter is
|
||
/// load-bearing for v0.3 multi-frontend scenarios where the
|
||
/// active-frontend identity is needed by hooks and commands
|
||
/// (`pmacs.frontend.id()`). Sets [`EditorCore::active_frontend`]
|
||
/// before any command body runs, so observers always see a fresh
|
||
/// value.
|
||
pub fn dispatch_key(&mut self, frontend_id: FrontendId, key: KeyEvent) {
|
||
let Some(chord) = key_event_to_chord(key) else {
|
||
return;
|
||
};
|
||
{
|
||
let mut core = self.core.borrow_mut();
|
||
core.status.clear();
|
||
core.active_frontend = frontend_id;
|
||
}
|
||
|
||
// Minibuffer interception: when a prompt is active, every key
|
||
// routes through the minibuffer's hardcoded handler. The main
|
||
// editor's keymap is bypassed; the user can still cancel with
|
||
// C-g and resume normal dispatch.
|
||
if self.core.borrow().minibuffer.is_active() {
|
||
self.dispatch_minibuffer_key(chord);
|
||
return;
|
||
}
|
||
|
||
// Buffer-scope keybindings need the active buffer id passed
|
||
// through the dispatcher (otherwise `keymap_stack::resolve`
|
||
// skips the buffer-local map entirely and every "scope =
|
||
// buffer" binding falls through to global). The id is read
|
||
// outside the keymap borrow so a single-buffer focus check
|
||
// doesn't collide with the stack lookup below.
|
||
let active_buffer = Some(self.core.borrow().active_buffer_id());
|
||
let action = {
|
||
let stack = self.lua_host.keymaps().borrow();
|
||
self.dispatcher.dispatch(chord, &stack, active_buffer, &[])
|
||
};
|
||
|
||
// Snapshot the active buffer's edit revision before the command
|
||
// runs so we can fire `buffer.after-edit` only when it changes.
|
||
// Stale-id paths (active buffer killed mid-dispatch) fall back
|
||
// to "no edit observed", which is the correct conservative call.
|
||
let pre_revision = self.active_buffer_revision();
|
||
|
||
match action {
|
||
Action::Run { command, .. } => {
|
||
if let Err(e) = self
|
||
.lua_host
|
||
.invoke_command(&command, mlua::MultiValue::new())
|
||
{
|
||
self.core.borrow_mut().status =
|
||
format!("error in {command}: {}", first_line(&e.to_string()));
|
||
}
|
||
}
|
||
Action::Pending { .. } => {
|
||
// The pending prefix is rendered from
|
||
// `dispatcher.pending()`; no command runs yet.
|
||
}
|
||
Action::Unbound { sequence } => match printable_char(&sequence) {
|
||
Some(ch) => {
|
||
let mut args = mlua::MultiValue::new();
|
||
args.push_back(mlua::Value::Integer(ch as i64));
|
||
if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) {
|
||
self.core.borrow_mut().status =
|
||
format!("self-insert failed: {}", first_line(&e.to_string()));
|
||
}
|
||
}
|
||
None => {
|
||
self.core.borrow_mut().status =
|
||
format!("{}: not bound", display_sequence(&sequence));
|
||
}
|
||
},
|
||
}
|
||
|
||
let post_revision = self.active_buffer_revision();
|
||
if pre_revision != post_revision {
|
||
self.lua_host
|
||
.run_hook("buffer.after-edit", mlua::MultiValue::new());
|
||
}
|
||
}
|
||
|
||
/// Active buffer's edit revision, or `None` if the registry no
|
||
/// longer knows about the active buffer (e.g. a command killed it
|
||
/// mid-dispatch).
|
||
fn active_buffer_revision(&self) -> Option<u64> {
|
||
let id = self.core.borrow().active_buffer_id();
|
||
let reg = self.lua_host.registry().borrow();
|
||
reg.get(id).ok().map(crate::buffer::Buffer::revision)
|
||
}
|
||
|
||
/// Hardcoded handler for keys delivered while a minibuffer prompt
|
||
/// is active. Recognized chords:
|
||
///
|
||
/// * `RET` / `C-m` --- accept (invoke `on_accept`).
|
||
/// * `C-g` --- cancel (invoke `on_cancel`).
|
||
/// * `TAB` / `C-i` --- complete to selected candidate.
|
||
/// * `Up` / `C-p` --- previous history entry.
|
||
/// * `Down` / `C-n` --- next history entry.
|
||
/// * `BS` --- delete codepoint left of cursor.
|
||
/// * `DEL` / `C-d` --- delete codepoint at cursor.
|
||
/// * `Left` / `C-b` --- cursor left.
|
||
/// * `Right` / `C-f` --- cursor right.
|
||
/// * `Home` / `C-a` --- cursor to start.
|
||
/// * `End` / `C-e` --- cursor to end.
|
||
/// * `M-n` --- scroll candidate forward.
|
||
/// * `M-p` --- scroll candidate backward.
|
||
/// * Otherwise: a printable char self-inserts.
|
||
///
|
||
/// Keys without a handler are silently ignored --- this matches
|
||
/// Emacs's behaviour, where minibuffer mode shadows the global
|
||
/// keymap.
|
||
fn dispatch_minibuffer_key(&mut self, chord: Chord) {
|
||
use crate::minibuffer::MinibufferAction;
|
||
use crossterm::event::{KeyCode, KeyModifiers};
|
||
|
||
let action = MinibufferAction::from_chord(chord);
|
||
match action {
|
||
MinibufferAction::Accept => self.minibuffer_accept(),
|
||
MinibufferAction::Cancel => self.minibuffer_cancel(),
|
||
MinibufferAction::Complete => self.minibuffer_complete(),
|
||
MinibufferAction::HistoryPrev => self.with_minibuffer(Minibuffer::history_prev),
|
||
MinibufferAction::HistoryNext => self.with_minibuffer(Minibuffer::history_next),
|
||
MinibufferAction::ScrollNext => self.with_minibuffer(|m| m.scroll_candidate(1)),
|
||
MinibufferAction::ScrollPrev => self.with_minibuffer(|m| m.scroll_candidate(-1)),
|
||
MinibufferAction::Backspace => {
|
||
self.with_minibuffer(Minibuffer::backspace);
|
||
self.recompute_minibuffer_candidates();
|
||
}
|
||
MinibufferAction::DeleteForward => {
|
||
self.with_minibuffer(Minibuffer::delete_forward);
|
||
self.recompute_minibuffer_candidates();
|
||
}
|
||
MinibufferAction::Left => self.with_minibuffer(Minibuffer::move_left),
|
||
MinibufferAction::Right => self.with_minibuffer(Minibuffer::move_right),
|
||
MinibufferAction::LineStart => self.with_minibuffer(Minibuffer::move_line_start),
|
||
MinibufferAction::LineEnd => self.with_minibuffer(Minibuffer::move_line_end),
|
||
MinibufferAction::SelfInsert(ch) => {
|
||
self.with_minibuffer(|m| m.insert_char(ch));
|
||
self.recompute_minibuffer_candidates();
|
||
}
|
||
MinibufferAction::Ignore => {
|
||
// Suppress noise from unrecognized chords; alternative
|
||
// (status-line warnings) would clobber the prompt.
|
||
let _ = (KeyCode::Null, KeyModifiers::NONE);
|
||
}
|
||
}
|
||
}
|
||
|
||
fn with_minibuffer<F: FnOnce(&mut Minibuffer)>(&mut self, f: F) {
|
||
f(&mut self.core.borrow_mut().minibuffer);
|
||
}
|
||
|
||
fn recompute_minibuffer_candidates(&mut self) {
|
||
let cmds = self.lua_host.commands().borrow();
|
||
let reg = self.lua_host.registry().borrow();
|
||
let mut core = self.core.borrow_mut();
|
||
if let Err(e) = core.minibuffer.recompute_candidates(&cmds, ®) {
|
||
core.status = format!("completion source error: {}", first_line(&e.to_string()));
|
||
}
|
||
}
|
||
|
||
fn minibuffer_accept(&mut self) {
|
||
let outcome = self.core.borrow_mut().minibuffer.accept();
|
||
let Some((on_accept, contents)) = outcome else {
|
||
return;
|
||
};
|
||
// Drop all borrows before the callback fires --- it may
|
||
// re-enter the editor (e.g. the M-x callback invokes a
|
||
// command which mutates the core).
|
||
let mut args = mlua::MultiValue::new();
|
||
args.push_back(mlua::Value::String(
|
||
self.lua_host
|
||
.lua()
|
||
.create_string(&contents)
|
||
.expect("Lua VM out of memory while building minibuffer callback args"),
|
||
));
|
||
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
|
||
self.core.borrow_mut().status = format!(
|
||
"minibuffer on_accept failed: {}",
|
||
first_line(&e.to_string())
|
||
);
|
||
}
|
||
}
|
||
|
||
fn minibuffer_cancel(&mut self) {
|
||
let on_cancel = self.core.borrow_mut().minibuffer.cancel();
|
||
if let Some(cb) = on_cancel
|
||
&& let Err(e) = cb.call::<mlua::MultiValue>(mlua::MultiValue::new())
|
||
{
|
||
self.core.borrow_mut().status = format!(
|
||
"minibuffer on_cancel failed: {}",
|
||
first_line(&e.to_string())
|
||
);
|
||
}
|
||
self.core.borrow_mut().status = "Quit".into();
|
||
}
|
||
|
||
fn minibuffer_complete(&mut self) {
|
||
self.core.borrow_mut().minibuffer.complete();
|
||
self.recompute_minibuffer_candidates();
|
||
}
|
||
|
||
/// Dispatch a mouse event (T M2.12).
|
||
///
|
||
/// Mapping:
|
||
/// * `Down(Left)` activates the window under the cursor and
|
||
/// positions the buffer cursor at the corresponding rope
|
||
/// position. Starts an empty selection at that position so
|
||
/// a drag continues the region from there.
|
||
/// * A second `Down(Left)` in the same cell within the double-click
|
||
/// threshold selects the word at the click position.
|
||
/// * `Drag(Left)` updates the cursor as the mouse moves; the
|
||
/// anchor stays put, so the region grows.
|
||
/// * `Up(Left)` ends a drag. If anchor and cursor coincide
|
||
/// (a plain click with no drag), the empty selection is
|
||
/// dropped so subsequent commands don't see a phantom region.
|
||
/// * `ScrollUp` / `ScrollDown` scroll the window under the
|
||
/// cursor by [`SCROLL_LINES`] lines, without changing the
|
||
/// buffer cursor or active window.
|
||
///
|
||
/// Mouse moves with no buttons (`Moved`) and other buttons are
|
||
/// ignored. Clicks on a window's mode line are also ignored
|
||
/// (the click neither activates the window nor positions the
|
||
/// cursor; that gesture is reserved for future binding to
|
||
/// "switch to this window" without disturbing buffer state).
|
||
pub fn dispatch_mouse(
|
||
&mut self,
|
||
frontend_id: FrontendId,
|
||
ev: MouseEvent,
|
||
term_size: crate::cell::CellSize,
|
||
) {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
self.core.borrow_mut().active_frontend = frontend_id;
|
||
let cell_row = u32::from(ev.row);
|
||
let cell_col = u32::from(ev.column);
|
||
|
||
let Some((win_id, rect)) =
|
||
window_at_cell(&self.core.borrow(), term_size, cell_row, cell_col)
|
||
else {
|
||
return;
|
||
};
|
||
let inner_rows = rect.size.rows.saturating_sub(1);
|
||
let local_row = cell_row.saturating_sub(rect.origin.row);
|
||
let local_col = cell_col.saturating_sub(rect.origin.col);
|
||
|
||
match ev.kind {
|
||
MouseEventKind::Down(MouseButton::Left) => {
|
||
if local_row >= inner_rows {
|
||
self.mouse_click = None;
|
||
return; // Mode-line click: reserved.
|
||
}
|
||
let click_cell = CellCoord::new(cell_row, cell_col);
|
||
let is_double_click = self.is_double_click(frontend_id, win_id, click_cell);
|
||
self.activate_and_position(win_id, local_row, local_col);
|
||
if is_double_click && self.core.borrow_mut().select_word_at_cursor() {
|
||
self.mouse_click = None;
|
||
} else {
|
||
let mut core = self.core.borrow_mut();
|
||
let pos = core.cursor();
|
||
core.begin_selection(pos);
|
||
self.mouse_click = Some(MouseClickState {
|
||
frontend_id,
|
||
window_id: win_id,
|
||
cell: click_cell,
|
||
at: Instant::now(),
|
||
});
|
||
}
|
||
}
|
||
MouseEventKind::Drag(MouseButton::Left) => {
|
||
self.mouse_click = None;
|
||
if local_row >= inner_rows {
|
||
return;
|
||
}
|
||
self.activate_and_position(win_id, local_row, local_col);
|
||
}
|
||
MouseEventKind::Up(MouseButton::Left) => {
|
||
let mut core = self.core.borrow_mut();
|
||
if let Some(sel) = core.active_window().selection
|
||
&& sel.anchor == core.cursor()
|
||
{
|
||
core.clear_selection();
|
||
}
|
||
}
|
||
MouseEventKind::ScrollUp => {
|
||
self.mouse_click = None;
|
||
self.scroll_window(win_id, -SCROLL_LINES);
|
||
}
|
||
MouseEventKind::ScrollDown => {
|
||
self.mouse_click = None;
|
||
self.scroll_window(win_id, SCROLL_LINES);
|
||
}
|
||
_ => {
|
||
self.mouse_click = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
fn is_double_click(
|
||
&self,
|
||
frontend_id: FrontendId,
|
||
window_id: WindowId,
|
||
cell: CellCoord,
|
||
) -> bool {
|
||
let Some(prev) = self.mouse_click else {
|
||
return false;
|
||
};
|
||
prev.frontend_id == frontend_id
|
||
&& prev.window_id == window_id
|
||
&& prev.cell == cell
|
||
&& prev.at.elapsed() <= DOUBLE_CLICK_MAX_DELAY
|
||
}
|
||
|
||
/// Mouse framing Q#M1 — apply a semantic frontend's locally
|
||
/// hit-tested pointer gesture (`FrontendEvent::Pointer`) to its
|
||
/// window. The byte-space twin of [`Self::dispatch_mouse`]: same
|
||
/// gesture semantics, but the position arrives as a source byte
|
||
/// offset the frontend resolved against its own layout (fonts,
|
||
/// inline adornments, scroll), so no cell geometry is consulted.
|
||
///
|
||
/// * `Down` places the cursor and anchors a selection there
|
||
/// (a following drag grows it).
|
||
/// * `Drag` moves the cursor; the anchor stays.
|
||
/// * `Up` collapses an empty selection (a click without drag).
|
||
/// * `DoubleDown` selects the word at the hit (frontend-side
|
||
/// double-click detection — only it knows pixel proximity).
|
||
///
|
||
/// The hit byte is clamped into the buffer and snapped back to a
|
||
/// UTF-8 boundary: the frontend's hit may race an in-flight edit.
|
||
/// `mods` is carried for future Shift-click extension and ignored
|
||
/// today, matching `dispatch_mouse`.
|
||
pub fn dispatch_pointer(
|
||
&mut self,
|
||
frontend_id: FrontendId,
|
||
buffer_id: crate::buffer::BufferId,
|
||
byte: u64,
|
||
kind: crate::protocol::PointerKind,
|
||
_mods: crate::protocol::Modifiers,
|
||
) {
|
||
use crate::protocol::PointerKind;
|
||
let mut core = self.core.borrow_mut();
|
||
core.active_frontend = frontend_id;
|
||
let Some(win_id) = core.views.get(&frontend_id).map(|v| v.active) else {
|
||
return;
|
||
};
|
||
// The dispatcher aligns the session's window to the declared
|
||
// buffer before calling here; re-check defensively (a click
|
||
// can race a buffer switch).
|
||
if core.windows.get(&win_id).map(|w| w.buffer_id) != Some(buffer_id) {
|
||
return;
|
||
}
|
||
core.set_active_window_id(win_id);
|
||
let byte = {
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let Ok(buf) = reg.get(buffer_id) else {
|
||
return;
|
||
};
|
||
snap_to_char_boundary(buf, byte)
|
||
};
|
||
match kind {
|
||
PointerKind::Down => {
|
||
let aw = core.active_window_mut();
|
||
aw.cursor = byte;
|
||
aw.goal_col = None;
|
||
core.begin_selection(byte);
|
||
}
|
||
PointerKind::Drag => {
|
||
let aw = core.active_window_mut();
|
||
aw.cursor = byte;
|
||
aw.goal_col = None;
|
||
}
|
||
PointerKind::Up => {
|
||
if let Some(sel) = core.active_window().selection
|
||
&& sel.anchor == core.cursor()
|
||
{
|
||
core.clear_selection();
|
||
}
|
||
}
|
||
PointerKind::DoubleDown => {
|
||
let aw = core.active_window_mut();
|
||
aw.cursor = byte;
|
||
aw.goal_col = None;
|
||
core.select_word_at_cursor();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Make `win_id` the active window and place its cursor at the
|
||
/// buffer position corresponding to `(local_row, local_col)`,
|
||
/// where the coordinates are relative to the window's viewport
|
||
/// origin (0 row = first text row of this window's content).
|
||
fn activate_and_position(&mut self, win_id: WindowId, local_row: u32, local_col: u32) {
|
||
let mut core = self.core.borrow_mut();
|
||
core.set_active_window_id(win_id);
|
||
let view_top = core.windows[&win_id].view_top;
|
||
let buffer_id = core.windows[&win_id].buffer_id;
|
||
let display_row = view_top.saturating_add(local_row as usize);
|
||
let target = crate::view::DisplayCoord::new(display_row as u32, local_col);
|
||
let pos = {
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let Ok(buf) = reg.get(buffer_id) else {
|
||
return;
|
||
};
|
||
core.windows[&win_id].text_view.display_to_pos(buf, target)
|
||
};
|
||
if let Some(p) = pos {
|
||
let aw = core
|
||
.windows
|
||
.get_mut(&win_id)
|
||
.expect("invariant: win_id passed in must be a live window in core.windows");
|
||
aw.cursor = p;
|
||
aw.goal_col = None;
|
||
}
|
||
}
|
||
|
||
/// Adjust `view_top` of `win_id` by `delta` lines and shift the
|
||
/// cursor by the same delta so it keeps its relative position in
|
||
/// the viewport. Negative scrolls up (toward earlier content);
|
||
/// positive scrolls down.
|
||
///
|
||
/// The cursor must follow the scroll: the renderer has an
|
||
/// "auto-scroll to keep cursor visible" pass that would otherwise
|
||
/// snap `view_top` straight back to wherever the cursor sits, so
|
||
/// the user's mouse-wheel scroll would feel stuck after one
|
||
/// notch. Carrying the cursor with the view matches Emacs's
|
||
/// `mouse-wheel-mode` and every modern editor's wheel behaviour.
|
||
fn scroll_window(&mut self, win_id: WindowId, delta: i32) {
|
||
let mut core = self.core.borrow_mut();
|
||
let line_count = core.windows[&win_id].text_view.line_count();
|
||
let max_top = line_count.saturating_sub(1);
|
||
let old_top = core.windows[&win_id].view_top;
|
||
let scroll_up = delta < 0;
|
||
let magnitude = delta.unsigned_abs() as usize;
|
||
let new_top = if scroll_up {
|
||
old_top.saturating_sub(magnitude)
|
||
} else {
|
||
old_top.saturating_add(magnitude).min(max_top)
|
||
};
|
||
// Effective view delta — buffer-boundary clamping may shrink
|
||
// the requested move, so the cursor only follows by however
|
||
// many lines the view actually shifted.
|
||
let view_shift = if scroll_up {
|
||
old_top.saturating_sub(new_top)
|
||
} else {
|
||
new_top.saturating_sub(old_top)
|
||
};
|
||
let buffer_id = core.windows[&win_id].buffer_id;
|
||
let new_cursor = {
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
reg.get(buffer_id).ok().and_then(|buf| {
|
||
let aw = &core.windows[&win_id];
|
||
let cur = aw.text_view.pos_to_display(buf, aw.cursor)?;
|
||
let cur_row = cur.row as usize;
|
||
let target_row_usize = if scroll_up {
|
||
cur_row.saturating_sub(view_shift)
|
||
} else {
|
||
cur_row.saturating_add(view_shift).min(max_top)
|
||
};
|
||
let target_row = u32::try_from(target_row_usize).ok()?;
|
||
aw.text_view
|
||
.display_to_pos(buf, crate::view::DisplayCoord::new(target_row, cur.col))
|
||
.or_else(|| aw.text_view.line_offset(target_row_usize))
|
||
})
|
||
};
|
||
let aw = core
|
||
.windows
|
||
.get_mut(&win_id)
|
||
.expect("invariant: win_id passed in must be a live window in core.windows");
|
||
aw.view_top = new_top;
|
||
if let Some(p) = new_cursor {
|
||
aw.cursor = p;
|
||
aw.goal_col = None;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Lines to scroll per mouse-wheel notch. Three matches the GNU
|
||
/// readline / Emacs default and is what most terminal users expect.
|
||
const SCROLL_LINES: i32 = 3;
|
||
|
||
/// Find the leaf window whose viewport rectangle contains
|
||
/// `(cell_row, cell_col)` in the global cell grid. Used by the mouse
|
||
/// dispatcher to route clicks. The bottom row of the terminal (status
|
||
/// / minibuffer) is *not* part of any window — clicks there return
|
||
/// `None`.
|
||
fn window_at_cell(
|
||
core: &EditorCore,
|
||
term_size: crate::cell::CellSize,
|
||
cell_row: u32,
|
||
cell_col: u32,
|
||
) -> Option<(WindowId, Rect)> {
|
||
if term_size.rows < 2 {
|
||
return None;
|
||
}
|
||
let text_rows = term_size.rows - 1;
|
||
if cell_row >= text_rows {
|
||
return None;
|
||
}
|
||
let area = Rect::new(0, 0, text_rows, term_size.cols);
|
||
let placements = core.active_layout().compute(area);
|
||
placements.iter().find_map(|(id, rect)| {
|
||
if cell_row >= rect.origin.row
|
||
&& cell_row < rect.origin.row + rect.size.rows
|
||
&& cell_col >= rect.origin.col
|
||
&& cell_col < rect.origin.col + rect.size.cols
|
||
{
|
||
Some((*id, *rect))
|
||
} else {
|
||
None
|
||
}
|
||
})
|
||
}
|
||
|
||
impl Default for EditorState {
|
||
fn default() -> Self {
|
||
Self::new()
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Run loop
|
||
// ---------------------------------------------------------------------------
|
||
|
||
/// Main run loop. Opens the file (if any), takes over the terminal,
|
||
/// renders, dispatches keys, until the user quits.
|
||
///
|
||
/// # Post-init dispatch (T M5.6g)
|
||
///
|
||
/// After [`EditorState::new`] / [`EditorState::open`] returns,
|
||
/// `init.lua` has had a chance to call `pmacs.attach{...}`. The
|
||
/// dispatcher in [`crate::attach_dispatch::dispatch_attach`] decides
|
||
/// whether to:
|
||
///
|
||
/// * run the local TUI as usual (no init-time attach request),
|
||
/// * hand off to [`crate::attach::run_attach`] against the requested
|
||
/// local socket, or
|
||
/// * surface a workaround-pointing error for transports whose
|
||
/// activation pathway hasn't shipped yet (ssh / tls / custom).
|
||
///
|
||
/// The dispatch happens *before* the local [`Frontend`] is
|
||
/// constructed, so a hand-off to attach mode doesn't fight the
|
||
/// local-TUI for the terminal.
|
||
pub fn run(file: Option<PathBuf>) -> io::Result<()> {
|
||
install_panic_hook();
|
||
let mut state = match file {
|
||
Some(path) => EditorState::open(path)?,
|
||
None => EditorState::new(),
|
||
};
|
||
|
||
// 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 => {
|
||
// 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);
|
||
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);
|
||
return crate::attach::run_attach_ssh(target)
|
||
.map_err(|e| io::Error::other(format!("{e}")));
|
||
}
|
||
dispatch @ crate::attach_dispatch::AttachDispatch::DeferredInV01 { .. } => {
|
||
let msg = dispatch
|
||
.deferred_message()
|
||
.expect("DeferredInV01 always has a message");
|
||
return Err(io::Error::other(msg));
|
||
}
|
||
}
|
||
|
||
let mut frontend = Frontend::new()?;
|
||
let mut render_state = crate::instance_render::RenderState::new(frontend.size());
|
||
|
||
loop {
|
||
// In-process TUI never has remote frontends; no overlays.
|
||
let messages = render_state.render_frame(&state, &[]);
|
||
frontend.present_messages(&messages)?;
|
||
if state.core.borrow().quit {
|
||
break;
|
||
}
|
||
|
||
// Poll with a frame-sized timeout (60 Hz default; T M3.5
|
||
// exposes the cadence as a tunable knob via
|
||
// `pmacs.async_config.frame_target_ms`). The timeout is
|
||
// what lets the async runtime (T M3.3) wake on worker
|
||
// completions even when the user isn't typing --- a
|
||
// parallel grep finishing has to surface its results
|
||
// without waiting for a key press, and a streaming worker's
|
||
// 10K msgs/sec coalesce into one main-thread wakeup per
|
||
// frame at this cadence (T M3.5 acceptance). When events do
|
||
// arrive, we drain the burst the same way we did before
|
||
// (T M2.12): one render per burst, not one per event.
|
||
let frame_target = state.async_runtime.frame_target_ms();
|
||
let first = frontend.poll_event(Duration::from_millis(frame_target))?;
|
||
if let Some(ev) = first {
|
||
forward_resize(&ev, &mut render_state);
|
||
let term_size = frontend.size();
|
||
process_event(&mut state, ev, term_size);
|
||
while let Some(ev) = frontend.poll_event(Duration::from_millis(0))? {
|
||
forward_resize(&ev, &mut render_state);
|
||
let term_size = frontend.size();
|
||
process_event(&mut state, ev, term_size);
|
||
if state.core.borrow().quit {
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
// `tick_async` runs *last*, after the supervisor/LSP/MCP
|
||
// ticks have absorbed this frame's inbound I/O. The async
|
||
// bridge (T M4.5) settles an awaiter inside `tick_lsp`/
|
||
// `tick_mcp` by posting to the message bus; `tick_async`
|
||
// drains that bus and resumes the parked coroutine. With
|
||
// `tick_async` last, settle→resume happens in the *same*
|
||
// frame; running it first would defer every LSP/MCP await
|
||
// resumption by a full frame. The documented invariant is
|
||
// only `tick_processes → tick_lsp → tick_mcp` (same-batch
|
||
// supervisor I/O ordering), which is preserved.
|
||
state.tick_processes();
|
||
state.tick_lsp();
|
||
state.tick_mcp();
|
||
state.tick_async();
|
||
}
|
||
let _ = frontend.poll_event(Duration::from_millis(0));
|
||
Ok(())
|
||
}
|
||
|
||
/// Propagate a `Resize` event to the instance-side render buffers so the
|
||
/// next frame is reallocated and emitted as a full-grid sync. The
|
||
/// frontend updates its own size internally inside `poll_event`; this
|
||
/// helper keeps `RenderState` in lockstep.
|
||
fn forward_resize(ev: &Event, render_state: &mut crate::instance_render::RenderState) {
|
||
if let Event::Resize(cols, rows) = ev {
|
||
render_state.resize(crate::cell::CellSize::new(
|
||
u32::from(*rows),
|
||
u32::from(*cols),
|
||
));
|
||
}
|
||
}
|
||
|
||
#[allow(
|
||
clippy::needless_pass_by_value,
|
||
reason = "ownership ends here; variants we act on are Copy"
|
||
)]
|
||
fn process_event(state: &mut EditorState, ev: Event, term_size: crate::cell::CellSize) {
|
||
// v0.1: a single frontend per instance, hard-coded to
|
||
// [`FrontendId::LOCAL`]. v0.3 (multi-frontend) extracts the ID from
|
||
// the [`crate::protocol::FrontendEvent`] wrapper produced by the
|
||
// attached frontend.
|
||
let frontend_id = FrontendId::LOCAL;
|
||
match ev {
|
||
Event::Key(key) if matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) => {
|
||
state.dispatch_key(frontend_id, key);
|
||
}
|
||
Event::Mouse(m) => {
|
||
state.dispatch_mouse(frontend_id, m, term_size);
|
||
}
|
||
_ => {}
|
||
}
|
||
}
|
||
|
||
/// Paint one full frame into `grid` and return the desired terminal
|
||
/// cursor position.
|
||
///
|
||
/// This is the layout-and-paint half of the renderer.
|
||
/// [`crate::instance_render::RenderState`] drives it (T M5.2): the
|
||
/// `RenderState` owns the `prev`/`next` cell buffers, calls this
|
||
/// function to fill `next`, then diffs against `prev` to produce a
|
||
/// [`crate::protocol::InstanceMessage::CellDelta`]. Tests (and any
|
||
/// future non-crossterm frontend) can drive it directly against a
|
||
/// Vec-backed [`crate::cell::CellGrid`] without going through a
|
||
/// `RenderState`.
|
||
#[allow(clippy::too_many_lines, reason = "linear paint pipeline")]
|
||
pub fn paint_frame(
|
||
state: &EditorState,
|
||
grid: &mut crate::cell::CellGrid<'_>,
|
||
term_size: crate::cell::CellSize,
|
||
) -> Option<CellCoord> {
|
||
if term_size.rows < 2 || term_size.cols == 0 {
|
||
return None;
|
||
}
|
||
let text_rows = term_size.rows - 1;
|
||
|
||
let mut core_ref = state.core.borrow_mut();
|
||
let core: &mut EditorCore = &mut core_ref;
|
||
|
||
// Compute per-window rectangles. The text area is the term size
|
||
// minus the bottom row (status / minibuffer).
|
||
let text_area = crate::window::Rect::new(0, 0, text_rows, term_size.cols);
|
||
let placements = core.active_layout().compute(text_area);
|
||
let active = core.active_window_id();
|
||
|
||
// Clear the whole grid first so windows that shrink on resize
|
||
// don't leak the old contents.
|
||
for row in 0..term_size.rows {
|
||
for col in 0..term_size.cols {
|
||
*grid.at(CellCoord::new(row, col)) = crate::cell::Cell::default();
|
||
}
|
||
}
|
||
|
||
// Scroll the active window so its cursor stays visible. Inactive
|
||
// windows keep their existing scroll.
|
||
if let Some(active_rect) = placements.get(&active) {
|
||
let inner_rows = inner_rows(active_rect);
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let buf_id = core.active_buffer_id();
|
||
if let Ok(buf) = reg.get(buf_id) {
|
||
let aw = core.windows.get_mut(&active).expect(
|
||
"invariant: active_window_id always references a live window in core.windows",
|
||
);
|
||
let cursor_row = aw
|
||
.text_view
|
||
.pos_to_display(buf, aw.cursor)
|
||
.map_or(0, |d| d.row as usize);
|
||
if cursor_row < aw.view_top {
|
||
aw.view_top = cursor_row;
|
||
} else if inner_rows > 0 && cursor_row >= aw.view_top + inner_rows as usize {
|
||
aw.view_top = cursor_row + 1 - inner_rows as usize;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Render every window.
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let diag_store = state.lsp_manager.borrow().diag_store();
|
||
for (id, window) in &mut core.windows {
|
||
let Some(rect) = placements.get(id).copied() else {
|
||
continue;
|
||
};
|
||
let inner_rows = inner_rows(&rect);
|
||
// Record viewport height for page motion (cursor.page-down /
|
||
// cursor.page-up consume this).
|
||
window.last_visible_rows = inner_rows;
|
||
if inner_rows == 0 || rect.size.cols == 0 {
|
||
continue;
|
||
}
|
||
let Ok(buf) = reg.get(window.buffer_id) else {
|
||
continue;
|
||
};
|
||
let viewport_buffer_start = window.text_view.line_offset(window.view_top).unwrap_or(0);
|
||
let viewport = Viewport {
|
||
buffer_start: viewport_buffer_start,
|
||
buffer_end: buf.len(),
|
||
cell_origin: rect.origin,
|
||
cell_size: crate::cell::CellSize::new(inner_rows, rect.size.cols),
|
||
};
|
||
// Composition (T M2.9): base text_view paints first, then
|
||
// each overlay in attach order. See [`crate::view::View`].
|
||
window.text_view.render(buf, viewport, grid);
|
||
for overlay in &mut window.overlays {
|
||
overlay.render(buf, viewport, grid);
|
||
}
|
||
paint_local_selection(grid, buf, window, &rect, inner_rows);
|
||
// Mode line for this window. Painted last so the line
|
||
// itself is always visible regardless of overlay activity.
|
||
let coord = window
|
||
.text_view
|
||
.pos_to_display(buf, window.cursor)
|
||
.unwrap_or_default();
|
||
let scroll = format_scroll_indicator(
|
||
window.view_top,
|
||
inner_rows as usize,
|
||
window.text_view.line_count(),
|
||
coord.row as usize,
|
||
);
|
||
// Lock scoped to the summary computation only: the overlay
|
||
// renders above include `DiagnosticView`, which takes this
|
||
// same mutex — holding the guard across the loop deadlocked
|
||
// the daemon on the first frame after a file (and thus a
|
||
// diagnostic overlay) was opened.
|
||
let diags = {
|
||
let guard = diag_store.lock().expect("diag store mutex poisoned");
|
||
diag_mode_line_summary(&guard, buf)
|
||
};
|
||
paint_mode_line(
|
||
grid,
|
||
&rect,
|
||
buf.name(),
|
||
buf.is_modified(),
|
||
*id == active,
|
||
coord.row,
|
||
coord.col,
|
||
&scroll,
|
||
&diags,
|
||
);
|
||
}
|
||
drop(reg);
|
||
|
||
paint_status_line(grid, core, &state.lua_host, &state.dispatcher, term_size);
|
||
|
||
let mb_cursor_col = if core.minibuffer.is_active() {
|
||
Some(paint_minibuffer(grid, core, term_size))
|
||
} else {
|
||
None
|
||
};
|
||
|
||
if let Some(col) = mb_cursor_col {
|
||
return Some(CellCoord::new(term_size.rows - 1, col));
|
||
}
|
||
let active_rect = placements.get(&active).copied()?;
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let aw = &core.windows[&active];
|
||
let inner_rows = inner_rows(&active_rect);
|
||
let buf = reg.get(aw.buffer_id).ok()?;
|
||
let disp = aw.text_view.pos_to_display(buf, aw.cursor)?;
|
||
if (disp.row as usize) < aw.view_top || (disp.row as usize) >= aw.view_top + inner_rows as usize
|
||
{
|
||
return None;
|
||
}
|
||
let grid_row = active_rect.origin.row + (disp.row - aw.view_top as u32);
|
||
let max_col = active_rect.origin.col + active_rect.size.cols.saturating_sub(1);
|
||
let grid_col = (active_rect.origin.col + disp.col).min(max_col);
|
||
Some(CellCoord::new(grid_row, grid_col))
|
||
}
|
||
|
||
fn paint_status_line(
|
||
grid: &mut crate::cell::CellGrid<'_>,
|
||
core: &EditorCore,
|
||
lua_host: &LuaHost,
|
||
dispatcher: &KeyDispatcher,
|
||
term_size: crate::cell::CellSize,
|
||
) {
|
||
let status = build_status_line(core, lua_host, dispatcher, term_size.cols);
|
||
let row = term_size.rows - 1;
|
||
for (col, ch) in status.chars().enumerate() {
|
||
if col >= term_size.cols as usize {
|
||
break;
|
||
}
|
||
let cell = grid.at(CellCoord::new(row, col as u32));
|
||
cell.glyph = crate::cell::Glyph::Char(ch);
|
||
cell.style = crate::cell::Style {
|
||
reverse: true,
|
||
..Default::default()
|
||
};
|
||
}
|
||
for col in (status.chars().count() as u32)..term_size.cols {
|
||
let cell = grid.at(CellCoord::new(row, col));
|
||
cell.glyph = crate::cell::Glyph::Char(' ');
|
||
cell.style = crate::cell::Style {
|
||
reverse: true,
|
||
..Default::default()
|
||
};
|
||
}
|
||
}
|
||
|
||
/// Rows available for buffer text inside `rect`, after subtracting
|
||
/// the per-window mode line (one row).
|
||
/// Clamp `pos` into `buf` and walk back to the nearest UTF-8
|
||
/// codepoint boundary. Pointer hits arrive from a frontend whose text
|
||
/// may be a few unconfirmed edits ahead of or behind the instance, so
|
||
/// a raw byte offset can land mid-codepoint; a snapped position is
|
||
/// always safe to assign to a window cursor.
|
||
fn snap_to_char_boundary(buf: &crate::buffer::Buffer, pos: u64) -> u64 {
|
||
let len = buf.len();
|
||
let mut pos = pos.min(len);
|
||
while pos > 0 && pos < len {
|
||
match buf.snapshot_rope().byte_at(pos) {
|
||
// UTF-8 continuation byte (0b10xx_xxxx) ⇒ mid-codepoint.
|
||
Some(b) if b & 0b1100_0000 == 0b1000_0000 => pos -= 1,
|
||
_ => break,
|
||
}
|
||
}
|
||
pos
|
||
}
|
||
|
||
fn inner_rows(rect: &crate::window::Rect) -> u32 {
|
||
rect.size.rows.saturating_sub(1)
|
||
}
|
||
|
||
fn paint_local_selection(
|
||
grid: &mut crate::cell::CellGrid<'_>,
|
||
buf: &crate::buffer::Buffer,
|
||
window: &crate::window::Window,
|
||
rect: &crate::window::Rect,
|
||
inner_rows: u32,
|
||
) {
|
||
let Some((sel_start, sel_end)) = window.region() else {
|
||
return;
|
||
};
|
||
if inner_rows == 0 || rect.size.cols == 0 || sel_start >= sel_end {
|
||
return;
|
||
}
|
||
|
||
let first_row = window.view_top;
|
||
let last_row = first_row.saturating_add(inner_rows as usize);
|
||
for display_row in first_row..last_row {
|
||
let Some(line_start) = window.text_view.line_offset(display_row) else {
|
||
continue;
|
||
};
|
||
let Some(line_len) = window.text_view.line_len(buf, display_row) else {
|
||
continue;
|
||
};
|
||
let line_end = line_start.saturating_add(line_len);
|
||
let paint_start = sel_start.max(line_start);
|
||
let paint_end = sel_end.min(line_end);
|
||
if paint_start >= paint_end {
|
||
continue;
|
||
}
|
||
|
||
let Some(start_coord) = window.text_view.pos_to_display(buf, paint_start) else {
|
||
continue;
|
||
};
|
||
let Some(end_coord) = window.text_view.pos_to_display(buf, paint_end) else {
|
||
continue;
|
||
};
|
||
if start_coord.row as usize != display_row || end_coord.row as usize != display_row {
|
||
continue;
|
||
}
|
||
|
||
let row_offset = display_row.saturating_sub(first_row) as u32;
|
||
let start_col = start_coord.col.min(rect.size.cols);
|
||
let end_col = end_coord.col.min(rect.size.cols);
|
||
if start_col >= end_col {
|
||
continue;
|
||
}
|
||
for col in start_col..end_col {
|
||
let cell = grid.at(CellCoord::new(
|
||
rect.origin.row + row_offset,
|
||
rect.origin.col + col,
|
||
));
|
||
cell.style.reverse = true;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Format the mode-line diagnostic readout for a buffer: `"E:2 W:5"`
|
||
/// with only the nonzero severities (errors, then warnings; info and
|
||
/// hints stay off the mode line). Empty when the buffer has no file
|
||
/// path, no diagnostics, or the stored diagnostics are stale — the
|
||
/// document was edited since the last `publishDiagnostics`, so the
|
||
/// counts would describe text that no longer exists (T M4.6).
|
||
fn diag_mode_line_summary(
|
||
store: &crate::diag::DiagnosticStore,
|
||
buf: &crate::buffer::Buffer,
|
||
) -> String {
|
||
let Some(path) = buf.file_path() else {
|
||
return String::new();
|
||
};
|
||
let uri = crate::lsp::path_to_file_uri(path);
|
||
if store.is_stale(&uri) {
|
||
return String::new();
|
||
}
|
||
let mut errors = 0usize;
|
||
let mut warnings = 0usize;
|
||
for d in store.for_uri(&uri) {
|
||
match d.severity {
|
||
crate::diag::DiagnosticSeverity::Error => errors += 1,
|
||
crate::diag::DiagnosticSeverity::Warning => warnings += 1,
|
||
_ => {}
|
||
}
|
||
}
|
||
match (errors, warnings) {
|
||
(0, 0) => String::new(),
|
||
(e, 0) => format!("E:{e}"),
|
||
(0, w) => format!("W:{w}"),
|
||
(e, w) => format!("E:{e} W:{w}"),
|
||
}
|
||
}
|
||
|
||
#[allow(
|
||
clippy::too_many_arguments,
|
||
reason = "the mode line packs nine unrelated facts; bundling them into a struct just adds ceremony"
|
||
)]
|
||
fn paint_mode_line(
|
||
grid: &mut crate::cell::CellGrid<'_>,
|
||
rect: &crate::window::Rect,
|
||
name: &str,
|
||
modified: bool,
|
||
is_active: bool,
|
||
cursor_row: u32,
|
||
cursor_col: u32,
|
||
scroll: &str,
|
||
diags: &str,
|
||
) {
|
||
if rect.size.rows == 0 || rect.size.cols == 0 {
|
||
return;
|
||
}
|
||
let row = rect.origin.row + rect.size.rows - 1;
|
||
let marker = if modified { '*' } else { ' ' };
|
||
let active_marker = if is_active { '+' } else { '-' };
|
||
let left = format!(" {active_marker}{marker} {name} ");
|
||
let right = if diags.is_empty() {
|
||
format!(" L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
|
||
} else {
|
||
format!(" {diags} L{}:C{} {scroll} ", cursor_row + 1, cursor_col + 1)
|
||
};
|
||
|
||
// Fill the row with reverse-video spaces.
|
||
let mode_style = crate::cell::Style {
|
||
reverse: true,
|
||
..Default::default()
|
||
};
|
||
for c in 0..rect.size.cols {
|
||
let cell = grid.at(CellCoord::new(row, rect.origin.col + c));
|
||
cell.glyph = crate::cell::Glyph::Char(' ');
|
||
cell.style = mode_style;
|
||
}
|
||
|
||
// Right-align the cursor / scroll readout. If the window is too
|
||
// narrow to fit both halves, drop the right side rather than
|
||
// overlap the buffer name.
|
||
let right_chars: Vec<char> = right.chars().collect();
|
||
let right_len = right_chars.len() as u32;
|
||
let right_start_col = if right_len < rect.size.cols {
|
||
Some(rect.size.cols - right_len)
|
||
} else {
|
||
None
|
||
};
|
||
if let Some(start_col) = right_start_col {
|
||
for (i, ch) in right_chars.iter().enumerate() {
|
||
let col = rect.origin.col + start_col + i as u32;
|
||
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(*ch);
|
||
}
|
||
}
|
||
|
||
// Paint the left side, stopping before the right side begins.
|
||
let stop_col = right_start_col.unwrap_or(rect.size.cols);
|
||
for (i, ch) in left.chars().enumerate() {
|
||
let i = i as u32;
|
||
if i >= stop_col {
|
||
break;
|
||
}
|
||
let col = rect.origin.col + i;
|
||
grid.at(CellCoord::new(row, col)).glyph = crate::cell::Glyph::Char(ch);
|
||
}
|
||
}
|
||
|
||
/// Paint the minibuffer line on the bottom row, replacing the status
|
||
/// line. Returns the screen column the terminal cursor should sit
|
||
/// at (so the user can see what they're typing).
|
||
fn paint_minibuffer(
|
||
grid: &mut crate::cell::CellGrid<'_>,
|
||
core: &EditorCore,
|
||
term_size: crate::cell::CellSize,
|
||
) -> u32 {
|
||
let session = core
|
||
.minibuffer
|
||
.session
|
||
.as_ref()
|
||
.expect("called only when active");
|
||
let prompt = &session.prompt;
|
||
let contents = core.minibuffer.contents();
|
||
let mut suffix = String::new();
|
||
if let Some(idx) = session.selected
|
||
&& let Some(cand) = session.candidates.get(idx)
|
||
{
|
||
suffix = format!(" [{cand}]");
|
||
}
|
||
let row = term_size.rows - 1;
|
||
let mut col: u32 = 0;
|
||
let mut written: u32 = 0;
|
||
let max = term_size.cols;
|
||
let cursor_byte = core.minibuffer.cursor;
|
||
|
||
for ch in prompt.chars() {
|
||
if col >= max {
|
||
break;
|
||
}
|
||
let cell = grid.at(CellCoord::new(row, col));
|
||
cell.glyph = crate::cell::Glyph::Char(ch);
|
||
cell.style = crate::cell::Style::default();
|
||
col += 1;
|
||
written += 1;
|
||
}
|
||
let prompt_end = col;
|
||
let mut cursor_col: u32 = prompt_end;
|
||
|
||
let mut byte_pos: u64 = 0;
|
||
for ch in contents.chars() {
|
||
if byte_pos < cursor_byte {
|
||
cursor_col = col + 1;
|
||
}
|
||
if col >= max {
|
||
break;
|
||
}
|
||
let cell = grid.at(CellCoord::new(row, col));
|
||
cell.glyph = crate::cell::Glyph::Char(ch);
|
||
cell.style = crate::cell::Style::default();
|
||
col += 1;
|
||
written += 1;
|
||
byte_pos += ch.len_utf8() as u64;
|
||
}
|
||
if byte_pos < cursor_byte {
|
||
cursor_col = col;
|
||
} else if cursor_byte == 0 {
|
||
cursor_col = prompt_end;
|
||
}
|
||
|
||
for ch in suffix.chars() {
|
||
if col >= max {
|
||
break;
|
||
}
|
||
let cell = grid.at(CellCoord::new(row, col));
|
||
cell.glyph = crate::cell::Glyph::Char(ch);
|
||
cell.style = crate::cell::Style {
|
||
reverse: true,
|
||
..Default::default()
|
||
};
|
||
col += 1;
|
||
written += 1;
|
||
}
|
||
|
||
for col in written..max {
|
||
let cell = grid.at(CellCoord::new(row, col));
|
||
cell.glyph = crate::cell::Glyph::Char(' ');
|
||
cell.style = crate::cell::Style::default();
|
||
}
|
||
|
||
cursor_col.min(max.saturating_sub(1))
|
||
}
|
||
|
||
/// Build the global status (echo area) row: pure ephemeral state.
|
||
///
|
||
/// Per-window facts (buffer name, modified marker, cursor coord,
|
||
/// scroll indicator) live on each window's mode line — see
|
||
/// [`paint_mode_line`]. The status row is reserved for things that
|
||
/// don't belong to any window in particular: command result text
|
||
/// (`core.status`), captured Lua errors, and the in-flight key
|
||
/// prefix when a multi-chord sequence is open.
|
||
///
|
||
/// When all three are empty, the returned string is empty and the
|
||
/// row renders as blanks.
|
||
fn build_status_line(
|
||
core: &EditorCore,
|
||
lua_host: &LuaHost,
|
||
dispatcher: &KeyDispatcher,
|
||
cols: u32,
|
||
) -> String {
|
||
let mut line = String::new();
|
||
if !core.status.is_empty() {
|
||
line.push_str(&sanitize_single_line(&core.status));
|
||
} else if let Some(err) = lua_host.last_error() {
|
||
use std::fmt::Write;
|
||
let _ = write!(line, "lua: {}", sanitize_single_line(&err.message));
|
||
}
|
||
if !dispatcher.pending().is_empty() {
|
||
use std::fmt::Write;
|
||
if !line.is_empty() {
|
||
line.push_str(" ");
|
||
}
|
||
let _ = write!(line, "[{}-]", display_sequence(dispatcher.pending()));
|
||
}
|
||
let max = cols as usize;
|
||
if line.chars().count() > max {
|
||
line.chars().take(max).collect()
|
||
} else {
|
||
line
|
||
}
|
||
}
|
||
|
||
/// First line of `s`, or the whole string if no newline is present.
|
||
fn first_line(s: &str) -> &str {
|
||
s.split_once('\n').map_or(s, |(head, _)| head)
|
||
}
|
||
|
||
/// Render the Neovim/Doom-style scroll indicator that follows the
|
||
/// `L:C` cursor coordinate in the status line.
|
||
///
|
||
/// * `All` --- the entire buffer fits in the viewport (or the buffer
|
||
/// is one line).
|
||
/// * `Top` --- the viewport's first line is the buffer's first line
|
||
/// and the buffer doesn't fit.
|
||
/// * `Bot` --- the viewport's last line reaches or passes the
|
||
/// buffer's last line.
|
||
/// * `NN%` --- otherwise, the cursor's line as a percent of the
|
||
/// buffer's total line count.
|
||
///
|
||
/// `visible` may be 0 in tests that never rendered (so
|
||
/// `last_visible_rows` was never populated); in that case we fall
|
||
/// back to cursor-row-based percent without the All/Top/Bot caps.
|
||
fn format_scroll_indicator(
|
||
view_top: usize,
|
||
visible: usize,
|
||
total_lines: usize,
|
||
cursor_row: usize,
|
||
) -> String {
|
||
if total_lines <= 1 {
|
||
return "All".to_string();
|
||
}
|
||
if visible > 0 {
|
||
if visible >= total_lines {
|
||
return "All".to_string();
|
||
}
|
||
if view_top == 0 {
|
||
return "Top".to_string();
|
||
}
|
||
if view_top.saturating_add(visible) >= total_lines {
|
||
return "Bot".to_string();
|
||
}
|
||
}
|
||
let pct = (cursor_row + 1).saturating_mul(100) / total_lines;
|
||
format!("{pct}%")
|
||
}
|
||
|
||
/// Flatten `s` to a printable single line for the status row.
|
||
///
|
||
/// Lua errors caught by user-level `pcall` (e.g. M-x dispatching an
|
||
/// unknown command) carry a multi-line traceback when stringified.
|
||
/// Storing those newlines verbatim and copying them into the cell grid
|
||
/// makes the terminal frontend emit literal `\n` bytes, which jumps the
|
||
/// cursor and corrupts the rest of the frame. Truncate at the first
|
||
/// newline (the informative summary), then replace any remaining
|
||
/// control characters with spaces so terminal layout cannot leak.
|
||
fn sanitize_single_line(s: &str) -> String {
|
||
first_line(s)
|
||
.chars()
|
||
.map(|c| if c.is_control() { ' ' } else { c })
|
||
.collect()
|
||
}
|
||
|
||
fn key_event_to_chord(key: KeyEvent) -> Option<Chord> {
|
||
// Accept Press and Repeat. Some terminals (notably ones speaking
|
||
// the kitty keyboard protocol with auto-repeat) deliver held-key
|
||
// events as `Repeat` rather than `Press`, and rejecting them made
|
||
// the second chord of a multi-key sequence appear "never
|
||
// registered" — the user pressed C-x then quickly pressed C-b
|
||
// without fully releasing first, the C-b arrived as Repeat, we
|
||
// dropped it, and the dispatcher stayed pending on [C-x].
|
||
// Releases stay filtered: they aren't input, and treating them as
|
||
// a chord would clear pending prefixes after every keystroke.
|
||
if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
|
||
return None;
|
||
}
|
||
Some(Chord::new(key.code, key.modifiers))
|
||
}
|
||
|
||
/// True for chords that should self-insert: a single chord with a
|
||
/// printable [`KeyCode::Char`] and no non-shift modifiers.
|
||
fn printable_char(seq: &[Chord]) -> Option<char> {
|
||
if seq.len() != 1 {
|
||
return None;
|
||
}
|
||
let chord = seq[0];
|
||
if chord.modifiers.intersects(
|
||
KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER | KeyModifiers::META,
|
||
) {
|
||
return None;
|
||
}
|
||
match chord.code {
|
||
KeyCode::Char(ch) => Some(ch),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// Tests
|
||
// ---------------------------------------------------------------------------
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
// Acceptance home for T M5.4 (FrontendId on input events) — the
|
||
// `m5_4_*`-prefixed tests verify the FrontendId field threads from
|
||
// synthetic event construction through `dispatch_key` /
|
||
// `dispatch_mouse` to a Lua hook that reads it back. The Lua-side
|
||
// `pmacs.frontend.id()` introspection is covered in
|
||
// `src/lua_bindings.rs::tests`. See tests/INDEX.md for the full
|
||
// M5.x → coverage map.
|
||
|
||
use super::*;
|
||
use crate::frontend::KeyEventKind;
|
||
|
||
fn fresh_with(content: &[u8]) -> EditorState {
|
||
let s = EditorState::new();
|
||
let new_id = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("test", content);
|
||
let mut core = s.core.borrow_mut();
|
||
let _ = core.switch_active_buffer(new_id);
|
||
drop(core);
|
||
s
|
||
}
|
||
|
||
fn key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
|
||
KeyEvent {
|
||
code,
|
||
modifiers,
|
||
kind: KeyEventKind::Press,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
}
|
||
}
|
||
|
||
fn ctrl(c: char) -> KeyEvent {
|
||
key(KeyCode::Char(c), KeyModifiers::CONTROL)
|
||
}
|
||
|
||
fn plain(code: KeyCode) -> KeyEvent {
|
||
key(code, KeyModifiers::NONE)
|
||
}
|
||
|
||
// ---- M1 acceptance ports -------------------------------------------------
|
||
|
||
#[test]
|
||
fn typing_inserts_characters_through_self_insert() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('h'), KeyModifiers::NONE),
|
||
);
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('i'), KeyModifiers::NONE),
|
||
);
|
||
let core = s.core.borrow();
|
||
assert_eq!(core.cursor(), 2);
|
||
assert_eq!(core.active_buffer_len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn enter_inserts_newline() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter));
|
||
assert_eq!(s.core.borrow().active_buffer_len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn backspace_deletes_previous_char() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('a'), KeyModifiers::NONE),
|
||
);
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Backspace));
|
||
let core = s.core.borrow();
|
||
assert_eq!(core.active_buffer_len(), 0);
|
||
assert_eq!(core.cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_a_e_navigate_line() {
|
||
let mut s = fresh_with(b"");
|
||
for c in "hello world".chars() {
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE));
|
||
}
|
||
assert_eq!(s.core.borrow().cursor(), 11);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('a'));
|
||
assert_eq!(s.core.borrow().cursor(), 0);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('e'));
|
||
assert_eq!(s.core.borrow().cursor(), 11);
|
||
}
|
||
|
||
#[test]
|
||
fn arrow_keys_move_cursor() {
|
||
let mut s = fresh_with(b"");
|
||
for c in "abc".chars() {
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE));
|
||
}
|
||
assert_eq!(s.core.borrow().cursor(), 3);
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Left));
|
||
assert_eq!(s.core.borrow().cursor(), 2);
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Right));
|
||
assert_eq!(s.core.borrow().cursor(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn cx_cc_quits() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
assert_eq!(s.dispatcher.pending().len(), 1);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('c'));
|
||
assert!(s.core.borrow().quit);
|
||
assert!(s.dispatcher.pending().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn cx_cs_invokes_save_with_no_path() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
|
||
assert!(s.core.borrow().status.contains("no file"));
|
||
}
|
||
|
||
// ---- T M11.6 — DispatchIdle ---------------------------------------------
|
||
|
||
#[test]
|
||
fn dispatch_idle_true_on_fresh_editor() {
|
||
let s = fresh_with(b"");
|
||
assert!(s.dispatch_idle());
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_idle_false_while_prefix_pending() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
assert!(
|
||
!s.dispatch_idle(),
|
||
"C-x prefix should put dispatcher in non-idle state"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_idle_true_after_prefix_resolves() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
assert!(!s.dispatch_idle());
|
||
// C-x C-c resolves the prefix into the quit command. After
|
||
// the second chord arrives the dispatcher's `pending` is
|
||
// cleared regardless of whether the command succeeded.
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('c'));
|
||
assert!(s.dispatch_idle(), "prefix cleared ⇒ idle again");
|
||
}
|
||
|
||
#[test]
|
||
fn dispatch_idle_false_while_minibuffer_active() {
|
||
use crate::minibuffer::{CompletionSource, MinibufferSession};
|
||
|
||
let s = fresh_with(b"");
|
||
assert!(s.dispatch_idle());
|
||
|
||
// Open a synthetic minibuffer session — same shape Lua's
|
||
// `pmacs.minibuffer.read` produces.
|
||
let lua = mlua::Lua::new();
|
||
let on_accept: mlua::Function = lua
|
||
.create_function(|_, _: String| Ok(()))
|
||
.expect("create on_accept");
|
||
s.core.borrow_mut().minibuffer.begin(MinibufferSession {
|
||
prompt: "test: ".into(),
|
||
initial: String::new(),
|
||
history_bucket: String::new(),
|
||
source: CompletionSource::None,
|
||
on_accept,
|
||
on_cancel: None,
|
||
candidates: Vec::new(),
|
||
selected: None,
|
||
history_index: None,
|
||
typed_before_history_nav: None,
|
||
});
|
||
assert!(
|
||
!s.dispatch_idle(),
|
||
"active minibuffer prompt should put dispatcher in non-idle state"
|
||
);
|
||
|
||
// Dismissing returns to idle.
|
||
let _ = s.core.borrow_mut().minibuffer.cancel();
|
||
assert!(s.dispatch_idle(), "dismissed minibuffer ⇒ idle again");
|
||
}
|
||
|
||
#[test]
|
||
fn repeat_key_events_dispatch_like_press() {
|
||
// Some terminals deliver auto-repeated keys as KeyEventKind::Repeat
|
||
// rather than KeyEventKind::Press. Filtering out Repeat made
|
||
// the second chord of a fast-typed multi-key sequence look
|
||
// "never registered" — the user pressed C-x then C-b before
|
||
// releasing Ctrl, the C-b arrived as Repeat, we dropped it,
|
||
// and the dispatcher stayed pending on [C-x] until something
|
||
// recognized came in.
|
||
let mut s = fresh_with(b"");
|
||
let cx_press = KeyEvent {
|
||
code: KeyCode::Char('x'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
kind: KeyEventKind::Press,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
let cb_repeat = KeyEvent {
|
||
code: KeyCode::Char('b'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
kind: KeyEventKind::Repeat,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
s.dispatch_key(FrontendId::LOCAL, cx_press);
|
||
assert_eq!(s.dispatcher.pending().len(), 1);
|
||
s.dispatch_key(FrontendId::LOCAL, cb_repeat);
|
||
assert!(
|
||
s.dispatcher.pending().is_empty(),
|
||
"Repeat-kind C-b did not resolve the pending C-x prefix"
|
||
);
|
||
assert_eq!(s.core.borrow().active_buffer_name(), "*buffer-list*");
|
||
}
|
||
|
||
#[test]
|
||
fn release_key_events_are_still_ignored() {
|
||
// Conversely, Release events must not advance the dispatcher
|
||
// — they aren't input. If they did, every keystroke would
|
||
// clear the pending prefix immediately after firing.
|
||
let mut s = fresh_with(b"");
|
||
let cx_press = KeyEvent {
|
||
code: KeyCode::Char('x'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
kind: KeyEventKind::Press,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
let cx_release = KeyEvent {
|
||
code: KeyCode::Char('x'),
|
||
modifiers: KeyModifiers::CONTROL,
|
||
kind: KeyEventKind::Release,
|
||
state: crossterm::event::KeyEventState::NONE,
|
||
};
|
||
s.dispatch_key(FrontendId::LOCAL, cx_press);
|
||
s.dispatch_key(FrontendId::LOCAL, cx_release);
|
||
assert_eq!(
|
||
s.dispatcher.pending().len(),
|
||
1,
|
||
"Release events should be ignored, but the prefix was disturbed"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn keymap_has_cx_cb_after_boot() {
|
||
// Sanity: confirm the binding is actually present in the
|
||
// global keymap after the editor finishes loading
|
||
// builtin/keymaps/default.lua. If something breaks the loader
|
||
// and the binding is silently dropped, dispatch would fall
|
||
// through to "C-x not bound" and the user-visible symptom
|
||
// would be exactly "C-b is never registered".
|
||
let s = fresh_with(b"");
|
||
let stack = s.lua_host.keymaps().borrow();
|
||
let chord_x = Chord::new(KeyCode::Char('x'), KeyModifiers::CONTROL);
|
||
let chord_b = Chord::new(KeyCode::Char('b'), KeyModifiers::CONTROL);
|
||
let r = stack.resolve(&[chord_x, chord_b], None, &[]);
|
||
match r {
|
||
crate::keymap_stack::StackResolution::Bound(rb) => {
|
||
assert_eq!(rb.binding.command, "editor.list-buffers");
|
||
}
|
||
other => panic!("expected Bound editor.list-buffers; got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn cx_cb_invokes_list_buffers() {
|
||
// Regression for the user-reported "C-x C-b stalls" bug. After
|
||
// C-x the dispatcher must be Pending; after C-b it must
|
||
// resolve to `editor.list-buffers` (which switches the active
|
||
// window to the *buffer-list* buffer).
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
assert_eq!(s.dispatcher.pending().len(), 1, "C-x should start prefix");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('b'));
|
||
assert!(
|
||
s.dispatcher.pending().is_empty(),
|
||
"C-x C-b should resolve, leaving no pending prefix; status: {}",
|
||
s.core.borrow().status
|
||
);
|
||
let name = s.core.borrow().active_buffer_name();
|
||
assert_eq!(
|
||
name,
|
||
"*buffer-list*",
|
||
"active buffer should be *buffer-list*; got {name:?}, status: {:?}",
|
||
s.core.borrow().status
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn cx_cb_repeated_keeps_buffer_list_window_in_sync() {
|
||
// After C-x C-b the active window shows *buffer-list*. A second
|
||
// C-x C-b rewrites that buffer via Lua userdata methods
|
||
// (`buf:delete`, `buf:insert`). Without notifying windows
|
||
// displaying the rewritten buffer, the active window's TextView
|
||
// would keep its old line cache and the new content would
|
||
// render partially or not at all.
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('b'));
|
||
assert_eq!(s.core.borrow().active_buffer_name(), "*buffer-list*");
|
||
let lines_first = s.core.borrow().active_window().text_view.line_count();
|
||
|
||
// Add a buffer so the second list run produces a longer body.
|
||
s.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("scratch.txt", b"hello");
|
||
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('b'));
|
||
let lines_second = s.core.borrow().active_window().text_view.line_count();
|
||
assert!(
|
||
lines_second >= lines_first,
|
||
"view did not see the rewritten *buffer-list*: {lines_first} -> {lines_second}"
|
||
);
|
||
let buf_len = s.core.borrow().active_buffer_len();
|
||
let last_offset = s
|
||
.core
|
||
.borrow()
|
||
.active_window()
|
||
.text_view
|
||
.line_offset(lines_second - 1)
|
||
.unwrap();
|
||
assert!(
|
||
last_offset <= buf_len,
|
||
"stale last offset {last_offset} exceeds buf_len {buf_len}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn unknown_chord_continuation_clears_prefix_with_message() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
assert_eq!(s.dispatcher.pending().len(), 1);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('q'));
|
||
assert!(s.dispatcher.pending().is_empty());
|
||
assert!(s.core.borrow().status.contains("not bound"));
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_d_deletes_forward() {
|
||
let mut s = fresh_with(b"");
|
||
for c in "abc".chars() {
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE));
|
||
}
|
||
s.core.borrow_mut().active_window_mut().cursor = 0;
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('d'));
|
||
assert_eq!(s.core.borrow().active_buffer_len(), 2);
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_slash_undoes() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('a'), KeyModifiers::NONE),
|
||
);
|
||
assert_eq!(s.core.borrow().active_buffer_len(), 1);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('/'));
|
||
assert_eq!(s.core.borrow().active_buffer_len(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn cx_u_undoes() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('a'), KeyModifiers::NONE),
|
||
);
|
||
assert_eq!(s.core.borrow().active_buffer_len(), 1);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('u'), KeyModifiers::NONE),
|
||
);
|
||
assert_eq!(s.core.borrow().active_buffer_len(), 0);
|
||
assert!(s.dispatcher.pending().is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn cx_r_redoes() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('a'), KeyModifiers::NONE),
|
||
);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('u'), KeyModifiers::NONE),
|
||
);
|
||
assert_eq!(s.core.borrow().active_buffer_len(), 0);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('r'), KeyModifiers::NONE),
|
||
);
|
||
assert_eq!(s.core.borrow().active_buffer_len(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn unbound_key_sets_status_does_not_crash() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::F(12)));
|
||
assert!(s.core.borrow().status.contains("not bound"));
|
||
}
|
||
|
||
#[test]
|
||
fn shift_letters_typed_normally() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('A'), KeyModifiers::SHIFT),
|
||
);
|
||
let core = s.core.borrow();
|
||
let len = core.active_buffer_len();
|
||
let mut out = vec![0u8; len as usize];
|
||
let reg = core.registry.borrow();
|
||
reg.get(core.active_buffer_id())
|
||
.unwrap()
|
||
.snapshot_rope()
|
||
.slice(0, len, &mut out);
|
||
assert_eq!(out, b"A");
|
||
}
|
||
|
||
#[test]
|
||
fn cg_runs_editor_cancel() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('g'));
|
||
assert_eq!(s.core.borrow().status, "Quit");
|
||
}
|
||
|
||
// ---- Open semantics -----------------------------------------------------
|
||
|
||
#[test]
|
||
fn open_nonexistent_path_yields_empty_buffer_with_path() {
|
||
let dir = tempfile::TempDir::new().unwrap();
|
||
let path = dir.path().join("does-not-exist.txt");
|
||
let s = EditorState::open(path.clone()).expect("must succeed");
|
||
let core = s.core.borrow();
|
||
assert!(core.active_buffer_len() == 0);
|
||
assert_eq!(core.active_buffer_path().as_deref(), Some(path.as_path()));
|
||
assert!(core.active_file_meta().is_none());
|
||
assert_eq!(core.status, "[new file]");
|
||
}
|
||
|
||
#[test]
|
||
fn open_existing_path_loads_content() {
|
||
let dir = tempfile::TempDir::new().unwrap();
|
||
let path = dir.path().join("here.txt");
|
||
std::fs::write(&path, b"hello").unwrap();
|
||
let s = EditorState::open(path.clone()).expect("must succeed");
|
||
let core = s.core.borrow();
|
||
assert_eq!(core.active_buffer_len(), 5);
|
||
assert!(core.active_file_meta().is_some());
|
||
assert_eq!(core.status, "");
|
||
}
|
||
|
||
// ---- Lua-host wiring ----------------------------------------------------
|
||
|
||
#[test]
|
||
fn lua_host_runs_on_main_thread_and_returns_value() {
|
||
let mut s = fresh_with(b"");
|
||
let v = s.lua_host.eval(None, "return 1 + 2").unwrap();
|
||
match v {
|
||
mlua::Value::Integer(n) => assert_eq!(n, 3),
|
||
other => panic!("expected integer, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
// -------------------------------------------------------------------
|
||
// T M5.4 acceptance: FrontendId threads through dispatch_key /
|
||
// dispatch_mouse to a Lua-readable surface.
|
||
//
|
||
// Spec §sec:v01-remote-scope deliverable 3.
|
||
// -------------------------------------------------------------------
|
||
|
||
#[test]
|
||
fn m5_4_pmacs_frontend_id_defaults_to_local() {
|
||
let mut s = fresh_with(b"");
|
||
let v = s.lua_host.eval(None, "return pmacs.frontend.id()").unwrap();
|
||
let expected = i64::try_from(FrontendId::LOCAL.0).unwrap();
|
||
match v {
|
||
mlua::Value::Integer(n) => assert_eq!(n, expected),
|
||
other => panic!("expected integer, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn m5_4_dispatch_key_threads_frontend_id_to_lua_surface() {
|
||
// Acceptance criterion: a synthetic event constructed with a
|
||
// non-default FrontendId threads through to a hook (here, a
|
||
// Lua-side reader of `pmacs.frontend.id()`) that reads it back.
|
||
let mut s = fresh_with(b"");
|
||
let probe_id = FrontendId(0x00C0_FFEE);
|
||
s.dispatch_key(probe_id, plain(KeyCode::Char('a')));
|
||
let v = s.lua_host.eval(None, "return pmacs.frontend.id()").unwrap();
|
||
let expected = i64::try_from(probe_id.0).unwrap();
|
||
match v {
|
||
mlua::Value::Integer(n) => assert_eq!(n, expected),
|
||
other => panic!("expected integer, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn m5_4_dispatch_mouse_threads_frontend_id_to_lua_surface() {
|
||
let mut s = fresh_with(b"");
|
||
let probe_id = FrontendId(0x0000_BEEF);
|
||
let term_size = crate::cell::CellSize::new(24, 80);
|
||
let m = crossterm::event::MouseEvent {
|
||
kind: crossterm::event::MouseEventKind::Moved,
|
||
row: 1,
|
||
column: 1,
|
||
modifiers: KeyModifiers::NONE,
|
||
};
|
||
s.dispatch_mouse(probe_id, m, term_size);
|
||
let v = s.lua_host.eval(None, "return pmacs.frontend.id()").unwrap();
|
||
let expected = i64::try_from(probe_id.0).unwrap();
|
||
match v {
|
||
mlua::Value::Integer(n) => assert_eq!(n, expected),
|
||
other => panic!("expected integer, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// The status line carries a Neovim/Doom-style scroll indicator
|
||
/// after `L:C`: `All` when the buffer fits, `Top` at the start,
|
||
/// `Bot` at the end, otherwise `NN%` cursor-row percent.
|
||
#[test]
|
||
fn status_line_scroll_indicator_reports_position() {
|
||
// 1) Buffer fits in viewport => "All".
|
||
assert_eq!(format_scroll_indicator(0, 22, 5, 0), "All");
|
||
// 2) View at top, buffer overflows => "Top".
|
||
assert_eq!(format_scroll_indicator(0, 22, 100, 0), "Top");
|
||
// 3) View at bottom (last line in viewport) => "Bot".
|
||
assert_eq!(format_scroll_indicator(80, 22, 100, 99), "Bot");
|
||
// 4) Mid-buffer => percent of cursor line.
|
||
assert_eq!(format_scroll_indicator(20, 22, 100, 30), "31%");
|
||
// 5) visible == 0 (window never rendered) => percent fallback,
|
||
// never the All/Top/Bot caps.
|
||
assert_eq!(format_scroll_indicator(0, 0, 100, 49), "50%");
|
||
// 6) Single-line buffer is always "All".
|
||
assert_eq!(format_scroll_indicator(0, 22, 1, 0), "All");
|
||
}
|
||
|
||
/// The scroll indicator and L:C cursor coord live on each
|
||
/// window's mode line (Doom-style packing), not on the global
|
||
/// status row. Render a buffer with enough lines to overflow the
|
||
/// viewport and assert the mode line carries `L1:C1` and `Top`.
|
||
#[test]
|
||
fn mode_line_carries_cursor_and_scroll_indicator() {
|
||
let mut content = Vec::new();
|
||
for i in 0..200 {
|
||
content.extend_from_slice(format!("line {i}\n").as_bytes());
|
||
}
|
||
let s = fresh_with(&content);
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
// Mode line is row 22 (0-based) — the last row of the
|
||
// window's rect, which is text_rows-1 = 22.
|
||
let mode_row = row_text(&cells, stride, 22, 80);
|
||
assert!(
|
||
mode_row.contains("L1:C1"),
|
||
"mode line missing L:C: {mode_row:?}"
|
||
);
|
||
assert!(
|
||
mode_row.contains("Top"),
|
||
"mode line missing scroll indicator: {mode_row:?}"
|
||
);
|
||
}
|
||
|
||
/// The global status row is now pure echo area: when there's no
|
||
/// status message, no Lua error, and no pending key prefix, the
|
||
/// row renders as blanks.
|
||
#[test]
|
||
fn empty_status_row_is_blank() {
|
||
let s = fresh_with(b"hello\n");
|
||
let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 80);
|
||
assert_eq!(line, "", "status row should be empty when nothing to say");
|
||
}
|
||
|
||
#[test]
|
||
fn captured_lua_error_appears_in_status_line() {
|
||
let mut s = fresh_with(b"");
|
||
let _ = s.lua_host.eval(Some("usercfg"), "error('kapow')");
|
||
let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200);
|
||
assert!(line.contains("lua: "), "status line: {line}");
|
||
assert!(line.contains("kapow"), "status line: {line}");
|
||
}
|
||
|
||
#[test]
|
||
fn multiline_status_is_flattened_to_one_line() {
|
||
// Regression: M-x with an unknown command stored a multi-line
|
||
// traceback in `core.status`. The renderer copied each char into
|
||
// a cell, the frontend emitted literal `\n` bytes, and the frame
|
||
// was shredded. Sanitization must keep the informative first
|
||
// line and replace control chars in it with spaces.
|
||
let s = fresh_with(b"");
|
||
s.core.borrow_mut().status =
|
||
"M-x error: command \"foo\" not found\nstack traceback:\n\t[C]: in ?".into();
|
||
let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200);
|
||
assert!(!line.contains('\n'), "status line leaked newline: {line:?}");
|
||
assert!(!line.contains('\r'), "status line leaked CR: {line:?}");
|
||
assert!(
|
||
line.contains("command \"foo\" not found"),
|
||
"first line dropped: {line}"
|
||
);
|
||
assert!(
|
||
!line.contains("traceback"),
|
||
"traceback should be truncated: {line}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn captured_lua_error_with_traceback_does_not_break_status_line() {
|
||
let mut s = fresh_with(b"");
|
||
let _ = s
|
||
.lua_host
|
||
.eval(Some("usercfg"), "error('boom\\nlots\\nof\\nlines')");
|
||
let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200);
|
||
assert!(!line.contains('\n'), "status line leaked newline: {line:?}");
|
||
assert!(line.contains("lua: "), "status line: {line}");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_status_takes_priority_over_lua_error() {
|
||
let mut s = fresh_with(b"");
|
||
let _ = s.lua_host.eval(None, "error('latent')");
|
||
s.core.borrow_mut().status = "saved foo".into();
|
||
let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200);
|
||
assert!(line.contains("saved foo"));
|
||
assert!(!line.contains("lua: "));
|
||
}
|
||
|
||
// ---- User override of a binding -----------------------------------------
|
||
|
||
#[test]
|
||
fn user_can_unbind_and_rebind_a_chord() {
|
||
let mut s = fresh_with(b"");
|
||
// Replace C-a with editor.cancel to verify a config-style override
|
||
// takes effect on the live dispatch path.
|
||
s.lua_host
|
||
.eval(
|
||
Some("user-override"),
|
||
r#"
|
||
pmacs.keymap.unbind { scope = "global", sequence = "C-a" }
|
||
pmacs.keymap.bind { scope = "global", sequence = "C-a", command = "editor.cancel" }
|
||
"#,
|
||
)
|
||
.expect("override must succeed");
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('a'));
|
||
assert_eq!(s.core.borrow().status, "Quit");
|
||
}
|
||
|
||
// ---- T M2.11 acceptance --------------------------------------------------
|
||
|
||
/// Every chord in the default global keymap must round-trip through
|
||
/// `pmacs.describe.key`: returning a non-nil table whose `command`
|
||
/// matches the binding the keymap stack stores.
|
||
#[test]
|
||
fn describe_key_identifies_every_default_binding() {
|
||
let s = EditorState::new();
|
||
let kms = s.lua_host.keymaps().borrow();
|
||
let bindings: Vec<(String, String)> = kms
|
||
.iter_all()
|
||
.into_iter()
|
||
.map(|(_, seq, b)| (crate::key::display_sequence(&seq), b.command))
|
||
.collect();
|
||
drop(kms);
|
||
// Sanity floor: the default keymap binds at least the M1 surface.
|
||
assert!(
|
||
bindings.len() >= 20,
|
||
"default keymap unexpectedly small: {} bindings",
|
||
bindings.len()
|
||
);
|
||
|
||
for (seq, expected_command) in &bindings {
|
||
let script = format!(
|
||
"local r = pmacs.describe.key({seq:?}); \
|
||
if r == nil then return 'nil' else return r.command end"
|
||
);
|
||
let got: String = s.lua_host.lua().load(&script).eval().unwrap_or_else(|e| {
|
||
panic!("describe.key({seq}) raised: {e}");
|
||
});
|
||
assert_eq!(
|
||
&got, expected_command,
|
||
"describe.key for {seq:?} returned {got:?}, expected {expected_command:?}"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// `pmacs.help.show_command` must populate a real buffer named
|
||
/// `*help*` in the registry --- the spec requires it to be a regular
|
||
/// buffer (cross-references navigable once buffer-switching lands).
|
||
#[test]
|
||
fn help_buffer_is_a_regular_buffer_in_the_registry() {
|
||
let s = EditorState::new();
|
||
let buf_id: Option<crate::lua_bindings::BufferIdLua> = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return pmacs.help.show_command('cursor.left')")
|
||
.eval()
|
||
.unwrap();
|
||
let buf_id = buf_id.expect("help buffer returned");
|
||
let reg = s.lua_host.registry().borrow();
|
||
let buf = reg.get(buf_id.0).expect("help buffer present");
|
||
assert_eq!(buf.name(), crate::help::HELP_BUFFER_NAME);
|
||
let mut bytes = vec![0u8; buf.len() as usize];
|
||
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
|
||
let body = String::from_utf8(bytes).unwrap();
|
||
assert!(body.contains("Command: cursor.left"));
|
||
// Cross-references back into the help system.
|
||
assert!(body.contains("[key:"), "no key cross-ref: {body}");
|
||
}
|
||
|
||
/// `pmacs.help.follow_link` chases a `[key: ...]` cross-reference
|
||
/// and re-renders the help buffer with that key's description.
|
||
#[test]
|
||
fn help_follow_link_navigates_command_to_key() {
|
||
let s = EditorState::new();
|
||
// Render `cursor.left`, then find a `[key: ...]` token in the
|
||
// help body and follow it.
|
||
let cursor: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.help.show_command("cursor.left")
|
||
local list = pmacs.buffer.list()
|
||
local help_id
|
||
for _, id in ipairs(list) do
|
||
if pmacs.describe.buffer(id).name == "*help*" then
|
||
help_id = id
|
||
end
|
||
end
|
||
assert(help_id ~= nil, "help buffer must exist")
|
||
local body = help_id:slice(0, help_id:len())
|
||
local s, e = body:find("%[key: ")
|
||
assert(s ~= nil, "expected a [key: ...] cross-reference")
|
||
return e
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
let returned: Option<crate::lua_bindings::BufferIdLua> = s
|
||
.lua_host
|
||
.lua()
|
||
.load(format!("return pmacs.help.follow_link({cursor})"))
|
||
.eval()
|
||
.unwrap();
|
||
let id = returned.expect("follow_link should return the re-rendered help buffer");
|
||
let reg = s.lua_host.registry().borrow();
|
||
let buf = reg.get(id.0).unwrap();
|
||
let mut bytes = vec![0u8; buf.len() as usize];
|
||
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
|
||
let body = String::from_utf8(bytes).unwrap();
|
||
assert!(
|
||
body.starts_with("Key: "),
|
||
"follow_link should re-render to a Key: page, got: {body}"
|
||
);
|
||
}
|
||
|
||
/// describe-hook lists callbacks in registration order, even when
|
||
/// the hook subsystem is the M2.11 stub.
|
||
#[test]
|
||
fn describe_hook_round_trip_via_editor_state() {
|
||
let s = EditorState::new();
|
||
let cb_count: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.hook.define { name = "demo", description = "demo hook" }
|
||
pmacs.hook.add("demo", function() end)
|
||
pmacs.hook.add("demo", function() end)
|
||
local d = pmacs.describe.hook("demo")
|
||
return #d.callbacks
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(cb_count, 2);
|
||
}
|
||
|
||
// ---- T M2.6 acceptance --------------------------------------------------
|
||
|
||
/// All three required lifecycle hooks are defined out of the box,
|
||
/// with the spec-mandated kinds.
|
||
#[test]
|
||
fn lifecycle_hooks_defined_with_correct_kinds() {
|
||
let s = EditorState::new();
|
||
let kinds: mlua::Table = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
local out = {}
|
||
for _, name in ipairs({
|
||
"buffer.before-save",
|
||
"buffer.after-load",
|
||
"editor.before-quit",
|
||
}) do
|
||
local d = pmacs.describe.hook(name)
|
||
assert(d ~= nil, name .. " not defined")
|
||
out[name] = d.kind
|
||
end
|
||
return out
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(
|
||
kinds.get::<String>("buffer.before-save").unwrap(),
|
||
"short-circuit"
|
||
);
|
||
assert_eq!(
|
||
kinds.get::<String>("buffer.after-load").unwrap(),
|
||
"all-must-succeed"
|
||
);
|
||
assert_eq!(
|
||
kinds.get::<String>("editor.before-quit").unwrap(),
|
||
"short-circuit"
|
||
);
|
||
}
|
||
|
||
// ---- M4.12 buffer.after-edit / buffer.after-save -----------------------
|
||
|
||
/// `buffer.after-edit` and `buffer.after-save` ship as part of the
|
||
/// default lifecycle vocabulary so LSP wiring (and any user hook)
|
||
/// can subscribe without the editor having to register them.
|
||
#[test]
|
||
fn m4_12_after_edit_and_after_save_hooks_defined() {
|
||
let s = EditorState::new();
|
||
let kinds: mlua::Table = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
local out = {}
|
||
for _, name in ipairs({"buffer.after-edit", "buffer.after-save"}) do
|
||
local d = pmacs.describe.hook(name)
|
||
assert(d ~= nil, name .. " not defined")
|
||
out[name] = d.kind
|
||
end
|
||
return out
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(
|
||
kinds.get::<String>("buffer.after-edit").unwrap(),
|
||
"all-must-succeed"
|
||
);
|
||
assert_eq!(
|
||
kinds.get::<String>("buffer.after-save").unwrap(),
|
||
"all-must-succeed"
|
||
);
|
||
}
|
||
|
||
/// A self-insert keystroke fires `buffer.after-edit` exactly once.
|
||
#[test]
|
||
fn m4_12_after_edit_fires_on_self_insert() {
|
||
let mut s = fresh_with(b"");
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.edit_count = 0
|
||
pmacs.hook.add("buffer.after-edit", function()
|
||
_G.edit_count = _G.edit_count + 1
|
||
end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('a'), KeyModifiers::NONE),
|
||
);
|
||
let n: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.edit_count")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(n, 1, "expected 1 after-edit per typed char, got {n}");
|
||
}
|
||
|
||
/// Cursor motion does not fire `buffer.after-edit`.
|
||
#[test]
|
||
fn m4_12_after_edit_does_not_fire_on_motion() {
|
||
let mut s = fresh_with(b"hello");
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.edit_count = 0
|
||
pmacs.hook.add("buffer.after-edit", function()
|
||
_G.edit_count = _G.edit_count + 1
|
||
end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('f'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('b'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('a'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('e'));
|
||
let n: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.edit_count")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(n, 0, "motion fired after-edit unexpectedly");
|
||
}
|
||
|
||
/// Undo and redo each fire `buffer.after-edit` because they mutate
|
||
/// the buffer state.
|
||
#[test]
|
||
fn m4_12_after_edit_fires_on_undo_and_redo() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('x'), KeyModifiers::NONE),
|
||
);
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.edit_count = 0
|
||
pmacs.hook.add("buffer.after-edit", function()
|
||
_G.edit_count = _G.edit_count + 1
|
||
end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('/'), KeyModifiers::CONTROL),
|
||
);
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(
|
||
KeyCode::Char('?'),
|
||
KeyModifiers::CONTROL | KeyModifiers::SHIFT,
|
||
),
|
||
);
|
||
let n: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.edit_count")
|
||
.eval()
|
||
.unwrap();
|
||
assert!(
|
||
n >= 1,
|
||
"expected at least one undo-driven after-edit, got {n}"
|
||
);
|
||
}
|
||
|
||
/// A successful save fires `buffer.after-save` exactly once.
|
||
#[test]
|
||
fn m4_12_after_save_fires_on_successful_save() {
|
||
let dir = tempfile::TempDir::new().unwrap();
|
||
let path = dir.path().join("save_hook.txt");
|
||
std::fs::write(&path, b"x").unwrap();
|
||
let mut s = EditorState::open(path.clone()).unwrap();
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.save_count = 0
|
||
pmacs.hook.add("buffer.after-save", function()
|
||
_G.save_count = _G.save_count + 1
|
||
end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
// Type a char so the save is non-trivial, then save.
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::End, KeyModifiers::NONE));
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('y'), KeyModifiers::NONE),
|
||
);
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
|
||
let n: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.save_count")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(n, 1, "expected 1 after-save, got {n}");
|
||
assert_eq!(std::fs::read(&path).unwrap(), b"xy");
|
||
}
|
||
|
||
/// A vetoed save does not fire `buffer.after-save`.
|
||
#[test]
|
||
fn m4_12_after_save_does_not_fire_when_save_vetoed() {
|
||
let dir = tempfile::TempDir::new().unwrap();
|
||
let path = dir.path().join("vetoed.txt");
|
||
std::fs::write(&path, b"x").unwrap();
|
||
let mut s = EditorState::open(path.clone()).unwrap();
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.save_count = 0
|
||
pmacs.hook.add("buffer.before-save", function() return false end)
|
||
pmacs.hook.add("buffer.after-save", function()
|
||
_G.save_count = _G.save_count + 1
|
||
end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
|
||
let n: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.save_count")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(n, 0);
|
||
}
|
||
|
||
/// `buffer.before-save` can veto a save: when a callback returns
|
||
/// false, `pmacs.editor.save()` is never reached.
|
||
#[test]
|
||
fn before_save_hook_can_veto() {
|
||
let dir = tempfile::TempDir::new().unwrap();
|
||
let path = dir.path().join("guard.txt");
|
||
std::fs::write(&path, b"original").unwrap();
|
||
let mut s = EditorState::open(path.clone()).unwrap();
|
||
// Mutate so the save would visibly happen (different bytes).
|
||
s.core.borrow_mut().active_window_mut().cursor = 8;
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('X'), KeyModifiers::NONE),
|
||
);
|
||
// Attach a vetoing callback before triggering save.
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
pmacs.hook.add("buffer.before-save", function() return false end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('s'));
|
||
// File on disk should still match the original content.
|
||
let on_disk = std::fs::read(&path).unwrap();
|
||
assert_eq!(on_disk, b"original");
|
||
assert!(s.core.borrow().status.contains("vetoed"));
|
||
}
|
||
|
||
/// `editor.before-quit` can veto quitting.
|
||
#[test]
|
||
fn before_quit_hook_can_veto() {
|
||
let mut s = fresh_with(b"");
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
pmacs.hook.add("editor.before-quit", function() return false end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('c'));
|
||
assert!(!s.core.borrow().quit, "veto should have prevented quit");
|
||
assert!(s.core.borrow().status.contains("vetoed"));
|
||
}
|
||
|
||
/// `process.after-tick` is defined out of the box and shipped with
|
||
/// `kind = "all-must-succeed"` so multiple subscribers (REPL handles,
|
||
/// future packages) can attach independently. Listed here because
|
||
/// the M6.5 contract is "the hook exists, it's fireable, the run
|
||
/// loop fires it once per `tick_processes`."
|
||
#[test]
|
||
fn m6_5_process_after_tick_hook_defined_with_correct_kind() {
|
||
let s = EditorState::new();
|
||
let kind: String = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
local d = pmacs.describe.hook("process.after-tick")
|
||
assert(d ~= nil, "process.after-tick not defined")
|
||
return d.kind
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(kind, "all-must-succeed");
|
||
}
|
||
|
||
/// Each call to `tick_processes` fires `process.after-tick` exactly
|
||
/// once. The REPL package's per-frame event pump (T M6.5) depends on
|
||
/// this 1:1 cadence to drain `pmacs.process.events_take` for every
|
||
/// registered handle without missing a frame.
|
||
#[test]
|
||
fn m6_5_tick_processes_fires_after_tick_hook_once_per_call() {
|
||
let mut s = EditorState::new();
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.tick_count = 0
|
||
pmacs.hook.add("process.after-tick", function()
|
||
_G.tick_count = _G.tick_count + 1
|
||
end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
for _ in 0..5 {
|
||
s.tick_processes();
|
||
}
|
||
let n: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.tick_count")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(n, 5, "expected 1 after-tick per tick_processes, got {n}");
|
||
}
|
||
|
||
/// When a file is opened, the after-load hook is fired. Test
|
||
/// shape: register a listener then fire `run_hook` directly, since
|
||
/// `EditorState::open` constructs its own host (so we can't
|
||
/// pre-attach). This covers the Rust-side wiring path.
|
||
#[test]
|
||
fn after_load_hook_fires_with_loaded_buffer_visible() {
|
||
let mut s = EditorState::new();
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.after_load_count = 0
|
||
pmacs.hook.add("buffer.after-load", function()
|
||
_G.after_load_count = _G.after_load_count + 1
|
||
end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
let outcome = s
|
||
.lua_host
|
||
.run_hook("buffer.after-load", mlua::MultiValue::new())
|
||
.expect("hook is defined");
|
||
assert!(outcome.proceed);
|
||
let n: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return _G.after_load_count")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(n, 1);
|
||
}
|
||
|
||
/// describe-hook reports the kind, source, and callbacks in
|
||
/// registration order. Satisfies the M2.6 acceptance bullet on
|
||
/// `describe-hook` listing attached functions with source
|
||
/// locations.
|
||
#[test]
|
||
fn describe_hook_reports_kind_and_source_locations() {
|
||
let s = EditorState::new();
|
||
let info: mlua::Table = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.hook.add("buffer.before-save", function() return true end)
|
||
pmacs.hook.add("buffer.before-save", function() return true end)
|
||
return pmacs.describe.hook("buffer.before-save")
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(info.get::<String>("kind").unwrap(), "short-circuit");
|
||
assert!(info.get::<String>("source").unwrap().contains(':'));
|
||
let callbacks: mlua::Table = info.get("callbacks").unwrap();
|
||
let len = callbacks.len().unwrap();
|
||
assert_eq!(len, 2, "expected 2 callbacks; describe says {len}");
|
||
let cb1: mlua::Table = callbacks.get(1).unwrap();
|
||
let cb2: mlua::Table = callbacks.get(2).unwrap();
|
||
let s1: String = cb1.get("source").unwrap();
|
||
let s2: String = cb2.get("source").unwrap();
|
||
// Both registrations come from the test chunk; the second
|
||
// must report a strictly later line.
|
||
let line = |s: &str| -> i32 {
|
||
s.rsplit_once(':')
|
||
.and_then(|(_, n)| n.parse().ok())
|
||
.unwrap_or(0)
|
||
};
|
||
assert!(line(&s1) < line(&s2), "source lines: {s1} vs {s2}");
|
||
}
|
||
|
||
/// Composition kind: short-circuit. A `false` from the first
|
||
/// callback prevents later callbacks from running.
|
||
#[test]
|
||
fn short_circuit_kind_stops_at_first_false() {
|
||
let s = EditorState::new();
|
||
let count: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.hook.define {
|
||
name = "demo.sc",
|
||
description = "demo short-circuit",
|
||
kind = "short-circuit",
|
||
}
|
||
_G.hits = 0
|
||
pmacs.hook.add("demo.sc", function() _G.hits = _G.hits + 1; return false end)
|
||
pmacs.hook.add("demo.sc", function() _G.hits = _G.hits + 1; return true end)
|
||
pmacs.hook.run("demo.sc")
|
||
return _G.hits
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(count, 1);
|
||
}
|
||
|
||
/// Composition kind: all-must-succeed. Every callback runs even if
|
||
/// an earlier one raises.
|
||
#[test]
|
||
fn all_must_succeed_kind_runs_every_callback() {
|
||
let s = EditorState::new();
|
||
let (proceed, hits): (bool, i64) = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.hook.define {
|
||
name = "demo.ams",
|
||
description = "demo all-must-succeed",
|
||
kind = "all-must-succeed",
|
||
}
|
||
_G.hits = 0
|
||
pmacs.hook.add("demo.ams", function() error('boom') end)
|
||
pmacs.hook.add("demo.ams", function() _G.hits = _G.hits + 1 end)
|
||
pmacs.hook.add("demo.ams", function() _G.hits = _G.hits + 1 end)
|
||
local ok = pmacs.hook.run("demo.ams")
|
||
return ok, _G.hits
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
assert!(!proceed, "errors must surface as a non-proceed return");
|
||
assert_eq!(hits, 2, "every non-failing callback must still run");
|
||
}
|
||
|
||
/// Composition kind: accumulate. Each callback receives the
|
||
/// previous return as its first argument.
|
||
#[test]
|
||
fn accumulate_kind_threads_value() {
|
||
let s = EditorState::new();
|
||
let final_value: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.hook.define {
|
||
name = "demo.acc",
|
||
description = "demo accumulate",
|
||
kind = "accumulate",
|
||
}
|
||
pmacs.hook.add("demo.acc", function(n) return n + 1 end)
|
||
pmacs.hook.add("demo.acc", function(n) return n * 2 end)
|
||
pmacs.hook.add("demo.acc", function(n) return n - 5 end)
|
||
return pmacs.hook.run("demo.acc", 10)
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
// (((10 + 1) * 2) - 5) = 17
|
||
assert_eq!(final_value, 17);
|
||
}
|
||
|
||
/// Hook callback errors land in the *errors* buffer alongside
|
||
/// chunk-level errors, not on stderr (terminal is in raw mode).
|
||
#[test]
|
||
fn hook_errors_are_captured_to_errors_buffer() {
|
||
let mut s = EditorState::new();
|
||
s.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
pmacs.hook.add("buffer.after-load", function() error('boom from hook') end)
|
||
"#,
|
||
)
|
||
.unwrap();
|
||
s.lua_host
|
||
.run_hook("buffer.after-load", mlua::MultiValue::new());
|
||
let id = s
|
||
.lua_host
|
||
.errors_buffer_id()
|
||
.expect("errors buffer present");
|
||
let reg = s.lua_host.registry().borrow();
|
||
let buf = reg.get(id).unwrap();
|
||
let mut bytes = vec![0u8; buf.len() as usize];
|
||
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
|
||
let body = String::from_utf8(bytes).unwrap();
|
||
assert!(
|
||
body.contains("hook:buffer.after-load") && body.contains("boom from hook"),
|
||
"errors body: {body}"
|
||
);
|
||
}
|
||
|
||
// ---- T M2.7 acceptance --------------------------------------------------
|
||
|
||
fn alt(c: char) -> KeyEvent {
|
||
key(KeyCode::Char(c), KeyModifiers::ALT)
|
||
}
|
||
|
||
/// Bullet 1: the minibuffer's contents are stored in a real Buffer
|
||
/// with a real `TextView`. No special path.
|
||
#[test]
|
||
fn minibuffer_uses_standard_rope_and_view_machinery() {
|
||
let s = EditorState::new();
|
||
let core = s.core.borrow();
|
||
// `Buffer::name` and `Buffer::len` are the same query surface
|
||
// every other buffer exposes.
|
||
assert_eq!(core.minibuffer.buffer.name(), "*minibuffer*");
|
||
assert_eq!(core.minibuffer.buffer.len(), 0);
|
||
// `TextView::line_count` is the same TextView API the main
|
||
// buffer uses.
|
||
assert_eq!(core.minibuffer.text_view.line_count(), 1);
|
||
}
|
||
|
||
#[test]
|
||
fn errors_buffer_window_textview_stays_in_sync_after_appends() {
|
||
// Regression: when a window displays the *errors* buffer and a
|
||
// new Lua error appends content via `LuaHost::append_to_errors_buffer`,
|
||
// the window's TextView must see the edit. Otherwise its line
|
||
// cache goes stale: `line_count` returns the old count, cursor
|
||
// motions land in unmappable positions, and the screen appears
|
||
// frozen until something else triggers a buffer switch.
|
||
let mut s = fresh_with(b"");
|
||
// Provoke a first error so the *errors* buffer exists.
|
||
let _ = s.lua_host.eval(Some("first"), "error('alpha')");
|
||
let errors_id = s
|
||
.lua_host
|
||
.errors_buffer_id()
|
||
.expect("first error created the buffer");
|
||
// Switch the active window to *errors*.
|
||
s.core.borrow_mut().switch_active_buffer(errors_id).unwrap();
|
||
let lines_before = s.core.borrow().active_window().text_view.line_count();
|
||
// Provoke a second error while the window is on *errors*.
|
||
let _ = s.lua_host.eval(Some("second"), "error('beta\\ngamma')");
|
||
let lines_after = s.core.borrow().active_window().text_view.line_count();
|
||
assert!(
|
||
lines_after > lines_before,
|
||
"TextView line count did not grow: before={lines_before} after={lines_after}"
|
||
);
|
||
// The view's line index must reach the end of the buffer (a
|
||
// trailing newline yields one extra empty line, so the last
|
||
// offset can equal `buffer.len()` but never exceed it).
|
||
let buf_len = s.core.borrow().active_buffer_len();
|
||
let last_offset = s
|
||
.core
|
||
.borrow()
|
||
.active_window()
|
||
.text_view
|
||
.line_offset(lines_after - 1)
|
||
.unwrap();
|
||
assert!(
|
||
last_offset <= buf_len,
|
||
"last cached line offset {last_offset} exceeds buffer length {buf_len}"
|
||
);
|
||
// The pre-append last offset would be smaller than the post-
|
||
// append buffer length; if the view weren't notified, the new
|
||
// content would be unreachable.
|
||
let pre_append_max_offset = s
|
||
.core
|
||
.borrow()
|
||
.active_window()
|
||
.text_view
|
||
.line_offset(lines_before - 1)
|
||
.unwrap();
|
||
assert!(
|
||
last_offset > pre_append_max_offset,
|
||
"view did not advance past pre-append last offset"
|
||
);
|
||
}
|
||
|
||
/// Bullet 2: `M-x` opens a fuzzy-completing prompt over every
|
||
/// registered command. Typing a fragment narrows the candidate
|
||
/// list; accepting invokes the chosen command.
|
||
#[test]
|
||
fn m_x_with_fuzzy_completion_runs_a_command() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, alt('x'));
|
||
assert!(s.core.borrow().minibuffer.is_active());
|
||
// Type "edcan" --- the subsequence ranks editor.cancel
|
||
// strictly above editor.execute-command (editor.cancel has
|
||
// every needle char consecutive after the word-boundary `.`,
|
||
// which scores far higher than the wide gaps in
|
||
// editor.execute-command).
|
||
for c in "edcan".chars() {
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE));
|
||
}
|
||
let cands = s
|
||
.core
|
||
.borrow()
|
||
.minibuffer
|
||
.session
|
||
.as_ref()
|
||
.unwrap()
|
||
.candidates
|
||
.clone();
|
||
assert!(!cands.is_empty(), "expected at least one candidate");
|
||
assert_eq!(cands[0], "editor.cancel", "candidates: {cands:?}");
|
||
// Accept (RET).
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter));
|
||
// editor.cancel sets status = "Quit".
|
||
assert_eq!(s.core.borrow().status, "Quit");
|
||
assert!(!s.core.borrow().minibuffer.is_active());
|
||
}
|
||
|
||
#[test]
|
||
fn m_x_unknown_command_does_not_corrupt_status_line() {
|
||
// The exact failure mode reported from a real run: M-x with a
|
||
// name that does not resolve to any command. mlua's
|
||
// `tostring(err)` returns a multi-line traceback; the on_accept
|
||
// handler in default.lua takes only the first line, and the
|
||
// Rust-side status renderer sanitizes again at the cell-grid
|
||
// boundary. Both raw and rendered status must be single-line.
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, alt('x'));
|
||
assert!(s.core.borrow().minibuffer.is_active());
|
||
for c in "definitely-not-a-command".chars() {
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE));
|
||
}
|
||
// Force-accept the typed text instead of any fuzzy candidate
|
||
// (matches the runtime path when no candidate is highlighted).
|
||
{
|
||
let mut core = s.core.borrow_mut();
|
||
if let Some(session) = core.minibuffer.session.as_mut() {
|
||
session.selected = None;
|
||
session.candidates.clear();
|
||
}
|
||
}
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Enter));
|
||
let raw = s.core.borrow().status.clone();
|
||
assert!(
|
||
!raw.contains('\n'),
|
||
"raw status leaked newline: {raw:?} (default.lua should take first line)"
|
||
);
|
||
assert!(raw.starts_with("M-x error: "), "raw status: {raw}");
|
||
let line = build_status_line(&s.core.borrow(), &s.lua_host, &s.dispatcher, 200);
|
||
assert!(
|
||
!line.contains('\n'),
|
||
"rendered status line leaked newline: {line:?} (raw: {raw:?})"
|
||
);
|
||
assert!(
|
||
line.contains("M-x error"),
|
||
"expected M-x error prefix; got: {line}"
|
||
);
|
||
}
|
||
|
||
/// Bullet 3: history persists across "sessions". We test by
|
||
/// pointing the minibuffer at a tempdir, accepting two entries,
|
||
/// then constructing a fresh minibuffer pointed at the same dir
|
||
/// and verifying the entries reload from disk.
|
||
#[test]
|
||
fn history_persists_across_sessions_via_dir_injection() {
|
||
use crate::minibuffer::{CompletionSource, History, Minibuffer, MinibufferSession};
|
||
let dir = tempfile::TempDir::new().unwrap();
|
||
let dir_path = dir.path().to_path_buf();
|
||
|
||
let lua = mlua::Lua::new();
|
||
let dummy = lua.create_function(|_, _: String| Ok(())).unwrap();
|
||
let mut mb1 = Minibuffer::new();
|
||
mb1.history_dir = Some(dir_path.clone());
|
||
for entry in ["alpha", "beta"] {
|
||
mb1.begin(MinibufferSession {
|
||
prompt: "P: ".into(),
|
||
initial: String::new(),
|
||
history_bucket: "test".into(),
|
||
source: CompletionSource::None,
|
||
on_accept: dummy.clone(),
|
||
on_cancel: None,
|
||
candidates: Vec::new(),
|
||
selected: None,
|
||
history_index: None,
|
||
typed_before_history_nav: None,
|
||
});
|
||
for c in entry.chars() {
|
||
mb1.insert_char(c);
|
||
}
|
||
mb1.accept().unwrap();
|
||
}
|
||
drop(mb1);
|
||
|
||
// Fresh instance pointed at the same dir: open a session;
|
||
// history is loaded lazily on `begin`. After that, the
|
||
// history bucket should carry both entries.
|
||
let mut mb2 = Minibuffer::new();
|
||
mb2.history_dir = Some(dir_path);
|
||
mb2.begin(MinibufferSession {
|
||
prompt: "P: ".into(),
|
||
initial: String::new(),
|
||
history_bucket: "test".into(),
|
||
source: CompletionSource::None,
|
||
on_accept: dummy,
|
||
on_cancel: None,
|
||
candidates: Vec::new(),
|
||
selected: None,
|
||
history_index: None,
|
||
typed_before_history_nav: None,
|
||
});
|
||
let h: &History = mb2.history.get("test").expect("history loaded");
|
||
let entries: Vec<_> = h.entries.iter().cloned().collect();
|
||
assert_eq!(entries, vec!["alpha".to_string(), "beta".into()]);
|
||
}
|
||
|
||
/// Bullet 4: every named completion source (commands, buffers,
|
||
/// files, custom Lua function) is selectable from `pmacs.minibuffer.read`.
|
||
#[test]
|
||
fn every_completion_source_is_selectable() {
|
||
let s = EditorState::new();
|
||
// commands
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.minibuffer.read {
|
||
prompt = "X: ", source = "commands",
|
||
on_accept = function() end,
|
||
}
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
assert!(matches!(
|
||
s.core.borrow().minibuffer.session.as_ref().unwrap().source,
|
||
crate::minibuffer::CompletionSource::Commands
|
||
));
|
||
s.core.borrow_mut().minibuffer.cancel();
|
||
|
||
// buffers
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.minibuffer.read {
|
||
prompt = "X: ", source = "buffers",
|
||
on_accept = function() end,
|
||
}
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
assert!(matches!(
|
||
s.core.borrow().minibuffer.session.as_ref().unwrap().source,
|
||
crate::minibuffer::CompletionSource::Buffers
|
||
));
|
||
s.core.borrow_mut().minibuffer.cancel();
|
||
|
||
// files
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.minibuffer.read {
|
||
prompt = "X: ", source = "files", source_root = "/tmp",
|
||
on_accept = function() end,
|
||
}
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
assert!(matches!(
|
||
s.core.borrow().minibuffer.session.as_ref().unwrap().source,
|
||
crate::minibuffer::CompletionSource::Files { .. }
|
||
));
|
||
s.core.borrow_mut().minibuffer.cancel();
|
||
|
||
// custom function
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.minibuffer.read {
|
||
prompt = "X: ",
|
||
source = function() return { "alpha", "beta" } end,
|
||
on_accept = function() end,
|
||
}
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
assert!(matches!(
|
||
s.core.borrow().minibuffer.session.as_ref().unwrap().source,
|
||
crate::minibuffer::CompletionSource::Custom(_)
|
||
));
|
||
let cands = s
|
||
.core
|
||
.borrow()
|
||
.minibuffer
|
||
.session
|
||
.as_ref()
|
||
.unwrap()
|
||
.candidates
|
||
.clone();
|
||
assert_eq!(cands, vec!["alpha".to_string(), "beta".into()]);
|
||
}
|
||
|
||
/// `pmacs.minibuffer.read` rejects unknown spec keys per R50.
|
||
#[test]
|
||
fn read_rejects_unknown_spec_keys() {
|
||
let s = EditorState::new();
|
||
let result = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.minibuffer.read {
|
||
prompt = "X: ",
|
||
bogus = true,
|
||
on_accept = function() end,
|
||
}
|
||
"#,
|
||
)
|
||
.exec();
|
||
assert!(result.is_err(), "unknown key should error");
|
||
let msg = result.unwrap_err().to_string();
|
||
assert!(
|
||
msg.contains("unknown field") && msg.contains("bogus"),
|
||
"msg: {msg}"
|
||
);
|
||
}
|
||
|
||
/// `C-g` while a prompt is active cancels the session without
|
||
/// invoking `on_accept`.
|
||
#[test]
|
||
fn cg_cancels_active_prompt() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, alt('x'));
|
||
assert!(s.core.borrow().minibuffer.is_active());
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('g'));
|
||
assert!(!s.core.borrow().minibuffer.is_active());
|
||
assert_eq!(s.core.borrow().status, "Quit");
|
||
}
|
||
|
||
/// TAB on an active session replaces the buffer with the
|
||
/// currently-selected candidate.
|
||
#[test]
|
||
fn tab_completes_to_selected_candidate() {
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, alt('x'));
|
||
for c in "save".chars() {
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::NONE));
|
||
}
|
||
// TAB completes to the top candidate.
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Tab));
|
||
let contents = s.core.borrow().minibuffer.contents();
|
||
assert_eq!(contents, "buffer.save", "minibuffer: {contents:?}");
|
||
}
|
||
|
||
// ---- T M2.8 acceptance --------------------------------------------------
|
||
|
||
/// Bullet 1: 8 splits in a single frame render correctly. We
|
||
/// verify by computing per-window rectangles via the layout and
|
||
/// asserting all are non-empty + non-overlapping for a typical
|
||
/// 24×80 terminal.
|
||
#[test]
|
||
fn eight_splits_render_in_distinct_rects_via_lua_api() {
|
||
let s = EditorState::new();
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
"
|
||
-- Build an 8-way layout: 3 vertical splits then 1
|
||
-- horizontal split per resulting column.
|
||
pmacs.window.split_vertical()
|
||
pmacs.window.focus_next()
|
||
pmacs.window.split_vertical()
|
||
pmacs.window.focus_next()
|
||
pmacs.window.split_vertical()
|
||
pmacs.window.focus_next()
|
||
-- Now 4 columns; horizontal-split each.
|
||
for _ = 1, 4 do
|
||
pmacs.window.split_horizontal()
|
||
pmacs.window.focus_next()
|
||
pmacs.window.focus_next()
|
||
end
|
||
",
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
let core = s.core.borrow();
|
||
assert_eq!(core.windows.len(), 8);
|
||
let area = crate::window::Rect::new(0, 0, 40, 120);
|
||
let placements = core.active_layout().compute(area);
|
||
assert_eq!(placements.len(), 8);
|
||
for r in placements.values() {
|
||
assert!(!r.is_empty(), "rect was empty: {r:?}");
|
||
}
|
||
}
|
||
|
||
/// Bullet 2: focus-next walks the layout deterministically and
|
||
/// returns to the starting window after a full cycle.
|
||
#[test]
|
||
fn focus_next_walks_predictably() {
|
||
let s = EditorState::new();
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
"
|
||
pmacs.window.split_vertical()
|
||
pmacs.window.split_horizontal()
|
||
",
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
let start = s.core.borrow().active_window_id();
|
||
let total = s.core.borrow().windows.len();
|
||
assert_eq!(total, 3);
|
||
for _ in 0..total {
|
||
s.core.borrow_mut().focus_next();
|
||
}
|
||
assert_eq!(s.core.borrow().active_window_id(), start);
|
||
}
|
||
|
||
/// Bullet 3: the buffer-list buffer is a regular Buffer in the
|
||
/// registry. Searchable, addressable, has bytes.
|
||
#[test]
|
||
fn buffer_list_is_a_regular_buffer() {
|
||
let s = EditorState::new();
|
||
// Force allocation of the *help* buffer so the listing has at
|
||
// least two entries.
|
||
let _: Option<crate::lua_bindings::BufferIdLua> = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return pmacs.help.show_command('cursor.left')")
|
||
.eval()
|
||
.unwrap();
|
||
s.lua_host
|
||
.invoke_command("editor.list-buffers", mlua::MultiValue::new())
|
||
.unwrap();
|
||
let id = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow()
|
||
.find_by_name("*buffer-list*")
|
||
.expect("*buffer-list* must exist");
|
||
let reg = s.lua_host.registry().borrow();
|
||
let buf = reg.get(id).unwrap();
|
||
assert!(!buf.is_empty(), "buffer-list should have content");
|
||
let mut bytes = vec![0u8; buf.len() as usize];
|
||
buf.snapshot_rope().slice(0, buf.len(), &mut bytes);
|
||
let body = String::from_utf8(bytes).unwrap();
|
||
assert!(body.contains("*scratch*"), "body: {body}");
|
||
assert!(body.contains("*help*"), "body: {body}");
|
||
}
|
||
|
||
/// `pmacs.buffer.kill` removes a buffer from the registry but
|
||
/// first redirects every window pointing at it to a safe fallback
|
||
/// (the existing `*scratch*` if present), so windows never end up
|
||
/// referring to a missing id.
|
||
#[test]
|
||
fn buffer_kill_redirects_active_window_to_fallback() {
|
||
let s = EditorState::new();
|
||
let doomed = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("doomed.txt", b"hello");
|
||
s.core.borrow_mut().switch_active_buffer(doomed).unwrap();
|
||
assert_eq!(s.core.borrow().active_buffer_name(), "doomed.txt");
|
||
s.lua_host
|
||
.lua()
|
||
.load("pmacs.buffer.kill(...)")
|
||
.call::<()>(crate::lua_bindings::BufferIdLua(doomed))
|
||
.unwrap();
|
||
assert!(
|
||
!s.lua_host.registry().borrow().contains(doomed),
|
||
"buffer should be removed from registry"
|
||
);
|
||
assert_ne!(
|
||
s.core.borrow().active_buffer_id(),
|
||
doomed,
|
||
"active window should have been redirected"
|
||
);
|
||
assert_eq!(
|
||
s.core.borrow().active_buffer_name(),
|
||
"*scratch*",
|
||
"fallback should be *scratch*"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn buffer_kill_fires_on_removed_callbacks() {
|
||
let s = EditorState::new();
|
||
let doomed = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("doomed.txt", b"hello");
|
||
let called: bool = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r"
|
||
local doomed = ...
|
||
local called = false
|
||
pmacs.buffer.on_removed(doomed, function(dead)
|
||
assert(dead == doomed)
|
||
called = true
|
||
end)
|
||
pmacs.buffer.kill(doomed)
|
||
return called
|
||
",
|
||
)
|
||
.call(crate::lua_bindings::BufferIdLua(doomed))
|
||
.unwrap();
|
||
assert!(called, "kill should fire buffer removal callbacks");
|
||
}
|
||
|
||
/// `pmacs.buffer.kill` refuses to remove the last remaining
|
||
/// buffer; the registry must never go empty.
|
||
#[test]
|
||
fn buffer_kill_refuses_last_buffer() {
|
||
let s = EditorState::new();
|
||
// EditorState::new starts with *scratch*. Drop every other
|
||
// buffer (there shouldn't be any, but be defensive) and try to
|
||
// kill the lone survivor.
|
||
let last = s.core.borrow().active_buffer_id();
|
||
let result: mlua::Result<()> = s
|
||
.lua_host
|
||
.lua()
|
||
.load("pmacs.buffer.kill(...)")
|
||
.call(crate::lua_bindings::BufferIdLua(last));
|
||
assert!(
|
||
result.is_err(),
|
||
"kill should refuse the last buffer, got {result:?}"
|
||
);
|
||
assert!(
|
||
s.lua_host.registry().borrow().contains(last),
|
||
"buffer must remain after refused kill"
|
||
);
|
||
}
|
||
|
||
/// Inside `*buffer-list*`, RET (bound to `editor.buffer-list-visit`)
|
||
/// switches the active window to the buffer named on the cursor's
|
||
/// line. Drives the path through `editor.list-buffers` to set up
|
||
/// the line-to-buffer mapping, then `move_down` once more to land
|
||
/// on the second data line, then visits.
|
||
#[test]
|
||
fn buffer_list_visit_switches_to_buffer_at_cursor() {
|
||
let s = EditorState::new();
|
||
let _ = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("target.txt", b"x");
|
||
s.lua_host
|
||
.invoke_command("editor.list-buffers", mlua::MultiValue::new())
|
||
.unwrap();
|
||
// After list-buffers, the cursor sits on data line 1 (the
|
||
// first registered buffer, i.e. *scratch*). Walk down until we
|
||
// land on `target.txt`.
|
||
let mut hops = 0;
|
||
loop {
|
||
let line: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return pmacs.editor.cursor_line()")
|
||
.eval()
|
||
.unwrap();
|
||
assert!(line >= 1, "cursor should be on a data line");
|
||
let name_at_cursor = s.lua_host.lua()
|
||
.load("local i = pmacs.editor.cursor_line(); local ids = pmacs.buffer.list(); local nth = 1; for _, id in ipairs(ids) do if pmacs.describe.buffer(id).name == '*buffer-list*' then else if nth == i then return pmacs.describe.buffer(id).name end; nth = nth + 1 end end")
|
||
.eval::<Option<String>>().unwrap();
|
||
if name_at_cursor.as_deref() == Some("target.txt") {
|
||
break;
|
||
}
|
||
s.lua_host
|
||
.invoke_command("cursor.down", mlua::MultiValue::new())
|
||
.unwrap();
|
||
hops += 1;
|
||
assert!(hops < 32, "couldn't find target.txt in buffer list");
|
||
}
|
||
s.lua_host
|
||
.invoke_command("editor.buffer-list-visit", mlua::MultiValue::new())
|
||
.unwrap();
|
||
assert_eq!(s.core.borrow().active_buffer_name(), "target.txt");
|
||
}
|
||
|
||
#[test]
|
||
fn editor_move_to_line_positions_cursor_by_zero_based_line() {
|
||
let s = fresh_with(b"alpha\nbeta\ngamma");
|
||
s.lua_host
|
||
.lua()
|
||
.load("pmacs.editor.move_to_line(1)")
|
||
.exec()
|
||
.unwrap();
|
||
assert_eq!(s.core.borrow().cursor_line(), 1);
|
||
assert_eq!(s.core.borrow().cursor(), 6);
|
||
|
||
s.lua_host
|
||
.lua()
|
||
.load("pmacs.editor.move_to_line(99)")
|
||
.exec()
|
||
.unwrap();
|
||
assert_eq!(s.core.borrow().cursor_line(), 2);
|
||
assert_eq!(s.core.borrow().cursor(), 11);
|
||
}
|
||
|
||
/// `editor.next-buffer` walks the active window through the
|
||
/// buffer registry in order, wrapping past the end. Three buffers
|
||
/// in registry order: walking next four times returns to the
|
||
/// starting buffer.
|
||
#[test]
|
||
fn next_buffer_cycles_through_registry_with_wrap() {
|
||
let s = EditorState::new(); // creates *scratch*
|
||
let a = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("a.txt", b"x");
|
||
let b = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("b.txt", b"y");
|
||
// Start on *scratch*. The registry order is [scratch, a, b].
|
||
let names: Vec<String> = (0..4)
|
||
.map(|_| {
|
||
s.lua_host
|
||
.invoke_command("editor.next-buffer", mlua::MultiValue::new())
|
||
.unwrap();
|
||
s.core.borrow().active_buffer_name()
|
||
})
|
||
.collect();
|
||
assert_eq!(
|
||
names,
|
||
vec![
|
||
"a.txt".to_string(),
|
||
"b.txt".to_string(),
|
||
"*scratch*".to_string(),
|
||
"a.txt".to_string(),
|
||
],
|
||
"next-buffer should cycle scratch -> a -> b -> scratch -> a"
|
||
);
|
||
// Cleanup so the test is self-contained.
|
||
let _ = s.lua_host.registry().borrow_mut().remove(a);
|
||
let _ = s.lua_host.registry().borrow_mut().remove(b);
|
||
}
|
||
|
||
/// `editor.previous-buffer` walks the registry backward, wrapping
|
||
/// past the start.
|
||
#[test]
|
||
fn previous_buffer_cycles_backward_with_wrap() {
|
||
let s = EditorState::new();
|
||
s.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("a.txt", b"x");
|
||
s.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("b.txt", b"y");
|
||
// From *scratch*, previous wraps to b.txt (last in registry).
|
||
s.lua_host
|
||
.invoke_command("editor.previous-buffer", mlua::MultiValue::new())
|
||
.unwrap();
|
||
assert_eq!(s.core.borrow().active_buffer_name(), "b.txt");
|
||
s.lua_host
|
||
.invoke_command("editor.previous-buffer", mlua::MultiValue::new())
|
||
.unwrap();
|
||
assert_eq!(s.core.borrow().active_buffer_name(), "a.txt");
|
||
s.lua_host
|
||
.invoke_command("editor.previous-buffer", mlua::MultiValue::new())
|
||
.unwrap();
|
||
assert_eq!(s.core.borrow().active_buffer_name(), "*scratch*");
|
||
}
|
||
|
||
/// With only one buffer in the registry, both cycling commands
|
||
/// are no-ops.
|
||
#[test]
|
||
fn buffer_cycling_is_noop_with_one_buffer() {
|
||
let s = EditorState::new(); // only *scratch*
|
||
let before = s.core.borrow().active_buffer_name();
|
||
s.lua_host
|
||
.invoke_command("editor.next-buffer", mlua::MultiValue::new())
|
||
.unwrap();
|
||
assert_eq!(s.core.borrow().active_buffer_name(), before);
|
||
s.lua_host
|
||
.invoke_command("editor.previous-buffer", mlua::MultiValue::new())
|
||
.unwrap();
|
||
assert_eq!(s.core.borrow().active_buffer_name(), before);
|
||
}
|
||
|
||
/// `mark-delete` and `unmark` re-seat the cursor on the same line
|
||
/// after the wholesale buffer rewrite, then advance one row
|
||
/// (Emacs's `Buffer-menu-mark` semantics). Without the re-seat,
|
||
/// the cursor would dangle at a stale byte offset across the
|
||
/// rewrite. This test asserts the row-advance contract.
|
||
#[test]
|
||
fn buffer_list_mark_advances_cursor_one_row() {
|
||
let s = EditorState::new();
|
||
let _ = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("a.txt", b"x");
|
||
let _ = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("b.txt", b"x");
|
||
s.lua_host
|
||
.invoke_command("editor.list-buffers", mlua::MultiValue::new())
|
||
.unwrap();
|
||
let line_before: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return pmacs.editor.cursor_line()")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(line_before, 1, "should land on first data line");
|
||
s.lua_host
|
||
.invoke_command("editor.buffer-list-mark-delete", mlua::MultiValue::new())
|
||
.unwrap();
|
||
let line_after: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return pmacs.editor.cursor_line()")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(line_after, 2, "mark-delete should advance one row");
|
||
s.lua_host
|
||
.invoke_command("editor.buffer-list-unmark", mlua::MultiValue::new())
|
||
.unwrap();
|
||
let line_after_unmark: i64 = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return pmacs.editor.cursor_line()")
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(line_after_unmark, 3, "unmark should also advance one row");
|
||
}
|
||
|
||
/// `editor.buffer-list-mark-delete` followed by
|
||
/// `editor.buffer-list-execute` removes the marked buffer from the
|
||
/// registry. The active window (showing `*buffer-list*`) is left
|
||
/// alone since the kill only targeted a different buffer.
|
||
#[test]
|
||
fn buffer_list_mark_then_execute_removes_marked_buffers() {
|
||
let s = EditorState::new();
|
||
let doomed = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow_mut()
|
||
.create_from_bytes("doomed.txt", b"x");
|
||
s.lua_host
|
||
.invoke_command("editor.list-buffers", mlua::MultiValue::new())
|
||
.unwrap();
|
||
// Walk the cursor to the doomed.txt row.
|
||
let mut hops = 0;
|
||
loop {
|
||
let name_at_cursor = s.lua_host.lua()
|
||
.load("local i = pmacs.editor.cursor_line(); local ids = pmacs.buffer.list(); local nth = 1; for _, id in ipairs(ids) do if pmacs.describe.buffer(id).name == '*buffer-list*' then else if nth == i then return pmacs.describe.buffer(id).name end; nth = nth + 1 end end")
|
||
.eval::<Option<String>>().unwrap();
|
||
if name_at_cursor.as_deref() == Some("doomed.txt") {
|
||
break;
|
||
}
|
||
s.lua_host
|
||
.invoke_command("cursor.down", mlua::MultiValue::new())
|
||
.unwrap();
|
||
hops += 1;
|
||
assert!(hops < 32, "couldn't reach doomed.txt");
|
||
}
|
||
s.lua_host
|
||
.invoke_command("editor.buffer-list-mark-delete", mlua::MultiValue::new())
|
||
.unwrap();
|
||
s.lua_host
|
||
.invoke_command("editor.buffer-list-execute", mlua::MultiValue::new())
|
||
.unwrap();
|
||
assert!(
|
||
!s.lua_host.registry().borrow().contains(doomed),
|
||
"doomed.txt should have been killed by execute"
|
||
);
|
||
assert_eq!(
|
||
s.core.borrow().active_buffer_name(),
|
||
"*buffer-list*",
|
||
"active window should still be on *buffer-list*"
|
||
);
|
||
}
|
||
|
||
/// Bullet 4: SIGWINCH-equivalent (recomputing layout against a
|
||
/// new area) preserves split ratios.
|
||
#[test]
|
||
fn resize_preserves_split_ratios() {
|
||
let s = EditorState::new();
|
||
s.lua_host
|
||
.lua()
|
||
.load("pmacs.window.split_vertical()")
|
||
.exec()
|
||
.unwrap();
|
||
// Set a 2:1 weight on the root split.
|
||
if let crate::window::LayoutNode::Split { weights, .. } =
|
||
&mut s.core.borrow_mut().active_layout_mut().root
|
||
{
|
||
*weights = vec![2, 1];
|
||
} else {
|
||
panic!("expected split");
|
||
}
|
||
let p1 = s
|
||
.core
|
||
.borrow()
|
||
.active_layout()
|
||
.compute(crate::window::Rect::new(0, 0, 24, 90));
|
||
let p2 = s
|
||
.core
|
||
.borrow()
|
||
.active_layout()
|
||
.compute(crate::window::Rect::new(0, 0, 24, 60));
|
||
// Both should preserve the 2:1 ratio. Find the two windows
|
||
// and verify the larger:smaller ratio is 2:1 in both.
|
||
let wider1 = p1.values().map(|r| r.size.cols).max().unwrap();
|
||
let narrower1 = p1.values().map(|r| r.size.cols).min().unwrap();
|
||
assert_eq!(wider1 / narrower1, 2);
|
||
let wider2 = p2.values().map(|r| r.size.cols).max().unwrap();
|
||
let narrower2 = p2.values().map(|r| r.size.cols).min().unwrap();
|
||
assert_eq!(wider2 / narrower2, 2);
|
||
}
|
||
|
||
/// Edits in one window propagate to all windows on the same
|
||
/// buffer (multi-window `TextView` coherence).
|
||
#[test]
|
||
fn edits_in_one_window_visible_in_another_on_same_buffer() {
|
||
let mut s = fresh_with(b"hello");
|
||
// Open a second window on the same buffer.
|
||
s.lua_host
|
||
.lua()
|
||
.load("pmacs.window.split_vertical()")
|
||
.exec()
|
||
.unwrap();
|
||
let buf_id = s.core.borrow().active_buffer_id();
|
||
// Edit through the active window.
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('e')); // cursor.line-end
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('!'), KeyModifiers::NONE),
|
||
);
|
||
// Buffer length is now 6; the *other* window points at the
|
||
// same buffer id and its TextView was notified by
|
||
// apply_active_edit.
|
||
let core = s.core.borrow();
|
||
assert_eq!(core.active_buffer_len(), 6);
|
||
let active = core.active_window_id();
|
||
let other_id = core
|
||
.windows
|
||
.keys()
|
||
.find(|id| **id != active)
|
||
.copied()
|
||
.unwrap();
|
||
assert_eq!(core.windows[&other_id].buffer_id, buf_id);
|
||
}
|
||
|
||
// ---- T M2.9: multi-view composition ------------------------------------
|
||
|
||
/// Render `core`'s active window into a freshly-zeroed cell buffer and
|
||
/// return it. Mirrors what `editor::render` does for window content,
|
||
/// but skips status / minibuffer / mode-line so the assertions can
|
||
/// look at the buffer cells directly.
|
||
fn render_active_window_to_grid(
|
||
core: &mut crate::editor_core::EditorCore,
|
||
) -> Vec<crate::cell::Cell> {
|
||
use crate::cell::{Cell, CellGrid, CellSize};
|
||
use crate::view::Viewport;
|
||
let active = core.active_window_id();
|
||
let win = core.windows.get_mut(&active).unwrap();
|
||
let rect = crate::window::Rect::new(0, 0, 24, 80);
|
||
let cell_count = (rect.size.rows * rect.size.cols) as usize;
|
||
let mut backing = vec![Cell::default(); cell_count];
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let buf = reg.get(win.buffer_id).unwrap();
|
||
let viewport = Viewport {
|
||
buffer_start: 0,
|
||
buffer_end: buf.len(),
|
||
cell_origin: rect.origin,
|
||
cell_size: CellSize::new(rect.size.rows, rect.size.cols),
|
||
};
|
||
let mut grid = CellGrid {
|
||
cells: &mut backing,
|
||
stride: rect.size.cols,
|
||
size: CellSize::new(rect.size.rows, rect.size.cols),
|
||
};
|
||
win.text_view.render(buf, viewport, &mut grid);
|
||
for overlay in &mut win.overlays {
|
||
overlay.render(buf, viewport, &mut grid);
|
||
}
|
||
backing
|
||
}
|
||
|
||
/// Acceptance bullet 1: a buffer with three views (text + style
|
||
/// overlay + virtual cells) renders correctly into one cell grid.
|
||
#[test]
|
||
fn three_views_compose_in_a_real_window() {
|
||
use crate::cell::{Cell, CellCoord, Glyph, Style, UnderlineStyle};
|
||
use crate::overlay::{StyleSpan, StyleSpanOverlay, VirtualCell, VirtualCellOverlay};
|
||
let s = fresh_with(b"hello world\nsecond\n");
|
||
{
|
||
let mut core = s.core.borrow_mut();
|
||
let win = core.active_window_mut();
|
||
// Layer 2: bold underline on "hello".
|
||
let mut style = StyleSpanOverlay::new();
|
||
style.add(StyleSpan {
|
||
row: 0,
|
||
start_col: 0,
|
||
end_col: 5,
|
||
style: Style {
|
||
bold: true,
|
||
underline: UnderlineStyle::Curly,
|
||
..Default::default()
|
||
},
|
||
});
|
||
// Layer 3: virtual cell '★' past the end of "hello world".
|
||
let mut virt = VirtualCellOverlay::new();
|
||
virt.add(VirtualCell {
|
||
row: 0,
|
||
col: 12,
|
||
cell: Cell {
|
||
glyph: Glyph::Char('★'),
|
||
style: Style {
|
||
italic: true,
|
||
..Default::default()
|
||
},
|
||
attachment: None,
|
||
},
|
||
});
|
||
win.push_overlay(Box::new(style));
|
||
win.push_overlay(Box::new(virt));
|
||
}
|
||
let mut core = s.core.borrow_mut();
|
||
let cells = render_active_window_to_grid(&mut core);
|
||
let stride = 80usize;
|
||
let at = |row: u32, col: u32| -> &Cell { &cells[row as usize * stride + col as usize] };
|
||
// Layer 1 (text): glyphs come from the buffer.
|
||
assert_eq!(at(0, 0).glyph, Glyph::Char('h'));
|
||
assert_eq!(at(0, 4).glyph, Glyph::Char('o'));
|
||
assert_eq!(at(0, 6).glyph, Glyph::Char('w'));
|
||
assert_eq!(at(1, 0).glyph, Glyph::Char('s'));
|
||
// Layer 2 (style): "hello" is bold + curly-underlined; glyphs preserved.
|
||
for col in 0..5 {
|
||
let c = at(0, col);
|
||
assert!(c.style.bold, "col {col} not bold");
|
||
assert_eq!(c.style.underline, UnderlineStyle::Curly);
|
||
}
|
||
// " world" plain style.
|
||
for col in 5..11 {
|
||
let c = at(0, col);
|
||
assert!(!c.style.bold);
|
||
assert_eq!(c.style.underline, UnderlineStyle::None);
|
||
}
|
||
// Layer 3 (virtual): glyph replaced.
|
||
assert_eq!(at(0, 12).glyph, Glyph::Char('★'));
|
||
assert!(at(0, 12).style.italic);
|
||
// Sanity that we didn't bleed past the active window region.
|
||
let _ = CellCoord::new(0, 0);
|
||
}
|
||
|
||
/// Acceptance bullet 3: composition adds <10% overhead over
|
||
/// single-view rendering. Measured against the *composition
|
||
/// machinery* — the cost of holding additional views and
|
||
/// dispatching to them — independent of the work each overlay
|
||
/// chooses to do, since that work scales with what it paints.
|
||
///
|
||
/// Concretely: render the same buffer with `text_view` alone vs.
|
||
/// `text_view` plus two overlays whose `render` immediately
|
||
/// returns. The difference is the dispatch loop cost. Anything
|
||
/// above ~5% would mean the per-overlay setup cost dominates a
|
||
/// small frame, and overlay-heavy frames would suffer.
|
||
///
|
||
/// As an informational data point we also time a *realistic*
|
||
/// composed frame (with non-empty overlays) and print it; we do
|
||
/// not assert on it because overlay work scales linearly with
|
||
/// cells touched and "10%" is a meaningful budget only against
|
||
/// machinery, not against work.
|
||
#[test]
|
||
#[allow(
|
||
clippy::too_many_lines,
|
||
reason = "perf measurement is intentionally linear"
|
||
)]
|
||
fn composition_overhead_under_ten_percent() {
|
||
use crate::cell::{Cell, CellCoord, CellGrid, CellSize, Glyph, Style};
|
||
use crate::overlay::{StyleSpan, StyleSpanOverlay, VirtualCell, VirtualCellOverlay};
|
||
use crate::view::{View, Viewport};
|
||
use std::time::Instant;
|
||
const ITERS: usize = 5000;
|
||
const WARMUP: usize = 500;
|
||
|
||
struct NoopOverlay;
|
||
impl View for NoopOverlay {}
|
||
|
||
// Buffer with 200 lines of plausible source code so the base
|
||
// view does meaningful work each frame.
|
||
let mut content = Vec::new();
|
||
for i in 0..200 {
|
||
content.extend_from_slice(format!(" let value_{i} = {i} * 2;\n").as_bytes());
|
||
}
|
||
let s = fresh_with(&content);
|
||
|
||
let (single_avg_ns, dispatch_avg_ns, realistic_avg_ns) = {
|
||
let mut core = s.core.borrow_mut();
|
||
let active = core.active_window_id();
|
||
let buf_id = core.windows[&active].buffer_id;
|
||
let registry = core.registry.clone();
|
||
let reg = registry.borrow();
|
||
let buf = reg.get(buf_id).unwrap();
|
||
let viewport = Viewport {
|
||
buffer_start: 0,
|
||
buffer_end: buf.len(),
|
||
cell_origin: CellCoord::new(0, 0),
|
||
cell_size: CellSize::new(24, 80),
|
||
};
|
||
|
||
// Two no-op overlays: probe the dispatch cost only.
|
||
let mut empty1: Box<dyn View> = Box::new(NoopOverlay);
|
||
let mut empty2: Box<dyn View> = Box::new(NoopOverlay);
|
||
|
||
// Realistic overlay payload, ~3% of cells.
|
||
let mut style = StyleSpanOverlay::new();
|
||
for row in 0..8 {
|
||
style.add(StyleSpan {
|
||
row: (row as u32 * 3) % 24,
|
||
start_col: 4,
|
||
end_col: 8,
|
||
style: Style {
|
||
bold: true,
|
||
..Default::default()
|
||
},
|
||
});
|
||
}
|
||
let mut virt = VirtualCellOverlay::new();
|
||
for row in 0..8 {
|
||
virt.add(VirtualCell {
|
||
row: (row as u32 * 3) % 24,
|
||
col: 60,
|
||
cell: Cell {
|
||
glyph: Glyph::Char('|'),
|
||
style: Style::default(),
|
||
attachment: None,
|
||
},
|
||
});
|
||
}
|
||
|
||
let mut backing = vec![Cell::default(); 24 * 80];
|
||
|
||
// Warmup, then time: text_view only.
|
||
for _ in 0..WARMUP {
|
||
let win = core.windows.get_mut(&active).unwrap();
|
||
let mut grid = CellGrid {
|
||
cells: &mut backing,
|
||
stride: 80,
|
||
size: CellSize::new(24, 80),
|
||
};
|
||
win.text_view.render(buf, viewport, &mut grid);
|
||
}
|
||
let t = Instant::now();
|
||
for _ in 0..ITERS {
|
||
let win = core.windows.get_mut(&active).unwrap();
|
||
let mut grid = CellGrid {
|
||
cells: &mut backing,
|
||
stride: 80,
|
||
size: CellSize::new(24, 80),
|
||
};
|
||
win.text_view.render(buf, viewport, &mut grid);
|
||
}
|
||
let single = t.elapsed().as_nanos() / ITERS as u128;
|
||
|
||
// text_view + two no-op overlays: pure dispatch overhead.
|
||
for _ in 0..WARMUP {
|
||
let win = core.windows.get_mut(&active).unwrap();
|
||
let mut grid = CellGrid {
|
||
cells: &mut backing,
|
||
stride: 80,
|
||
size: CellSize::new(24, 80),
|
||
};
|
||
win.text_view.render(buf, viewport, &mut grid);
|
||
empty1.render(buf, viewport, &mut grid);
|
||
empty2.render(buf, viewport, &mut grid);
|
||
}
|
||
let t = Instant::now();
|
||
for _ in 0..ITERS {
|
||
let win = core.windows.get_mut(&active).unwrap();
|
||
let mut grid = CellGrid {
|
||
cells: &mut backing,
|
||
stride: 80,
|
||
size: CellSize::new(24, 80),
|
||
};
|
||
win.text_view.render(buf, viewport, &mut grid);
|
||
empty1.render(buf, viewport, &mut grid);
|
||
empty2.render(buf, viewport, &mut grid);
|
||
}
|
||
let dispatch = t.elapsed().as_nanos() / ITERS as u128;
|
||
|
||
// text_view + realistic overlays: informational only.
|
||
for _ in 0..WARMUP {
|
||
let win = core.windows.get_mut(&active).unwrap();
|
||
let mut grid = CellGrid {
|
||
cells: &mut backing,
|
||
stride: 80,
|
||
size: CellSize::new(24, 80),
|
||
};
|
||
win.text_view.render(buf, viewport, &mut grid);
|
||
style.render(buf, viewport, &mut grid);
|
||
virt.render(buf, viewport, &mut grid);
|
||
}
|
||
let t = Instant::now();
|
||
for _ in 0..ITERS {
|
||
let win = core.windows.get_mut(&active).unwrap();
|
||
let mut grid = CellGrid {
|
||
cells: &mut backing,
|
||
stride: 80,
|
||
size: CellSize::new(24, 80),
|
||
};
|
||
win.text_view.render(buf, viewport, &mut grid);
|
||
style.render(buf, viewport, &mut grid);
|
||
virt.render(buf, viewport, &mut grid);
|
||
}
|
||
let realistic = t.elapsed().as_nanos() / ITERS as u128;
|
||
|
||
(single, dispatch, realistic)
|
||
};
|
||
|
||
eprintln!("single render avg : {single_avg_ns} ns");
|
||
eprintln!("dispatch (2 no-op overlays): {dispatch_avg_ns} ns");
|
||
eprintln!("realistic 3-view frame : {realistic_avg_ns} ns");
|
||
let dispatch_ratio = dispatch_avg_ns as f64 / single_avg_ns as f64;
|
||
let realistic_ratio = realistic_avg_ns as f64 / single_avg_ns as f64;
|
||
eprintln!(
|
||
"dispatch overhead : {:.1}%",
|
||
(dispatch_ratio - 1.0) * 100.0
|
||
);
|
||
eprintln!(
|
||
"realistic overhead : {:.1}%",
|
||
(realistic_ratio - 1.0) * 100.0
|
||
);
|
||
|
||
if !cfg!(target_os = "macos") {
|
||
assert!(
|
||
dispatch_ratio < 1.10,
|
||
"composition machinery added more than 10% overhead: {dispatch_ratio:.3} \
|
||
(single={single_avg_ns} ns, dispatch={dispatch_avg_ns} ns)"
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Edits to the buffer reach overlays via `on_edit`, just like
|
||
/// they reach the base text view. Without this, overlays that
|
||
/// cache buffer-derived state would silently desync after the
|
||
/// first edit.
|
||
#[test]
|
||
fn overlays_receive_on_edit_alongside_text_view() {
|
||
use crate::buffer::EditOp;
|
||
use crate::view::View;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicU32, Ordering};
|
||
|
||
struct CountingOverlay {
|
||
count: Arc<AtomicU32>,
|
||
}
|
||
impl View for CountingOverlay {
|
||
fn on_edit(
|
||
&mut self,
|
||
_buf: &crate::buffer::Buffer,
|
||
_edit: &crate::rope::Edit,
|
||
) -> Result<(), crate::buffer::BufferError> {
|
||
self.count.fetch_add(1, Ordering::Relaxed);
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
let s = fresh_with(b"hi");
|
||
let count = Arc::new(AtomicU32::new(0));
|
||
s.core
|
||
.borrow_mut()
|
||
.active_window_mut()
|
||
.push_overlay(Box::new(CountingOverlay {
|
||
count: count.clone(),
|
||
}));
|
||
s.core
|
||
.borrow_mut()
|
||
.apply_active_edit(EditOp::Insert {
|
||
pos: 2,
|
||
bytes: b"!",
|
||
})
|
||
.unwrap();
|
||
assert_eq!(
|
||
count.load(Ordering::Relaxed),
|
||
1,
|
||
"overlay did not see on_edit"
|
||
);
|
||
}
|
||
|
||
// ---- T M2.12: mouse input ----------------------------------------------
|
||
|
||
fn mouse(kind: crossterm::event::MouseEventKind, row: u16, col: u16) -> MouseEvent {
|
||
MouseEvent {
|
||
kind,
|
||
column: col,
|
||
row,
|
||
modifiers: KeyModifiers::NONE,
|
||
}
|
||
}
|
||
|
||
fn term_size_24x80() -> crate::cell::CellSize {
|
||
crate::cell::CellSize::new(24, 80)
|
||
}
|
||
|
||
/// Acceptance bullet 1: click on any cell positions the cursor at
|
||
/// the corresponding rope position.
|
||
#[test]
|
||
fn mouse_click_positions_cursor() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello\nworld\n");
|
||
// Click at row 1, col 3 — should land in the middle of "world".
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 1, 3),
|
||
term_size_24x80(),
|
||
);
|
||
// "hello\n" is 6 bytes; "wor" puts us at byte 6+3 = 9.
|
||
assert_eq!(s.core.borrow().cursor(), 9);
|
||
// A click also begins an empty selection at the click point.
|
||
// The "is empty" check is via region() — empty selection has no region.
|
||
assert!(s.core.borrow().active_region().is_none());
|
||
}
|
||
|
||
/// Acceptance bullet 2: drag selection produces a region usable
|
||
/// by region-aware commands.
|
||
#[test]
|
||
fn mouse_drag_produces_region_usable_by_delete() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello world\n");
|
||
// Click at col 0 (start of buffer).
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 0),
|
||
term_size_24x80(),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), 0);
|
||
// Drag to col 5 ("hello").
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Drag(MouseButton::Left), 0, 5),
|
||
term_size_24x80(),
|
||
);
|
||
let region = s.core.borrow().active_region();
|
||
assert_eq!(region, Some((0, 5)), "expected (0, 5); got {region:?}");
|
||
// Region-aware command consumes the region.
|
||
s.lua_host
|
||
.invoke_command("region.delete", mlua::MultiValue::new())
|
||
.unwrap();
|
||
// Buffer now contains " world\n"; cursor moved to start.
|
||
let core = s.core.borrow();
|
||
assert_eq!(core.active_buffer_len(), 7);
|
||
assert_eq!(core.cursor(), 0);
|
||
assert!(core.active_region().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_drag_selection_paints_in_tui_grid() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello world\n");
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 0),
|
||
term_size_24x80(),
|
||
);
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Drag(MouseButton::Left), 0, 5),
|
||
term_size_24x80(),
|
||
);
|
||
|
||
let (cells, _, _) = render_to_grid(&s, 24, 80);
|
||
for col in 0..5 {
|
||
let style = cells[col as usize].style;
|
||
assert!(style.reverse, "selected col {col} was not reverse video");
|
||
}
|
||
assert!(
|
||
!cells[5].style.reverse,
|
||
"unselected cell after mouse selection was reverse video"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_double_click_selects_word_and_paints_in_tui_grid() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello world\n");
|
||
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 7),
|
||
term_size_24x80(),
|
||
);
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Up(MouseButton::Left), 0, 7),
|
||
term_size_24x80(),
|
||
);
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 7),
|
||
term_size_24x80(),
|
||
);
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Up(MouseButton::Left), 0, 7),
|
||
term_size_24x80(),
|
||
);
|
||
|
||
assert_eq!(s.core.borrow().cursor(), 11);
|
||
assert_eq!(s.core.borrow().active_region(), Some((6, 11)));
|
||
|
||
let (cells, _, _) = render_to_grid(&s, 24, 80);
|
||
assert!(!cells[5].style.reverse, "selection leaked into separator");
|
||
for col in 6..11 {
|
||
assert!(
|
||
cells[col as usize].style.reverse,
|
||
"double-click selected word missing col {col}"
|
||
);
|
||
}
|
||
assert!(!cells[11].style.reverse, "selection leaked past word");
|
||
}
|
||
|
||
#[test]
|
||
fn mouse_double_click_on_separator_leaves_no_region() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello world\n");
|
||
|
||
for _ in 0..2 {
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 5),
|
||
term_size_24x80(),
|
||
);
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Up(MouseButton::Left), 0, 5),
|
||
term_size_24x80(),
|
||
);
|
||
}
|
||
|
||
assert_eq!(s.core.borrow().cursor(), 5);
|
||
assert!(s.core.borrow().active_region().is_none());
|
||
}
|
||
|
||
/// Mouse framing Q#M1 — `dispatch_pointer` replays the mouse
|
||
/// gesture semantics in byte space for semantic frontends.
|
||
#[test]
|
||
fn dispatch_pointer_replays_mouse_semantics_in_byte_space() {
|
||
use crate::protocol::{Modifiers as WireMods, PointerKind};
|
||
// Bytes: h=0 é=1,2 ' '=3 l=4 l=5 o=6 ' '=7 w=8 ö=9,10 r=11
|
||
// l=12 d=13 \n=14; len=15.
|
||
let mut s = fresh_with("hé llo wörld\n".as_bytes());
|
||
let bid = s.core.borrow().active_buffer_id();
|
||
let none = WireMods::NONE;
|
||
|
||
// Down places the cursor — a mid-codepoint hit (inside 'é')
|
||
// snaps back to the boundary — and anchors a selection.
|
||
s.dispatch_pointer(FrontendId::LOCAL, bid, 2, PointerKind::Down, none);
|
||
assert_eq!(
|
||
s.core.borrow().cursor(),
|
||
1,
|
||
"mid-codepoint hit snaps to the char boundary"
|
||
);
|
||
|
||
// Drag grows the region from the anchor; Up keeps it.
|
||
s.dispatch_pointer(FrontendId::LOCAL, bid, 6, PointerKind::Drag, none);
|
||
assert_eq!(s.core.borrow().cursor(), 6);
|
||
assert_eq!(s.core.borrow().active_region(), Some((1, 6)));
|
||
s.dispatch_pointer(FrontendId::LOCAL, bid, 6, PointerKind::Up, none);
|
||
assert_eq!(s.core.borrow().active_region(), Some((1, 6)));
|
||
|
||
// A plain click (Down + Up, no drag) leaves no region.
|
||
s.dispatch_pointer(FrontendId::LOCAL, bid, 4, PointerKind::Down, none);
|
||
s.dispatch_pointer(FrontendId::LOCAL, bid, 4, PointerKind::Up, none);
|
||
assert_eq!(s.core.borrow().cursor(), 4);
|
||
assert!(s.core.borrow().active_region().is_none());
|
||
|
||
// DoubleDown selects the word at the hit ("wörld").
|
||
s.dispatch_pointer(FrontendId::LOCAL, bid, 8, PointerKind::DoubleDown, none);
|
||
assert_eq!(s.core.borrow().active_region(), Some((8, 14)));
|
||
assert_eq!(s.core.borrow().cursor(), 14);
|
||
|
||
// Past-EOF hits clamp to the buffer length.
|
||
s.dispatch_pointer(FrontendId::LOCAL, bid, 999, PointerKind::Down, none);
|
||
assert_eq!(s.core.borrow().cursor(), 15);
|
||
|
||
// A pointer for a buffer the window isn't displaying is
|
||
// dropped (click racing a buffer switch).
|
||
let other = crate::buffer::BufferId::next();
|
||
s.dispatch_pointer(FrontendId::LOCAL, other, 0, PointerKind::Down, none);
|
||
assert_eq!(s.core.borrow().cursor(), 15, "mismatched buffer ignored");
|
||
}
|
||
|
||
/// Acceptance bullet 3: mouse events are coalesced at frame
|
||
/// boundaries — many drag events between renders all apply, and
|
||
/// the cursor ends up at the last position.
|
||
#[test]
|
||
fn mouse_drag_events_coalesce_across_a_frame() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"abcdefghij\n");
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 0),
|
||
term_size_24x80(),
|
||
);
|
||
// Burst of drags through cols 1..=8 — simulates `process_event`
|
||
// being invoked repeatedly between renders.
|
||
for col in 1..=8u16 {
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Drag(MouseButton::Left), 0, col),
|
||
term_size_24x80(),
|
||
);
|
||
}
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Up(MouseButton::Left), 0, 8),
|
||
term_size_24x80(),
|
||
);
|
||
// Cursor lands at the final drag col, anchor stays at click.
|
||
assert_eq!(s.core.borrow().cursor(), 8);
|
||
assert_eq!(s.core.borrow().active_region(), Some((0, 8)));
|
||
}
|
||
|
||
/// Plain click without a drag should *not* leave a phantom empty
|
||
/// selection — `Up(Left)` clears it.
|
||
#[test]
|
||
fn plain_click_clears_empty_selection_on_release() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello\n");
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 2),
|
||
term_size_24x80(),
|
||
);
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Up(MouseButton::Left), 0, 2),
|
||
term_size_24x80(),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), 2);
|
||
assert!(s.core.borrow().active_window().selection.is_none());
|
||
}
|
||
|
||
/// Mouse-wheel scrolls advance `view_top` and drag the cursor
|
||
/// along by the same delta so it keeps its relative position in
|
||
/// the viewport. Without the cursor-shift, the renderer's
|
||
/// auto-scroll-to-cursor pass would snap `view_top` straight back
|
||
/// the moment the cursor fell offscreen, making wheel scrolling
|
||
/// feel stuck after one notch.
|
||
#[test]
|
||
fn scroll_wheel_advances_view_top_and_drags_cursor() {
|
||
use crossterm::event::MouseEventKind;
|
||
let mut content = Vec::new();
|
||
for i in 0..50 {
|
||
content.extend_from_slice(format!("line {i}\n").as_bytes());
|
||
}
|
||
let mut s = fresh_with(&content);
|
||
let view_top_before = s.core.borrow().view_top();
|
||
let cursor_line_before = s.core.borrow().cursor_line();
|
||
// Wheel down 3 notches: view_top advances by 3 * SCROLL_LINES.
|
||
for _ in 0..3 {
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::ScrollDown, 5, 5),
|
||
term_size_24x80(),
|
||
);
|
||
}
|
||
let view_top_after = s.core.borrow().view_top();
|
||
let cursor_line_after = s.core.borrow().cursor_line();
|
||
assert_eq!(
|
||
view_top_after - view_top_before,
|
||
3 * SCROLL_LINES as usize,
|
||
"three notches should move view_top by 3*SCROLL_LINES"
|
||
);
|
||
assert_eq!(
|
||
cursor_line_after - cursor_line_before,
|
||
3 * SCROLL_LINES as usize,
|
||
"cursor should follow view by the same delta"
|
||
);
|
||
// Wheel up enough notches to reach the top.
|
||
for _ in 0..10 {
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::ScrollUp, 5, 5),
|
||
term_size_24x80(),
|
||
);
|
||
}
|
||
assert_eq!(
|
||
s.core.borrow().view_top(),
|
||
0,
|
||
"scroll up should reach the top of the buffer"
|
||
);
|
||
assert_eq!(
|
||
s.core.borrow().cursor_line(),
|
||
0,
|
||
"cursor should ride back up with the view"
|
||
);
|
||
}
|
||
|
||
/// Click on a non-active window activates it (mouse click selects
|
||
/// the focused window in addition to positioning the cursor).
|
||
#[test]
|
||
fn click_in_other_window_activates_it() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello\nworld\n");
|
||
s.lua_host
|
||
.lua()
|
||
.load("pmacs.window.split_vertical()")
|
||
.exec()
|
||
.unwrap();
|
||
let original_active = s.core.borrow().active_window_id();
|
||
// Click on the right side (col 60 — guaranteed in the second window
|
||
// for any standard 80-col terminal split in half).
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 60),
|
||
term_size_24x80(),
|
||
);
|
||
let new_active = s.core.borrow().active_window_id();
|
||
assert_ne!(
|
||
new_active, original_active,
|
||
"click in other window did not activate it"
|
||
);
|
||
}
|
||
|
||
/// Click on a window's mode line is ignored — it does not move
|
||
/// the cursor or activate the window. Reserved for future use.
|
||
#[test]
|
||
fn click_on_mode_line_is_ignored() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello\n");
|
||
let cursor_before = s.core.borrow().cursor();
|
||
// The single window occupies all but the bottom row of the
|
||
// terminal; its mode line is at row term_rows - 2 = 22.
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 22, 5),
|
||
term_size_24x80(),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), cursor_before);
|
||
}
|
||
|
||
/// `pmacs.editor.region()` exposes the active region to Lua, and
|
||
/// returns nil otherwise.
|
||
#[test]
|
||
fn lua_region_binding_returns_active_region() {
|
||
use crossterm::event::{MouseButton, MouseEventKind};
|
||
let mut s = fresh_with(b"hello\n");
|
||
// No region yet.
|
||
let v: mlua::Value = s
|
||
.lua_host
|
||
.lua()
|
||
.load("return pmacs.editor.region()")
|
||
.eval()
|
||
.unwrap();
|
||
assert!(matches!(v, mlua::Value::Nil));
|
||
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Down(MouseButton::Left), 0, 1),
|
||
term_size_24x80(),
|
||
);
|
||
s.dispatch_mouse(
|
||
FrontendId::LOCAL,
|
||
mouse(MouseEventKind::Drag(MouseButton::Left), 0, 4),
|
||
term_size_24x80(),
|
||
);
|
||
let result: (i64, i64) = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
local r = pmacs.editor.region()
|
||
return r.start, r["end"]
|
||
"#,
|
||
)
|
||
.eval()
|
||
.unwrap();
|
||
assert_eq!(result, (1, 4));
|
||
}
|
||
|
||
// ---- Word and page motion ----------------------------------------------
|
||
|
||
#[test]
|
||
fn word_right_skips_separators_then_word_run() {
|
||
let mut s = fresh_with(b" hello world foo");
|
||
// Cursor at 0 (in leading whitespace). One word-right lands
|
||
// after "hello".
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Right, KeyModifiers::CONTROL),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), 7); // end of "hello"
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Right, KeyModifiers::CONTROL),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), 13); // end of "world"
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Right, KeyModifiers::CONTROL),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), 18); // end of "foo" (and buffer)
|
||
// Past the end stays clamped.
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Right, KeyModifiers::CONTROL),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), 18);
|
||
}
|
||
|
||
#[test]
|
||
fn word_left_mirrors_word_right() {
|
||
let mut s = fresh_with(b" hello world foo");
|
||
// Drop cursor at the end.
|
||
s.core.borrow_mut().active_window_mut().cursor = 18;
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::CONTROL));
|
||
assert_eq!(s.core.borrow().cursor(), 15); // start of "foo"
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::CONTROL));
|
||
assert_eq!(s.core.borrow().cursor(), 8); // start of "world"
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::CONTROL));
|
||
assert_eq!(s.core.borrow().cursor(), 2); // start of "hello"
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Left, KeyModifiers::CONTROL));
|
||
assert_eq!(s.core.borrow().cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn word_motion_treats_underscore_as_word_char() {
|
||
let mut s = fresh_with(b"foo_bar baz");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Right, KeyModifiers::CONTROL),
|
||
);
|
||
// "foo_bar" is a single word.
|
||
assert_eq!(s.core.borrow().cursor(), 7);
|
||
}
|
||
|
||
#[test]
|
||
fn word_motion_handles_multibyte_codepoints() {
|
||
let mut s = fresh_with("café résumé".as_bytes());
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Right, KeyModifiers::CONTROL),
|
||
);
|
||
// "café" is 5 bytes (é is 2 bytes); cursor at 5.
|
||
assert_eq!(s.core.borrow().cursor(), 5);
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Right, KeyModifiers::CONTROL),
|
||
);
|
||
// "résumé" is 8 bytes; cursor at 5 + 1 (space) + 8 = 14.
|
||
assert_eq!(s.core.borrow().cursor(), 14);
|
||
}
|
||
|
||
#[test]
|
||
fn shift_arrow_extends_selection_and_paints_in_tui_grid() {
|
||
let mut s = fresh_with(b"abcdef\n");
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT));
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Right, KeyModifiers::SHIFT));
|
||
|
||
assert_eq!(s.core.borrow().cursor(), 2);
|
||
assert_eq!(s.core.borrow().active_region(), Some((0, 2)));
|
||
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
assert!(cells[0].style.reverse, "selection did not paint col 0");
|
||
assert!(cells[1].style.reverse, "selection did not paint col 1");
|
||
assert!(!cells[2].style.reverse, "selection leaked into col 2");
|
||
assert_eq!(glyph_at(&cells, stride, 0, 0), 'a');
|
||
assert_eq!(glyph_at(&cells, stride, 0, 1), 'b');
|
||
}
|
||
|
||
#[test]
|
||
fn ctrl_shift_arrow_extends_selection_by_words_and_paragraphs() {
|
||
let mut s = fresh_with(b"alpha beta\n\nsecond\n");
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Right, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), 5);
|
||
assert_eq!(s.core.borrow().active_region(), Some((0, 5)));
|
||
|
||
s.core.borrow_mut().active_window_mut().cursor = 0;
|
||
s.core.borrow_mut().clear_selection();
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Down, KeyModifiers::CONTROL | KeyModifiers::SHIFT),
|
||
);
|
||
assert_eq!(s.core.borrow().cursor(), 11);
|
||
assert_eq!(s.core.borrow().active_region(), Some((0, 11)));
|
||
}
|
||
|
||
#[test]
|
||
fn page_down_advances_cursor_and_view_top() {
|
||
let mut content = Vec::new();
|
||
for i in 0..100 {
|
||
content.extend_from_slice(format!("line {i}\n").as_bytes());
|
||
}
|
||
let mut s = fresh_with(&content);
|
||
// Set a known viewport size so page step is predictable.
|
||
s.core.borrow_mut().active_window_mut().last_visible_rows = 10;
|
||
let cursor_before = s.core.borrow().cursor();
|
||
let view_top_before = s.core.borrow().view_top();
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::PageDown));
|
||
let cursor_after = s.core.borrow().cursor();
|
||
let view_top_after = s.core.borrow().view_top();
|
||
assert!(
|
||
cursor_after > cursor_before,
|
||
"page-down did not advance cursor"
|
||
);
|
||
assert!(
|
||
view_top_after > view_top_before,
|
||
"page-down did not advance view_top"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn page_up_returns_to_top() {
|
||
let mut content = Vec::new();
|
||
for i in 0..100 {
|
||
content.extend_from_slice(format!("line {i}\n").as_bytes());
|
||
}
|
||
let mut s = fresh_with(&content);
|
||
s.core.borrow_mut().active_window_mut().last_visible_rows = 10;
|
||
// Page down a few times.
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::PageDown));
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::PageDown));
|
||
// Page up enough times to overshoot.
|
||
for _ in 0..5 {
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::PageUp));
|
||
}
|
||
assert_eq!(s.core.borrow().view_top(), 0);
|
||
}
|
||
|
||
// ---- Paragraph motion --------------------------------------------------
|
||
|
||
#[test]
|
||
fn paragraph_down_lands_on_blank_lines_in_sequence() {
|
||
let mut s = fresh_with(b"para 1 line a\npara 1 line b\n\npara 2 line a\n\npara 3\n");
|
||
// Cursor at 0 (start of para 1). C-down should land at the
|
||
// first blank line (after "para 1 line b\n").
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Down, KeyModifiers::CONTROL));
|
||
// "para 1 line a\npara 1 line b\n" = 14 + 14 = 28 bytes.
|
||
assert_eq!(s.core.borrow().cursor(), 28);
|
||
// Press again: lands at the blank between para 2 and para 3.
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Down, KeyModifiers::CONTROL));
|
||
// 28 + "\n" + "para 2 line a\n" = 28 + 1 + 14 = 43.
|
||
assert_eq!(s.core.borrow().cursor(), 43);
|
||
// Once more: end of buffer.
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Down, KeyModifiers::CONTROL));
|
||
let len = s.core.borrow().active_buffer_len();
|
||
assert_eq!(s.core.borrow().cursor(), len);
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_up_mirrors_paragraph_down() {
|
||
let mut s = fresh_with(b"para 1\n\npara 2\n\npara 3\n");
|
||
let len = s.core.borrow().active_buffer_len();
|
||
s.core.borrow_mut().active_window_mut().cursor = len;
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Up, KeyModifiers::CONTROL));
|
||
// Lands at start of blank line between para 2 and para 3.
|
||
// "para 1\n\npara 2\n" = 7 + 1 + 7 = 15.
|
||
assert_eq!(s.core.borrow().cursor(), 15);
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Up, KeyModifiers::CONTROL));
|
||
// Lands at start of blank between para 1 and para 2.
|
||
// "para 1\n" = 7.
|
||
assert_eq!(s.core.borrow().cursor(), 7);
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Up, KeyModifiers::CONTROL));
|
||
// Top of buffer.
|
||
assert_eq!(s.core.borrow().cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_motion_treats_whitespace_only_lines_as_blank() {
|
||
// Lines with only spaces / tabs separate paragraphs the same
|
||
// way as truly empty lines.
|
||
let mut s = fresh_with(b"alpha\n \nbeta\n");
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Down, KeyModifiers::CONTROL));
|
||
// Lands at start of the whitespace-only line, byte 6.
|
||
assert_eq!(s.core.borrow().cursor(), 6);
|
||
}
|
||
|
||
// ---- Window-notify audit: every buffer-mutating path must keep
|
||
// windows displaying that buffer in sync. -------------------------
|
||
|
||
#[test]
|
||
fn help_show_command_updates_window_on_help_buffer() {
|
||
// Regression: render_command does delete-all + insert on
|
||
// *help*; if a window is showing it, the window's TextView
|
||
// must be rebuilt or the new content is unreachable.
|
||
let s = fresh_with(b"");
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.command.define {
|
||
name = "alpha",
|
||
description = "Alpha cmd.",
|
||
fn = function() end,
|
||
}
|
||
pmacs.help.show_command("alpha")
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
let help_id = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow()
|
||
.find_by_name(crate::help::HELP_BUFFER_NAME)
|
||
.expect("*help* exists after show_command");
|
||
s.core.borrow_mut().switch_active_buffer(help_id).unwrap();
|
||
let lines_first = s.core.borrow().active_window().text_view.line_count();
|
||
|
||
// Define another command and re-render — the help buffer is
|
||
// rewritten end-to-end. The window's text_view must reflect
|
||
// the new content.
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.command.define {
|
||
name = "beta",
|
||
description = "Beta cmd has a much longer description that produces noticeably more lines.",
|
||
fn = function() end,
|
||
}
|
||
pmacs.help.show_command("beta")
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
let lines_second = s.core.borrow().active_window().text_view.line_count();
|
||
let buf_len = s.core.borrow().active_buffer_len();
|
||
let last_offset = s
|
||
.core
|
||
.borrow()
|
||
.active_window()
|
||
.text_view
|
||
.line_offset(lines_second - 1)
|
||
.unwrap();
|
||
assert!(
|
||
last_offset <= buf_len,
|
||
"stale offset {last_offset} > buf_len {buf_len}"
|
||
);
|
||
// The two renders may produce different line counts; the
|
||
// important invariant is that the view tracks the buffer.
|
||
let _ = lines_first;
|
||
}
|
||
|
||
#[test]
|
||
fn hook_error_logging_updates_window_on_errors_buffer() {
|
||
// Hook callbacks that raise route through log_hook_error,
|
||
// which appends to *errors*. If a window is displaying
|
||
// *errors*, its TextView must be notified.
|
||
let mut s = fresh_with(b"");
|
||
// Trigger one error so *errors* exists, then switch to it.
|
||
let _ = s.lua_host.eval(Some("seed"), "error('seed')");
|
||
let errors_id = s
|
||
.lua_host
|
||
.errors_buffer_id()
|
||
.expect("*errors* exists after eval-error");
|
||
s.core.borrow_mut().switch_active_buffer(errors_id).unwrap();
|
||
let lines_before = s.core.borrow().active_window().text_view.line_count();
|
||
|
||
// Define a hook that raises, then run it — that path goes
|
||
// through `log_hook_error`, distinct from `eval`.
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.hook.define {
|
||
name = "test.boom",
|
||
description = "Test hook that raises.",
|
||
kind = "all-must-succeed",
|
||
}
|
||
pmacs.hook.add("test.boom", function() error("kaboom") end)
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
let _ = s.lua_host.run_hook("test.boom", mlua::MultiValue::new());
|
||
|
||
let lines_after = s.core.borrow().active_window().text_view.line_count();
|
||
assert!(
|
||
lines_after >= lines_before,
|
||
"*errors* window TextView did not reflect new entry"
|
||
);
|
||
// Pre-fix, the new content would be unreachable past the
|
||
// pre-append last line. Confirm the new last offset advanced.
|
||
let buf_len = s.core.borrow().active_buffer_len();
|
||
let last_offset = s
|
||
.core
|
||
.borrow()
|
||
.active_window()
|
||
.text_view
|
||
.line_offset(lines_after - 1)
|
||
.unwrap();
|
||
assert!(last_offset <= buf_len);
|
||
}
|
||
|
||
/// Audit: every public `EditorCore` mutation path that touches a
|
||
/// buffer must leave windows on that buffer with a `TextView`
|
||
/// whose line index covers the buffer end. This is the invariant
|
||
/// the last four user-reported bugs all violated.
|
||
#[test]
|
||
fn windows_textview_invariant_holds_for_every_known_mutator() {
|
||
use crate::buffer::EditOp;
|
||
let mut s = fresh_with(b"alpha\nbeta\n");
|
||
|
||
// 1) apply_active_edit — the canonical path.
|
||
s.core
|
||
.borrow_mut()
|
||
.apply_active_edit(EditOp::Insert {
|
||
pos: 0,
|
||
bytes: b"prefix\n",
|
||
})
|
||
.unwrap();
|
||
assert_textview_covers_buffer(&s);
|
||
|
||
// 2) Lua buf:insert / buf:delete via userdata methods.
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
local buf = pmacs.buffer.list()[1]
|
||
buf:insert(buf:len(), "appended")
|
||
buf:delete(0, 3)
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
assert_textview_covers_buffer(&s);
|
||
|
||
// 3) LuaHost::eval error path → append_to_errors_buffer.
|
||
let errors_id = {
|
||
let _ = s.lua_host.eval(Some("e"), "error('x')");
|
||
s.lua_host.errors_buffer_id().unwrap()
|
||
};
|
||
s.core.borrow_mut().switch_active_buffer(errors_id).unwrap();
|
||
let _ = s.lua_host.eval(Some("e2"), "error('y')");
|
||
assert_textview_covers_buffer(&s);
|
||
|
||
// 4) Help renderer.
|
||
s.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
pmacs.command.define {
|
||
name = "audit-cmd",
|
||
description = "audit",
|
||
fn = function() end,
|
||
}
|
||
pmacs.help.show_command("audit-cmd")
|
||
"#,
|
||
)
|
||
.exec()
|
||
.unwrap();
|
||
let help_id = s
|
||
.lua_host
|
||
.registry()
|
||
.borrow()
|
||
.find_by_name(crate::help::HELP_BUFFER_NAME)
|
||
.unwrap();
|
||
s.core.borrow_mut().switch_active_buffer(help_id).unwrap();
|
||
s.lua_host
|
||
.lua()
|
||
.load("pmacs.help.show_command('audit-cmd')")
|
||
.exec()
|
||
.unwrap();
|
||
assert_textview_covers_buffer(&s);
|
||
|
||
// 5) editor.list-buffers (Lua userdata delete + insert).
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('x'));
|
||
s.dispatch_key(FrontendId::LOCAL, ctrl('b'));
|
||
assert_textview_covers_buffer(&s);
|
||
}
|
||
|
||
// ---- Render-grid correctness -------------------------------------------
|
||
//
|
||
// The M-x stack-traceback corruption shipped despite a green
|
||
// suite because we had no tests asserting against actual cell
|
||
// content — only against the strings that fed into rendering.
|
||
// These tests exercise the full `paint_frame` pipeline (window
|
||
// text, mode line, status line, minibuffer overlay, cursor
|
||
// placement) and read the resulting cells back.
|
||
|
||
/// Render the editor state into a Vec-backed grid and return
|
||
/// `(cells, stride, cursor)`. Tests use this and then index cells
|
||
/// directly to verify what reached the screen.
|
||
fn render_to_grid(
|
||
s: &EditorState,
|
||
rows: u32,
|
||
cols: u32,
|
||
) -> (Vec<crate::cell::Cell>, u32, Option<CellCoord>) {
|
||
let mut backing = vec![crate::cell::Cell::default(); (rows * cols) as usize];
|
||
let mut grid = crate::cell::CellGrid {
|
||
cells: &mut backing,
|
||
stride: cols,
|
||
size: crate::cell::CellSize::new(rows, cols),
|
||
};
|
||
let cursor = paint_frame(s, &mut grid, crate::cell::CellSize::new(rows, cols));
|
||
(backing, cols, cursor)
|
||
}
|
||
|
||
fn glyph_at(cells: &[crate::cell::Cell], stride: u32, row: u32, col: u32) -> char {
|
||
match &cells[(row * stride + col) as usize].glyph {
|
||
crate::cell::Glyph::Char(c) => *c,
|
||
crate::cell::Glyph::Cluster(_) => '?',
|
||
crate::cell::Glyph::Continuation => ' ',
|
||
}
|
||
}
|
||
|
||
fn row_text(cells: &[crate::cell::Cell], stride: u32, row: u32, cols: u32) -> String {
|
||
(0..cols)
|
||
.map(|c| glyph_at(cells, stride, row, c))
|
||
.collect::<String>()
|
||
.trim_end()
|
||
.to_string()
|
||
}
|
||
|
||
#[test]
|
||
fn render_paints_buffer_text_into_window_cells() {
|
||
let s = fresh_with(b"hello\nworld\n");
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
assert_eq!(row_text(&cells, stride, 0, 80), "hello");
|
||
assert_eq!(row_text(&cells, stride, 1, 80), "world");
|
||
}
|
||
|
||
#[test]
|
||
fn render_status_line_truncates_multiline_status_to_one_row() {
|
||
// Direct verification that the M-x error fix reaches the cell
|
||
// grid, not just `build_status_line`. Set a multi-line status
|
||
// and confirm that no cell on rows above the status line
|
||
// contains traceback content, and the status row is intact.
|
||
let s = fresh_with(b"hello\n");
|
||
s.core.borrow_mut().status =
|
||
"M-x error: command \"foo\" not found\nstack traceback:\n[C]: in ?\n".into();
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
// Status line is the bottom row.
|
||
let status_row = row_text(&cells, stride, 23, 80);
|
||
assert!(
|
||
status_row.contains("M-x error: command \"foo\" not found"),
|
||
"status row missing main message: {status_row:?}"
|
||
);
|
||
assert!(
|
||
!status_row.contains("traceback"),
|
||
"traceback leaked into status row: {status_row:?}"
|
||
);
|
||
// No row other than status should contain "traceback".
|
||
for row in 0..23 {
|
||
let text = row_text(&cells, stride, row, 80);
|
||
assert!(
|
||
!text.contains("traceback"),
|
||
"traceback leaked into row {row}: {text:?}"
|
||
);
|
||
}
|
||
// No cell anywhere should hold a control character.
|
||
for cell in &cells {
|
||
if let crate::cell::Glyph::Char(c) = cell.glyph {
|
||
assert!(!c.is_control(), "control character {c:?} reached a cell");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn render_mode_line_marks_active_window_and_modified_buffer() {
|
||
let mut s = fresh_with(b"hello");
|
||
// Make the buffer modified.
|
||
s.dispatch_key(
|
||
FrontendId::LOCAL,
|
||
key(KeyCode::Char('!'), KeyModifiers::NONE),
|
||
);
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
// Mode line is row 22 (rows-2 = 24-2).
|
||
let mode_text = row_text(&cells, stride, 22, 80);
|
||
// Active marker `+`, modified marker `*`.
|
||
assert!(
|
||
mode_text.contains("+*"),
|
||
"mode line missing active+modified markers: {mode_text:?}"
|
||
);
|
||
assert!(
|
||
mode_text.contains("test"),
|
||
"mode line missing buffer name: {mode_text:?}"
|
||
);
|
||
// Mode line cells should be in reverse video.
|
||
for col in 0..80 {
|
||
let style = cells[(22 * stride + col) as usize].style;
|
||
assert!(style.reverse, "mode line col {col} not reverse video");
|
||
}
|
||
}
|
||
|
||
/// Give the active buffer a file path and return its `file://`
|
||
/// URI, so diag-store entries can be keyed to it.
|
||
fn set_active_buffer_path(s: &EditorState, path: &str) -> String {
|
||
let core = s.core.borrow();
|
||
let registry = core.registry.clone();
|
||
let mut reg = registry.borrow_mut();
|
||
let buf = reg.get_mut(core.active_buffer_id()).unwrap();
|
||
buf.set_file_path(Some(std::path::PathBuf::from(path)));
|
||
crate::lsp::path_to_file_uri(buf.file_path().unwrap())
|
||
}
|
||
|
||
fn diag_with_severity(severity: crate::diag::DiagnosticSeverity) -> crate::diag::Diagnostic {
|
||
crate::diag::Diagnostic {
|
||
start_line: 0,
|
||
start_col: 0,
|
||
end_line: 0,
|
||
end_col: 1,
|
||
severity,
|
||
message: "boom".into(),
|
||
source: None,
|
||
code: None,
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn render_mode_line_shows_diagnostic_counts() {
|
||
use crate::diag::DiagnosticSeverity::{Error, Hint, Warning};
|
||
let s = fresh_with(b"hello\n");
|
||
let uri = set_active_buffer_path(&s, "/tmp/modeline_diag.rs");
|
||
let store = s.lsp_manager.borrow().diag_store();
|
||
store.lock().unwrap().set(
|
||
uri,
|
||
vec![
|
||
diag_with_severity(Error),
|
||
diag_with_severity(Error),
|
||
diag_with_severity(Warning),
|
||
diag_with_severity(Hint), // hints stay off the mode line
|
||
],
|
||
);
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
let mode_text = row_text(&cells, stride, 22, 80);
|
||
assert!(
|
||
mode_text.contains("E:2 W:1"),
|
||
"mode line missing diagnostic counts: {mode_text:?}"
|
||
);
|
||
assert!(
|
||
!mode_text.contains("H:"),
|
||
"hints should not appear on the mode line: {mode_text:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_mode_line_hides_diagnostic_counts_while_stale() {
|
||
use crate::diag::DiagnosticSeverity::Error;
|
||
let s = fresh_with(b"hello\n");
|
||
let uri = set_active_buffer_path(&s, "/tmp/modeline_stale.rs");
|
||
let store = s.lsp_manager.borrow().diag_store();
|
||
{
|
||
let mut guard = store.lock().unwrap();
|
||
guard.set(uri.clone(), vec![diag_with_severity(Error)]);
|
||
guard.mark_stale(uri);
|
||
}
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
let mode_text = row_text(&cells, stride, 22, 80);
|
||
assert!(
|
||
!mode_text.contains("E:"),
|
||
"stale diagnostics must not reach the mode line: {mode_text:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_with_attached_diagnostic_view_does_not_deadlock() {
|
||
// Regression: paint_frame once held the diag-store mutex
|
||
// across the whole window loop, and `DiagnosticView::render`
|
||
// (attached as a window overlay when a file with an LSP
|
||
// opens) locks the same mutex — the daemon froze on the
|
||
// first frame after C-x C-f. This test renders the full
|
||
// paint_frame path with a real DiagnosticView attached; it
|
||
// hangs the suite if the lock is ever widened again.
|
||
use crate::diag::DiagnosticSeverity::Error;
|
||
let s = fresh_with(b"hello\n");
|
||
let uri = set_active_buffer_path(&s, "/tmp/modeline_overlay.rs");
|
||
let store = s.lsp_manager.borrow().diag_store();
|
||
store
|
||
.lock()
|
||
.unwrap()
|
||
.set(uri.clone(), vec![diag_with_severity(Error)]);
|
||
{
|
||
let mut core = s.core.borrow_mut();
|
||
core.active_window_mut()
|
||
.push_overlay(Box::new(crate::diag::DiagnosticView::new(uri, store)));
|
||
}
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
// Both surfaces of the same store: the overlay's underline
|
||
// and the mode line's count.
|
||
assert_eq!(
|
||
cells[0].style.underline,
|
||
crate::cell::UnderlineStyle::Curly,
|
||
"diagnostic overlay should underline the error range"
|
||
);
|
||
let mode_text = row_text(&cells, stride, 22, 80);
|
||
assert!(
|
||
mode_text.contains("E:1"),
|
||
"mode line missing count: {mode_text:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_mode_line_omits_diagnostic_counts_when_clean() {
|
||
let s = fresh_with(b"hello\n");
|
||
let _uri = set_active_buffer_path(&s, "/tmp/modeline_clean.rs");
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
let mode_text = row_text(&cells, stride, 22, 80);
|
||
assert!(
|
||
!mode_text.contains("E:") && !mode_text.contains("W:"),
|
||
"clean buffer must not show diagnostic counts: {mode_text:?}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn render_places_cursor_on_active_window() {
|
||
let mut s = fresh_with(b"abc\n");
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Right));
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::Right));
|
||
let (_, _, cursor) = render_to_grid(&s, 24, 80);
|
||
assert_eq!(cursor, Some(CellCoord::new(0, 2)));
|
||
}
|
||
|
||
#[test]
|
||
fn render_minibuffer_replaces_status_row_when_active() {
|
||
// Open M-x; the minibuffer takes over the bottom row.
|
||
let mut s = fresh_with(b"");
|
||
s.dispatch_key(FrontendId::LOCAL, alt('x'));
|
||
assert!(s.core.borrow().minibuffer.is_active());
|
||
let (cells, stride, cursor) = render_to_grid(&s, 24, 80);
|
||
let bottom = row_text(&cells, stride, 23, 80);
|
||
assert!(
|
||
bottom.starts_with("M-x"),
|
||
"minibuffer prompt missing: {bottom:?}"
|
||
);
|
||
// Cursor is on the bottom row (in the minibuffer), not in
|
||
// the buffer area.
|
||
assert_eq!(cursor.unwrap().row, 23);
|
||
}
|
||
|
||
#[test]
|
||
fn render_clears_grid_between_frames() {
|
||
// Frame 1 renders some text; frame 2 with shorter content
|
||
// must not leak frame-1 cells into the now-empty area.
|
||
let s = fresh_with(b"the quick brown fox\n");
|
||
let (cells_a, stride_a, _) = render_to_grid(&s, 24, 80);
|
||
assert!(row_text(&cells_a, stride_a, 0, 80).contains("quick"));
|
||
|
||
// Replace the buffer with shorter content via apply_active_edit.
|
||
s.core
|
||
.borrow_mut()
|
||
.apply_active_edit(crate::buffer::EditOp::Replace {
|
||
range: crate::rope::Range { start: 0, end: 20 },
|
||
bytes: b"hi\n",
|
||
})
|
||
.unwrap();
|
||
let (cells_b, stride_b, _) = render_to_grid(&s, 24, 80);
|
||
// Row 0 now has only "hi"; the rest of that row must be blank.
|
||
assert_eq!(row_text(&cells_b, stride_b, 0, 80), "hi");
|
||
}
|
||
|
||
#[test]
|
||
fn render_split_windows_paint_into_distinct_columns() {
|
||
let s = fresh_with(b"hello\nworld\n");
|
||
s.lua_host
|
||
.lua()
|
||
.load("pmacs.window.split_vertical()")
|
||
.exec()
|
||
.unwrap();
|
||
let (cells, stride, _) = render_to_grid(&s, 24, 80);
|
||
// Both halves should show "hello" on row 0, in their own
|
||
// column ranges. With a 50/50 vertical split, left half is
|
||
// cols 0..40 and right half is cols 40..80.
|
||
let left_row = (0..40)
|
||
.map(|c| glyph_at(&cells, stride, 0, c))
|
||
.collect::<String>()
|
||
.trim_end()
|
||
.to_string();
|
||
let right_row = (40..80)
|
||
.map(|c| glyph_at(&cells, stride, 0, c))
|
||
.collect::<String>()
|
||
.trim_end()
|
||
.to_string();
|
||
assert_eq!(left_row, "hello");
|
||
assert_eq!(right_row, "hello");
|
||
// Each window has its own mode line at row 22; both should
|
||
// be in reverse video.
|
||
for col in [0, 39, 40, 79] {
|
||
assert!(cells[(22 * stride + col) as usize].style.reverse);
|
||
}
|
||
}
|
||
|
||
/// Helper: assert that the active window's `TextView` has line
|
||
/// offsets covering the active buffer end. This is the universal
|
||
/// "view is in sync with buffer" invariant; staleness manifests as
|
||
/// `line_offset(line_count - 1) > buf.len()`.
|
||
fn assert_textview_covers_buffer(s: &EditorState) {
|
||
let core = s.core.borrow();
|
||
let buf_len = core.active_buffer_len();
|
||
let view = &core.active_window().text_view;
|
||
let line_count = view.line_count();
|
||
assert!(line_count >= 1, "view should always have at least one line");
|
||
let last_offset = view.line_offset(line_count - 1).unwrap();
|
||
assert!(
|
||
last_offset <= buf_len,
|
||
"TextView out of sync: last line offset {last_offset} > buf_len {buf_len}"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn paragraph_motion_no_op_at_buffer_edges() {
|
||
let mut s = fresh_with(b"single line, no breaks\n");
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Down, KeyModifiers::CONTROL));
|
||
let len = s.core.borrow().active_buffer_len();
|
||
assert_eq!(s.core.borrow().cursor(), len);
|
||
// Another C-down stays at end.
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Down, KeyModifiers::CONTROL));
|
||
assert_eq!(s.core.borrow().cursor(), len);
|
||
// C-up from there returns to start (no internal blanks).
|
||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Up, KeyModifiers::CONTROL));
|
||
assert_eq!(s.core.borrow().cursor(), 0);
|
||
}
|
||
|
||
#[test]
|
||
fn page_step_falls_back_when_no_render_yet() {
|
||
// last_visible_rows = 0 (never rendered). page_step uses 20.
|
||
let mut content = Vec::new();
|
||
for i in 0..200 {
|
||
content.extend_from_slice(format!("L{i}\n").as_bytes());
|
||
}
|
||
let mut s = fresh_with(&content);
|
||
assert_eq!(s.core.borrow().active_window().last_visible_rows, 0);
|
||
s.dispatch_key(FrontendId::LOCAL, plain(KeyCode::PageDown));
|
||
assert!(s.core.borrow().view_top() >= 20);
|
||
}
|
||
|
||
// ---- T M3.3 acceptance: Lua coroutine async API --------------------------
|
||
|
||
/// Drive `tick_async` until `predicate` is true, sleeping briefly
|
||
/// between ticks so workers have a chance to send replies. Panics
|
||
/// after a 2-second deadline so a stuck test doesn't hang CI.
|
||
fn pump_async<F: Fn(&EditorState) -> bool>(state: &mut EditorState, predicate: F) {
|
||
let deadline = std::time::Instant::now() + Duration::from_secs(2);
|
||
while !predicate(state) {
|
||
assert!(
|
||
std::time::Instant::now() < deadline,
|
||
"async pump deadline exceeded"
|
||
);
|
||
state.tick_async();
|
||
std::thread::sleep(Duration::from_millis(2));
|
||
}
|
||
}
|
||
|
||
fn lua_get<T: for<'a> mlua::FromLua + Clone>(state: &EditorState, var: &str) -> Option<T> {
|
||
state.lua_host.lua().globals().get::<T>(var).ok()
|
||
}
|
||
|
||
/// Acceptance bullet 1 + 2: a Lua coroutine yields cleanly when
|
||
/// awaiting a Handle, and resumes with the worker's result on
|
||
/// completion.
|
||
#[test]
|
||
fn async_coroutine_resumes_with_compute_sum_result() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_TEST_RESULT = nil
|
||
pmacs.async(function()
|
||
local v = pmacs.workers.compute_sum(10):await()
|
||
_G.PMACS_TEST_RESULT = v
|
||
end)
|
||
",
|
||
)
|
||
.expect("spawn coroutine");
|
||
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<i64>(s, "PMACS_TEST_RESULT").is_some()
|
||
});
|
||
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_TEST_RESULT"), Some(55));
|
||
}
|
||
|
||
/// `pmacs.workers.dispatch("compute_sum", { n = 7 })` is the
|
||
/// canonical name-based form from the spec example. It must
|
||
/// return a Handle whose `:await()` yields the same value as the
|
||
/// direct constructor.
|
||
#[test]
|
||
fn dispatch_by_name_routes_to_registered_handler() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.PMACS_TEST_RESULT = nil
|
||
pmacs.async(function()
|
||
_G.PMACS_TEST_RESULT =
|
||
pmacs.workers.dispatch("compute_sum", { n = 7 }):await()
|
||
end)
|
||
"#,
|
||
)
|
||
.expect("dispatch by name");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<i64>(s, "PMACS_TEST_RESULT").is_some()
|
||
});
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_TEST_RESULT"), Some(28));
|
||
}
|
||
|
||
/// Acceptance bullet 3: cancelled awaits raise a structured error
|
||
/// with `tag = "cancelled"` per R45.
|
||
#[test]
|
||
fn cancelled_await_raises_tagged_error() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.PMACS_TEST_TAG = nil
|
||
_G.PMACS_TEST_HANDLE_ID = nil
|
||
pmacs.async(function()
|
||
local h = pmacs.workers.sleep(2000)
|
||
_G.PMACS_TEST_HANDLE_ID = h:id()
|
||
-- Cancel ourselves before awaiting. The runtime
|
||
-- has not yet ticked, so the handle is still in
|
||
-- flight; await will park us, the worker observes
|
||
-- the cancel and replies, tick resumes us, await
|
||
-- raises { tag = "cancelled" }.
|
||
h:cancel()
|
||
local ok, err = pcall(function() return h:await() end)
|
||
if not ok and type(err) == "table" then
|
||
_G.PMACS_TEST_TAG = err.tag
|
||
elseif not ok then
|
||
_G.PMACS_TEST_TAG = "non-table-error:" .. tostring(err)
|
||
else
|
||
_G.PMACS_TEST_TAG = "unexpected-success"
|
||
end
|
||
end)
|
||
"#,
|
||
)
|
||
.expect("spawn cancelled coroutine");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<String>(s, "PMACS_TEST_TAG").is_some()
|
||
});
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_TAG"),
|
||
Some("cancelled".to_string()),
|
||
"expected R45-tagged cancellation error"
|
||
);
|
||
assert!(
|
||
lua_get::<i64>(&state, "PMACS_TEST_HANDLE_ID").is_some(),
|
||
"handle id should have been recorded"
|
||
);
|
||
}
|
||
|
||
/// `Handle:on_complete` fires the callback without requiring a
|
||
/// coroutine. This satisfies the "non-coroutine consumer" half of
|
||
/// the acceptance surface.
|
||
#[test]
|
||
fn on_complete_callback_fires_outside_a_coroutine() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_TEST_STATUS = nil
|
||
_G.PMACS_TEST_VALUE = nil
|
||
local h = pmacs.workers.compute_sum(5)
|
||
h:on_complete(function(status, value)
|
||
_G.PMACS_TEST_STATUS = status
|
||
_G.PMACS_TEST_VALUE = value
|
||
end)
|
||
",
|
||
)
|
||
.expect("install callback");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<String>(s, "PMACS_TEST_STATUS").is_some()
|
||
});
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_STATUS"),
|
||
Some("ok".to_string())
|
||
);
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_TEST_VALUE"), Some(15));
|
||
}
|
||
|
||
/// Multiple coroutines awaiting different handles all complete,
|
||
/// each with their own value. Exercises the parked-coroutine
|
||
/// table's keying.
|
||
#[test]
|
||
fn multiple_concurrent_awaits_resolve_independently() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_TEST_DONE = 0
|
||
_G.PMACS_TEST_SUM = 0
|
||
for i = 1, 5 do
|
||
pmacs.async(function()
|
||
local v = pmacs.workers.compute_sum(i):await()
|
||
_G.PMACS_TEST_SUM = _G.PMACS_TEST_SUM + v
|
||
_G.PMACS_TEST_DONE = _G.PMACS_TEST_DONE + 1
|
||
end)
|
||
end
|
||
",
|
||
)
|
||
.expect("spawn fan-out");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<i64>(s, "PMACS_TEST_DONE") == Some(5)
|
||
});
|
||
// sum_{i=1..5} of i*(i+1)/2 = 1 + 3 + 6 + 10 + 15 = 35
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_TEST_SUM"), Some(35));
|
||
}
|
||
|
||
/// T M3.4: a second dispatch with the same `supersede` key
|
||
/// cancels the first. Mirrors the spec example from R45 ---
|
||
/// the canonical "fast typist queues stale searches" pattern.
|
||
/// The first await raises `{ tag = "cancelled" }`; the second
|
||
/// completes with the new value.
|
||
#[test]
|
||
fn supersede_via_opts_cancels_predecessor_and_runs_successor() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_TEST_FIRST_TAG = nil
|
||
_G.PMACS_TEST_SECOND = nil
|
||
pmacs.async(function()
|
||
local h = pmacs.workers.sleep(2000, { supersede = 'search' })
|
||
local ok, err = pcall(function() return h:await() end)
|
||
if not ok and type(err) == 'table' then
|
||
_G.PMACS_TEST_FIRST_TAG = err.tag
|
||
end
|
||
end)
|
||
pmacs.async(function()
|
||
-- Second dispatch under the same supersede key.
|
||
-- Must settle Complete; the first must be Cancelled.
|
||
_G.PMACS_TEST_SECOND =
|
||
pmacs.workers.compute_sum(10, { supersede = 'search' }):await()
|
||
end)
|
||
",
|
||
)
|
||
.expect("spawn pair of supersede-keyed coroutines");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<String>(s, "PMACS_TEST_FIRST_TAG").is_some()
|
||
&& lua_get::<i64>(s, "PMACS_TEST_SECOND").is_some()
|
||
});
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_FIRST_TAG"),
|
||
Some("cancelled".to_string())
|
||
);
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_TEST_SECOND"), Some(55));
|
||
}
|
||
|
||
/// T M3.5: a streaming handler delivers all items through
|
||
/// `:on_batch`, terminated by `:on_close`. The Lua-side test
|
||
/// counts both items and batches and verifies coalescing.
|
||
#[test]
|
||
fn stream_on_batch_delivers_all_items_in_few_callbacks() {
|
||
let mut state = EditorState::new();
|
||
// Deliberately small cap (32) so we can prove the batch
|
||
// boundary while keeping item count moderate (1024).
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_STREAM_TOTAL = 0
|
||
_G.PMACS_STREAM_BATCHES = 0
|
||
_G.PMACS_STREAM_CLOSED = nil
|
||
local s = pmacs.workers.emit_n(1024, { max_batch = 32 })
|
||
s:on_batch(function(items)
|
||
_G.PMACS_STREAM_BATCHES = _G.PMACS_STREAM_BATCHES + 1
|
||
_G.PMACS_STREAM_TOTAL = _G.PMACS_STREAM_TOTAL + #items
|
||
end)
|
||
s:on_close(function(status, _value)
|
||
_G.PMACS_STREAM_CLOSED = status
|
||
end)
|
||
",
|
||
)
|
||
.expect("spawn stream");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<String>(s, "PMACS_STREAM_CLOSED").is_some()
|
||
});
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_STREAM_TOTAL"), Some(1024));
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_STREAM_CLOSED"),
|
||
Some("ok".to_string())
|
||
);
|
||
// Coalescing: with cap=32, ≥32 batches structurally
|
||
// (1024/32). The pump loop ticks at 2ms, the runtime
|
||
// coalesces all queued items per drain bounded by the cap.
|
||
// Bound is 1024/32 ≤ batches ≤ 1024/32 + scheduler slack.
|
||
let batches = lua_get::<i64>(&state, "PMACS_STREAM_BATCHES").unwrap_or(0);
|
||
assert!(
|
||
(32..=200).contains(&batches),
|
||
"expected batches in [32, 200], got {batches}"
|
||
);
|
||
}
|
||
|
||
/// T M3.5: the frame target and default max batch are tunable
|
||
/// from Lua via `pmacs.async_config.*`.
|
||
#[test]
|
||
fn async_config_round_trips_through_lua() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_DEFAULT_FT = pmacs.async_config.frame_target_ms()
|
||
_G.PMACS_DEFAULT_MB = pmacs.async_config.default_max_batch()
|
||
pmacs.async_config.frame_target_ms(33)
|
||
pmacs.async_config.default_max_batch(64)
|
||
_G.PMACS_NEW_FT = pmacs.async_config.frame_target_ms()
|
||
_G.PMACS_NEW_MB = pmacs.async_config.default_max_batch()
|
||
",
|
||
)
|
||
.expect("config round-trip");
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_DEFAULT_FT"), Some(16));
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_DEFAULT_MB"), Some(1024));
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_NEW_FT"), Some(33));
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_NEW_MB"), Some(64));
|
||
}
|
||
|
||
/// T M3.5 + T M3.4: a stream supersession surfaces the
|
||
/// predecessor's `Cancelled` outcome through `:on_close`.
|
||
#[test]
|
||
fn stream_supersede_delivers_cancelled_to_on_close() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_FIRST_STATUS = nil
|
||
_G.PMACS_SECOND_STATUS = nil
|
||
local first = pmacs.workers.emit_n(1000000,
|
||
{ supersede = 'emit', max_batch = 32 })
|
||
first:on_close(function(status, _v)
|
||
_G.PMACS_FIRST_STATUS = status
|
||
end)
|
||
local second = pmacs.workers.emit_n(8,
|
||
{ supersede = 'emit', max_batch = 8 })
|
||
second:on_close(function(status, _v)
|
||
_G.PMACS_SECOND_STATUS = status
|
||
end)
|
||
",
|
||
)
|
||
.expect("spawn supersede pair");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<String>(s, "PMACS_FIRST_STATUS").is_some()
|
||
&& lua_get::<String>(s, "PMACS_SECOND_STATUS").is_some()
|
||
});
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_FIRST_STATUS"),
|
||
Some("cancelled".to_string())
|
||
);
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_SECOND_STATUS"),
|
||
Some("ok".to_string())
|
||
);
|
||
}
|
||
|
||
/// `pmacs.workers.dispatch(name, args, opts)` --- the spec
|
||
/// example shape, including supersede.
|
||
#[test]
|
||
fn dispatch_by_name_accepts_supersede_opt() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_TEST_TAG = nil
|
||
_G.PMACS_TEST_VALUE = nil
|
||
pmacs.async(function()
|
||
local h = pmacs.workers.dispatch('sleep', { ms = 5000 },
|
||
{ supersede = 'job' })
|
||
local ok, err = pcall(function() return h:await() end)
|
||
if not ok and type(err) == 'table' then
|
||
_G.PMACS_TEST_TAG = err.tag
|
||
end
|
||
end)
|
||
pmacs.async(function()
|
||
_G.PMACS_TEST_VALUE = pmacs.workers.dispatch(
|
||
'compute_sum', { n = 4 },
|
||
{ supersede = 'job' }
|
||
):await()
|
||
end)
|
||
",
|
||
)
|
||
.expect("dispatch by name with supersede");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<String>(s, "PMACS_TEST_TAG").is_some()
|
||
&& lua_get::<i64>(s, "PMACS_TEST_VALUE").is_some()
|
||
});
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_TAG"),
|
||
Some("cancelled".to_string())
|
||
);
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_TEST_VALUE"), Some(10));
|
||
}
|
||
|
||
/// T M3.6: `pmacs.workers.grep` end-to-end. We build a synthetic
|
||
/// tree, dispatch a grep through Lua, and verify match items
|
||
/// arrive on `:on_batch` with the expected `{file, line,
|
||
/// match_start, match_end, text}` shape, terminated by a clean
|
||
/// `:on_close`. This is the "Lua code can do expensive things
|
||
/// without freezing the editor" surface in user-facing form.
|
||
#[test]
|
||
fn grep_via_lua_delivers_matches_through_on_batch() {
|
||
let dir = tempfile::tempdir().expect("tempdir");
|
||
std::fs::write(dir.path().join("a.txt"), "first\nneedle here\nthird\n").expect("a.txt");
|
||
std::fs::write(dir.path().join("b.txt"), "no match\n").expect("b.txt");
|
||
std::fs::write(dir.path().join("c.txt"), "needle\nfoo\nneedle again\n").expect("c.txt");
|
||
let root = dir.path().to_string_lossy().into_owned();
|
||
let mut state = EditorState::new();
|
||
let script = format!(
|
||
r#"
|
||
_G.PMACS_GREP_TOTAL = 0
|
||
_G.PMACS_GREP_FIRST_FILE = nil
|
||
_G.PMACS_GREP_FIRST_LINE = nil
|
||
_G.PMACS_GREP_FIRST_TEXT = nil
|
||
_G.PMACS_GREP_FIRST_MS = nil
|
||
_G.PMACS_GREP_FIRST_ME = nil
|
||
_G.PMACS_GREP_CLOSED = nil
|
||
local s = pmacs.workers.grep({{
|
||
root = {root:?},
|
||
pattern = "needle",
|
||
}})
|
||
s:on_batch(function(items)
|
||
for _, m in ipairs(items) do
|
||
_G.PMACS_GREP_TOTAL = _G.PMACS_GREP_TOTAL + 1
|
||
if _G.PMACS_GREP_FIRST_FILE == nil then
|
||
_G.PMACS_GREP_FIRST_FILE = m.file
|
||
_G.PMACS_GREP_FIRST_LINE = m.line
|
||
_G.PMACS_GREP_FIRST_TEXT = m.text
|
||
_G.PMACS_GREP_FIRST_MS = m.match_start
|
||
_G.PMACS_GREP_FIRST_ME = m.match_end
|
||
end
|
||
end
|
||
end)
|
||
s:on_close(function(status, _v)
|
||
_G.PMACS_GREP_CLOSED = status
|
||
end)
|
||
"#,
|
||
);
|
||
state
|
||
.lua_host
|
||
.eval(Some("test"), &script)
|
||
.expect("dispatch grep");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<String>(s, "PMACS_GREP_CLOSED").is_some()
|
||
});
|
||
// 3 matches: a.txt:2 + c.txt:1 + c.txt:3
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_GREP_TOTAL"), Some(3));
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_GREP_CLOSED"),
|
||
Some("ok".to_string())
|
||
);
|
||
// The first match table carries every documented field.
|
||
assert!(
|
||
lua_get::<String>(&state, "PMACS_GREP_FIRST_FILE").is_some_and(|f| {
|
||
std::path::Path::new(&f)
|
||
.extension()
|
||
.is_some_and(|ext| ext.eq_ignore_ascii_case("txt"))
|
||
})
|
||
);
|
||
let line = lua_get::<i64>(&state, "PMACS_GREP_FIRST_LINE").unwrap_or(0);
|
||
assert!(line >= 1, "line numbers are 1-based, got {line}");
|
||
let text = lua_get::<String>(&state, "PMACS_GREP_FIRST_TEXT").unwrap_or_default();
|
||
assert!(
|
||
text.contains("needle"),
|
||
"first match text should contain 'needle', got {text:?}"
|
||
);
|
||
let ms = lua_get::<i64>(&state, "PMACS_GREP_FIRST_MS").unwrap_or(-1);
|
||
let me = lua_get::<i64>(&state, "PMACS_GREP_FIRST_ME").unwrap_or(-1);
|
||
assert!(
|
||
ms >= 0 && me - ms == 6,
|
||
"match offsets should span 6 bytes (len 'needle'), got [{ms}, {me})"
|
||
);
|
||
}
|
||
|
||
/// T M3.6: a Lua grep dispatched under a supersede key gets
|
||
/// cancelled when a successor is dispatched under the same
|
||
/// key. The predecessor's `:on_close` fires with `"cancelled"`.
|
||
#[test]
|
||
fn grep_supersede_via_lua_cancels_predecessor() {
|
||
let dir = tempfile::tempdir().expect("tempdir");
|
||
// Synthetic load: enough work to outlive the supersede tick.
|
||
let body = "noise noise noise noise\n".repeat(50);
|
||
for i in 0..2_000 {
|
||
std::fs::write(dir.path().join(format!("f{i:04}.txt")), &body).expect("write");
|
||
}
|
||
let root = dir.path().to_string_lossy().into_owned();
|
||
let mut state = EditorState::new();
|
||
let script = format!(
|
||
r#"
|
||
_G.PMACS_GREP_FIRST_STATUS = nil
|
||
_G.PMACS_GREP_SECOND_STATUS = nil
|
||
local first = pmacs.workers.grep(
|
||
{{ root = {root:?}, pattern = "needle", fanout = 1 }},
|
||
{{ supersede = "search" }}
|
||
)
|
||
first:on_close(function(status, _v)
|
||
_G.PMACS_GREP_FIRST_STATUS = status
|
||
end)
|
||
local second = pmacs.workers.grep(
|
||
{{ root = {root:?}, pattern = "alpha", fanout = 1 }},
|
||
{{ supersede = "search" }}
|
||
)
|
||
second:on_close(function(status, _v)
|
||
_G.PMACS_GREP_SECOND_STATUS = status
|
||
end)
|
||
"#,
|
||
);
|
||
state
|
||
.lua_host
|
||
.eval(Some("test"), &script)
|
||
.expect("dispatch grep pair");
|
||
pump_async(&mut state, |s| {
|
||
lua_get::<String>(s, "PMACS_GREP_FIRST_STATUS").is_some()
|
||
&& lua_get::<String>(s, "PMACS_GREP_SECOND_STATUS").is_some()
|
||
});
|
||
let first = lua_get::<String>(&state, "PMACS_GREP_FIRST_STATUS").unwrap_or_default();
|
||
// First either ran-to-completion (extremely fast host) or got
|
||
// cancelled. Both are acceptable outcomes for the supersede
|
||
// path; the regression we guard against is the first stream
|
||
// never settling at all.
|
||
assert!(
|
||
first == "cancelled" || first == "ok",
|
||
"first close status should be cancelled or ok, got {first:?}"
|
||
);
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_GREP_SECOND_STATUS"),
|
||
Some("ok".to_string())
|
||
);
|
||
}
|
||
|
||
/// T M3.7: `pmacs.workers.snapshot()` returns a Lua-shaped
|
||
/// version of the runtime's snapshot. Active jobs come back
|
||
/// with kind labels and (non-zero) ages.
|
||
#[test]
|
||
fn workers_snapshot_via_lua_lists_active_jobs() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
local h = pmacs.workers.sleep(2000, { supersede = 'job' })
|
||
_G.PMACS_TEST_ID = h:id()
|
||
local snap = pmacs.workers.snapshot()
|
||
_G.PMACS_TEST_ACTIVE = #snap.active
|
||
_G.PMACS_TEST_KIND = snap.active[1].kind
|
||
_G.PMACS_TEST_KEY = snap.active[1].supersede
|
||
",
|
||
)
|
||
.expect("snapshot via lua");
|
||
assert!(lua_get::<i64>(&state, "PMACS_TEST_ID").is_some());
|
||
assert_eq!(lua_get::<i64>(&state, "PMACS_TEST_ACTIVE"), Some(1));
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_KIND"),
|
||
Some("sleep".to_string())
|
||
);
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_KEY"),
|
||
Some("job".to_string())
|
||
);
|
||
}
|
||
|
||
/// `pmacs.workers.show()` creates the *workers* buffer, fills
|
||
/// it with content, and binds C-c C-k to the cancel command in
|
||
/// that buffer. After tick the buffer's content reflects the
|
||
/// runtime state --- so the spec's "updates within 100 ms"
|
||
/// bound is met by frame-cadence ticks.
|
||
#[test]
|
||
fn workers_show_creates_and_refreshes_the_buffer() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
_G.PMACS_TEST_BUF = pmacs.workers.show()
|
||
_G.PMACS_TEST_LEN_BEFORE = pmacs.window.buffer():len()
|
||
",
|
||
)
|
||
.expect("show buffer");
|
||
// The show call sets workers_buffer_visible; subsequent ticks
|
||
// should refresh. Dispatch a job and tick, then re-read the
|
||
// buffer's content. We assert the second snapshot is at least
|
||
// as long as the first (it now has an active row).
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
local h = pmacs.workers.sleep(2000, { supersede = 'show' })
|
||
_G.PMACS_TEST_ID = h:id()
|
||
",
|
||
)
|
||
.expect("dispatch sleep");
|
||
// Force a tick to refresh the buffer.
|
||
state.tick_async();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r##"
|
||
local id = _G.PMACS_TEST_BUF
|
||
_G.PMACS_TEST_LEN_AFTER = id:len()
|
||
local len = id:len()
|
||
local body = id:slice(0, len)
|
||
_G.PMACS_TEST_BODY = body
|
||
_G.PMACS_TEST_HAS_ID = string.find(body, "#" .. tostring(_G.PMACS_TEST_ID), 1, true) ~= nil
|
||
_G.PMACS_TEST_HAS_KIND = string.find(body, "sleep", 1, true) ~= nil
|
||
"##,
|
||
)
|
||
.expect("read buffer body");
|
||
assert_eq!(
|
||
lua_get::<bool>(&state, "PMACS_TEST_HAS_ID"),
|
||
Some(true),
|
||
"buffer body should mention the dispatched job id"
|
||
);
|
||
assert_eq!(
|
||
lua_get::<bool>(&state, "PMACS_TEST_HAS_KIND"),
|
||
Some(true),
|
||
"buffer body should label the kind 'sleep'"
|
||
);
|
||
}
|
||
|
||
/// `pmacs.workers.cancel_at_point()` reads the cursor, parses
|
||
/// the job id at the line, and cancels the corresponding job.
|
||
/// We synthesize the cursor position by dispatching, showing,
|
||
/// finding the row's offset in the buffer body, and seeking
|
||
/// the editor's cursor there.
|
||
#[test]
|
||
fn workers_cancel_at_point_cancels_the_named_job() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r##"
|
||
local h = pmacs.workers.sleep(5000, { supersede = 'targeted' })
|
||
_G.PMACS_TEST_ID = h:id()
|
||
local buf = pmacs.workers.show()
|
||
_G.PMACS_TEST_BUF = buf
|
||
local body = buf:slice(0, buf:len())
|
||
local needle = "#" .. tostring(_G.PMACS_TEST_ID)
|
||
local row_start = string.find(body, needle, 1, true)
|
||
_G.PMACS_TEST_ROW_START = row_start
|
||
"##,
|
||
)
|
||
.expect("set up buffer + locate row");
|
||
let row_start = lua_get::<i64>(&state, "PMACS_TEST_ROW_START").expect("row located");
|
||
// Move cursor to the row's first byte by inserting/seeking via
|
||
// the editor's API. The simplest way to position the cursor
|
||
// is to switch to the *workers* buffer and call move-by-bytes.
|
||
// Easier still: call cancel_at_point directly with a synthetic
|
||
// cursor by exposing a programmatic surface. We use the raw
|
||
// binding `_job_id_at_byte` directly to verify the parser, and
|
||
// then call `_cancel` --- exactly what cancel_at_point does in
|
||
// sequence.
|
||
let id_pre_cancel = lua_get::<i64>(&state, "PMACS_TEST_ID").unwrap();
|
||
let script = format!(
|
||
r"
|
||
local id = pmacs._async._job_id_at_byte(_G.PMACS_TEST_BUF, {row_start})
|
||
_G.PMACS_TEST_PARSED = id
|
||
if id ~= nil then
|
||
pmacs._async._cancel(id)
|
||
end
|
||
"
|
||
);
|
||
state
|
||
.lua_host
|
||
.eval(Some("test"), &script)
|
||
.expect("cancel via parsed id");
|
||
assert_eq!(
|
||
lua_get::<i64>(&state, "PMACS_TEST_PARSED"),
|
||
Some(id_pre_cancel),
|
||
"parser should recover the id from the row"
|
||
);
|
||
// Pump until the job settles into Cancelled.
|
||
let id_u64 = u64::try_from(id_pre_cancel).expect("non-negative id");
|
||
pump_async(&mut state, |s| s.async_runtime.is_cancelled(id_u64));
|
||
}
|
||
|
||
/// R46 enforcement: package code that yields a non-Handle is
|
||
/// reported through `pmacs.error`, not silently accepted. The
|
||
/// runtime should not park the coroutine on a bogus value.
|
||
#[test]
|
||
fn non_handle_yield_is_reported_via_pmacs_error() {
|
||
let mut state = EditorState::new();
|
||
// Install a stub pmacs.error that records the message.
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r#"
|
||
_G.PMACS_ERROR_MSG = nil
|
||
pmacs.error = function(msg) _G.PMACS_ERROR_MSG = msg end
|
||
pmacs.async(function()
|
||
coroutine.yield("not a handle") -- R46 violation
|
||
end)
|
||
"#,
|
||
)
|
||
.expect("spawn bad coroutine");
|
||
let msg: Option<String> = lua_get(&state, "PMACS_ERROR_MSG");
|
||
assert!(msg.is_some(), "pmacs.error should have been invoked");
|
||
assert!(
|
||
msg.as_deref().unwrap_or("").contains("non-Handle"),
|
||
"message did not mention the cause: {msg:?}"
|
||
);
|
||
}
|
||
|
||
/// T M5.6f: `M-x editor.describe-instance` echoes a one-line
|
||
/// summary into the status row.
|
||
#[test]
|
||
fn editor_describe_instance_echoes_status_line() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
"pmacs.command.invoke('editor.describe-instance')",
|
||
)
|
||
.expect("invoke editor.describe-instance");
|
||
let status = state.core.borrow().status.clone();
|
||
assert!(
|
||
status.starts_with("pmacs "),
|
||
"expected pmacs version prefix in status; got {status:?}"
|
||
);
|
||
assert!(
|
||
status.contains("[local]"),
|
||
"expected default instance name marker; got {status:?}"
|
||
);
|
||
}
|
||
|
||
/// T M5.6f: `M-x editor.describe-instance-buffer` switches the
|
||
/// active window to *pmacs-instance* and binds buffer-local `q`
|
||
/// to `buffer.kill-this`. The buffer-local binding is verified by
|
||
/// resolving directly against the keymap stack — `pmacs.describe.key`
|
||
/// only consults global scope.
|
||
#[test]
|
||
fn editor_describe_instance_buffer_switches_and_binds_q() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
pmacs.command.invoke('editor.describe-instance-buffer')
|
||
_G.PMACS_TEST_NAME = pmacs.window.buffer():name()
|
||
",
|
||
)
|
||
.expect("invoke editor.describe-instance-buffer");
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_NAME"),
|
||
Some("*pmacs-instance*".to_string()),
|
||
);
|
||
|
||
let km = state.lua_host.keymaps().borrow();
|
||
let buffer_id = state.core.borrow().active_window().buffer_id;
|
||
let chords = crate::key::parse_sequence("q").unwrap();
|
||
match km.resolve(&chords, Some(buffer_id), &[]) {
|
||
crate::keymap_stack::StackResolution::Bound(rb) => {
|
||
assert_eq!(
|
||
rb.binding.command, "buffer.kill-this",
|
||
"q in *pmacs-instance* must dispatch to buffer.kill-this"
|
||
);
|
||
}
|
||
other => panic!("expected buffer-local Bound for `q`, got {other:?}"),
|
||
}
|
||
}
|
||
|
||
/// T M5.6f: `q` in the *pmacs-instance* buffer kills the buffer
|
||
/// (via `buffer.kill-this`).
|
||
#[test]
|
||
fn editor_describe_instance_buffer_q_kills_the_buffer() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
local id = pmacs.instance.show()
|
||
pmacs.keymap.bind {
|
||
scope = 'buffer', buffer = id, sequence = 'q',
|
||
command = 'buffer.kill-this',
|
||
}
|
||
pmacs.window.switch_buffer(id)
|
||
_G.PMACS_TEST_BEFORE = pmacs.window.buffer():name()
|
||
pmacs.command.invoke('buffer.kill-this')
|
||
_G.PMACS_TEST_AFTER = pmacs.window.buffer():name()
|
||
",
|
||
)
|
||
.expect("instance show + kill");
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_BEFORE"),
|
||
Some("*pmacs-instance*".to_string()),
|
||
);
|
||
assert_ne!(
|
||
lua_get::<String>(&state, "PMACS_TEST_AFTER"),
|
||
Some("*pmacs-instance*".to_string()),
|
||
"the buffer should no longer be active after kill-this"
|
||
);
|
||
}
|
||
|
||
/// `M-x editor.list-workers` opens the *workers* observability
|
||
/// buffer in the active window. The user can then use the
|
||
/// buffer-local `C-c C-k` binding (T M3.7) to cancel a job.
|
||
#[test]
|
||
fn editor_list_workers_command_switches_to_workers_buffer() {
|
||
let mut state = EditorState::new();
|
||
state
|
||
.lua_host
|
||
.eval(
|
||
Some("test"),
|
||
r"
|
||
pmacs.command.invoke('editor.list-workers')
|
||
_G.PMACS_TEST_NAME = pmacs.window.buffer():name()
|
||
",
|
||
)
|
||
.expect("invoke editor.list-workers");
|
||
assert_eq!(
|
||
lua_get::<String>(&state, "PMACS_TEST_NAME"),
|
||
Some("*workers*".to_string()),
|
||
"active window should now show the *workers* buffer"
|
||
);
|
||
}
|
||
|
||
/// `pmacs.project.search(query, opts)` is the programmatic side
|
||
/// of the `project.search` command. It dispatches a parallel
|
||
/// grep, streams batches into `*search-results*`, and supersedes
|
||
/// any predecessor under the `"search"` key. We drive it
|
||
/// against a synthetic tempdir tree, pump until the closing
|
||
/// status marker arrives, and confirm the buffer's body carries
|
||
/// the match.
|
||
#[test]
|
||
fn project_search_streams_matches_into_search_results_buffer() {
|
||
let dir = tempfile::tempdir().expect("tempdir");
|
||
std::fs::write(dir.path().join("alpha.txt"), "needle on this line\n").expect("write alpha");
|
||
std::fs::write(dir.path().join("beta.txt"), "no match here\n").expect("write beta");
|
||
let root = dir.path().display().to_string();
|
||
|
||
let mut state = EditorState::new();
|
||
let script = format!(
|
||
r#"
|
||
pmacs.project.search("needle", {{ root = "{root}" }})
|
||
"#
|
||
);
|
||
state
|
||
.lua_host
|
||
.eval(Some("test"), &script)
|
||
.expect("kick off project.search");
|
||
|
||
// Pump until the `*search-results*` buffer carries the close
|
||
// marker that our `on_close` handler appends.
|
||
pump_async(&mut state, |s| {
|
||
let _ = s.lua_host.lua().globals().set("PMACS_TEST_BODY", "");
|
||
let _ = s
|
||
.lua_host
|
||
.lua()
|
||
.load(
|
||
r#"
|
||
for _, id in ipairs(pmacs.buffer.list()) do
|
||
if pmacs.describe.buffer(id).name == "*search-results*" then
|
||
_G.PMACS_TEST_BODY = id:slice(0, id:len())
|
||
break
|
||
end
|
||
end
|
||
"#,
|
||
)
|
||
.exec();
|
||
lua_get::<String>(s, "PMACS_TEST_BODY").is_some_and(|b| b.contains("-- search "))
|
||
});
|
||
|
||
let body = lua_get::<String>(&state, "PMACS_TEST_BODY").expect("body captured");
|
||
assert!(
|
||
body.contains("alpha.txt"),
|
||
"results should mention the matching file: {body}"
|
||
);
|
||
assert!(
|
||
body.contains("needle on this line"),
|
||
"results should include the matched text: {body}"
|
||
);
|
||
assert!(
|
||
!body.contains("beta.txt"),
|
||
"non-matching file should not appear: {body}"
|
||
);
|
||
}
|
||
}
|