vorto 0.15.0

A Vim-flavored modal terminal editor with batteries included: tree-sitter, LSP, fuzzy pickers, vim-surround, multi-cursor, and optional Copilot.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
//! Top-level UI orchestrator.
//!
//! Splits the frame into the buffer viewport, the status bar, and the
//! command line, then dispatches to submodule renderers. Overlays
//! (`:command` hints, fuzzy picker, pending-operator which-key hints)
//! are drawn last so they sit above the base layout.
//!
//! Submodules:
//! - [`buffer`] — the main edit viewport, gutter, syntax highlighting,
//!   visual-selection painting, and the cursor placement that the
//!   terminal needs at the end of each frame.
//! - [`status`] — the one-line status bar (mode badge, status text,
//!   diagnostic under cursor, pending count, cursor position) and the
//!   `:` / `/` / rename command line directly under it.
//! - [`toast`] — the floating bottom-right toast for transient
//!   info / warn / error messages.
//! - [`hints`] — overlay panels: `:command` autocomplete and the
//!   which-key panel that appears while an operator is pending.
//! - [`fuzzy`] — the fuzzy picker popup with its source-preview pane.

mod agent_pane;
mod agent_picker;
mod buffer;
mod code_action;
mod completion;
mod copilot_signin;
mod explorer;
mod file_reload;
mod fuzzy;
mod grammar_list;
mod hints;
mod hover;
mod lsp_status;
mod signature;
mod status;
mod theme_picker;
mod toast;

use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::Span;
use ratatui::widgets::Paragraph;

use crate::app::{App, PaneContent, PaneId, PaneLayout, PaneRect, Prompt, SplitDir};

/// Shared overlay panel background — the active theme's `ui.popup`, or
/// `Color::Reset` (terminal default) when the theme leaves it unset, so
/// floating widgets (command hints, pending-op hints, toasts, completion,
/// hover, signature, code actions) blend with the terminal. `Clear`
/// already wipes the area first, so an unset bg shows the terminal
/// through.
pub(crate) fn panel_bg() -> Color {
    crate::theme::active().ui_popup().bg.unwrap_or(Color::Reset)
}

/// Foreground for popup borders — the active theme's `ui.popup.border`,
/// or ANSI gray (color 7) so the frame reads clearly against the panel
/// bg without being loud.
pub(crate) fn panel_border_fg() -> Color {
    crate::theme::active()
        .ui_popup_border()
        .fg
        .unwrap_or(Color::Gray)
}

/// Foreground for popup *text* — the active theme's `ui.popup` fg, or
/// `Color::Reset` (terminal default) when unset. Applied as a panel's
/// base fg so its text stays legible on the panel bg even for light
/// themes, where the terminal's own fg would vanish.
pub(crate) fn panel_fg() -> Color {
    crate::theme::active().ui_popup().fg.unwrap_or(Color::Reset)
}

