fresh/test_api.rs
1//! Test-only observation API for the editor.
2//!
3//! The semantic test suite (under `tests/semantic/`) binds **only** to this
4//! module — it must never reach into `crate::app::Editor`, `crate::model::*`,
5//! or `crate::view::*` directly. That keeps the test/production contract
6//! explicit and one-directional: production internals can be refactored
7//! freely, the test API is the only thing that has to stay stable.
8//!
9//! See `docs/internal/e2e-test-migration-design.md` for the full rationale.
10//!
11//! # Layers
12//!
13//! Phase 2 (current) exposes only Class A — pure state observables:
14//! `dispatch`, `dispatch_seq`, `buffer_text`, `primary_caret`, `carets`,
15//! `selection_text`. Layout (`RenderSnapshot`) and styled-frame
16//! (`StyledFrame`) observables are reserved for Phase 3+ and intentionally
17//! not present here yet — adding them is a design decision that should be
18//! made when the first theorem demanding them is written.
19//!
20//! # Determinism
21//!
22//! `carets()` returns cursors in ascending byte-position order so that
23//! tests don't depend on `HashMap` iteration order (cursors are stored in
24//! a hashmap internally).
25
26// Re-export Action so semantic tests can `use fresh::test_api::Action`
27// without reaching into `fresh::input::keybindings` directly. Keeping
28// the action alphabet behind the test_api module is part of the
29// one-directional contract documented in §2.1 of the design doc.
30pub use crate::input::keybindings::Action;
31
32/// A test-side projection of `crate::model::cursor::Cursor`.
33///
34/// Carries only the fields that semantic tests typically assert on
35/// (position + selection anchor). Internal fields like `sticky_column`,
36/// `deselect_on_move`, and `block_anchor` are intentionally hidden — if a
37/// test needs them, the right fix is to extend this projection (with
38/// review) rather than reach past it.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
40pub struct Caret {
41 /// Byte offset where edits happen.
42 pub position: usize,
43 /// Selection anchor, if a selection is active.
44 pub anchor: Option<usize>,
45}
46
47/// Test-side projection of the editor's popup stack. Captures only
48/// the fields scenario tests assert on — kind, title, items,
49/// selection — so internal popup struct refactors don't break tests.
50///
51/// Two distinct modal channels live on the editor and both are
52/// projected here:
53/// - `top_popup` / `depth` come from the popup stacks
54/// (`global_popups`, per-window `popups`) — completion,
55/// hover, action, list, text overlays.
56/// - `prompt` comes from `active_window().prompt` — the
57/// minibuffer / floating-overlay prompts opened by actions
58/// like `CommandPalette`, `QuickOpen`, `GotoLine`, `Search`,
59/// `OpenLiveGrep`, `SaveAs`, `RecordMacro`, etc. These do not
60/// live on the popup stacks; without projecting them, modal
61/// scenarios that drive prompt flows pass by tautology.
62#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
63pub struct ModalSnapshot {
64 /// `None` ⇒ no popup visible on either popup stack.
65 pub top_popup: Option<PopupView>,
66 /// Popup-stack depth across both stacks (0 = no popups).
67 /// Does NOT count an active prompt — see the `prompt` field
68 /// for that.
69 pub depth: usize,
70 /// Active minibuffer prompt, if one is open. `None` ⇒ no
71 /// prompt; the user is in normal editing mode.
72 pub prompt: Option<PromptView>,
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
76pub struct PopupView {
77 /// Popup kind name: `"completion"`, `"hover"`, `"action"`,
78 /// `"list"`, `"text"`. Stable strings (not the enum variant
79 /// debug repr) so corpus JSON survives refactors.
80 pub kind: String,
81 pub title: Option<String>,
82 /// List items as plain text. Empty for non-list popups.
83 pub items: Vec<String>,
84 pub selected_index: Option<usize>,
85}
86
87/// Test-side projection of an active minibuffer prompt.
88///
89/// `prompt_type` is the `PromptType` variant name as a stable
90/// string (`"CommandPalette"`, `"QuickOpen"`, `"GotoLine"`,
91/// `"Search"`, …) so corpus JSON survives variant renames in
92/// production code. The runner inserts characters into the active
93/// prompt via `Action::InsertChar` (the production input handler
94/// routes those automatically when a prompt is open), so the
95/// `input` / `cursor_pos` fields are the right level to assert on.
96#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
97pub struct PromptView {
98 pub prompt_type: String,
99 pub input: String,
100 pub cursor_pos: usize,
101 /// Filtered suggestion texts shown to the user. Empty for
102 /// prompts that don't filter (e.g. `GotoLine`).
103 pub suggestions: Vec<String>,
104 pub selected_suggestion: Option<usize>,
105}
106
107impl Caret {
108 /// Cursor with no selection.
109 pub fn at(position: usize) -> Self {
110 Self {
111 position,
112 anchor: None,
113 }
114 }
115
116 /// Cursor with a selection from `anchor` to `position`.
117 /// Direction is preserved (anchor may be greater than position).
118 pub fn range(anchor: usize, position: usize) -> Self {
119 Self {
120 position,
121 anchor: Some(anchor),
122 }
123 }
124
125 /// Sorted byte range covered by this caret's selection, if any.
126 pub fn selection_range(&self) -> Option<std::ops::Range<usize>> {
127 self.anchor.map(|a| {
128 if a <= self.position {
129 a..self.position
130 } else {
131 self.position..a
132 }
133 })
134 }
135}
136
137/// The single observation surface for semantic theorem tests.
138///
139/// Implemented by `crate::app::Editor`. Tests obtain a
140/// `&mut dyn EditorTestApi` from the test harness and never see the
141/// underlying `Editor` type directly.
142pub trait EditorTestApi {
143 // ── Drive ────────────────────────────────────────────────────────────
144
145 /// Apply a single semantic action, then drain async messages.
146 fn dispatch(&mut self, action: Action);
147
148 /// Apply a sequence of actions in order, draining async messages once
149 /// at the end. Equivalent to calling `dispatch` per action but cheaper.
150 fn dispatch_seq(&mut self, actions: &[Action]);
151
152 // ── Class A: pure state observables ──────────────────────────────────
153
154 /// Full buffer text. Panics if the buffer has unloaded regions
155 /// (large-file mode); semantic theorems are not the right tool for
156 /// large-file scenarios — write a layout/E2E test instead.
157 fn buffer_text(&self) -> String;
158
159 /// Primary cursor projected to a `Caret`.
160 fn primary_caret(&self) -> Caret;
161
162 /// All cursors projected to `Caret`s, sorted by ascending position.
163 /// The primary cursor is included. Use `primary_caret()` if you only
164 /// care about the primary; use `carets()` for multi-cursor theorems.
165 fn carets(&self) -> Vec<Caret>;
166
167 /// Concatenated selected text across all cursors, in ascending position
168 /// order, joined by `\n`. Returns the empty string if no cursor has a
169 /// selection.
170 fn selection_text(&mut self) -> String;
171
172 // ── Class B: layout observables ──────────────────────────────────────
173 //
174 // These reflect viewport state that is reconciled by the render
175 // pipeline (`Viewport::ensure_visible_in_layout`), not by action
176 // dispatch alone. The `LayoutTheorem` runner invokes
177 // `EditorTestHarness::render` exactly once before reading them.
178 //
179 // This is intentionally a *thin* layout surface — just `top_byte`
180 // for now. The `RenderSnapshot` design (see §9.1 of the migration
181 // doc) is the right home for richer layout observables (gutter
182 // spans, scrollbar geometry, hardware cursor row/col, popup
183 // placement) and is reserved for a future expansion when a
184 // theorem actually needs them.
185
186 /// Byte offset of the first line currently visible in the active
187 /// viewport. After the renderer has run, this is the viewport's
188 /// scroll position. Without a render, this reflects the last
189 /// reconciliation point.
190 fn viewport_top_byte(&self) -> usize;
191
192 /// Width of the active terminal in cells, as set at harness
193 /// construction or via resize.
194 fn terminal_width(&self) -> u16;
195
196 /// Height of the active terminal in cells.
197 fn terminal_height(&self) -> u16;
198
199 /// Width of the line-number gutter in cells, computed from the
200 /// active buffer's line count. Includes the trailing separator
201 /// if the renderer adds one.
202 fn gutter_width(&self) -> u16;
203
204 /// Screen cell of the primary cursor, in `(col, row)`. None ⇒
205 /// the cursor is off-screen (scrolled past). Requires a prior
206 /// render to be meaningful.
207 fn hardware_cursor_position(&mut self) -> Option<(u16, u16)>;
208
209 /// `(start_byte, end_byte)` of the currently-visible buffer
210 /// region. End is exclusive. None ⇒ unknown / not yet
211 /// reconciled.
212 fn visible_byte_range(&self) -> Option<(usize, usize)>;
213
214 // ── Class C: modal observables (Phase 3) ─────────────────────────────
215
216 /// Snapshot of the modal-popup stack visible to the user. Used
217 /// by `ModalScenario` to assert on palette / picker / menu /
218 /// completion state without screen scraping.
219 fn modal_snapshot(&self) -> ModalSnapshot;
220
221 // ── Class D: workspace observables (Phase 7) ─────────────────────────
222
223 /// Number of buffers currently open across the workspace.
224 fn buffer_count(&self) -> usize;
225
226 /// Display path of the active buffer. None for unnamed buffers.
227 fn active_buffer_path(&self) -> Option<String>;
228
229 /// Display paths of every open buffer in stable insertion
230 /// order. Unnamed buffers appear as `"<unnamed:NNN>"`.
231 fn buffer_paths(&self) -> Vec<String>;
232
233 // ── Class E: input dispatch (Phase 9) ────────────────────────────────
234
235 /// Dispatch a mouse click projected through the active
236 /// viewport. `(col, row)` are absolute screen coordinates;
237 /// gutter offset is applied internally. Returns true if the
238 /// editor consumed the event.
239 fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool;
240
241 /// `true` if the active buffer has unsaved changes since it was
242 /// last loaded from / saved to disk. The "save point" is the
243 /// commit in the undo/redo log at which the buffer's on-disk
244 /// representation matches its in-memory state. After loading a
245 /// fresh file (no edits applied), this is `false`. After any
246 /// edit it becomes `true`. Undoing back to the save point flips
247 /// it back to `false` — the property under test in
248 /// `tests/semantic/undo_redo.rs::theorem_undo_to_save_point_*`.
249 fn is_modified(&self) -> bool;
250
251 /// Consume the one-shot "full hardware redraw" flag the editor's
252 /// event loop polls each frame: `Action::RedrawScreen` sets it,
253 /// the loop's tick clears it. Returns the previous value and
254 /// resets the flag to `false`.
255 ///
256 /// Exposed for `LayoutScenario`'s `expected_full_redraw_requested`
257 /// assertion — the only stable observation surface for the
258 /// "redraw screen" claim (issue #1070).
259 fn take_full_redraw_request_for_tests(&mut self) -> bool;
260
261 // ── Class F: marker observables (used by MarkerRoundtripScenario) ────
262 //
263 // Markers / line indicators track a byte position that survives
264 // edits, undo, and redo. They have no `Action` projection — the
265 // marker model is a production-internal thing — so the scenario
266 // runner needs declarative setters/getters on the test API instead
267 // of reaching into `editor.active_state_mut().margins`.
268
269 /// Seed a line indicator (margin marker) at `byte_offset` with the
270 /// given `symbol` (used as both the indicator glyph and the
271 /// namespace) and a `color` name (`"red"`, `"green"`, `"blue"`,
272 /// `"yellow"`; anything else falls back to `Color::Red`).
273 fn seed_marker(&mut self, byte_offset: usize, symbol: &str, color: &str);
274
275 /// Current byte positions of every marker whose namespace matches
276 /// `symbol`, in ascending order. Empty if no marker was seeded for
277 /// that namespace.
278 fn marker_positions(&self, symbol: &str) -> Vec<usize>;
279
280 /// Length (in events) of the active buffer's event log. Used by
281 /// `PersistenceScenario` save-point claims that check no undo
282 /// history is dropped on a no-op file-watcher notification.
283 fn active_event_log_len(&self) -> usize;
284
285 /// Notify the editor that `path` on disk has changed. Triggers the
286 /// auto-revert path; the editor reads the file and updates its
287 /// buffer (or skips when on-disk content already matches).
288 fn notify_file_changed(&mut self, path: &str);
289
290 // ── Class G: composite-buffer setup (used by diff layout scenarios) ──
291 //
292 // Side-by-side diff and unified-diff layout scenarios need a
293 // composite buffer built from two virtual buffers + a hunk-derived
294 // line alignment. Each accessor below is a stable, declarative
295 // wrapper around the equivalent `Editor` / `Window` method so the
296 // layout scenario runner can construct the composite from a data
297 // spec (`CompositeDiffSpec`) without reaching into production
298 // internals.
299
300 /// Create a side-by-side composite diff view named `name` with
301 /// mode `mode`. Builds two virtual buffers (OLD, NEW), seeds them
302 /// with `old_content` / `new_content`, computes a line alignment
303 /// from `hunks`, and switches the active buffer to the composite.
304 /// Returns a 64-bit handle to the composite for follow-up calls
305 /// (`composite_next_hunk_on`, `set_composite_initial_focus_hunk_on`).
306 fn create_side_by_side_diff(
307 &mut self,
308 name: &str,
309 mode: &str,
310 old_content: &str,
311 new_content: &str,
312 hunks: &[(usize, usize, usize, usize)],
313 ) -> usize;
314
315 /// Set the composite buffer's `initial_focus_hunk` field — the
316 /// one-shot "scroll to hunk N on first render" knob. The first
317 /// render consumes the value and resets the field to `None`.
318 fn set_composite_initial_focus_hunk_on(&mut self, composite_handle: usize, hunk_index: usize);
319
320 /// Read the composite buffer's current `initial_focus_hunk`
321 /// (after the first render this should be `None`, i.e. the field
322 /// was consumed). Returns `None` for missing or non-composite
323 /// buffers.
324 fn composite_initial_focus_hunk_on(&self, composite_handle: usize) -> Option<usize>;
325
326 /// Jump the active split's view of `composite_handle` to the next
327 /// hunk. Mirrors the `n` / `]` keybinding semantics; returns
328 /// `true` iff a next hunk existed.
329 fn composite_next_hunk_active_on(&mut self, composite_handle: usize) -> bool;
330
331 /// Jump back to the previous hunk; companion of
332 /// `composite_next_hunk_active_on`.
333 fn composite_prev_hunk_active_on(&mut self, composite_handle: usize) -> bool;
334
335 /// Force-materialize composite view state across visible splits
336 /// without performing a render. Lets a scenario reach hunk-nav
337 /// state before any frame paints.
338 fn flush_layout_for_tests(&mut self);
339
340 // ── Class H: scenario-seeding extensions for the per-row sweep ───────
341 //
342 // These give the `LayoutScenario` runner declarative knobs to
343 // inject virtual lines, margin annotations, and to read status
344 // messages / margin width without reaching into production
345 // internals or holding an `EditorTestHarness` reference.
346
347 /// Latest status message set by the editor (the same string the
348 /// status bar would display). `None` ⇒ no message has been set
349 /// since the last clear. Used by scrollbar-toggle scenarios that
350 /// assert on the "Vertical scrollbar hidden/shown" round-trip.
351 fn status_message(&self) -> Option<String>;
352
353 /// Inject a virtual line at the marker for `byte_offset` with
354 /// `text`, fg/bg colors (each as `Option<(r,g,b)>`), placement
355 /// (`"above"` or `"below"`), namespace, and priority.
356 fn seed_virtual_line(
357 &mut self,
358 byte_offset: usize,
359 text: &str,
360 fg: Option<(u8, u8, u8)>,
361 bg: Option<(u8, u8, u8)>,
362 placement: &str,
363 namespace: &str,
364 priority: i32,
365 );
366
367 /// Total count of virtual texts on the active state.
368 fn virtual_text_count(&self) -> usize;
369
370 /// Clear every virtual text belonging to `namespace`.
371 fn clear_virtual_text_namespace(&mut self, namespace: &str);
372
373 /// Inject a margin annotation (gutter symbol with optional color)
374 /// at `line` (0-indexed). `position` is `"left"` or `"right"`.
375 fn add_margin_annotation(
376 &mut self,
377 line: usize,
378 position: &str,
379 symbol: &str,
380 color: Option<(u8, u8, u8)>,
381 annotation_id: Option<&str>,
382 );
383
384 /// Remove the previously-added margin annotation with this id.
385 fn remove_margin_annotation(&mut self, annotation_id: &str);
386
387 /// `margins.left_total_width()` of the active state, in cells.
388 fn margin_left_total_width(&self) -> usize;
389
390 /// 1-indexed logical line number of the viewport's top byte —
391 /// the same observable the e2e scrollbar tests read via
392 /// `harness.top_line_number()`. Used to assert a scroll
393 /// position is (un)changed after a mouse interaction.
394 fn top_line_number(&mut self) -> usize;
395
396 /// Cached scrollbar geometry of the primary (first) split:
397 /// `(thumb_start, thumb_end, scrollbar_height, scrollbar_y)` —
398 /// thumb extent in scrollbar-row offsets, the track's height,
399 /// and the track's top terminal row. `None` ⇒ no split areas
400 /// cached (no render yet). `thumb_end > thumb_start` indicates
401 /// a non-degenerate thumb; `thumb_end - thumb_start <
402 /// scrollbar_height` indicates the content is scrollable (the
403 /// thumb does not fill the track) — the load-bearing claim of
404 /// `test_scrollbar_shows_scrollable_content_with_wrapped_lines`.
405 /// `scrollbar_y` lets a test resolve the thumb's terminal row
406 /// for a click/drag at the thumb midpoint.
407 fn primary_scrollbar_geometry(&self) -> Option<(usize, usize, u16, u16)>;
408}
409
410// ─────────────────────────────────────────────────────────────────────────
411// Implementation on Editor.
412//
413// Implementation lives in this file (rather than next to Editor) so that
414// the entire test-facing surface — trait + impl + projection types — is
415// reviewable as one unit.
416// ─────────────────────────────────────────────────────────────────────────
417
418impl EditorTestApi for crate::app::Editor {
419 fn dispatch(&mut self, action: Action) {
420 // Routes through the same handle_action path the input layer
421 // uses; dispatch_action_for_tests is the existing pub shim.
422 self.dispatch_action_for_tests(action);
423 let _ = self.process_async_messages();
424 }
425
426 fn dispatch_seq(&mut self, actions: &[Action]) {
427 for a in actions {
428 self.dispatch_action_for_tests(a.clone());
429 }
430 let _ = self.process_async_messages();
431 }
432
433 fn buffer_text(&self) -> String {
434 self.active_state()
435 .buffer
436 .to_string()
437 .expect("buffer_text(): buffer has unloaded regions; semantic tests do not support large-file mode")
438 }
439
440 fn primary_caret(&self) -> Caret {
441 let c = self.active_cursors().primary();
442 Caret {
443 position: c.position,
444 anchor: c.anchor,
445 }
446 }
447
448 fn carets(&self) -> Vec<Caret> {
449 let mut out: Vec<Caret> = self
450 .active_cursors()
451 .iter()
452 .map(|(_, c)| Caret {
453 position: c.position,
454 anchor: c.anchor,
455 })
456 .collect();
457 out.sort_by_key(|c| c.position);
458 out
459 }
460
461 fn selection_text(&mut self) -> String {
462 // Collect ranges first to avoid holding an immutable borrow of
463 // `active_cursors` across the mutable `get_text_range` call.
464 let mut ranges: Vec<std::ops::Range<usize>> = self
465 .active_cursors()
466 .iter()
467 .filter_map(|(_, c)| c.selection_range())
468 .collect();
469 if ranges.is_empty() {
470 return String::new();
471 }
472 ranges.sort_by_key(|r| r.start);
473
474 let state = self.active_state_mut();
475 let parts: Vec<String> = ranges
476 .into_iter()
477 .map(|r| state.get_text_range(r.start, r.end))
478 .collect();
479 parts.join("\n")
480 }
481
482 fn viewport_top_byte(&self) -> usize {
483 self.active_viewport().top_byte
484 }
485
486 fn terminal_width(&self) -> u16 {
487 self.active_viewport().width
488 }
489
490 fn terminal_height(&self) -> u16 {
491 self.active_viewport().height
492 }
493
494 fn gutter_width(&self) -> u16 {
495 let buffer = &self.active_state().buffer;
496 u16::try_from(self.active_viewport().gutter_width(buffer)).unwrap_or(u16::MAX)
497 }
498
499 fn hardware_cursor_position(&mut self) -> Option<(u16, u16)> {
500 // The viewport's `cursor_screen_position` requires
501 // `&mut Buffer`. Cloning the viewport (cheap; mostly
502 // primitives) lets us drop the immutable viewport borrow
503 // before taking the mutable buffer borrow on the next
504 // accessor call.
505 let cursor = *self.active_cursors().primary();
506 let viewport = self.active_viewport().clone();
507 let viewport_height = viewport.height;
508 let viewport_width = viewport.width;
509 let buffer = &mut self.active_state_mut().buffer;
510 let (col, row) = viewport.cursor_screen_position(buffer, &cursor);
511 if row >= viewport_height || col >= viewport_width {
512 None
513 } else {
514 Some((col, row))
515 }
516 }
517
518 fn visible_byte_range(&self) -> Option<(usize, usize)> {
519 // Viewport tracks `top_byte` exactly but the bottom of the
520 // visible region depends on the wrapped view-line layout,
521 // which only the renderer knows. Today we conservatively
522 // return None until a future expansion plumbs the
523 // last-visible byte through the test API.
524 None
525 }
526
527 fn is_modified(&self) -> bool {
528 self.active_state().buffer.is_modified()
529 }
530
531 fn take_full_redraw_request_for_tests(&mut self) -> bool {
532 self.take_full_redraw_request()
533 }
534
535 fn modal_snapshot(&self) -> ModalSnapshot {
536 // Two popup stacks live on the editor:
537 // - `global_popups`: editor-wide modals (palette, file open, …)
538 // - `active_state().popups`: per-buffer popups (completion, hover, …)
539 // We return the topmost across both, choosing global first
540 // since modal scenarios target the foreground stack.
541 use crate::view::popup::{Popup, PopupContent, PopupKind};
542
543 fn kind_name(kind: PopupKind) -> &'static str {
544 match kind {
545 PopupKind::Completion => "completion",
546 PopupKind::Hover => "hover",
547 PopupKind::Action => "action",
548 PopupKind::List => "list",
549 PopupKind::Text => "text",
550 }
551 }
552
553 fn project(p: &Popup) -> PopupView {
554 let (items, selected_index) = match &p.content {
555 PopupContent::List { items, selected } => (
556 items.iter().map(|i| i.text.clone()).collect(),
557 Some(*selected),
558 ),
559 _ => (Vec::new(), None),
560 };
561 PopupView {
562 kind: kind_name(p.kind).to_string(),
563 title: p.title.clone(),
564 items,
565 selected_index,
566 }
567 }
568
569 let global = self.global_popups.all();
570 let local = &self.active_state().popups;
571 let depth = global.len() + local.all().len();
572
573 // `top()` of the global stack is highest-priority. Fall back
574 // to per-buffer top if global is empty.
575 let top = self
576 .global_popups
577 .top()
578 .or_else(|| local.top())
579 .map(project);
580
581 // Project the active prompt (minibuffer / floating overlay).
582 // Lives on `active_window().prompt`, not on the popup stacks.
583 let prompt = self.active_window().prompt.as_ref().map(|p| PromptView {
584 prompt_type: format!("{:?}", p.prompt_type),
585 input: p.input.clone(),
586 cursor_pos: p.cursor_pos,
587 suggestions: p.suggestions.iter().map(|s| s.text.clone()).collect(),
588 selected_suggestion: p.selected_suggestion,
589 });
590
591 ModalSnapshot {
592 top_popup: top,
593 depth,
594 prompt,
595 }
596 }
597
598 fn buffer_count(&self) -> usize {
599 // `Editor::buffers` is the per-tab map; that's the count
600 // the workspace surface advertises.
601 self.buffer_count_for_tests()
602 }
603
604 fn active_buffer_path(&self) -> Option<String> {
605 let id = self.active_buffer();
606 let name = self.get_buffer_display_name(id);
607 if name.is_empty() {
608 None
609 } else {
610 Some(name)
611 }
612 }
613
614 fn buffer_paths(&self) -> Vec<String> {
615 self.all_buffer_ids_for_tests()
616 .into_iter()
617 .map(|id| {
618 let name = self.get_buffer_display_name(id);
619 if name.is_empty() {
620 format!("<unnamed:{}>", id.0)
621 } else {
622 name
623 }
624 })
625 .collect()
626 }
627
628 fn dispatch_mouse_click(&mut self, col: u16, row: u16) -> bool {
629 use crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
630 let down = MouseEvent {
631 kind: MouseEventKind::Down(MouseButton::Left),
632 column: col,
633 row,
634 modifiers: KeyModifiers::NONE,
635 };
636 // Discard the down result; we only act on the up — but
637 // explicitly use the value so clippy's
638 // `let_underscore_must_use` is satisfied.
639 if let Err(e) = self.handle_mouse(down) {
640 tracing::trace!("mouse down errored in test dispatch: {e}");
641 }
642 let up = MouseEvent {
643 kind: MouseEventKind::Up(MouseButton::Left),
644 column: col,
645 row,
646 modifiers: KeyModifiers::NONE,
647 };
648 self.handle_mouse(up).unwrap_or(false)
649 }
650
651 fn seed_marker(&mut self, byte_offset: usize, symbol: &str, color: &str) {
652 use crate::view::margin::LineIndicator;
653 use ratatui::style::Color;
654 let color = match color.to_ascii_lowercase().as_str() {
655 "red" => Color::Red,
656 "green" => Color::Green,
657 "blue" => Color::Blue,
658 "yellow" => Color::Yellow,
659 "cyan" => Color::Cyan,
660 "magenta" => Color::Magenta,
661 "white" => Color::White,
662 "black" => Color::Black,
663 _ => Color::Red,
664 };
665 let indicator = LineIndicator::new(symbol, color, 10);
666 let state = self.active_state_mut();
667 let _ = state
668 .margins
669 .set_line_indicator(byte_offset, symbol.to_string(), indicator);
670 }
671
672 fn marker_positions(&self, symbol: &str) -> Vec<usize> {
673 let margins = &self.active_state().margins;
674 // Collect every marker whose namespace map contains `symbol`.
675 // Iterate the indicator markers in the byte range covering the
676 // whole buffer so positions come back sorted.
677 let max = self
678 .active_state()
679 .buffer
680 .to_string()
681 .map(|s| s.len())
682 .unwrap_or(usize::MAX);
683 let mut out: Vec<usize> = margins
684 .query_indicator_range(0, max.saturating_add(1))
685 .into_iter()
686 .filter_map(|(id, start, _end)| {
687 // `id` is the MarkerId; look up whether `symbol` is one
688 // of its namespaces via `get_indicator_position` for the
689 // existence check plus a namespace-membership test.
690 if margins.get_indicator_position(id).is_some()
691 && margins
692 .namespaces_for_marker(id)
693 .iter()
694 .any(|n| n == symbol)
695 {
696 Some(start)
697 } else {
698 None
699 }
700 })
701 .collect();
702 out.sort_unstable();
703 out
704 }
705
706 fn active_event_log_len(&self) -> usize {
707 self.active_event_log().len()
708 }
709
710 fn notify_file_changed(&mut self, path: &str) {
711 self.handle_file_changed(path);
712 }
713
714 fn create_side_by_side_diff(
715 &mut self,
716 name: &str,
717 mode: &str,
718 old_content: &str,
719 new_content: &str,
720 hunks: &[(usize, usize, usize, usize)],
721 ) -> usize {
722 use crate::model::composite_buffer::{
723 CompositeLayout, DiffHunk, LineAlignment, PaneStyle, SourcePane,
724 };
725 use crate::primitives::text_property::TextPropertyEntry;
726
727 let old_buffer_id = self.active_window_mut().create_virtual_buffer(
728 "OLD".to_string(),
729 "text".to_string(),
730 true,
731 );
732 self.set_virtual_buffer_content(old_buffer_id, vec![TextPropertyEntry::text(old_content)])
733 .expect("seed OLD virtual buffer");
734
735 let new_buffer_id = self.active_window_mut().create_virtual_buffer(
736 "NEW".to_string(),
737 "text".to_string(),
738 true,
739 );
740 self.set_virtual_buffer_content(new_buffer_id, vec![TextPropertyEntry::text(new_content)])
741 .expect("seed NEW virtual buffer");
742
743 let sources = vec![
744 SourcePane::new(old_buffer_id, "OLD", false).with_style(PaneStyle::old_diff()),
745 SourcePane::new(new_buffer_id, "NEW", false).with_style(PaneStyle::new_diff()),
746 ];
747 let layout = CompositeLayout::SideBySide {
748 ratios: vec![0.5, 0.5],
749 show_separator: true,
750 };
751 let composite_id =
752 self.create_composite_buffer(name.to_string(), mode.to_string(), layout, sources);
753
754 let hunk_vec: Vec<DiffHunk> = hunks
755 .iter()
756 .map(|(os, oc, ns, nc)| DiffHunk::new(*os, *oc, *ns, *nc))
757 .collect();
758 let old_line_count = old_content.lines().count();
759 let new_line_count = new_content.lines().count();
760 let alignment = LineAlignment::from_hunks(&hunk_vec, old_line_count, new_line_count);
761 self.active_window_mut()
762 .set_composite_alignment(composite_id, alignment);
763
764 self.switch_buffer(composite_id);
765
766 composite_id.0
767 }
768
769 fn set_composite_initial_focus_hunk_on(&mut self, composite_handle: usize, hunk_index: usize) {
770 use crate::model::event::BufferId;
771 let id = BufferId(composite_handle);
772 if let Some(c) = self.active_window_mut().get_composite_mut(id) {
773 c.initial_focus_hunk = Some(hunk_index);
774 }
775 }
776
777 fn composite_initial_focus_hunk_on(&self, composite_handle: usize) -> Option<usize> {
778 use crate::model::event::BufferId;
779 let id = BufferId(composite_handle);
780 self.active_window()
781 .get_composite(id)
782 .and_then(|c| c.initial_focus_hunk)
783 }
784
785 fn composite_next_hunk_active_on(&mut self, composite_handle: usize) -> bool {
786 use crate::model::event::BufferId;
787 let id = BufferId(composite_handle);
788 self.active_window_mut().composite_next_hunk_active(id)
789 }
790
791 fn composite_prev_hunk_active_on(&mut self, composite_handle: usize) -> bool {
792 use crate::model::event::BufferId;
793 let id = BufferId(composite_handle);
794 self.active_window_mut().composite_prev_hunk_active(id)
795 }
796
797 fn flush_layout_for_tests(&mut self) {
798 self.flush_layout();
799 }
800
801 fn status_message(&self) -> Option<String> {
802 self.get_status_message().cloned()
803 }
804
805 fn seed_virtual_line(
806 &mut self,
807 byte_offset: usize,
808 text: &str,
809 fg: Option<(u8, u8, u8)>,
810 bg: Option<(u8, u8, u8)>,
811 placement: &str,
812 namespace: &str,
813 priority: i32,
814 ) {
815 use crate::view::virtual_text::{VirtualTextNamespace, VirtualTextPosition};
816 use ratatui::style::{Color, Style};
817 let pos = match placement {
818 "above" | "Above" | "LineAbove" => VirtualTextPosition::LineAbove,
819 "below" | "Below" | "LineBelow" => VirtualTextPosition::LineBelow,
820 other => panic!(
821 "seed_virtual_line: unsupported placement {other:?}; want 'above' or 'below'"
822 ),
823 };
824 let mut style = Style::default();
825 if let Some((r, g, b)) = fg {
826 style = style.fg(Color::Rgb(r, g, b));
827 } else {
828 style = style.fg(Color::DarkGray);
829 }
830 if let Some((r, g, b)) = bg {
831 style = style.bg(Color::Rgb(r, g, b));
832 }
833 let ns = VirtualTextNamespace::from_string(namespace.to_string());
834 let state = self.active_state_mut();
835 state.virtual_texts.add_line(
836 &mut state.marker_list,
837 byte_offset,
838 text.to_string(),
839 style,
840 pos,
841 ns,
842 priority,
843 );
844 }
845
846 fn virtual_text_count(&self) -> usize {
847 self.active_state().virtual_texts.len()
848 }
849
850 fn clear_virtual_text_namespace(&mut self, namespace: &str) {
851 use crate::view::virtual_text::VirtualTextNamespace;
852 let ns = VirtualTextNamespace::from_string(namespace.to_string());
853 let state = self.active_state_mut();
854 state
855 .virtual_texts
856 .clear_namespace(&mut state.marker_list, &ns);
857 }
858
859 fn add_margin_annotation(
860 &mut self,
861 line: usize,
862 position: &str,
863 symbol: &str,
864 color: Option<(u8, u8, u8)>,
865 annotation_id: Option<&str>,
866 ) {
867 use crate::model::event::{Event, MarginContentData, MarginPositionData};
868 let pos = match position {
869 "left" | "Left" => MarginPositionData::Left,
870 "right" | "Right" => MarginPositionData::Right,
871 other => panic!("add_margin_annotation: unsupported position {other:?}"),
872 };
873 let event = Event::AddMarginAnnotation {
874 line,
875 position: pos,
876 content: MarginContentData::Symbol {
877 text: symbol.to_string(),
878 color,
879 },
880 annotation_id: annotation_id.map(|s| s.to_string()),
881 };
882 self.apply_event_to_active_buffer(&event);
883 }
884
885 fn remove_margin_annotation(&mut self, annotation_id: &str) {
886 use crate::model::event::Event;
887 let event = Event::RemoveMarginAnnotation {
888 annotation_id: annotation_id.to_string(),
889 };
890 self.apply_event_to_active_buffer(&event);
891 }
892
893 fn margin_left_total_width(&self) -> usize {
894 self.active_state().margins.left_total_width()
895 }
896
897 fn top_line_number(&mut self) -> usize {
898 let top_byte = self.active_viewport().top_byte;
899 self.active_state_mut().buffer.get_line_number(top_byte)
900 }
901
902 fn primary_scrollbar_geometry(&self) -> Option<(usize, usize, u16, u16)> {
903 let areas = self.get_split_areas();
904 let (_split, _buf, _content, scrollbar_rect, thumb_start, thumb_end) = areas.first()?;
905 Some((
906 *thumb_start,
907 *thumb_end,
908 scrollbar_rect.height,
909 scrollbar_rect.y,
910 ))
911 }
912}