Skip to main content

strop_engine/editor/
mod.rs

1//! The editor state: modes, pending keys, named registers, buffers.
2//! One input path for TUI and headless — both call `feed`.
3//!
4//! Mode handlers live beside this file: `normal`, `visual`, `insert`.
5
6mod blame;
7pub mod block;
8#[cfg(test)]
9pub mod conformance;
10#[cfg(test)]
11pub mod contract_probes;
12pub mod trace;
13
14pub(crate) mod analysis;
15mod changes;
16pub use changes::review::ReviewRow;
17pub use dispatch::InputOwner;
18pub mod collections;
19mod containers;
20mod cursor;
21mod diagnostics;
22mod dispatch;
23pub mod resolution;
24pub use diagnostics::DocumentDiagnostics;
25mod dive;
26mod document;
27pub mod events;
28mod explain;
29mod git;
30mod git_memory;
31mod help;
32mod indent;
33mod input;
34mod insert;
35pub mod io;
36mod jumps;
37pub mod keys;
38mod lsp;
39pub mod macros;
40pub(crate) mod matching;
41#[cfg(test)]
42mod multicursor_tests;
43pub(crate) mod normal;
44mod occurrence;
45#[cfg(test)]
46mod occurrence_tests;
47mod panes;
48pub mod pending;
49mod permalink;
50mod picker;
51mod registers;
52pub mod remote;
53mod remote_completion;
54mod shell;
55pub mod transact;
56mod undo;
57pub mod view;
58mod visual;
59mod workspaces;
60
61pub use collections::{CollectionRow, CollectionRowInfo};
62pub use document::Document;
63pub use document::{DiffRow, DocumentSource, RemoteDirectory, RemoteDocument, Surface};
64pub use git_memory::{git_channel, BlameGutter, GitJob};
65pub use git_memory::{CommitFiles, PreparedDiff, PreparedFiles, Sidebar, SidebarRow};
66pub use panes::{LayoutDir, Pane};
67pub use picker::{PickerGlue, PreviewKey, PreviewResult, PreviewSource, Previews};
68pub use registers::{ClipboardKey, ClipboardResult, Register};
69pub use shell::{ShellIntent, ShellKey, ShellResult};
70
71pub use containers::{ContainerKey, ContainerResult};
72pub use lsp::attach::AttachRecord;
73pub use lsp::LspServer;
74pub use permalink::PendingPermalink;
75pub use picker::ranking::Event as RankingEvent;
76pub use remote::{RemoteEvent, RemoteView};
77pub use remote_completion::{RemoteCompletionKey, RemoteCompletionResult};
78pub use resolution::{ResolutionEvent, ResolutionState};
79use std::collections::HashMap;
80use std::path::PathBuf;
81use std::time::Duration;
82pub use workspaces::WorkspaceRegistry;
83
84use strop_core::{Buffer, Range};
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum Mode {
88    Normal,
89    Insert,
90    Visual,
91    VisualLine,
92    /// ctrl-v: the rectangle selection (0017).
93    VisualBlock,
94}
95
96impl Mode {
97    pub fn chip(self) -> &'static str {
98        match self {
99            Mode::Normal => "NORMAL",
100            Mode::Insert => "INSERT",
101            Mode::Visual => "VISUAL",
102            Mode::VisualLine => "V-LINE",
103            Mode::VisualBlock => "V-BLOCK",
104        }
105    }
106}
107
108#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
109pub enum Key {
110    Char(char),
111    Esc,
112    Enter,
113    Backspace,
114    Up,
115    Down,
116    Left,
117    Right,
118    Tab,
119    Backtab,
120    /// Replace picker: exclude the row's whole file (vscode's toggle).
121    CtrlD,
122    CtrlR,
123    /// vim's jump-back (ctrl-i forward is Tab in a terminal).
124    CtrlO,
125    /// ctrl-space: query suggestions in a query field (0051 R02).
126    CtrlSpace,
127    CtrlW,
128    /// Replace picker: exclude/include the selected match (0007 §2).
129    CtrlX,
130    /// vim ctrl-u/ctrl-f/ctrl-b: half/full page up.
131    CtrlU,
132    CtrlF,
133    CtrlB,
134    /// vim ctrl-^: alternate buffer.
135    CtrlCaret,
136    /// vim ctrl-v: visual block mode.
137    CtrlV,
138    /// vim ctrl-l: force a full terminal repaint (desync recovery).
139    CtrlL,
140}
141
142pub const FLASH_FOR: Duration = Duration::from_millis(280);
143
144/// The injected frame renderer's shape (0046): editor, columns, rows,
145/// record-action flag — the binary's implementation renders via TestBackend.
146pub type FrameDraw = fn(&mut Editor, u16, u16, bool) -> std::io::Result<()>;
147
148pub struct Editor {
149    pub docs: strop_core::id::Arena<strop_core::id::DocumentKind, Document>,
150    pub(crate) trace_documents:
151        HashMap<strop_core::id::DocumentId, strop_core::diagnostics::BufferTraceId>,
152    pub io: io::IoState,
153    pub(crate) remote: remote::RemoteState,
154    pub(crate) remote_completion: remote_completion::RemoteCompletionState,
155    pub(crate) worker_ids: strop_core::worker::WorkerIds,
156    pub(crate) worker_handles:
157        HashMap<strop_core::worker::WorkerId, strop_core::worker::CancelHandle>,
158    pub(crate) focus_epoch: u64,
159    pub(crate) finishing: bool,
160    pub tape: std::rc::Rc<strop_trace::replay::Tape>,
161    pub git_view: strop_core::worker::WorkerId,
162    pub git_discovery: strop_core::worker::Load<git_memory::ContextKey>,
163    pub hunk_load: strop_core::worker::Load<git_memory::HunkKey>,
164    pub hunks_untracked: bool,
165    pub log_requests:
166        HashMap<strop_core::id::DocumentId, strop_core::worker::Ticket<git_memory::LogKey>>,
167    pub card_request: Option<strop_core::worker::Ticket<git_memory::CardKey>>,
168    pub dive_requests:
169        HashMap<strop_core::id::DocumentId, strop_core::worker::Ticket<git_memory::DiveKey>>,
170    pub git_mutations: std::collections::VecDeque<git_memory::GitMutation>,
171    pub git_mutation: Option<strop_core::worker::Ticket<git_memory::MutationKey>>,
172    /// vim's jumplist (ctrl-o/ctrl-i): past/future stacks of named
173    /// navigation/view records — caret, selection, viewport and
174    /// horizontal origin (0051 §7, jumps.rs).
175    pub jumplist_past: Vec<jumps::JumpRecord>,
176    pub jumplist_future: Vec<jumps::JumpRecord>,
177    pub mode: Mode,
178    pub pending: pending::PendingInput,
179    /// The input walker (0008 stage 2): typed parser state for
180    /// counts/registers/operators/prefixes — pending stays for the
181    /// free-text lines only.
182    pub walker: input::Walker,
183    /// `Space u` browser state (editor/undo.rs); None when closed.
184    pub undo_browser: Option<undo::UndoBrowser>,
185    /// Last `f/F/t/T` find: (char, backward, till). `;` and `,` replay it.
186    pub last_find: Option<(char, bool, bool)>,
187    /// Armed by `/`/`?`/`*`/`#` searches. `n`/`N` replay it; the render
188    /// highlights matches persistently (rootle: current match underlined).
189    pub last_search: Option<LastSearch>,
190    /// Live occurrence selection (0049 §7): needle + add-order ranges.
191    pub(crate) occurrence: Option<occurrence::OccurrenceState>,
192    pub registers: HashMap<char, Register>,
193    /// Marks: char → (document, byte offset). `m{a}` sets, `'{a}` jumps.
194    pub marks: HashMap<char, (strop_core::id::DocumentId, usize)>,
195    pub flash: Option<(Range, strop_trace::replay::Tick)>,
196    pub message: String,
197    pub should_quit: bool,
198    /// ctrl-c is armed after the first warn (0015 quit policy).
199    pub ctrl_c_armed: bool,
200    /// Last visual range for `gv` (recorded per visual-mode key).
201    pub last_visual: Option<(usize, usize)>,
202    /// Where the last insert session was for `gi`.
203    pub last_insert_pos: Option<usize>,
204    /// g;/g, walk: (index, history depth it was taken at) — a new
205    /// commit invalidates the walk.
206    pub change_idx: Option<(usize, usize)>,
207    /// Text area height in rows — the render loop feeds it via
208    /// scroll_to_cursor; viewport motions read it.
209    pub view_rows: usize,
210    /// Macro recording (0016): the register being recorded into.
211    pub recording: Option<char>,
212    /// The app event channel (0018): set by connect_events; late LSP
213    /// attaches forward through it.
214    pub app_tx: Option<events::EventSender>,
215    pub(crate) lsp_state: lsp::state::LspState,
216    /// Recorded macros: register → key events.
217    pub macros: std::collections::HashMap<char, Vec<Key>>,
218    /// The last replayed macro register (@@).
219    pub last_macro: Option<char>,
220    /// The rows and cell edge owned by an ongoing block insert/change.
221    pub(crate) block_insert_state: Option<block::BlockInsertState>,
222    /// Macro self-replay depth guard.
223    pub macro_depth: usize,
224    pub picker: Option<PickerGlue>,
225    pub(crate) picker_ranking: picker::ranking::State,
226    pub(crate) analysis: analysis::AnalysisState,
227    pub resolution: resolution::ResolutionState,
228    pub cwd: PathBuf,
229    /// Bound workspace contexts (0042 slice 2): one per filesystem in use.
230    pub workspaces: workspaces::WorkspaceRegistry,
231    /// Applied change plans and their receipts (0043); grouped undo reads it.
232    pub(crate) changes: changes::ChangeState,
233    pub(crate) review: changes::review::ReviewState,
234    /// Open editable code collections by their buffer document (0044).
235    pub(crate) collections: HashMap<strop_core::id::DocumentId, collections::Collection>,
236    /// A build waiting on background source loads (0044 v2).
237    pub(crate) collection_build: Option<collections::CollectionBuild>,
238    /// Container attach/browse state (0037 DC1a).
239    pub(crate) containers: containers::ContainerState,
240    /// MRU document order (most recent first); drives `Space b`.
241    pub mru: Vec<strop_core::id::DocumentId>,
242    /// Picker preview file cache.
243    pub previews: Previews,
244    /// Git working surface state (M2).
245    pub git: Option<strop_git::GitContext>,
246    /// Preview file reads run on worker threads (0001 §3); results and
247    /// the in-flight set are drained in drain_picker.
248    pub preview_tx: std::sync::mpsc::Sender<PreviewResult>,
249    pub preview_rx: Option<std::sync::mpsc::Receiver<PreviewResult>>,
250    pub(crate) preview_loads: HashMap<PathBuf, strop_core::worker::Load<PreviewKey>>,
251    pub hunks: git_memory::HunkSet,
252    /// HEAD↔index — the staged set (0014 wave 4); rendered in the
253    /// gutter's committed-adjacent color.
254    pub staged_hunks: git_memory::HunkSet,
255    /// Git memory (M3): per-buffer surface kinds, blame card, job channel,
256    /// OSC52 clipboard payload drained by the TUI.
257    pub blame_card: Option<strop_git::memory::BlameCard>,
258    /// Each full/range/tail document owns its own revision-checked gutter.
259    pub blame_gutters: HashMap<strop_core::id::DocumentId, BlameGutter>,
260    /// Bumped on every buffer-list mutation; git jobs carry the
261    /// generation they were spawned under so results for dead
262    /// surfaces are dropped (0011 §2).
263    pub generation: u64,
264    pub git_tx: std::sync::mpsc::Sender<GitJob>,
265    pub git_rx: Option<std::sync::mpsc::Receiver<GitJob>>,
266    pub osc52: Option<String>,
267    pub terminal_output: Vec<String>,
268    /// ctrl-l: the terminal desynced from the model — the draw loop
269    /// answers with a full repaint (vim's redraw).
270    pub needs_repaint: bool,
271    /// System-clipboard reads (paste from `+`) run on a worker thread;
272    /// `clip_paste_pending` remembers before/after AND the initiating
273    /// document until the read lands (0023 §4).
274    pub clip_tx: std::sync::mpsc::Sender<ClipboardResult>,
275    pub clip_rx: Option<std::sync::mpsc::Receiver<ClipboardResult>>,
276    pub clip_paste_pending: Option<(bool, strop_core::worker::Ticket<ClipboardKey>)>,
277    /// LSP server pool (0014 wave 2): one client per (workspace root,
278    /// server) — a rust file and a python file in one session get their
279    /// own servers. Diagnostics by path, hover card, open bookkeeping.
280    pub lsp_servers: Vec<crate::editor::lsp::LspServer>,
281    pub diags: HashMap<strop_core::id::DocumentId, DocumentDiagnostics>,
282    pub hover_card: Option<String>,
283    /// Shell jobs (`:!cmd` output buffers, `|cmd` pipes): results land
284    /// in drain_shell — never a subprocess on the input path (0001 §3).
285    pub shell_tx: std::sync::mpsc::Sender<ShellResult>,
286    pub shell_rx: Option<std::sync::mpsc::Receiver<ShellResult>>,
287    pub(crate) shell_requests: HashMap<strop_core::worker::WorkerId, ShellIntent>,
288    pub(crate) shell_focus: Option<strop_core::worker::WorkerId>,
289    /// Splits: flat row/column of panes (v1; tree layout later).
290    pub panes: Vec<Pane>,
291    pub active_pane: usize,
292    pub layout: LayoutDir,
293    /// User config (0005-lite: TOML, embedded defaults, never bricks).
294    pub config: crate::config::Config,
295    /// Shared state root for explicit trust and optional session persistence.
296    pub state_dir: Option<PathBuf>,
297    pub session_policy: crate::session::SessionPolicy,
298    /// The last grammar-level change (dot-repeat's semantic form).
299    pub(crate) last_change: Option<strop_grammar::Command>,
300    /// Direct non-grammar commands (x, p, J…) replay their key string.
301    pub(crate) last_cmd_keys: String,
302    pub(crate) last_insert: Option<String>,
303    pub(crate) recording_insert: Option<String>,
304    /// vim insert counts: `3i…`/`2o` repeat the session's text (o/O
305    /// repeat the opened line too — `insert_open` carries it).
306    pub(crate) insert_count: usize,
307    pub(crate) insert_open: Option<String>,
308    /// Injected frame renderer (0046): cell-grid production belongs to the
309    /// binary; replay of a recorded `Frame` action renders through this
310    /// hook. The engine never renders on its own.
311    pub frame_draw: Option<FrameDraw>,
312}
313
314/// The compiled query owns matching semantics for repeat, preview and highlighting.
315#[derive(Debug, Clone)]
316pub struct LastSearch {
317    pub query: strop_grammar::CompiledQuery,
318    pub backward: bool,
319}
320
321/// A pending `f/F/t/T` awaiting its target char — the leap-style
322/// candidate overlay's input. Named fields, not a naked `(u8, bool)`.
323#[derive(Debug, Clone, Copy, PartialEq, Eq)]
324pub struct FindPending {
325    pub ch: char,
326    pub backward: bool,
327}
328
329/// The ctrl-v rectangle (0017): line span by buffer index, cell span
330/// by LineLayout columns — named fields, not a naked mixed-unit tuple.
331#[derive(Debug, Clone, Copy, PartialEq, Eq)]
332pub struct BlockRect {
333    pub first_line: usize,
334    pub last_line: usize,
335    pub left_cell: strop_core::id::DisplayColumn,
336    pub right_cell: strop_core::id::DisplayColumn,
337}
338
339impl Editor {
340    pub fn new(buf: Buffer) -> Self {
341        // cwd is the process directory (project-wide): pickers walk it,
342        // LSP/git resolve against it; a file's own dir is not the project.
343        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
344        Self::new_in(buf, cwd)
345    }
346
347    /// Pure construction. Native services start explicitly after forensic seeding.
348    pub fn new_in(buf: Buffer, cwd: PathBuf) -> Self {
349        let (preview_tx, preview_rx) = std::sync::mpsc::channel();
350        let (shell_tx, shell_rx) = std::sync::mpsc::channel();
351        let (clip_tx, clip_rx) = std::sync::mpsc::channel();
352        let (git_tx, git_rx) = git_channel();
353        let mut docs = strop_core::id::Arena::default();
354        // the source identity is set at construction, not by convention
355        let doc = if buf.path.is_some() {
356            Document::new(buf)
357        } else {
358            Document::scratch(buf)
359        };
360        let current = docs.insert(doc);
361        Self {
362            docs,
363            trace_documents: HashMap::new(),
364            io: io::IoState::default(),
365            remote: remote::RemoteState::default(),
366            remote_completion: remote_completion::RemoteCompletionState::default(),
367            picker_ranking: picker::ranking::State::default(),
368            analysis: analysis::AnalysisState::default(),
369            resolution: resolution::ResolutionState::default(),
370            worker_ids: strop_core::worker::WorkerIds::default(),
371            worker_handles: HashMap::new(),
372            focus_epoch: 0,
373            finishing: false,
374            tape: std::rc::Rc::new(strop_trace::replay::Tape::new()),
375            git_view: strop_core::worker::WorkerId::new(0),
376            git_discovery: strop_core::worker::Load::Idle,
377            hunk_load: strop_core::worker::Load::Idle,
378            hunks_untracked: false,
379            log_requests: HashMap::new(),
380            card_request: None,
381            dive_requests: HashMap::new(),
382            git_mutations: std::collections::VecDeque::new(),
383            git_mutation: None,
384            shell_requests: HashMap::new(),
385            shell_focus: None,
386            containers: containers::ContainerState::default(),
387            mru: vec![current],
388            changes: changes::ChangeState::default(),
389            review: changes::review::ReviewState::default(),
390            collections: HashMap::new(),
391            collection_build: None,
392            mode: Mode::Normal,
393            occurrence: None,
394            pending: pending::PendingInput::default(),
395            walker: input::Walker::new(),
396            last_search: None,
397            undo_browser: None,
398            registers: HashMap::new(),
399            marks: HashMap::new(),
400            last_find: None,
401            flash: None,
402            message: String::new(),
403            should_quit: false,
404            ctrl_c_armed: false,
405            last_visual: None,
406            last_insert_pos: None,
407            change_idx: None,
408            view_rows: 24,
409            recording: None,
410            app_tx: None,
411            lsp_state: lsp::state::LspState::default(),
412            macros: std::collections::HashMap::new(),
413            last_macro: None,
414            block_insert_state: None,
415            macro_depth: 0,
416            last_change: None,
417            last_cmd_keys: String::new(),
418            last_insert: None,
419            recording_insert: None,
420            insert_count: 1,
421            insert_open: None,
422            picker: None,
423            workspaces: {
424                let mut registry = workspaces::WorkspaceRegistry::default();
425                registry.bind(strop_workspace::Filesystem::Local, Some(cwd.clone()));
426                registry
427            },
428            cwd,
429            blame_gutters: HashMap::new(),
430            generation: 0,
431            previews: HashMap::new(),
432            shell_tx,
433            shell_rx: Some(shell_rx),
434            git: None,
435            hunks: git_memory::HunkSet::default(),
436            staged_hunks: git_memory::HunkSet::default(),
437            blame_card: None,
438            git_tx,
439            git_rx: Some(git_rx),
440            needs_repaint: false,
441            osc52: None,
442            terminal_output: Vec::new(),
443            preview_tx,
444            preview_rx: Some(preview_rx),
445            preview_loads: HashMap::new(),
446            jumplist_past: Vec::new(),
447            jumplist_future: Vec::new(),
448            lsp_servers: Vec::new(),
449            clip_tx,
450            clip_rx: Some(clip_rx),
451            clip_paste_pending: None,
452            diags: HashMap::new(),
453            hover_card: None,
454            panes: vec![Pane {
455                doc: current,
456                sels: strop_core::selection::SelectionSet::default(),
457                view_top: 0,
458                hscroll: strop_core::id::DisplayColumn::new(0),
459                desired_column: None,
460            }],
461            active_pane: 0,
462            layout: LayoutDir::Row,
463            config: crate::config::Config::default(),
464            state_dir: None,
465            session_policy: crate::session::SessionPolicy::Automatic,
466            frame_draw: None,
467        }
468    }
469
470    pub fn feed_text(&mut self, text: &str) {
471        for key in keys::parse(text) {
472            self.feed(key);
473        }
474    }
475
476    pub fn feed(&mut self, key: Key) {
477        let _trace_scope = trace::InputScope::enter(self, key);
478        self.trace_state();
479        let generated = self.resolution.in_action;
480        if self.resolution.blocked()
481            || (!generated && !self.resolution.queue.is_empty())
482            || (generated && !self.resolution.staged.is_empty())
483        {
484            if generated {
485                self.resolution
486                    .staged
487                    .push_back(resolution::DeferredInput::GeneratedKey {
488                        key,
489                        depth: self.macro_depth,
490                    });
491            } else {
492                self.resolution
493                    .queue
494                    .push_back(resolution::DeferredInput::Key(key));
495            }
496            return;
497        }
498        self.run_input_action(|editor| {
499            editor.feed_inner(key);
500            editor.prepare_resolution_preview();
501        });
502        self.trace_state();
503    }
504
505    fn feed_inner(&mut self, key: Key) {
506        self.lsp_state.hover = None;
507        if let Some(build) = self.collection_build.as_mut() {
508            build.focus_on_ready = false;
509        }
510        self.revoke_shell_focus();
511        self.message.clear();
512        if key == Key::Esc
513            && !self.pending.is_active()
514            && self.cancel_open(strop_core::worker::CancelReason::Dismissed)
515        {
516            self.message = "open cancelled".into();
517        }
518        // macro recording (0016): q at ground stops and never reaches
519        // the machine; everything else records BEFORE it runs, so
520        // replay is exactly the live stream
521        if let Some(reg) = self.recording {
522            let at_ground = self.walker.is_ground() && !self.pending.is_active();
523            if at_ground && key == Key::Char('q') {
524                self.recording = None;
525                self.message = format!("recorded @{}", reg);
526                return;
527            }
528            if let Some(buf) = self.macros.get_mut(&reg) {
529                buf.push(key);
530            }
531        }
532        self.dispatch_owned(key);
533    }
534
535    /// True when a modal input field sits in normal mode (picker field
536    /// or pending line) — the TUI draws the block cursor for it.
537    pub fn input_normal(&self) -> bool {
538        self.pending.normal()
539            || self
540                .picker
541                .as_ref()
542                .is_some_and(|g| g.picker.input_normal())
543    }
544
545    /// The modal line's sigil when a free-text line is open (`: / ? |`)
546    /// — the ONE authority; the render card, the terminal's bar-cursor
547    /// shape, and pending dispatch all ask here (a `|sed s/a/b/` body
548    /// is a pipe, not a search).
549    pub fn pending_sigil(&self) -> Option<char> {
550        self.pending.sigil()
551    }
552
553    /// The current document's indent (config default or detected at
554    /// open — resolved eagerly, so reads never rescan).
555    pub(crate) fn cur_indent(&self) -> document::Indent {
556        self.indentation_at(self.current(), self.head())
557    }
558
559    // ---- shared helpers -------------------------------------------------
560
561    /// `m{a}`: set mark a at the cursor.
562    pub(crate) fn set_mark(&mut self, mark: char) {
563        self.marks.insert(mark, (self.current(), self.head()));
564        self.message = format!("mark {mark} set");
565    }
566
567    /// (name, 1-based line, trimmed line text) per set mark, name-sorted —
568    /// the which-key mark cards' live rows (0047 §3).
569    pub fn mark_rows(&self) -> Vec<(char, usize, String)> {
570        let mut rows: Vec<_> = self
571            .marks
572            .iter()
573            .filter_map(|(name, (document, offset))| {
574                let doc = self.docs.get(*document)?;
575                let line = doc.buf.line_of(*offset);
576                let text: String = doc.buf.line_text(line).trim().chars().take(48).collect();
577                Some((*name, line + 1, text))
578            })
579            .collect();
580        rows.sort_by_key(|row| row.0);
581        rows
582    }
583
584    /// `'{a}`: jump to mark a (switches buffer if the mark lives there).
585    pub(crate) fn jump_mark(&mut self, mark: char) {
586        self.push_jump(); // mark jumps are jumplist entries
587        match self.marks.get(&mark).copied() {
588            Some((buf, offset)) => {
589                if self.docs.get(buf).is_some() {
590                    if buf != self.current() {
591                        self.switch_to(buf);
592                        self.discover_git();
593                    }
594                    self.set_head(
595                        self.buf()
596                            .clamp_boundary(offset.min(self.buf().len_bytes())),
597                    );
598                    self.clamp_cursor();
599                    // mark jumps use the 0051 §7 landing placement:
600                    // center unless comfortably visible
601                    self.place_jump_target();
602                }
603            }
604            None => self.message = format!("mark {mark} not set"),
605        }
606    }
607}
608
609/// Local-channel delivery and fixture helpers for engine-consumer tests;
610/// live drivers use the shared AppEvent channel.
611#[cfg(any(test, feature = "test-support"))]
612pub mod test_support;
613#[cfg(test)]
614mod tests;
615#[cfg(test)]
616mod transaction_conformance;
617
618impl Drop for Editor {
619    fn drop(&mut self) {
620        // One shutdown boundary, including headless errors and terminal failures.
621        for handle in std::mem::take(&mut self.worker_handles).into_values() {
622            handle.cancel(strop_core::worker::CancelReason::Shutdown);
623        }
624        for server in std::mem::take(&mut self.lsp_servers) {
625            if let Some(client) = server.client {
626                client.shutdown();
627                client.wait(Duration::from_millis(500));
628            }
629        }
630    }
631}
632
633/// The headless `state` directive's logical observation (0006): mode,
634/// cursor, pending input, picker and register state — no cells. The
635/// binary's headless driver prints it; the forensic observation embeds it.
636pub fn state_json(editor: &Editor) -> String {
637    if editor.docs.is_empty() {
638        return serde_json::json!({"should_quit":editor.should_quit,"documents":0,"message":editor.message}).to_string();
639    }
640    serde_json::json!({
641        "mode": editor.mode.chip(),
642        "cursor": editor.head(),
643        "line": editor.buf().line_of(editor.head()) + 1,
644        "col": editor.buf().col_of(editor.head()) + 1,
645        "pending": editor.pending.text(),
646        "message": editor.message,
647        "extra_cursors": editor.extra_selections().iter().map(|s| s.head).collect::<Vec<_>>(),
648        "panes": editor.panes.len(),
649        "active_pane": editor.active_pane,
650        "picker": editor.picker_open(),
651        "picker_input": editor.picker.as_ref().map(|g| g.picker.input.text.clone()),
652        "picker_items": editor.picker.as_ref().map(|g| g.picker.items.len()),
653        "picker_streaming": editor.picker.as_ref().map(|g| g.picker.streaming),
654        "register": editor.register(None).text,
655        "dirty": editor.buf().dirty,
656    })
657    .to_string()
658}