pub fn draw(f: &mut Frame, app: &App) {
    // Editor-wide background: when the theme sets `ui.background`, fill the
    // whole frame first so every surface that doesn't paint its own bg —
    // the command line, blank rows, gaps between panes — sits on the theme
    // background with the theme's text fg, instead of the terminal default.
    // Surfaces that set their own bg (buffer, statusline, popups) paint
    // over this. Themes without `ui.background` (e.g. `ansi`) skip the fill
    // and keep the terminal's own colors.
    if let Some(bg) = crate::theme::active().ui_background() {
        f.render_widget(ratatui::widgets::Block::default().style(bg), f.area());
    }

    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Min(1),
            Constraint::Length(1),
            Constraint::Length(1),
        ])
        .split(f.area());

    // Partition the buffer viewport into per-pane rectangles. With a
    // single-leaf layout this is a no-op; with one or more splits it
    // walks the tree applying the per-node ratios.
    let mut rects: std::collections::HashMap<PaneId, Rect> = std::collections::HashMap::new();
    compute_pane_rects(&app.layout, chunks[0], &mut rects);
    // Publish rectangles for directional focus navigation. Bypass
    // `rects` going stale by writing every frame.
    {
        let mut out = app.last_pane_rects.borrow_mut();
        out.clear();
        for (id, r) in rects.iter() {
            out.insert(
                *id,
                PaneRect {
                    x: r.x,
                    y: r.y,
                    width: r.width,
                    height: r.height,
                },
            );
        }
    }
    let active_rect = rects.get(&app.active_pane).copied().unwrap_or(chunks[0]);
    for (&id, &rect) in &rects {
        if id == app.editor_pane {
            // `App.editor` backs this leaf. Full active renderer when
            // it's the focused pane; the lighter inactive renderer when
            // focus is elsewhere (e.g. the agent pane is active).
            if id == app.active_pane {
                buffer::draw_buffer(f, app, rect);
            } else {
                let eff = app.effective_editor();
                buffer::draw_buffer_inactive(f, &app.editor, app.active_doc(), &eff, rect);
            }
        } else {
            match app.pane_content.get(&id) {
                Some(PaneContent::Agent) => {
                    agent_pane::draw_agent_pane(f, app, rect, id == app.active_pane);
                }
                Some(PaneContent::Editor(_)) => {
                    if let (Some(ed), Some(buf)) =
                        (app.editor_for_pane(id), app.buffer_for_pane(id))
                    {
                        // The pane's session carries its own cursor; the
                        // document is resolved through the pool. Two panes
                        // sharing one ref paint the same live content with
                        // independent cursors.
                        let eff = effective_editor_for_buffer(app, buf);
                        buffer::draw_buffer_inactive(f, ed, buf, &eff, rect);
                    }
                }
                None => {}
            }
        }
    }
    // Paint dividers between sibling panes and a focus-ring on the
    // active pane border. Both are cosmetic only.
    draw_pane_borders(f, &app.layout, chunks[0], app.active_pane);
    status::draw_status(f, app, chunks[1]);
    status::draw_command_line(f, app, chunks[2]);
    toast::draw_toast(f, app, chunks[0]);

    // Place the terminal cursor for the focused pane. An editor pane
    // parks it at the buffer cursor; the agent pane parks it at the
    // emulated terminal's grid cursor (so a full-screen agent shows its
    // own caret where it expects). Other panes leave the cursor hidden.
    if app.active_pane == app.editor_pane {
        buffer::place_cursor(f, app, active_rect);
    } else if Some(app.active_pane) == app.agent_pane
        && let Some(session) = app.agent.as_ref()
        && let Some(snap) = session.grid_snapshot()
    {
        agent_pane::place_agent_cursor(f, &snap, active_rect);
    }

    if let Prompt::Command(cp) = &app.prompt.state {
        hints::draw_command_hints(f, cp, chunks[2]);
    }
    if matches!(app.prompt.state, Prompt::Fuzzy(_)) {
        fuzzy::draw_fuzzy(f, app, f.area());
    }
    if matches!(app.prompt.state, Prompt::Explorer(_)) {
        explorer::draw_explorer(f, app, f.area());
    }
    // Cursor-anchored popups (code action menu, hover, completion)
    // need the *active pane's* rect, not the whole buffer area — with
    // splits the cursor lives inside a sub-region and the popup math
    // (`buf_area.x + gutter + col`) would otherwise anchor at the
    // wrong column.
    if matches!(app.prompt.state, Prompt::CodeActionMenu { .. }) {
        code_action::draw_code_action_menu(f, app, active_rect);
    }
    if matches!(app.prompt.state, Prompt::Hover { .. }) {
        hover::draw_hover(f, app, active_rect);
    }
    // `:lsp` modal is screen-centered rather than cursor-anchored —
    // hand it the full frame so the math stays simple under splits.
    if matches!(app.prompt.state, Prompt::LspStatus { .. }) {
        lsp_status::draw_lsp_status(f, app, f.area());
    }
    if matches!(app.prompt.state, Prompt::CopilotSignin { .. }) {
        copilot_signin::draw_copilot_signin(f, app, f.area());
    }
    // `:grammar` modal — screen-centered like `:lsp`.
    if matches!(app.prompt.state, Prompt::GrammarList { .. }) {
        grammar_list::draw_grammar_list(f, app, f.area());
    }
    // Open-time "install this grammar?" confirmation.
    if matches!(app.prompt.state, Prompt::GrammarInstallConfirm { .. }) {
        grammar_list::draw_grammar_install_prompt(f, app, f.area());
    }
    // Autoreload "file changed on disk — reload?" confirmation.
    if matches!(app.prompt.state, Prompt::FileReloadConfirm { .. }) {
        file_reload::draw_file_reload_prompt(f, app, f.area());
    }
    // `:agent` picker — screen-centered selection list.
    if matches!(app.prompt.state, Prompt::AgentPicker { .. }) {
        agent_picker::draw_agent_picker(f, app, f.area());
    }
    // `:theme` picker — screen-centered, filterable like `:grammar`.
    if matches!(app.prompt.state, Prompt::ThemePicker { .. }) {
        theme_picker::draw_theme_picker(f, app, f.area());
    }
    if app.completion.is_some() {
        completion::draw_completion(f, app, active_rect);
    }
    if app.signature.is_some() {
        signature::draw_signature(f, app, active_rect);
    }
    if !app.prompt.is_open() {
        hints::draw_pending_hints(f, app, chunks[1]);
    }
}

