mod render;
use crossterm::event::{KeyCode, KeyEvent};
use ratada::input::InputField;
use crate::domain::repo::{Repo, RepoKind};
use crate::domain::slug::slugify;
const LABEL_WIDTH: usize = 8;
const MIXED: &str = "(mixed)";
#[derive(Debug, 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 struct BulkDraft {
pub section: Option<Option<String>>,
pub fav: Option<bool>,
pub include_in_backup: Option<bool>,
pub kind: Option<RepoKind>,
}
pub enum FormResult {
Pending,
Save(RepoDraft),
SaveBulk(BulkDraft),
PickPath,
PickSection,
Cancel,
}
pub struct RepoForm {
title: String,
bulk: bool,
name: InputField,
path: InputField,
slug: InputField,
kind: RepoKind,
kind_touched: bool,
fav: bool,
fav_mixed: bool,
fav_touched: bool,
include_in_backup: bool,
backup_mixed: bool,
backup_touched: bool,
section: Option<String>,
section_mixed: bool,
section_touched: bool,
focus: usize,
}
impl RepoForm {
pub fn for_add(path: &str, kind: RepoKind) -> Self {
Self::single(
"Add entry",
"",
path,
"",
kind,
false,
kind == RepoKind::Git,
None,
)
}
pub fn for_edit(repo: &Repo) -> Self {
Self::single(
"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(),
)
}
pub fn for_bulk(
count: usize,
kind: RepoKind,
section: Option<Option<String>>,
fav: Option<bool>,
backup: Option<bool>,
) -> Self {
let (section, section_mixed) =
section.map_or((None, true), |value| (value, false));
let (fav, fav_mixed) =
fav.map_or((false, true), |value| (value, false));
let (include_in_backup, backup_mixed) =
backup.map_or((false, true), |value| (value, false));
RepoForm {
title: format!("Edit {count} entries"),
bulk: true,
name: InputField::new(""),
path: InputField::new(""),
slug: InputField::new(""),
kind,
kind_touched: false,
fav,
fav_mixed,
fav_touched: false,
include_in_backup,
backup_mixed,
backup_touched: false,
section,
section_mixed,
section_touched: false,
focus: 0,
}
}
#[allow(clippy::too_many_arguments)]
fn single(
title: &str,
name: &str,
path: &str,
slug: &str,
kind: RepoKind,
fav: bool,
include_in_backup: bool,
section: Option<String>,
) -> Self {
RepoForm {
title: title.to_string(),
bulk: false,
name: InputField::new(name),
path: InputField::new(path),
slug: InputField::new(slug),
kind,
kind_touched: false,
fav,
fav_mixed: false,
fav_touched: false,
include_in_backup,
backup_mixed: false,
backup_touched: false,
section,
section_mixed: false,
section_touched: false,
focus: 0,
}
}
pub fn kind(&self) -> RepoKind {
self.kind
}
pub fn section(&self) -> Option<String> {
self.section.clone()
}
pub fn set_section(&mut self, section: Option<String>) {
self.section = section;
self.section_mixed = false;
self.section_touched = true;
self.focus = self.field_index(Field::Section);
}
fn fields(&self) -> Vec<Field> {
if self.bulk {
return vec![
Field::Section,
Field::Fav,
Field::Backup,
Field::Kind,
];
}
vec![
Field::Path,
Field::Name,
Field::Slug,
Field::Section,
Field::Fav,
Field::Backup,
Field::Kind,
]
}
pub fn handle_key(&mut self, key: KeyEvent) -> FormResult {
let ctrl = ratada::input::is_command(key);
match key.code {
KeyCode::Esc => return FormResult::Cancel,
KeyCode::Enter => {
if self.focused_field() == Field::Section {
return FormResult::PickSection;
}
return self.save();
}
KeyCode::Char('s') if ctrl => return self.save(),
KeyCode::Char('o') if ctrl && !self.bulk => {
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
}
pub fn paste(&mut self, text: &str) {
match self.focused_field() {
Field::Path => self.path.paste(text),
Field::Name => self.name.paste(text),
Field::Slug => self.slug.paste(text),
Field::Section | Field::Kind | Field::Fav | Field::Backup => {}
}
}
fn save(&self) -> FormResult {
if self.bulk {
FormResult::SaveBulk(self.bulk_draft())
} else {
FormResult::Save(self.draft())
}
}
fn move_focus(&mut self, delta: usize) {
let fields = self.fields();
self.focus =
ratada::nav::cycle(self.focus, fields.len(), delta as isize);
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 => {}
Field::Kind => self.edit_kind(key),
Field::Fav => {
if key.code == KeyCode::Char(' ') {
self.toggle_fav();
}
}
Field::Backup => {
if key.code == KeyCode::Char(' ') {
self.toggle_backup();
}
}
}
}
fn toggle_fav(&mut self) {
self.fav = if self.fav_mixed { true } else { !self.fav };
self.fav_mixed = false;
self.fav_touched = true;
}
fn toggle_backup(&mut self) {
self.include_in_backup = if self.backup_mixed {
true
} else {
!self.include_in_backup
};
self.backup_mixed = false;
self.backup_touched = true;
}
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.kind_touched = true;
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)
}
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 {
RepoDraft {
name: non_empty(self.name.value().to_string()),
path: self.path.value().to_string(),
slug: non_empty(slugify(self.slug.value())),
section: self.section.clone(),
kind: self.kind,
fav: self.fav,
include_in_backup: self.include_in_backup,
}
}
fn bulk_draft(&self) -> BulkDraft {
BulkDraft {
section: self.section_touched.then(|| self.section.clone()),
fav: self.fav_touched.then_some(self.fav),
include_in_backup: self
.backup_touched
.then_some(self.include_in_backup),
kind: self.kind_touched.then_some(self.kind),
}
}
}
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::render::checkbox;
use super::*;
use crate::domain::sections::UNGROUPED;
#[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 single_form_shows_all_fields_for_both_kinds() {
for kind in [RepoKind::Git, RepoKind::Path] {
let form = RepoForm::for_add("/p", kind);
for field in [
Field::Path,
Field::Name,
Field::Slug,
Field::Section,
Field::Fav,
Field::Backup,
Field::Kind,
] {
assert!(form.fields().contains(&field));
}
}
}
#[test]
fn field_order_puts_slug_before_section() {
let form = RepoForm::for_add("/p", RepoKind::Git);
let fields = form.fields();
let slug = fields.iter().position(|f| *f == Field::Slug).unwrap();
let section = fields.iter().position(|f| *f == Field::Section).unwrap();
assert!(slug < section);
}
#[test]
fn kind_cycle_marks_it_touched_and_keeps_the_section() {
let mut form = RepoForm::for_edit(&{
let mut repo = Repo::new("/p".into());
repo.section = Some("Backend".to_string());
repo
});
assert!(!form.kind_touched);
form.edit_kind(KeyEvent::from(KeyCode::Right));
assert_eq!(form.kind, RepoKind::Path);
assert!(form.kind_touched);
assert_eq!(form.section.as_deref(), Some("Backend"));
}
#[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);
}
#[test]
fn paste_lands_in_the_focused_text_field() {
let mut form = RepoForm::for_add("", RepoKind::Git);
form.focus = form.field_index(Field::Path);
form.paste("/code/hop");
assert_eq!(form.path.value(), "/code/hop");
form.focus = form.field_index(Field::Name);
form.paste("hop");
assert_eq!(form.name.value(), "hop");
assert_eq!(form.path.value(), "/code/hop");
}
#[test]
fn paste_on_a_non_text_field_changes_nothing() {
let mut form = RepoForm::for_add("/p", RepoKind::Git);
form.focus = form.field_index(Field::Fav);
form.paste("/code/hop");
assert_eq!(form.path.value(), "/p");
assert_eq!(form.name.value(), "");
assert_eq!(form.slug.value(), "");
}
#[test]
fn set_section_marks_it_touched() {
let mut form = RepoForm::for_add("/p", RepoKind::Git);
assert!(!form.section_touched);
form.set_section(Some("Work".to_string()));
assert_eq!(form.section.as_deref(), Some("Work"));
assert!(form.section_touched);
}
#[test]
fn bulk_form_hides_per_entry_fields() {
let form =
RepoForm::for_bulk(3, RepoKind::Git, Some(None), Some(true), None);
let fields = form.fields();
assert_eq!(
fields,
vec![Field::Section, Field::Fav, Field::Backup, Field::Kind]
);
assert!(!fields.contains(&Field::Path));
assert!(!fields.contains(&Field::Name));
assert!(!fields.contains(&Field::Slug));
}
#[test]
fn bulk_draft_only_carries_touched_fields() {
let mut form = RepoForm::for_bulk(
2,
RepoKind::Git,
Some(Some("A".to_string())),
Some(false),
None,
);
let draft = form.bulk_draft();
assert!(draft.section.is_none());
assert!(draft.fav.is_none());
assert!(draft.include_in_backup.is_none());
assert!(draft.kind.is_none());
form.toggle_fav();
form.set_section(Some("B".to_string()));
let draft = form.bulk_draft();
assert_eq!(draft.fav, Some(true));
assert_eq!(draft.section, Some(Some("B".to_string())));
assert!(draft.include_in_backup.is_none());
assert!(draft.kind.is_none());
}
#[test]
fn a_mixed_bulk_checkbox_toggles_to_on_first() {
let mut form = RepoForm::for_bulk(2, RepoKind::Git, None, None, None);
assert_eq!(checkbox(form.fav, form.fav_mixed, form.fav_touched), "[-]");
form.toggle_fav();
assert!(form.fav);
assert_eq!(checkbox(form.fav, form.fav_mixed, form.fav_touched), "[x]");
}
#[test]
fn a_mixed_section_shows_the_placeholder_until_picked() {
let mut form =
RepoForm::for_bulk(2, RepoKind::Git, None, Some(true), None);
assert_eq!(form.section_label(), MIXED);
form.set_section(None);
assert_eq!(form.section_label(), UNGROUPED);
}
}