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                if self.buf().readonly && op != Op::Yank {
73                    self.message = "readonly buffer".into();
74                    self.mode = Mode::Normal;
75                    return;
76                }
77                let Some(range) = self.visual_range() else {
78                    return;
79                };
80                let linewise = self.mode == Mode::VisualLine;
81                if op == Op::Yank {
82                    let text = self.buf().slice_string(range);
83                    self.set_register(
84                        None,
85                        if linewise {
86                            super::Register::linewise(text)
87                        } else {
88                            super::Register::characterwise(text)
89                        },
90                    );
91                    self.flash(range);
92                } else {
93                    let text = self.buf().slice_string(range);
94                    let changes = crate::editor::transact::ChangeSet {
95                        edits: vec![strop_core::Replacement::new(range, String::new())],
96                        undo_open: false,
97                    };
98                    if let Err(error) = self.apply(self.current(), self.buf().revision(), changes) {
99                        self.message = error.to_string();
100                        return;
101                    }
102                    self.set_register(
103                        None,
104                        if linewise {
105                            super::Register::linewise(text)
106                        } else {
107                            super::Register::characterwise(text)
108                        },
109                    );
110                    self.set_head(range.start.get());
111                    self.flash(Range::charwise(self.head(), self.head()));
112                }
113                self.mode = Mode::Normal;
114                self.clamp_cursor();
115                if op == Op::Change {
116                    self.enter_insert_from(if linewise { "V..." } else { "v..." });
117                }
118            }
119            _ => {
120                let action = self.walker.feed_visual(key);
121                self.dispatch_visual_action(action);
122            }
123        }
124    }
125
126    pub(crate) fn select_visual_object(&mut self, command: &grammar::Command) {
127        if self.defer_resolution(
128            command,
129            self.all_cursors(),
130            super::resolution::ResolutionPurpose::VisualObject,
131        ) {
132            return;
133        }
134        match self
135            .resolved_many(command, &self.all_cursors())
136            .map(|resolved| resolved.into_iter().next().flatten())
137        {
138            Ok(Some(resolved)) => {
139                let head = self.head();
140                self.sels_mut()
141                    .stretch_primary(resolved.range.start.get(), head);
142                self.set_head(
143                    self.buf()
144                        .clamp_boundary(resolved.range.end.get().saturating_sub(1)),
145                );
146            }
147            Ok(None) => {}
148            Err(error) => self.message = error,
149        }
150    }
151
152    /// One typed visual action: motions extend, objects select, the
153    /// table's leader rows (Space y, Space g h) act on the selection,
154    /// `S<c>` wraps it.
155    fn dispatch_visual_action(&mut self, action: Action) {
156        match action {
157            Action::Pending => {}
158            Action::Invalid(keys) => self.message = format!("not an editor command: {keys}"),
159            Action::QueryError(error) => self.message = error.to_string(),
160            Action::EnterText { sigil, state } => self.begin_text_line(sigil, state),
161            Action::Grammar(command) if command.op.is_none() => {
162                if let grammar::Target::Object { .. } = command.target {
163                    self.select_visual_object(&command);
164                } else {
165                    self.move_cursor(&command);
166                }
167            }
168            Action::Grammar(_) => self.message = "operator needs a visual selection".into(),
169            Action::Row { row, .. } if row.id == "git-file-history" => {
170                // visual Space g h: history of the selected lines (0014 §4)
171                let a = self.buf().line_of(self.anchor()) + 1;
172                let b = self.buf().line_of(self.head()) + 1;
173                self.open_line_history(a.min(b), a.max(b));
174            }
175            Action::Row { row, .. } if row.id == "clip-yank" => {
176                // visual Space y: yank the selection to the clipboard
177                if let Some(range) = self.visual_range() {
178                    let linewise = self.mode == Mode::VisualLine;
179                    let text = self.buf().slice_string(range);
180                    self.set_register(
181                        Some('+'),
182                        if linewise {
183                            super::Register::linewise(text)
184                        } else {
185                            super::Register::characterwise(text)
186                        },
187                    );
188                    self.flash(range);
189                }
190                self.mode = Mode::Normal;
191                self.clamp_cursor();
192            }
193            Action::Row { .. } => self.message = "command unavailable in visual mode".into(),
194            Action::VisualSurround(c) => {
195                if self.buf().readonly {
196                    self.message = "readonly buffer".into();
197                    return;
198                }
199                let Some(range) = self.visual_range() else {
200                    return;
201                };
202                let pair = match c {
203                    'b' | '(' | ')' => ('(', ')'),
204                    'B' | '{' | '}' => ('{', '}'),
205                    'r' | '[' | ']' => ('[', ']'),
206                    'a' | '<' | '>' => ('<', '>'),
207                    q => (q, q),
208                };
209                // two distinct boundary insertions into pre-edit
210                // coordinates — one validated ChangeSet, no ordering
211                // ambiguity (R8)
212                let changes = crate::editor::transact::ChangeSet {
213                    edits: vec![
214                        strop_core::Replacement::new(
215                            Range::charwise(range.start, range.start),
216                            pair.0.to_string(),
217                        ),
218                        strop_core::Replacement::new(
219                            Range::charwise(range.end, range.end),
220                            pair.1.to_string(),
221                        ),
222                    ],
223                    undo_open: false,
224                };
225                if let Err(error) = self.apply(self.current(), self.buf().revision(), changes) {
226                    self.message = error.to_string();
227                    return;
228                }
229                self.mode = Mode::Normal;
230                self.flash(Range::charwise(
231                    range.start,
232                    range.end.get() + pair.0.len_utf8() + pair.1.len_utf8(),
233                ));
234                self.last_cmd_keys = format!("vS{c}"); // replay is visual-mode replay; approximated
235                self.last_insert = None;
236            }
237        }
238    }
239
240    pub fn visual_range(&self) -> Option<Range> {
241        match self.mode {
242            Mode::Visual => {
243                // charwise-inclusive spans whole CHARS (0020 §10): the
244                // +1 from a multibyte lead landed mid-char and panicked
245                let (s, e) = (
246                    self.anchor().min(self.head()),
247                    self.buf().ceil_boundary(self.anchor().max(self.head()) + 1),
248                );
249                Some(Range::charwise(s, e.min(self.buf().len_bytes())))
250            }
251            Mode::VisualLine => {
252                let (a, b) = (
253                    self.buf().line_of(self.anchor()),
254                    self.buf().line_of(self.head()),
255                );
256                let (a, b) = (a.min(b), a.max(b));
257                let start = self.buf().line_start(a);
258                let end = if b + 1 >= self.buf().len_lines() {
259                    self.buf().len_bytes()
260                } else {
261                    self.buf().line_start(b + 1)
262                };
263                Some(Range::linewise(start, end))
264            }
265            _ => None,
266        }
267    }
268}