/// Walk the pane layout tree and slice `area` into per-pane rectangles
/// based on each split's direction and child ratios. Result is keyed
/// by pane id so callers can look up the rectangle for the leaf they
/// want to draw (or, for directional focus, compare rectangles
/// against each other).
fn compute_pane_rects(
    node: &PaneLayout,
    area: Rect,
    out: &mut std::collections::HashMap<PaneId, Rect>,
) {
    match node {
        PaneLayout::Leaf(id) => {
            out.insert(*id, area);
        }
        PaneLayout::Split {
            dir,
            children,
            ratios,
        } => {
            // Normalize ratios defensively — `remove_leaf` already
            // renormalizes after a removal but `Constraint::Ratio`
            // expects u32 numerators that sum to a fixed total.
            let sum: f32 = ratios.iter().sum();
            let denom: u32 = 10_000;
            let mut numers: Vec<u32> = ratios
                .iter()
                .map(|r| {
                    if sum > 0.0 {
                        ((r / sum) * denom as f32).round() as u32
                    } else {
                        denom / children.len() as u32
                    }
                })
                .collect();
            // Distribute rounding remainder to the last child so the
            // numerators sum exactly to `denom`.
            let total: u32 = numers.iter().sum();
            if let Some(last) = numers.last_mut()
                && total < denom
            {
                *last += denom - total;
            }
            // Reserve one cell between sibling panes for the divider
            // bar. Total reserved = children.len() - 1.
            let direction = match dir {
                SplitDir::Vertical => Direction::Horizontal,
                SplitDir::Horizontal => Direction::Vertical,
            };
            let divider_cells = children.len().saturating_sub(1) as u16;
            let usable = match direction {
                Direction::Horizontal => area.width.saturating_sub(divider_cells),
                Direction::Vertical => area.height.saturating_sub(divider_cells),
            };
            // Build constraints, accounting for divider cells by
            // placing a 1-cell `Length(1)` between every pair of pane
            // constraints.
            let mut constraints: Vec<Constraint> = Vec::with_capacity(children.len() * 2);
            for (i, &n) in numers.iter().enumerate() {
                let pane_size = ((n * usable as u32) / denom) as u16;
                constraints.push(Constraint::Length(pane_size));
                if i + 1 < children.len() {
                    constraints.push(Constraint::Length(1));
                }
            }
            // Constraint::Length sums may not exactly cover `area`
            // (rounding); ratatui will absorb the remainder into the
            // last segment.
            let chunks = Layout::default()
                .direction(direction)
                .constraints(constraints)
                .split(area);
            // Even indices in `chunks` are panes; odd indices are
            // divider slots (drawn separately by `draw_pane_borders`).
            for (i, child) in children.iter().enumerate() {
                let chunk = chunks[i * 2];
                compute_pane_rects(child, chunk, out);
            }
        }
    }
}

