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::Yank {
88                    let text = self.buf().slice_string(range);
89                    self.set_register(
90                        None,
91                        if linewise {
92                            super::Register::linewise(text)
93                        } else {
94                            super::Register::characterwise(text)
95                        },
96                    );
97                    self.flash(range);
98                } else {
99                    let text = self.buf().slice_string(range);
100                    let changes = crate::editor::transact::ChangeSet {
101                        edits: vec![strop_core::Replacement::new(range, String::new())],
102                        undo_open: false,
103                    };
104                    if let Err(error) = self.apply(self.current(), self.buf().revision(), changes) {
105                        self.message = error.to_string();
106                        return;
107                    }
108                    self.set_register(
109                        None,
110                        if linewise {
111                            super::Register::linewise(text)
112                        } else {
113                            super::Register::characterwise(text)
114                        },
115                    );
116                    self.set_head(range.start.get());
117                    self.flash(Range::charwise(self.head(), self.head()));
118                }
119                self.mode = Mode::Normal;
120                self.clamp_cursor();
121                if op == Op::Change {
122                    self.enter_insert_from(if linewise { "V..." } else { "v..." });
123                }
124            }
125            _ => {
126                let action = self.walker.feed_visual(key);
127                self.dispatch_visual_action(action);
128            }
129        }
130    }
131
132    pub(crate) fn select_visual_object(&mut self, command: &grammar::Command) {
133        if self.defer_resolution(
134            command,
135            self.all_cursors(),
136            super::resolution::ResolutionPurpose::VisualObject,
137        ) {
138            return;
139        }
140        match self.resolved_many(command, &self.all_cursors()) {
141            Ok(resolutions) => {
142                if let Some(Some(resolved)) = resolutions.first() {
143                    let head = self.head();
144                    self.sels_mut()
145                        .stretch_primary(resolved.range.start.get(), head);
146                    self.set_head(
147                        self.buf()
148                            .clamp_boundary(resolved.range.end.get().saturating_sub(1)),
149                    );
150                }
151                // occurrence selections are real: every extra re-anchors
152                // on ITS object (0049 §7.4 — this was primary-only)
153                let olds = self.extra_selections().to_vec();
154                let extras: Vec<strop_core::selection::Selection> = olds
155                    .iter()
156                    .zip(resolutions.iter().skip(1))
157                    .map(|(old, resolved)| match resolved {
158                        Some(resolved) => strop_core::selection::Selection {
159                            anchor: resolved.range.start.get(),
160                            head: self
161                                .buf()
162                                .clamp_boundary(resolved.range.end.get().saturating_sub(1)),
163                        },
164                        None => *old,
165                    })
166                    .collect();
167                self.sels_mut().set_extra_selections(extras);
168            }
169            Err(error) => self.message = error,
170        }
171    }
172
173    /// d/y/c/x over every charwise selection (0049 §7.4): occurrence
174    /// selections are real ranges, so each one is yanked/deleted/
175    /// changed — the batch is ONE undo unit, the register one
176    /// newline-joined characterwise text (the normal cascade's rule).
177    fn visual_operate_cascade(&mut self, op: Op) {
178        if self.buf().readonly && op != Op::Yank {
179            self.message = "readonly buffer".into();
180            self.mode = Mode::Normal;
181            return;
182        }
183        let mut spans: Vec<((usize, usize), bool)> = std::iter::once((self.sels().primary(), true))
184            .chain(self.extra_selections().iter().copied().map(|s| (s, false)))
185            .map(|(selection, primary)| (self.selection_span(selection), primary))
186            .collect();
187        spans.sort_by_key(|(span, _)| span.0);
188        spans.dedup_by_key(|(span, _)| *span); // identical ranges edit once
189        let texts: Vec<String> = spans
190            .iter()
191            .map(|((start, end), _)| self.buf().slice_string(Range::charwise(*start, *end)))
192            .collect();
193        if op == Op::Yank {
194            self.set_register(None, super::Register::characterwise(texts.join("\n")));
195            // helix rule: yank keeps the selections (and Visual mode)
196            let flash = spans
197                .iter()
198                .find(|(_, primary)| *primary)
199                .map_or(spans[0].0, |(span, _)| *span);
200            self.flash(Range::charwise(flash.0, flash.1));
201            return;
202        }
203        self.tx_begin();
204        for ((start, end), _) in spans.iter().rev() {
205            self.buf_mut().delete(Range::charwise(*start, *end));
206        }
207        self.set_register(None, super::Register::characterwise(texts.join("\n")));
208        // landings: each range start minus what lower deletes removed
209        // (deletes applied bottom-up above)
210        let mut shift = 0usize;
211        let mut landings: Vec<(usize, bool)> = Vec::with_capacity(spans.len());
212        for ((start, end), primary) in &spans {
213            landings.push((*start - shift, *primary));
214            shift += end - start;
215        }
216        let head = landings
217            .iter()
218            .find(|(_, primary)| *primary)
219            .map(|(start, _)| *start)
220            .unwrap_or(self.head());
221        self.set_head(head);
222        self.sels_mut().set_extra_selections(
223            landings
224                .iter()
225                .filter(|(_, primary)| !*primary)
226                .map(|(start, _)| strop_core::selection::Selection::cursor(*start)),
227        );
228        self.mode = Mode::Normal;
229        if op == Op::Change {
230            // no commit: the insert session closes the undo unit
231            self.enter_insert_from("v...");
232        } else {
233            self.tx_commit();
234        }
235        self.clamp_cursor();
236        self.flash(Range::charwise(self.head(), self.head()));
237    }
238
239    /// One typed visual action: motions extend, objects select, the
240    /// table's leader rows (Space y, Space g h) act on the selection,
241    /// `S<c>` wraps it.
242    fn dispatch_visual_action(&mut self, action: Action) {
243        match action {
244            Action::Pending => {}
245            Action::Invalid(keys) => self.message = format!("not an editor command: {keys}"),
246            Action::QueryError(error) => self.message = error.to_string(),
247            Action::EnterText { sigil, state } => self.begin_text_line(sigil, state),
248            Action::Grammar(command) if command.op.is_none() => {
249                if let grammar::Target::Object { .. } = command.target {
250                    self.select_visual_object(&command);
251                } else {
252                    self.move_cursor(&command);
253                }
254            }
255            Action::Grammar(_) => self.message = "operator needs a visual selection".into(),
256            Action::Row { row, .. } if row.id == "git-file-history" => {
257                // visual Space g h: history of the selected lines (0014 §4)
258                let a = self.buf().line_of(self.anchor()) + 1;
259                let b = self.buf().line_of(self.head()) + 1;
260                self.open_line_history(a.min(b), a.max(b));
261            }
262            Action::Row { row, .. } if row.id == "clip-yank" => {
263                // visual Space y: yank the selection to the clipboard
264                if let Some(range) = self.visual_range() {
265                    let linewise = self.mode == Mode::VisualLine;
266                    let text = self.buf().slice_string(range);
267                    self.set_register(
268                        Some('+'),
269                        if linewise {
270                            super::Register::linewise(text)
271                        } else {
272                            super::Register::characterwise(text)
273                        },
274                    );
275                    self.flash(range);
276                }
277                self.mode = Mode::Normal;
278                self.clamp_cursor();
279            }
280            Action::Row { .. } => self.message = "command unavailable in visual mode".into(),
281            Action::VisualSurround(c) => {
282                if self.buf().readonly {
283                    self.message = "readonly buffer".into();
284                    return;
285                }
286                let Some(range) = self.visual_range() else {
287                    return;
288                };
289                let pair = match c {
290                    'b' | '(' | ')' => ('(', ')'),
291                    'B' | '{' | '}' => ('{', '}'),
292                    'r' | '[' | ']' => ('[', ']'),
293                    'a' | '<' | '>' => ('<', '>'),
294                    q => (q, q),
295                };
296                // two distinct boundary insertions into pre-edit
297                // coordinates — one validated ChangeSet, no ordering
298                // ambiguity (R8)
299                let changes = crate::editor::transact::ChangeSet {
300                    edits: vec![
301                        strop_core::Replacement::new(
302                            Range::charwise(range.start, range.start),
303                            pair.0.to_string(),
304                        ),
305                        strop_core::Replacement::new(
306                            Range::charwise(range.end, range.end),
307                            pair.1.to_string(),
308                        ),
309                    ],
310                    undo_open: false,
311                };
312                if let Err(error) = self.apply(self.current(), self.buf().revision(), changes) {
313                    self.message = error.to_string();
314                    return;
315                }
316                self.mode = Mode::Normal;
317                self.flash(Range::charwise(
318                    range.start,
319                    range.end.get() + pair.0.len_utf8() + pair.1.len_utf8(),
320                ));
321                self.last_cmd_keys = format!("vS{c}"); // replay is visual-mode replay; approximated
322                self.last_insert = None;
323            }
324        }
325    }
326
327    pub fn visual_range(&self) -> Option<Range> {
328        match self.mode {
329            Mode::Visual => {
330                // charwise-inclusive spans whole CHARS (0020 §10): the
331                // +1 from a multibyte lead landed mid-char and panicked
332                let (s, e) = (
333                    self.anchor().min(self.head()),
334                    self.buf().ceil_boundary(self.anchor().max(self.head()) + 1),
335                );
336                Some(Range::charwise(s, e.min(self.buf().len_bytes())))
337            }
338            Mode::VisualLine => {
339                let (a, b) = (
340                    self.buf().line_of(self.anchor()),
341                    self.buf().line_of(self.head()),
342                );
343                let (a, b) = (a.min(b), a.max(b));
344                let start = self.buf().line_start(a);
345                let end = if b + 1 >= self.buf().len_lines() {
346                    self.buf().len_bytes()
347                } else {
348                    self.buf().line_start(b + 1)
349                };
350                Some(Range::linewise(start, end))
351            }
352            _ => None,
353        }
354    }
355}