1use 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
20const PROMPT: &str = "› ";
22
23const MOST_LINES: usize = 10;
26
27#[derive(Component, Reflect, Clone, Debug, Default)]
49#[reflect(Component, Default)]
50#[require(Caret)]
51pub struct Editor {
52 text: String,
53 cursor: usize,
55 browsing: Option<usize>,
57 draft: String,
59}
60
61#[derive(Component, Clone, Copy, Debug, Default)]
66pub struct MainEditor;
67
68pub type MainInput<'w, 's> = Single<'w, 's, &'static mut Editor, With<MainEditor>>;
71
72#[derive(Component, Default)]
75pub(crate) struct Caret(pub(crate) Option<Position>);
76
77pub(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 pub fn text(&self) -> &str {
87 &self.text
88 }
89
90 pub fn cursor(&self) -> usize {
93 self.cursor
94 }
95
96 pub fn is_empty(&self) -> bool {
98 self.text.is_empty()
99 }
100
101 pub fn set(&mut self, text: impl Into<String>) {
103 self.browsing = None;
104 self.show(text.into());
105 }
106
107 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 fn show(&mut self, text: String) {
120 self.text = text;
121 self.cursor = self.text.len();
122 }
123
124 pub fn clear(&mut self) {
126 self.text.clear();
127 self.cursor = 0;
128 self.browsing = None;
129 }
130
131 pub fn take(&mut self) -> String {
133 self.cursor = 0;
134 self.browsing = None;
135 std::mem::take(&mut self.text)
136 }
137
138 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 pub fn insert_char(&mut self, character: char) {
147 self.text.insert(self.cursor, character);
148 self.cursor += character.len_utf8();
149 }
150
151 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 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 pub fn left(&mut self) {
191 self.cursor = self.previous_boundary(self.cursor);
192 }
193
194 pub fn right(&mut self) {
196 self.cursor = self.next_boundary(self.cursor);
197 }
198
199 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 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 pub fn word_left(&mut self) {
230 self.cursor = self.word_start();
231 }
232
233 pub fn word_right(&mut self) {
235 self.cursor = self.word_end();
236 }
237
238 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 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 pub fn home(&mut self) {
256 self.cursor = self.line_start();
257 }
258
259 pub fn end(&mut self) {
261 self.cursor = self.line_end();
262 }
263
264 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 pub fn delete(&mut self) {
273 let end = self.next_boundary(self.cursor);
274 self.text.replace_range(self.cursor..end, "");
275 }
276
277 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 pub fn delete_word_after(&mut self) {
286 let end = self.word_end();
287 self.text.replace_range(self.cursor..end, "");
288 }
289
290 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 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 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 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 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 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 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 pub(crate) fn layout(&self, width: u16) -> Layout {
397 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
452fn 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
467fn 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
484pub(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 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}