/// Resolve the effective editor settings (tab width, show-whitespace,
/// …) for an inactive pane's buffer. Mirrors `App::effective_editor`
/// which is hard-coded to read from `app.buffer`.
fn effective_editor_for_buffer(
    app: &App,
    buf: &crate::editor::Buffer,
) -> crate::config::EditorConfig {
    let base = app.config.editor;
    let Some(path) = buf.path.as_ref() else {
        return base;
    };
    let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
        return base;
    };
    let Some(lang) = app.config.languages.by_extension(ext) else {
        return base;
    };
    base.overlay(&lang.editor)
}

/// Paint the divider bar between sibling panes. Walks the tree the
/// same way `compute_pane_rects` does and renders a single-character
/// vertical or horizontal bar in the 1-cell gap each `Split` reserves
/// between children. The active pane's border gets a brighter color
/// so the user can spot which pane has focus.
fn draw_pane_borders(f: &mut Frame, node: &PaneLayout, area: Rect, active: PaneId) {
    use ratatui::widgets::Block;
    use ratatui::widgets::BorderType;
    if let PaneLayout::Split {
        dir,
        children,
        ratios,
    } = node
    {
        let sum: f32 = ratios.iter().sum();
        let denom: u32 = 10_000;
        let mut numers: Vec<u32> = ratios
            .iter()
            .map(|r| {
                if sum > 0.0 {
                    ((r / sum) * denom as f32).round() as u32
                } else {
                    denom / children.len() as u32
                }
            })
            .collect();
        let total: u32 = numers.iter().sum();
        if let Some(last) = numers.last_mut()
            && total < denom
        {
            *last += denom - total;
        }
        let direction = match dir {
            SplitDir::Vertical => Direction::Horizontal,
            SplitDir::Horizontal => Direction::Vertical,
        };
        let divider_cells = children.len().saturating_sub(1) as u16;
        let usable = match direction {
            Direction::Horizontal => area.width.saturating_sub(divider_cells),
            Direction::Vertical => area.height.saturating_sub(divider_cells),
        };
        let mut constraints: Vec<Constraint> = Vec::with_capacity(children.len() * 2);
        for (i, &n) in numers.iter().enumerate() {
            let pane_size = ((n * usable as u32) / denom) as u16;
            constraints.push(Constraint::Length(pane_size));
            if i + 1 < children.len() {
                constraints.push(Constraint::Length(1));
            }
        }
        let chunks = Layout::default()
            .direction(direction)
            .constraints(constraints)
            .split(area);
        // Render the divider chunks (odd indices). One-cell-thick
        // ratatui Block with a border gives us the line.
        for i in 0..(children.len().saturating_sub(1)) {
            let divider = chunks[i * 2 + 1];
            let glyph = match direction {
                Direction::Horizontal => "│",
                Direction::Vertical => "─",
            };
            let text: Vec<ratatui::text::Line> = match direction {
                Direction::Horizontal => (0..divider.height)
                    .map(|_| {
                        ratatui::text::Line::from(Span::styled(
                            glyph,
                            Style::default().fg(Color::DarkGray),
                        ))
                    })
                    .collect(),
                Direction::Vertical => vec![ratatui::text::Line::from(Span::styled(
                    glyph.repeat(divider.width as usize),
                    Style::default().fg(Color::DarkGray),
                ))],
            };
            f.render_widget(Paragraph::new(text), divider);
        }
        for (i, child) in children.iter().enumerate() {
            let chunk = chunks[i * 2];
            draw_pane_borders(f, child, chunk, active);
        }
    } else if let PaneLayout::Leaf(id) = node
        && *id == active
    {
        // Active-pane outline: 1-cell border ring. Useful only when
        // there is more than one pane; otherwise the ring just frames
        // the only buffer for no reason. Caller passes the same active
        // id all the way down, so we just check id == active here.
        let _ = Block::default().border_type(BorderType::Plain);
    }
}