use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use gotcha_gitea::{Provider, RepositoryId}; #[derive(Clone, Debug, Eq, PartialEq)] pub enum EditorAction { Issue { repository: RepositoryId, number: Option, }, Comment { repository: RepositoryId, number: i64, comment_id: Option, }, Milestone { repository: RepositoryId, id: Option, }, Server { original_name: Option, }, Settings, IssueFilter, PullFilter, ActionDispatch { repository: RepositoryId, workflow: String, }, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct Field { pub label: String, pub value: String, pub secret: bool, pub multiline: bool, } impl Field { pub fn new(label: &str, value: impl Into) -> Self { Self { label: label.into(), value: value.into(), secret: false, multiline: false, } } pub fn secret(mut self) -> Self { self.secret = true; self } pub fn multiline(mut self) -> Self { self.multiline = true; self } } #[derive(Clone, Debug)] pub struct Editor { pub title: String, pub fields: Vec, pub focus: usize, pub cursor: usize, pub action: EditorAction, } impl Editor { pub fn issue(repository: RepositoryId, number: Option, fields: [String; 6]) -> Self { let [title, body, labels, milestone, due_date, state] = fields; Self::new( if number.is_some() { "Edit issue" } else { "New issue" }, EditorAction::Issue { repository, number }, vec![ Field::new("Title", title), Field::new("Body", body).multiline(), Field::new("Labels (comma separated)", labels), Field::new("Milestone", milestone), Field::new("Due date (YYYY-MM-DD)", due_date), Field::new("State (open/closed)", state), ], ) } pub fn comment( repository: RepositoryId, number: i64, comment_id: Option, body: String, ) -> Self { Self::new( if comment_id.is_some() { "Edit comment" } else { "New comment" }, EditorAction::Comment { repository, number, comment_id, }, vec![Field::new("Comment", body).multiline()], ) } pub fn issue_filter(state: &str, labels: &str, milestone: &str, search: &str) -> Self { Self::new( "Issue filters", EditorAction::IssueFilter, vec![ Field::new("State (open/closed/all)", state), Field::new("Labels (comma separated)", labels), Field::new("Milestone", milestone), Field::new("Search", search), ], ) } pub fn pull_filter(state: &str, milestone: &str, search: &str) -> Self { Self::new( "Pull request filters", EditorAction::PullFilter, vec![ Field::new("State (open/closed)", state), Field::new("Milestone", milestone), Field::new("Search", search), ], ) } pub fn milestone( repository: RepositoryId, id: Option, title: String, description: String, due_date: String, state: String, ) -> Self { Self::new( if id.is_some() { "Edit milestone" } else { "New milestone" }, EditorAction::Milestone { repository, id }, vec![ Field::new("Title", title), Field::new("Description", description).multiline(), Field::new("Due date (YYYY-MM-DD)", due_date), Field::new("State (open/closed)", state), ], ) } pub fn server( original_name: Option, name: String, url: String, provider: Provider, ) -> Self { Self::new( if original_name.is_some() { "Edit server" } else { "Add server" }, EditorAction::Server { original_name }, vec![ Field::new("Profile name (host[:port])", name), Field::new("Server URL", url), Field::new("Token (blank keeps existing)", "").secret(), Field::new("Provider (gitea/forgejo)", provider.to_string()), ], ) } pub fn settings(refresh_seconds: u64) -> Self { Self::new( "TUI preferences", EditorAction::Settings, vec![Field::new( "Auto-reload seconds (0 disables)", refresh_seconds.to_string(), )], ) } pub fn action_dispatch(repository: RepositoryId, workflow: String, reference: String) -> Self { Self::new( "Dispatch workflow", EditorAction::ActionDispatch { repository, workflow, }, vec![ Field::new("Git reference", reference), Field::new("Inputs (one KEY=VALUE per line)", "").multiline(), ], ) } fn new(title: &str, action: EditorAction, fields: Vec) -> Self { let cursor = fields .first() .map_or(0, |field| field.value.chars().count()); Self { title: title.into(), fields, focus: 0, cursor, action, } } pub fn handle_key(&mut self, key: KeyEvent) -> EditorEvent { if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('s') { return EditorEvent::Submit; } match key.code { KeyCode::Esc => EditorEvent::Cancel, KeyCode::Tab | KeyCode::Down => { self.move_focus(1); EditorEvent::Changed } KeyCode::BackTab | KeyCode::Up => { self.move_focus(self.fields.len().saturating_sub(1)); EditorEvent::Changed } KeyCode::Left => { self.cursor = self.cursor.saturating_sub(1); EditorEvent::Changed } KeyCode::Right => { self.cursor = (self.cursor + 1).min(self.current_len()); EditorEvent::Changed } KeyCode::Home => { self.cursor = 0; EditorEvent::Changed } KeyCode::End => { self.cursor = self.current_len(); EditorEvent::Changed } KeyCode::Backspace => { if self.cursor > 0 { let end = byte_index(&self.fields[self.focus].value, self.cursor); let start = byte_index(&self.fields[self.focus].value, self.cursor - 1); self.fields[self.focus].value.replace_range(start..end, ""); self.cursor -= 1; } EditorEvent::Changed } KeyCode::Delete => { if self.cursor < self.current_len() { let start = byte_index(&self.fields[self.focus].value, self.cursor); let end = byte_index(&self.fields[self.focus].value, self.cursor + 1); self.fields[self.focus].value.replace_range(start..end, ""); } EditorEvent::Changed } KeyCode::Enter if self.fields[self.focus].multiline => { self.insert('\n'); EditorEvent::Changed } KeyCode::Enter => { self.move_focus(1); EditorEvent::Changed } KeyCode::Char(character) if key.modifiers.is_empty() || key.modifiers == KeyModifiers::SHIFT => { self.insert(character); EditorEvent::Changed } _ => EditorEvent::Ignored, } } fn insert(&mut self, character: char) { let index = byte_index(&self.fields[self.focus].value, self.cursor); self.fields[self.focus].value.insert(index, character); self.cursor += 1; } fn move_focus(&mut self, amount: usize) { self.focus = (self.focus + amount) % self.fields.len(); self.cursor = self.current_len(); } fn current_len(&self) -> usize { self.fields[self.focus].value.chars().count() } pub fn values(&self) -> Vec { self.fields .iter() .map(|field| field.value.clone()) .collect() } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum EditorEvent { Changed, Submit, Cancel, Ignored, } fn byte_index(value: &str, character: usize) -> usize { value .char_indices() .nth(character) .map_or(value.len(), |(index, _)| index) } #[cfg(test)] mod tests { use super::*; #[test] fn editor_backspace_edits_instead_of_navigating() { let mut editor = Editor::settings(5); editor.fields[0].value = "é5".into(); editor.cursor = 1; assert_eq!( editor.handle_key(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE)), EditorEvent::Changed ); assert_eq!(editor.fields[0].value, "5"); assert_eq!(editor.cursor, 0); assert_eq!( editor.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)), EditorEvent::Changed ); } }