use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratada::input::InputField;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph};
use crate::domain::repo::{Repo, RepoKind};
use crate::domain::sections::UNGROUPED;
use crate::domain::slug::slugify;
use crate::theme::Skin;
use crate::tui::presentation::{FieldView, field_spans};
use crate::tui::skin::Colors;
use crate::tui::widgets::centered_rect;
const LABEL_WIDTH: usize = 8;
struct LineCtx<'a> {
colors: &'a Colors,
skin: &'a Skin,
value_width: usize,
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Field {
Path,
Name,
Section,
Slug,
Kind,
Fav,
Backup,
}
pub struct RepoDraft {
pub name: Option<String>,
pub path: String,
pub slug: Option<String>,
pub section: Option<String>,
pub kind: RepoKind,
pub fav: bool,
pub include_in_backup: bool,
}
pub enum FormResult {
Pending,
Save(RepoDraft),
PickPath,
Cancel,
}
pub struct RepoForm {
title: String,
name: InputField,
path: InputField,
slug: InputField,
kind: RepoKind,
fav: bool,
include_in_backup: bool,
section_options: Vec<Option<String>>,
section_choice: usize,
seed_section: Option<String>,
focus: usize,
}
impl RepoForm {
pub fn for_add(path: &str, kind: RepoKind, sections: &[String]) -> Self {
Self::build(
"Add entry",
"",
path,
"",
kind,
false,
kind == RepoKind::Git,
None,
sections,
)
}
pub fn for_edit(repo: &Repo, sections: &[String]) -> Self {
Self::build(
"Edit entry",
repo.name.as_deref().unwrap_or(""),
&repo.path.to_string_lossy(),
repo.slug.as_deref().unwrap_or(""),
repo.kind,
repo.fav,
repo.include_in_backup,
repo.section.clone(),
sections,
)
}
#[allow(clippy::too_many_arguments)]
fn build(
title: &str,
name: &str,
path: &str,
slug: &str,
kind: RepoKind,
fav: bool,
include_in_backup: bool,
section: Option<String>,
sections: &[String],
) -> Self {
let section_options = section_options(sections);
let section_choice =
section_choice(§ion_options, section.as_deref());
RepoForm {
title: title.to_string(),
name: InputField::new(name),
path: InputField::new(path),
slug: InputField::new(slug),
kind,
fav,
include_in_backup,
section_options,
section_choice,
seed_section: section,
focus: 0,
}
}
fn fields(&self) -> Vec<Field> {
let mut fields = vec![Field::Path, Field::Name];
match self.kind {
RepoKind::Git => {
fields.push(Field::Slug);
fields.push(Field::Fav);
}
RepoKind::Path => {
fields.push(Field::Section);
fields.push(Field::Slug);
}
}
fields.push(Field::Backup);
fields.push(Field::Kind);
fields
}
pub fn handle_key(&mut self, key: KeyEvent) -> FormResult {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Esc => return FormResult::Cancel,
KeyCode::Enter => return FormResult::Save(self.draft()),
KeyCode::Char('s') if ctrl => {
return FormResult::Save(self.draft());
}
KeyCode::Char('o') if ctrl => return FormResult::PickPath,
KeyCode::Tab | KeyCode::Down => self.move_focus(1),
KeyCode::BackTab | KeyCode::Up => {
self.move_focus(self.fields().len() - 1)
}
_ => self.edit_focused(key),
}
FormResult::Pending
}
fn move_focus(&mut self, delta: usize) {
let fields = self.fields();
self.focus = (self.focus + delta) % fields.len();
if fields[self.focus] == Field::Name {
self.autofill_name();
}
}
fn edit_focused(&mut self, key: KeyEvent) {
match self.focused_field() {
Field::Path => {
self.path.handle_key(key);
}
Field::Name => {
self.name.handle_key(key);
}
Field::Slug => {
self.slug.handle_key(key);
}
Field::Section => match key.code {
KeyCode::Left => self.cycle_section(-1),
KeyCode::Right => self.cycle_section(1),
_ => {}
},
Field::Kind => self.edit_kind(key),
Field::Fav => {
if key.code == KeyCode::Char(' ') {
self.fav = !self.fav;
}
}
Field::Backup => {
if key.code == KeyCode::Char(' ') {
self.include_in_backup = !self.include_in_backup;
}
}
}
}
fn edit_kind(&mut self, key: KeyEvent) {
let changed = match key.code {
KeyCode::Left => {
self.kind = prev_kind(self.kind);
true
}
KeyCode::Right => {
self.kind = next_kind(self.kind);
true
}
_ => false,
};
if changed {
self.focus = self.field_index(Field::Kind);
}
}
fn focused_field(&self) -> Field {
self.fields()[self.focus]
}
fn field_index(&self, field: Field) -> usize {
self.fields()
.iter()
.position(|candidate| *candidate == field)
.unwrap_or(0)
}
fn cycle_section(&mut self, delta: isize) {
let len = self.section_options.len();
if len == 0 {
return;
}
let next =
(self.section_choice as isize + delta).rem_euclid(len as isize);
self.section_choice = next as usize;
}
pub fn path_value(&self) -> String {
self.path.value().to_string()
}
pub fn set_path(&mut self, path: &str) {
self.path = InputField::new(path);
self.autofill_name();
self.focus = 0;
}
fn autofill_name(&mut self) {
if !self.name.value().trim().is_empty() {
return;
}
if let Some(base) = basename(self.path.value()) {
self.name = InputField::new(&base);
}
}
fn draft(&self) -> RepoDraft {
let name = non_empty(self.name.value().to_string());
let slug = non_empty(slugify(self.slug.value()));
let section = if self.fields().contains(&Field::Section) {
self.section_options
.get(self.section_choice)
.cloned()
.flatten()
} else {
self.seed_section.clone()
};
RepoDraft {
name,
path: self.path.value().to_string(),
slug,
section,
kind: self.kind,
fav: self.fav,
include_in_backup: self.include_in_backup,
}
}
pub fn render(&self, frame: &mut Frame, area: Rect, skin: &Skin) {
let colors = Colors::from_palette(&skin.palette);
let fields = self.fields();
let height = fields.len() as u16 + 4;
let rect = centered_rect(70, height, area);
frame.render_widget(Clear, rect);
let block = ratada::chrome::modal_block(skin, &self.title);
let inner_width = block.inner(rect).width as usize;
let ctx = LineCtx {
colors: &colors,
skin,
value_width: inner_width.saturating_sub(LABEL_WIDTH),
};
let mut lines: Vec<Line> = fields
.iter()
.enumerate()
.map(|(index, field)| self.field_line(*field, index, &ctx))
.collect();
lines.push(Line::raw(""));
lines.push(Line::from(Span::styled(
"Tab field · \u{2190}\u{2192} change · Space fav · ^O pick path · \
Enter save · Esc cancel",
Style::default().fg(colors.dim),
)));
frame.render_widget(Paragraph::new(lines).block(block), rect);
}
fn field_line(
&self,
field: Field,
index: usize,
ctx: &LineCtx,
) -> Line<'static> {
let colors = ctx.colors;
match field {
Field::Path => self.text_line("Path", &self.path, index, ctx),
Field::Name => self.text_line("Name", &self.name, index, ctx),
Field::Slug => self.text_line("Slug", &self.slug, index, ctx),
Field::Section => {
let label = self.section_label();
self.choice_line("Section", label, index, colors)
}
Field::Kind => {
self.choice_line("Kind", kind_label(self.kind), index, colors)
}
Field::Fav => self.fav_line(index, colors),
Field::Backup => self.backup_line(index, colors),
}
}
fn text_line(
&self,
label: &str,
input: &InputField,
index: usize,
ctx: &LineCtx,
) -> Line<'static> {
let mut spans = vec![self.label_span(label, index, ctx.colors)];
spans.extend(field_spans(FieldView {
field: input,
palette: &ctx.skin.palette,
width: ctx.value_width,
focused: index == self.focus,
}));
self.styled(spans, index, ctx.colors)
}
fn choice_line(
&self,
label: &str,
value: &str,
index: usize,
colors: &Colors,
) -> Line<'static> {
let spans = vec![
self.label_span(label, index, colors),
Span::styled(
format!("< {value} >"),
Style::default().fg(colors.accent),
),
];
self.styled(spans, index, colors)
}
fn fav_line(&self, index: usize, colors: &Colors) -> Line<'static> {
let mark = if self.fav { "[x]" } else { "[ ]" };
let spans = vec![
self.label_span("Fav", index, colors),
Span::raw(mark.to_string()),
];
self.styled(spans, index, colors)
}
fn backup_line(&self, index: usize, colors: &Colors) -> Line<'static> {
let mark = if self.include_in_backup { "[x]" } else { "[ ]" };
let spans = vec![
self.label_span("Backup", index, colors),
Span::raw(mark.to_string()),
];
self.styled(spans, index, colors)
}
fn section_label(&self) -> &str {
match self.section_options.get(self.section_choice) {
Some(Some(name)) => name,
_ => UNGROUPED,
}
}
fn label_span(
&self,
label: &str,
index: usize,
colors: &Colors,
) -> Span<'static> {
let style = if index == self.focus {
Style::default()
.fg(colors.accent)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(colors.dim)
};
Span::styled(format!("{label:<LABEL_WIDTH$}"), style)
}
fn styled(
&self,
spans: Vec<Span<'static>>,
index: usize,
colors: &Colors,
) -> Line<'static> {
let line = Line::from(spans);
if index == self.focus {
line.style(Style::default().bg(colors.selection_bg))
} else {
line
}
}
}
fn section_options(sections: &[String]) -> Vec<Option<String>> {
let mut options = vec![None];
options.extend(sections.iter().cloned().map(Some));
options
}
fn section_choice(options: &[Option<String>], section: Option<&str>) -> usize {
let Some(name) = section else {
return 0;
};
options
.iter()
.position(|option| {
option
.as_deref()
.is_some_and(|o| o.eq_ignore_ascii_case(name))
})
.unwrap_or(0)
}
fn basename(path: &str) -> Option<String> {
let trimmed = path.trim();
if trimmed.is_empty() {
return None;
}
std::path::Path::new(trimmed)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
}
fn non_empty(value: String) -> Option<String> {
if value.trim().is_empty() {
None
} else {
Some(value)
}
}
fn next_kind(kind: RepoKind) -> RepoKind {
toggle_kind(kind)
}
fn prev_kind(kind: RepoKind) -> RepoKind {
toggle_kind(kind)
}
fn toggle_kind(kind: RepoKind) -> RepoKind {
match kind {
RepoKind::Git => RepoKind::Path,
RepoKind::Path => RepoKind::Git,
}
}
fn kind_label(kind: RepoKind) -> &'static str {
match kind {
RepoKind::Git => "git",
RepoKind::Path => "file/folder",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn basename_takes_final_component() {
assert_eq!(basename("/code/hop").as_deref(), Some("hop"));
assert_eq!(basename("/code/hop/").as_deref(), Some("hop"));
assert_eq!(basename(" ").as_deref(), None);
}
#[test]
fn section_choice_matches_case_insensitively() {
let options = section_options(&["Work".to_string()]);
assert_eq!(section_choice(&options, Some("work")), 1);
assert_eq!(section_choice(&options, Some("Misc")), 0);
assert_eq!(section_choice(&options, None), 0);
}
#[test]
fn visible_fields_follow_the_kind() {
let git = RepoForm::for_add("/p", RepoKind::Git, &[]);
assert!(git.fields().contains(&Field::Fav));
assert!(!git.fields().contains(&Field::Section));
let folder = RepoForm::for_add("/p", RepoKind::Path, &[]);
assert!(folder.fields().contains(&Field::Section));
assert!(!folder.fields().contains(&Field::Fav));
assert!(git.fields().contains(&Field::Backup));
assert!(folder.fields().contains(&Field::Backup));
}
#[test]
fn for_add_seeds_backup_toggle_per_kind() {
let git = RepoForm::for_add("/p", RepoKind::Git, &[]);
assert!(git.draft().include_in_backup);
let folder = RepoForm::for_add("/p", RepoKind::Path, &[]);
assert!(!folder.draft().include_in_backup);
}
}