Files
Gotcha/crates/tui/src/editor.rs
2026-08-15 20:06:52 +02:00

345 lines
9.8 KiB
Rust

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use gotcha_gitea::{Provider, RepositoryId};
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum EditorAction {
Issue {
repository: RepositoryId,
number: Option<i64>,
},
Comment {
repository: RepositoryId,
number: i64,
comment_id: Option<i64>,
},
Milestone {
repository: RepositoryId,
id: Option<i64>,
},
Server {
original_name: Option<String>,
},
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<String>) -> 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<Field>,
pub focus: usize,
pub cursor: usize,
pub action: EditorAction,
}
impl Editor {
pub fn issue(repository: RepositoryId, number: Option<i64>, 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<i64>,
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<i64>,
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<String>,
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<Field>) -> 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<String> {
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
);
}
}