use super::{
render::{self, PopupKind, PopupRow},
tag_input,
};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::path::PathBuf;
use unicode_segmentation::UnicodeSegmentation;
pub(super) const DEFAULT_PROMPT: &str = "/goal Use the `qqq cli --wait`, to look for next task, and work on that. Repeat this forever. Please don't wake up repeatedly to poll the status when running waiter.";
pub(super) enum Action {
Create(String),
Cancel,
}
pub(super) struct View {
root: PathBuf,
value: String,
cursor: tag_input::Cursor,
error: String,
}
impl View {
pub fn new(root: PathBuf, prompt: Option<String>) -> Self {
let value = prompt.unwrap_or_else(|| DEFAULT_PROMPT.into());
let cursor = tag_input::Cursor::at_end(&value);
Self {
root,
value,
cursor,
error: String::new(),
}
}
pub fn paste(&mut self, text: &str) {
self.cursor
.insert(&mut self.value, &text.replace("\r\n", "\n"));
self.error.clear();
}
pub fn key(&mut self, key: KeyEvent, width: usize) -> Option<Action> {
if key.code == KeyCode::Esc
|| (key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('c'))
{
return Some(Action::Cancel);
}
if key.modifiers == KeyModifiers::CONTROL && key.code == KeyCode::Char('h') {
if self.value.trim().is_empty() {
self.error = "Enter a prompt".into();
} else {
return Some(Action::Create(self.value.clone()));
}
} else if matches!(key.code, KeyCode::Up | KeyCode::Down)
&& !key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER)
{
let layout = self.layout(width);
self.cursor
.visual_vertical(&self.value, &layout, key.code == KeyCode::Up);
} else {
self.cursor.edit(&mut self.value, &mut self.error, key);
}
None
}
fn layout(&self, width: usize) -> render::Layout {
let fragments: Vec<_> = self.value.graphemes(true).map(str::to_owned).collect();
render::Layout::new(&fragments, &[], width.saturating_sub(1))
}
pub fn popup(
&self,
area: ratatui::layout::Rect,
) -> (super::dashboard::PopupLayout, Vec<PopupRow>) {
let content = super::dashboard::popup_layout(area, usize::MAX).content;
let rows = self.rows(usize::from(content.width), usize::from(content.height));
let popup = super::dashboard::popup_layout(area, rows.len()).enlarged(area);
let mut rows = self.rows(
usize::from(popup.content.width),
usize::from(popup.content.height),
);
let padding = usize::from(popup.content.height).saturating_sub(rows.len());
let footer = rows.len().saturating_sub(1);
rows.splice(
footer..footer,
std::iter::repeat_with(|| PopupRow::new("", PopupKind::Input)).take(padding),
);
(popup, rows)
}
pub fn rows(&self, width: usize, height: usize) -> Vec<PopupRow> {
let heading = usize::from(height >= 3);
let guidance = usize::from(height >= if self.error.is_empty() { 4 } else { 5 });
let footer = usize::from(height >= 2);
let body = height.saturating_sub(heading + guidance + footer);
let errors: Vec<_> = super::wrap_modal(&self.error, width)
.into_iter()
.filter(|_| !self.error.is_empty())
.take(body.saturating_sub(1).min(3))
.collect();
let input_height = body.saturating_sub(errors.len());
let layout = self.layout(width);
let caret = self.value[..self.cursor.byte()].graphemes(true).count();
let (active, column) = layout.positions[caret];
let start = active
.saturating_sub(input_height / 2)
.min(layout.rows.len().saturating_sub(input_height));
let mut rows = Vec::new();
if heading > 0 {
rows.push(PopupRow::new("Create Herdr agent", PopupKind::Heading));
}
if guidance > 0 {
rows.push(PopupRow::new(
format!("Directory: {}", self.root.display()),
PopupKind::Hint,
));
}
rows.extend(
layout
.rows
.iter()
.enumerate()
.skip(start)
.take(input_height)
.map(|(index, text)| {
let mut row = PopupRow::new(text, PopupKind::Input);
if index == active {
row.cursor = Some(column.min(width.saturating_sub(1)));
}
row
}),
);
rows.extend(
errors
.into_iter()
.map(|text| PopupRow::new(text, PopupKind::Error)),
);
if footer > 0 {
let hint = if width >= 37 {
"Enter line Ctrl-H create Esc cancel"
} else if width >= 21 {
"Enter Ctrl-H Esc"
} else {
"↵ ^H Esc"
};
rows.push(PopupRow::new(hint, PopupKind::Hint));
}
rows
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn popup_is_half_larger_and_keeps_footer_and_cursor_inside_small_terminals() {
use ratatui::{Terminal, backend::TestBackend, layout::Rect};
let view = View::new(PathBuf::from("/project"), None);
for (width, height, expected_width, expected_height) in [
(120, 40, 72, 14),
(72, 24, 68, 14),
(40, 12, 40, 12),
(12, 8, 12, 8),
] {
for color in [true, false] {
let area = Rect::new(0, 0, width, height);
let (popup, rows) = view.popup(area);
assert_eq!(popup.outer.width, expected_width);
assert_eq!(popup.outer.height, expected_height);
assert_eq!(popup.outer.x, (width - expected_width) / 2);
assert_eq!(popup.outer.y, (height - expected_height) / 2);
assert_eq!(rows.len(), usize::from(popup.content.height));
assert_eq!(rows.last().unwrap().kind, PopupKind::Hint);
let (row, column) = rows
.iter()
.enumerate()
.find_map(|(row, value)| value.cursor.map(|column| (row, column)))
.unwrap();
let expected_cursor = ratatui::layout::Position::new(
popup.content.x + column as u16,
popup.content.y + row as u16,
);
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
terminal
.draw(|frame| {
super::super::dashboard::popup_at(frame, &rows, color, true, popup)
})
.unwrap();
assert_eq!(terminal.get_cursor_position().unwrap(), expected_cursor);
}
}
}
#[test]
fn ctrl_h_confirms_prompt_and_ctrl_s_leaves_it_open() {
let prompt = "Run 'literal $(text)'\nSecond line";
let mut view = View::new(PathBuf::from("/project"), Some(prompt.into()));
assert!(
view.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL), 64)
.is_none()
);
assert_eq!(view.value, prompt);
assert!(matches!(
view.key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), 64),
Some(Action::Create(value)) if value == prompt
));
let mut empty = View::new(PathBuf::from("/project"), Some(" \n ".into()));
assert!(
empty
.key(KeyEvent::new(KeyCode::Char('h'), KeyModifiers::CONTROL), 64)
.is_none()
);
assert_eq!(empty.error, "Enter a prompt");
}
#[test]
fn down_reaches_wrapped_row_starting_with_wide_grapheme() {
let mut view = View::new(PathBuf::from("/project"), None);
view.value = format!("{}界bbb", "a".repeat(45));
view.cursor = tag_input::Cursor::at_end(&view.value);
view.key(KeyEvent::new(KeyCode::Home, KeyModifiers::NONE), 46);
view.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), 46);
let layout = view.layout(46);
let caret = view.value[..view.cursor.byte()].graphemes(true).count();
assert_eq!(layout.positions[caret].0, 1);
assert!(view.value.is_char_boundary(view.cursor.byte()));
}
#[test]
fn arrows_move_within_wrapped_prompt_before_editing() {
let mut view = View::new(PathBuf::from("/project"), None);
let end = view.cursor.byte();
view.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), 36);
assert!(view.cursor.byte() < end);
let insertion = view.cursor.byte();
view.key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE), 36);
assert_eq!(view.cursor.byte(), end);
view.key(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE), 36);
assert_eq!(view.cursor.byte(), insertion);
view.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE), 36);
let mut expected = DEFAULT_PROMPT.to_owned();
expected.insert(insertion, 'X');
assert_eq!(view.value, expected);
}
#[test]
fn default_prompt_is_readable_without_horizontal_scrolling() {
let view = View::new(PathBuf::from("/project"), None);
for (width, height) in [(64, 10), (36, 12)] {
let rows = view.rows(width, height);
let text: String = rows
.iter()
.filter(|row| row.kind == PopupKind::Input)
.map(|row| row.text.as_str())
.collect();
assert_eq!(text, DEFAULT_PROMPT);
assert!(rows.iter().any(|row| row.cursor.is_some()));
assert!(rows.len() <= height);
assert!(rows.last().unwrap().text.contains("Ctrl-H"));
}
assert_eq!(view.rows(20, 4).last().unwrap().text, "↵ ^H Esc");
}
}