Skip to main content

strop_engine/editor/
visual.rs

1//! Visual mode (charwise `v` and linewise `V`): motions extend the
2//! selection, operators consume it. Structural composition shares the
3//! Walker; text prompts (`space |`) route through the shared pending
4//! owner — visual has no input state of its own (R7).
5
6use strop_core::Range;
7use strop_grammar::{self as grammar, Op};
8
9use super::input::Action;
10use super::{Editor, Key, Mode};
11
12impl Editor {
13    pub(crate) fn feed_visual(&mut self, key: Key) {
14        // gv's memory: the live visual range, refreshed per key — any
15        // exit path (Esc, operator, yank) leaves the last range behind
16        let p = self.sels().primary();
17        self.last_visual = Some((p.anchor, p.head));
18        match key {
19            Key::Esc => {
20                self.mode = Mode::Normal;
21                self.walker.clear();
22            }
23            Key::Up if self.walker.is_ground() => self.run_motion("k"),
24            Key::Down if self.walker.is_ground() => self.run_motion("j"),
25            Key::Left if self.walker.is_ground() => self.run_motion("h"),
26            Key::Right if self.walker.is_ground() => self.run_motion("l"),
27            Key::Char('>') | Key::Char('<') if self.walker.is_ground() => {
28                // visual indent: apply to every selected line, one undo
29                // unit, back to normal (vim re-selects with gv)
30                let Some(range) = self.visual_range() else {
31                    return;
32                };
33                if self.buf().readonly {
34                    self.message = "readonly buffer".into();
35                    self.mode = Mode::Normal;
36                    return;
37                }
38                let right = key == Key::Char('>');
39                self.tx_begin();
40                self.apply_indent(range, right);
41                self.tx_commit();
42                self.mode = Mode::Normal;
43                self.set_head(range.start.get());
44                self.clamp_cursor();
45                self.flash(Range::charwise(self.head(), self.head()));
46                self.last_cmd_keys = if right { "V>" } else { "V<" }.into();
47                self.last_insert = None;
48            }
49            Key::Char('d') | Key::Char('y') | Key::Char('c') | Key::Char('x')
50                if self.walker.is_ground() && self.mode == Mode::VisualBlock =>
51            {
52                match key {
53                    Key::Char('y') => self.block_yank(),
54                    Key::Char('c') => self.block_change(),
55                    _ => self.block_delete(),
56                }
57            }
58            Key::Char('I') if self.mode == Mode::VisualBlock && self.walker.is_ground() => {
59                self.block_insert(false)
60            }
61            Key::Char('A') if self.mode == Mode::VisualBlock && self.walker.is_ground() => {
62                self.block_insert(true)
63            }
64            Key::Char('d') | Key::Char('y') | Key::Char('c') | Key::Char('x')
65                if self.walker.is_ground() =>
66            {
67                let op = match key {
68                    Key::Char('d') | Key::Char('x') => Op::Delete,
69                    Key::Char('y') => Op::Yank,
70                    _ => Op::Change,
71                };
72                // occurrence selections (0049 §7.4): real stretched
73                // ranges cascade the operator over every occurrence
74                if self.mode == Mode::Visual && self.sels().count() > 1 {
75                    self.visual_operate_cascade(op);
76                    return;
77                }
78                if self.buf().readonly && op != Op::Yank {
79                    self.message = "readonly buffer".into();
80                    self.mode = Mode::Normal;
81                    return;
82                }
83                let Some(range) = self.visual_range() else {
84                    return;
85                };
86                let linewise = self.mode == Mode::VisualLine;
87                if op == Op::Change && linewise {
88                    self.mode = Mode::Normal;
89                    self.change_lines(None, "V...", &[(self.head(), range, true)]);
90                    return;
91                }
92                if op == Op::Yank {
93                    let text = self.buf().slice_string(range);
94                    let mut register = if linewise {
95                        super::Register::linewise(text)
96                    } else {
97                        super::Register::characterwise(text)
98                    };
99                    register.file_provenance =
100                        self.capture_filename_register([(range, linewise)], false, false);
101                    self.set_register(None, register);
102                    self.flash(range);
103                } else {
104                    let text = self.buf().slice_string(range);
105                    let provenance =
106                        self.capture_filename_register([(range, linewise)], true, false);
107                    let changes = crate::editor::transact::ChangeSet {
108                        edits: vec![strop_core::Replacement::new(range, String::new())],
109                        undo_open: false,
110                    };
111                    self.filename_delete_hint(range, linewise);
112                    if let Err(error) = self.apply(self.current(), self.buf().revision(), changes) {
113                        self.clear_filename_hint();
114                        self.message = error.to_string();
115                        return;
116                    }
117                    self.clear_filename_hint();
118                    let mut register = if linewise {
119                        super::Register::linewise(text)
120                    } else {
121                        super::Register::characterwise(text)
122                    };
123                    register.file_provenance = provenance;
124                    self.set_register(None, register);
125                    self.set_head(range.start.get());
126                    self.flash(Range::charwise(self.head(), self.head()));
127                }
128                self.mode = Mode::Normal;
129                self.clamp_cursor();
130                if op == Op::Change {
131                    self.enter_insert_from(if linewise { "V..." } else { "v..." });
132                }
133            }
134            _ => {
135                let action = self.walker.feed_visual(key);
136                self.dispatch_visual_action(action);
137            }
138        }
139    }
140
141    pub(crate) fn select_visual_object(&mut self, command: &grammar::Command) {
142        if self.defer_resolution(
143            command,
144            self.all_cursors(),
145            super::resolution::ResolutionPurpose::VisualObject,
146        ) {
147            return;
148        }
149        match self.resolved_many(command, &self.all_cursors()) {
150            Ok(resolutions) => {
151                if let Some(Some(resolved)) = resolutions.first() {
152                    let head = self.head();
153                    self.sels_mut()
154                        .stretch_primary(resolved.range.start.get(), head);
155                    self.set_head(
156                        self.buf()
157                            .clamp_boundary(resolved.range.end.get().saturating_sub(1)),
158                    );
159                }
160                // occurrence selections are real: every extra re-anchors
161                // on ITS object (0049 §7.4 — this was primary-only)
162                let olds = self.extra_selections().to_vec();
163                let extras: Vec<strop_core::selection::Selection> = olds
164                    .iter()
165                    .zip(resolutions.iter().skip(1))
166                    .map(|(old, resolved)| match resolved {
167                        Some(resolved) => strop_core::selection::Selection {
168                            anchor: resolved.range.start.get(),
169                            head: self
170                                .buf()
171                                .clamp_boundary(resolved.range.end.get().saturating_sub(1)),
172                        },
173                        None => *old,
174                    })
175                    .collect();
176                self.sels_mut().set_extra_selections(extras);
177            }
178            Err(error) => self.message = error,
179        }
180    }
181
182    /// d/y/c/x over every charwise selection (0049 §7.4): occurrence
183    /// selections are real ranges, so each one is yanked/deleted/
184    /// changed — the batch is ONE undo unit, the register one
185    /// newline-joined characterwise text (the normal cascade's rule).
186    fn visual_operate_cascade(&mut self, op: Op) {
187        if self.buf().readonly && op != Op::Yank {
188            self.message = "readonly buffer".into();
189            self.mode = Mode::Normal;
190            return;
191        }
192        let mut spans: Vec<((usize, usize), bool)> = std::iter::once((self.sels().primary(), true))
193            .chain(self.extra_selections().iter().copied().map(|s| (s, false)))
194            .map(|(selection, primary)| (self.selection_span(selection), primary))
195            .collect();
196        spans.sort_by_key(|(span, _)| span.0);
197        spans.dedup_by_key(|(span, _)| *span); // identical ranges edit once
198        let texts: Vec<String> = spans
199            .iter()
200            .map(|((start, end), _)| self.buf().slice_string(Range::charwise(*start, *end)))
201            .collect();
202        if op == Op::Yank {
203            self.set_register(None, super::Register::characterwise(texts.join("\n")));
204            // helix rule: yank keeps the selections (and Visual mode)
205            let flash = spans
206                .iter()
207                .find(|(_, primary)| *primary)
208                .map_or(spans[0].0, |(span, _)| *span);
209            self.flash(Range::charwise(flash.0, flash.1));
210            return;
211        }
212        self.tx_begin();
213        for ((start, end), _) in spans.iter().rev() {
214            self.buf_mut().delete(Range::charwise(*start, *end));
215        }
216        self.set_register(None, super::Register::characterwise(texts.join("\n")));
217        // landings: each range start minus what lower deletes removed
218        // (deletes applied bottom-up above)
219        let mut shift = 0usize;
220        let mut landings: Vec<(usize, bool)> = Vec::with_capacity(spans.len());
221        for ((start, end), primary) in &spans {
222            landings.push((*start - shift, *primary));
223            shift += end - start;
224        }
225        let head = landings
226            .iter()
227            .find(|(_, primary)| *primary)
228            .map(|(start, _)| *start)
229            .unwrap_or(self.head());
230        self.set_head(head);
231        self.sels_mut().set_extra_selections(
232            landings
233                .iter()
234                .filter(|(_, primary)| !*primary)
235                .map(|(start, _)| strop_core::selection::Selection::cursor(*start)),
236        );
237        self.mode = Mode::Normal;
238        if op == Op::Change {
239            // no commit: the insert session closes the undo unit
240            self.enter_insert_from("v...");
241        } else {
242            self.tx_commit();
243        }
244        self.clamp_cursor();
245        self.flash(Range::charwise(self.head(), self.head()));
246    }
247
248    /// One typed visual action: motions extend, objects select, the
249    /// table's leader rows (Space y, Space g h) act on the selection,
250    /// `S<c>` wraps it.
251    fn dispatch_visual_action(&mut self, action: Action) {
252        match action {
253            Action::Pending => {}
254            Action::Invalid(keys) => self.message = format!("not an editor command: {keys}"),
255            Action::QueryError(error) => self.message = error.to_string(),
256            Action::EnterText { sigil, state } => self.begin_text_line(sigil, state),
257            Action::Grammar(command) if command.op.is_none() => {
258                if let grammar::Target::Object { .. } = command.target {
259                    self.select_visual_object(&command);
260                } else {
261                    self.move_cursor(&command);
262                }
263            }
264            Action::Grammar(_) => self.message = "operator needs a visual selection".into(),
265            Action::Row { row, .. } if row.id == "git-file-history" => {
266                // visual Space g h: history of the selected lines (0014 §4)
267                let a = self.buf().line_of(self.anchor()) + 1;
268                let b = self.buf().line_of(self.head()) + 1;
269                self.open_line_history(a.min(b), a.max(b));
270            }
271            Action::Row { row, .. } if row.id == "clip-yank" => {
272                // visual Space y: yank the selection to the clipboard
273                if let Some(range) = self.visual_range() {
274                    let linewise = self.mode == Mode::VisualLine;
275                    let text = self.buf().slice_string(range);
276                    let mut register = if linewise {
277                        super::Register::linewise(text)
278                    } else {
279                        super::Register::characterwise(text)
280                    };
281                    register.file_provenance =
282                        self.capture_filename_register([(range, linewise)], false, false);
283                    self.set_register(Some('+'), register);
284                    self.flash(range);
285                }
286                self.mode = Mode::Normal;
287                self.clamp_cursor();
288            }
289            // Visual-mode Leaf rows (0049 §7: gb/gB occurrence adding)
290            // execute their handler; the leader/git ids above keep
291            // their bespoke arms.
292            Action::Row { row, key, .. } if row.sections.contains(&"visual") => {
293                if let crate::keymap::Handler::Leaf(f) = row.handler {
294                    f(self, key);
295                }
296            }
297            Action::Row { .. } => self.message = "command unavailable in visual mode".into(),
298            Action::VisualSurround(c) => {
299                if self.buf().readonly {
300                    self.message = "readonly buffer".into();
301                    return;
302                }
303                let Some(range) = self.visual_range() else {
304                    return;
305                };
306                let pair = match c {
307                    'b' | '(' | ')' => ('(', ')'),
308                    'B' | '{' | '}' => ('{', '}'),
309                    'r' | '[' | ']' => ('[', ']'),
310                    'a' | '<' | '>' => ('<', '>'),
311                    q => (q, q),
312                };
313                // two distinct boundary insertions into pre-edit
314                // coordinates — one validated ChangeSet, no ordering
315                // ambiguity (R8)
316                let changes = crate::editor::transact::ChangeSet {
317                    edits: vec![
318                        strop_core::Replacement::new(
319                            Range::charwise(range.start, range.start),
320                            pair.0.to_string(),
321                        ),
322                        strop_core::Replacement::new(
323                            Range::charwise(range.end, range.end),
324                            pair.1.to_string(),
325                        ),
326                    ],
327                    undo_open: false,
328                };
329                if let Err(error) = self.apply(self.current(), self.buf().revision(), changes) {
330                    self.message = error.to_string();
331                    return;
332                }
333                self.mode = Mode::Normal;
334                self.flash(Range::charwise(
335                    range.start,
336                    range.end.get() + pair.0.len_utf8() + pair.1.len_utf8(),
337                ));
338                self.last_cmd_keys = format!("vS{c}"); // replay is visual-mode replay; approximated
339                self.last_insert = None;
340            }
341        }
342    }
343
344    pub fn visual_range(&self) -> Option<Range> {
345        match self.mode {
346            Mode::Visual => {
347                // charwise-inclusive spans whole CHARS (0020 §10): the
348                // +1 from a multibyte lead landed mid-char and panicked
349                let (s, e) = (
350                    self.anchor().min(self.head()),
351                    self.buf().ceil_boundary(self.anchor().max(self.head()) + 1),
352                );
353                Some(Range::charwise(s, e.min(self.buf().len_bytes())))
354            }
355            Mode::VisualLine => {
356                let (a, b) = (
357                    self.buf().line_of(self.anchor()),
358                    self.buf().line_of(self.head()),
359                );
360                let (a, b) = (a.min(b), a.max(b));
361                let start = self.buf().line_start(a);
362                let end = if b + 1 >= self.buf().len_lines() {
363                    self.buf().len_bytes()
364                } else {
365                    self.buf().line_start(b + 1)
366                };
367                Some(Range::linewise(start, end))
368            }
369            _ => None,
370        }
371    }
372}