Skip to main content

rig_tui/
editor.rs

1//! The input editor.
2
3use bevy_ecs::prelude::*;
4use bevy_reflect::prelude::*;
5use ratatui::layout::Position;
6use ratatui::style::Stylize;
7use ratatui::text::{Line, Span};
8use ratatui::widgets::{Block, Padding, Paragraph};
9use rig_ecs::agent::ActiveTurn;
10use rig_harness::main_agent::ShownAgent;
11use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
12
13use super::input::InputContext;
14use super::keys::KeyFocus;
15use super::panel::PanelCanvas;
16use super::pick::Choices;
17use super::status::{StatusLine, StatusSources};
18use super::theme::Theme;
19
20/// The prompt before the first line of the input.
21const PROMPT: &str = "› ";
22
23/// Most lines an editor that fits its text shows; past them it scrolls to
24/// keep the cursor in sight.
25const MOST_LINES: usize = 10;
26
27/// A text being typed: multiline, with a cursor and emacs-style editing
28/// keys. The keys type into the [`MainEditor`], or into the editor of the
29/// panel with the [`KeyFocus`](crate::KeyFocus); Enter sends the main
30/// one's text to the shown agent. A plugin reads and edits a draft as the
31/// keys do (a template, a dictated message, the text an editor wrote), the
32/// main one through [`MainInput`]; `is_changed` on it says the draft was
33/// edited. Up and Down move between lines and, from the first or last
34/// line, through the [`PromptHistory`](crate::PromptHistory), keeping the
35/// draft, which Down past the newest prompt brings back. In a panel placed
36/// at the top, the bottom or below the input, it is as tall as its text, up
37/// to ten lines.
38///
39/// ```no_run
40/// use rig_harness::prelude::*;
41/// use rig_tui::MainInput;
42///
43/// /// Puts a template in the input.
44/// fn template(mut input: MainInput) {
45///     input.set("Review the diff for:\n- ");
46/// }
47/// ```
48#[derive(Component, Reflect, Clone, Debug, Default)]
49#[reflect(Component, Default)]
50#[require(Caret)]
51pub struct Editor {
52    text: String,
53    /// A byte offset in `text`, on a character boundary.
54    cursor: usize,
55    /// The history entry shown, while browsing it.
56    browsing: Option<usize>,
57    /// What was typed before browsing started.
58    draft: String,
59}
60
61/// Marks the editor the keys type into while no panel has the
62/// [`KeyFocus`](crate::KeyFocus), whose text Enter sends to the shown
63/// agent, and which a recalled message or a reload's draft comes back to:
64/// the default screen's. An app that builds its own screen marks its own.
65#[derive(Component, Clone, Copy, Debug, Default)]
66pub struct MainEditor;
67
68/// The main editor's text, to read or edit. A system with it is skipped
69/// when there is no such editor, as in an app without the default screen.
70pub type MainInput<'w, 's> = Single<'w, 's, &'static mut Editor, With<MainEditor>>;
71
72/// Where an editor drew the cursor of the keys, on screen, while it has
73/// them and no picker does.
74#[derive(Component, Default)]
75pub(crate) struct Caret(pub(crate) Option<Position>);
76
77/// The input laid out to a width: its rows and where the cursor is.
78pub(crate) struct Layout {
79    pub(crate) rows: Vec<Line<'static>>,
80    pub(crate) cursor_row: usize,
81    pub(crate) cursor_column: usize,
82}
83
84impl Editor {
85    /// The text.
86    pub fn text(&self) -> &str {
87        &self.text
88    }
89
90    /// The cursor: a byte offset in [`Self::text`], on a character
91    /// boundary.
92    pub fn cursor(&self) -> usize {
93        self.cursor
94    }
95
96    /// Whether nothing is typed.
97    pub fn is_empty(&self) -> bool {
98        self.text.is_empty()
99    }
100
101    /// Replaces the text, with the cursor at its end.
102    pub fn set(&mut self, text: impl Into<String>) {
103        self.browsing = None;
104        self.show(text.into());
105    }
106
107    /// Puts the cursor at byte `offset`, or the nearest character boundary
108    /// before it.
109    pub fn set_cursor(&mut self, offset: usize) {
110        let mut offset = offset.min(self.text.len());
111        while !self.text.is_char_boundary(offset) {
112            offset -= 1;
113        }
114        self.cursor = offset;
115    }
116
117    /// Shows `text`, with the cursor at its end, keeping the history's
118    /// place.
119    fn show(&mut self, text: String) {
120        self.text = text;
121        self.cursor = self.text.len();
122    }
123
124    /// Empties the editor and stops browsing the history.
125    pub fn clear(&mut self) {
126        self.text.clear();
127        self.cursor = 0;
128        self.browsing = None;
129    }
130
131    /// Takes the text out, leaving the input empty.
132    pub fn take(&mut self) -> String {
133        self.cursor = 0;
134        self.browsing = None;
135        std::mem::take(&mut self.text)
136    }
137
138    /// Types `text` at the cursor, which ends up after it.
139    pub fn insert(&mut self, text: &str) {
140        let text = text.replace("\r\n", "\n").replace('\r', "\n");
141        self.text.insert_str(self.cursor, &text);
142        self.cursor += text.len();
143    }
144
145    /// Types `character` at the cursor, which ends up after it.
146    pub fn insert_char(&mut self, character: char) {
147        self.text.insert(self.cursor, character);
148        self.cursor += character.len_utf8();
149    }
150
151    /// Replaces the bytes `start..self.cursor()` with `text`.
152    pub(crate) fn replace_before_cursor(&mut self, start: usize, text: &str) {
153        if start <= self.cursor && self.text.is_char_boundary(start) {
154            self.text.replace_range(start..self.cursor, text);
155            self.cursor = start + text.len();
156        }
157    }
158
159    /// Enter with a `\` before the cursor: the `\` becomes a newline.
160    /// Returns whether it did.
161    pub(crate) fn continue_line(&mut self) -> bool {
162        if self
163            .text
164            .get(..self.cursor)
165            .is_some_and(|before| before.ends_with('\\'))
166        {
167            self.cursor -= 1;
168            self.text.remove(self.cursor);
169            self.insert_char('\n');
170            return true;
171        }
172        false
173    }
174
175    fn previous_boundary(&self, from: usize) -> usize {
176        self.text
177            .get(..from)
178            .and_then(|before| before.char_indices().next_back())
179            .map_or(0, |(index, _)| index)
180    }
181
182    fn next_boundary(&self, from: usize) -> usize {
183        self.text
184            .get(from..)
185            .and_then(|after| after.chars().next())
186            .map_or(self.text.len(), |character| from + character.len_utf8())
187    }
188
189    /// A character back.
190    pub fn left(&mut self) {
191        self.cursor = self.previous_boundary(self.cursor);
192    }
193
194    /// A character forward.
195    pub fn right(&mut self) {
196        self.cursor = self.next_boundary(self.cursor);
197    }
198
199    /// The start of the word before the cursor.
200    fn word_start(&self) -> usize {
201        let before = self.text.get(..self.cursor).unwrap_or_default();
202        let trimmed = before.trim_end_matches(|c: char| !c.is_alphanumeric() && c != '_');
203        trimmed
204            .rfind(|c: char| !c.is_alphanumeric() && c != '_')
205            .map_or(0, |index| {
206                index
207                    + trimmed
208                        .get(index..)
209                        .and_then(|rest| rest.chars().next())
210                        .map_or(1, char::len_utf8)
211            })
212    }
213
214    /// The end of the word after the cursor.
215    fn word_end(&self) -> usize {
216        let after = self.text.get(self.cursor..).unwrap_or_default();
217        let skipped = after.len()
218            - after
219                .trim_start_matches(|c: char| !c.is_alphanumeric() && c != '_')
220                .len();
221        let rest = after.get(skipped..).unwrap_or_default();
222        let word = rest
223            .find(|c: char| !c.is_alphanumeric() && c != '_')
224            .unwrap_or(rest.len());
225        self.cursor + skipped + word
226    }
227
228    /// The start of the word before the cursor.
229    pub fn word_left(&mut self) {
230        self.cursor = self.word_start();
231    }
232
233    /// The end of the word after the cursor.
234    pub fn word_right(&mut self) {
235        self.cursor = self.word_end();
236    }
237
238    /// The start of the cursor's line.
239    fn line_start(&self) -> usize {
240        self.text
241            .get(..self.cursor)
242            .and_then(|before| before.rfind('\n'))
243            .map_or(0, |index| index + 1)
244    }
245
246    /// The end of the cursor's line.
247    fn line_end(&self) -> usize {
248        self.text
249            .get(self.cursor..)
250            .and_then(|after| after.find('\n'))
251            .map_or(self.text.len(), |index| self.cursor + index)
252    }
253
254    /// The start of the cursor's line.
255    pub fn home(&mut self) {
256        self.cursor = self.line_start();
257    }
258
259    /// The end of the cursor's line.
260    pub fn end(&mut self) {
261        self.cursor = self.line_end();
262    }
263
264    /// Deletes the character before the cursor.
265    pub fn backspace(&mut self) {
266        let start = self.previous_boundary(self.cursor);
267        self.text.replace_range(start..self.cursor, "");
268        self.cursor = start;
269    }
270
271    /// Deletes the character after the cursor.
272    pub fn delete(&mut self) {
273        let end = self.next_boundary(self.cursor);
274        self.text.replace_range(self.cursor..end, "");
275    }
276
277    /// Deletes the word before the cursor.
278    pub fn delete_word_before(&mut self) {
279        let start = self.word_start();
280        self.text.replace_range(start..self.cursor, "");
281        self.cursor = start;
282    }
283
284    /// Deletes the word after the cursor.
285    pub fn delete_word_after(&mut self) {
286        let end = self.word_end();
287        self.text.replace_range(self.cursor..end, "");
288    }
289
290    /// Deletes to the start of the line, or the newline before it when the
291    /// cursor is at the start already.
292    pub fn delete_to_line_start(&mut self) {
293        let start = match self.line_start() {
294            start if start == self.cursor => self.previous_boundary(start),
295            start => start,
296        };
297        self.text.replace_range(start..self.cursor, "");
298        self.cursor = start;
299    }
300
301    /// Deletes to the end of the line, or the newline after it when the
302    /// cursor is at the end already.
303    pub fn delete_to_line_end(&mut self) {
304        let end = match self.line_end() {
305            end if end == self.cursor => self.next_boundary(end),
306            end => end,
307        };
308        self.text.replace_range(self.cursor..end, "");
309    }
310
311    /// The cursor's column on its line, in characters.
312    fn column(&self) -> usize {
313        self.text
314            .get(self.line_start()..self.cursor)
315            .map_or(0, |line| line.chars().count())
316    }
317
318    /// Moves to `column` of the line starting at `start`, or its end.
319    fn move_to_column(&mut self, start: usize, column: usize) {
320        let line = self.text.get(start..).unwrap_or_default();
321        let line = line.split('\n').next().unwrap_or_default();
322        self.cursor = start
323            + line
324                .char_indices()
325                .nth(column)
326                .map_or(line.len(), |(index, _)| index);
327    }
328
329    /// Up: the line above, else the previous prompt of `history` (oldest
330    /// first).
331    pub fn up(&mut self, history: &[String]) {
332        let start = self.line_start();
333        if start == 0 {
334            self.history_back(history);
335            return;
336        }
337        let column = self.column();
338        let above = self
339            .text
340            .get(..start - 1)
341            .and_then(|before| before.rfind('\n'))
342            .map_or(0, |index| index + 1);
343        self.move_to_column(above, column);
344    }
345
346    /// Down: the line below, else the next prompt of `history`, or the
347    /// draft after the newest.
348    pub fn down(&mut self, history: &[String]) {
349        let end = self.line_end();
350        if end == self.text.len() {
351            self.history_forward(history);
352            return;
353        }
354        let column = self.column();
355        self.move_to_column(end + 1, column);
356    }
357
358    fn history_back(&mut self, history: &[String]) {
359        let index = match self.browsing {
360            Some(0) => return,
361            Some(index) => index.min(history.len()).saturating_sub(1),
362            None if history.is_empty() => return,
363            None => {
364                self.draft = self.text.clone();
365                history.len() - 1
366            }
367        };
368        self.browsing = Some(index);
369        let text = history.get(index).cloned().unwrap_or_default();
370        self.show(text);
371    }
372
373    fn history_forward(&mut self, history: &[String]) {
374        let Some(index) = self.browsing else {
375            return;
376        };
377        let text = if index + 1 < history.len() {
378            self.browsing = Some(index + 1);
379            history.get(index + 1).cloned().unwrap_or_default()
380        } else {
381            self.browsing = None;
382            std::mem::take(&mut self.draft)
383        };
384        self.show(text);
385    }
386
387    /// The rows it fits its text in, `width` columns wide with its border:
388    /// one line at least, [`MOST_LINES`] at most.
389    pub(crate) fn fit_rows(&self, width: u16) -> u16 {
390        let lines = self.layout(width.saturating_sub(2)).rows.len();
391        u16::try_from(lines.clamp(1, MOST_LINES) + 2).unwrap_or(u16::MAX)
392    }
393
394    /// The text laid out in rows `width` columns wide, broken anywhere, the
395    /// first row after the prompt and the others indented under it.
396    pub(crate) fn layout(&self, width: u16) -> Layout {
397        // The prompt is two columns wide.
398        let width = usize::from(width).max(3);
399        let mut rows = Vec::new();
400        let mut row = String::new();
401        let mut row_width = 2;
402        let mut cursor_row = 0;
403        let mut cursor_column = 2;
404        let mut first = true;
405        let finish = |row: &mut String, first: &mut bool, rows: &mut Vec<Line<'static>>| {
406            let prefix = if *first { PROMPT } else { "  " };
407            *first = false;
408            rows.push(Line::from(vec![
409                Span::from(prefix).dim(),
410                Span::from(std::mem::take(row)),
411            ]));
412        };
413        for (index, character) in self.text.char_indices() {
414            if index == self.cursor {
415                cursor_row = rows.len();
416                cursor_column = row_width;
417            }
418            if character == '\n' {
419                finish(&mut row, &mut first, &mut rows);
420                row_width = 2;
421                continue;
422            }
423            let cell = character.width().unwrap_or(0);
424            if row_width + cell > width {
425                finish(&mut row, &mut first, &mut rows);
426                row_width = 2;
427                if index == self.cursor {
428                    cursor_row = rows.len();
429                    cursor_column = row_width;
430                }
431            }
432            row.push(if character == '\t' { ' ' } else { character });
433            row_width += cell;
434        }
435        if self.cursor >= self.text.len() {
436            if row_width >= width {
437                finish(&mut row, &mut first, &mut rows);
438                row_width = 2;
439            }
440            cursor_row = rows.len();
441            cursor_column = row_width;
442        }
443        finish(&mut row, &mut first, &mut rows);
444        Layout {
445            rows,
446            cursor_row,
447            cursor_column,
448        }
449    }
450}
451
452/// The keys of an empty editor that has them, over where its text goes:
453/// a message's, with Enter steering and Tab and Esc for the turn while one
454/// runs.
455fn hint(turn_running: bool, panel_keys: bool) -> &'static str {
456    match (panel_keys, turn_running) {
457        (true, _) => " A panel has the keys · Esc gives them back ",
458        (false, true) => {
459            " Enter steers · Tab sends after the turn · Esc stops · Shift+Enter or Ctrl+J new line · Ctrl+V image · / commands · @ files "
460        }
461        (false, false) => {
462            " Enter sends · Shift+Enter or Ctrl+J new line · Ctrl+V image · / commands · @ files "
463        }
464    }
465}
466
467/// `keys`, items split by ` · `, in `width` columns: the first items that
468/// fit, whole.
469fn fit_keys(keys: &str, width: usize) -> String {
470    let mut fitted = String::new();
471    for item in keys.split(" · ").map(str::trim) {
472        let next = match fitted.is_empty() {
473            true => format!(" {item} "),
474            false => format!("{} · {item} ", fitted.trim_end()),
475        };
476        if next.width() > width {
477            break;
478        }
479        fitted = next;
480    }
481    fitted
482}
483
484/// Draws each editor with room into its canvas, on the input's band with a
485/// row above and below and a cell each side, scrolled to
486/// keep its cursor in sight. The one the keys type into is titled by the
487/// question it answers, if any, and while nothing is typed says over its
488/// text what the keys do; it places the caret, unless a picker or a panel
489/// without an editor has the keys. An editor with a [`StatusLine`] draws
490/// it in its bottom row while nothing is typed.
491pub(crate) fn draw_editors(
492    mut editors: Query<(
493        Entity,
494        &Editor,
495        Option<&StatusLine>,
496        &mut PanelCanvas,
497        &mut Caret,
498    )>,
499    (state, choices, focus): (Res<InputContext>, Choices, Res<KeyFocus>),
500    (shown, active): (Res<ShownAgent>, Query<(), With<ActiveTurn>>),
501    (theme, status): (Res<Theme>, StatusSources),
502) {
503    let answering = state.answering().and_then(|ask| choices.shown(ask));
504    let busy = shown.0.is_some_and(|agent| active.contains(agent));
505    for (entity, editor, line, mut canvas, mut caret) in &mut editors {
506        let caret = &mut caret.bypass_change_detection().0;
507        *caret = None;
508        let area = canvas.area();
509        if area.width < 3 || area.height < 3 {
510            continue;
511        }
512        let layout = editor.layout(area.width - 2);
513        let top = (layout.cursor_row + 1).saturating_sub(usize::from(area.height - 2));
514        let (mut title, mut keys) = (Line::default(), "");
515        if state.editor() == Some(entity) {
516            let elsewhere = focus.0.is_some_and(|focused| focused != entity);
517            keys = match &answering {
518                Some(ask) => {
519                    title = Line::from(format!(" {} ", ask.title)).bold();
520                    " Enter answers · Shift+Enter or Ctrl+J new line · Esc refuses "
521                }
522                None => hint(busy, elsewhere),
523            };
524            if !editor.is_empty() {
525                keys = "";
526            }
527            if state.picking().is_none() && !elsewhere {
528                let column = u16::try_from(layout.cursor_column).unwrap_or(u16::MAX);
529                let row = u16::try_from(layout.cursor_row - top).unwrap_or(0);
530                let x = area.x.saturating_add(1).saturating_add(column);
531                *caret = Some(Position::new(
532                    x,
533                    area.y.saturating_add(1).saturating_add(row),
534                ));
535            }
536        }
537        let top = u16::try_from(top).unwrap_or(u16::MAX);
538        let (left, right) = match line.filter(|_| editor.is_empty()) {
539            Some(line) => {
540                let (left, right) = status.line(line, area.width - 2);
541                let pad = |mut line: Line<'static>, at: usize| {
542                    line.spans.insert(at.min(line.spans.len()), Span::from(" "));
543                    line
544                };
545                (pad(left, 0), pad(right, usize::MAX))
546            }
547            None => (Line::default(), Line::default()),
548        };
549        let keys = fit_keys(keys, usize::from(area.width).saturating_sub(title.width()));
550        // Titles, empty or not, keep the rows above and below the text.
551        let block = Block::new()
552            .bg(theme.input)
553            .padding(Padding::horizontal(1))
554            .title(title.left_aligned())
555            .title(Line::from(keys).dim().right_aligned())
556            .title_bottom(left.left_aligned())
557            .title_bottom(right.right_aligned());
558        canvas.render(Paragraph::new(layout.rows).scroll((top, 0)).block(block));
559    }
560}