use std::collections::BTreeSet;
use std::path::Path;
use anyhow::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use org_core::{Change, EditIntent, PlanningTimestamp, WriteEngine, parse_date_input};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, Paragraph, Wrap};
use super::theme::centered;
use super::{App, Overlay, write_context};
const TITLE: usize = 0;
const STATE: usize = 1;
const PRIORITY: usize = 2;
const TAGS: usize = 3;
const SCHEDULED: usize = 4;
const DEADLINE: usize = 5;
const LABELS: [&str; 6] = [
"Title",
"State",
"Priority",
"Tags",
"Scheduled",
"Deadline",
];
#[derive(Debug)]
pub(super) struct EditForm {
item_ref: String,
location: String,
keywords: Vec<String>,
priorities: Vec<String>,
original: [String; 6],
values: [String; 6],
field: usize,
preview: Result<String, String>,
}
impl EditForm {
pub(super) fn open(app: &App) -> Option<Self> {
let item = &app.selected_row()?.item;
let date = |timestamp: &Option<PlanningTimestamp>| {
timestamp.as_ref().map(date_text).unwrap_or_default()
};
let original = [
item.title.clone(),
item.todo.clone().unwrap_or_default(),
item.priority.clone().unwrap_or_default(),
item.tags.join(" "),
date(&item.planning.scheduled),
date(&item.planning.deadline),
];
let mut keywords = vec![String::new()];
keywords.extend(
app.selected_keywords()
.into_iter()
.map(|(keyword, _)| keyword),
);
let letter = |value: &str| value.chars().next().unwrap_or('A');
let (highest, lowest) = (
letter(&app.settings.highest_priority),
letter(&app.settings.lowest_priority),
);
let mut priorities = vec![String::new()];
priorities.extend((highest..=lowest).map(|letter| letter.to_string()));
Some(Self {
item_ref: item.item_ref.clone(),
location: app.short_location(item),
keywords,
priorities,
values: original.clone(),
original,
field: TITLE,
preview: Ok(String::new()),
})
}
fn changed(&self, field: usize) -> Option<&str> {
let value = self.values[field].trim();
(value != self.original[field].trim()).then_some(value)
}
fn intent(&self, app: &App) -> Result<EditIntent, String> {
let text = |field: usize| match self.changed(field) {
None => Change::Keep,
Some("") => Change::Clear,
Some(value) => Change::Set(value.to_string()),
};
let date = |field: usize| match self.changed(field) {
None => Ok(Change::Keep),
Some("") => Ok(Change::Clear),
Some(value) => parse_date_input(value, app.today)
.map(Change::Set)
.map_err(|error| format!("{}: {error}", LABELS[field])),
};
let words = |field: usize| -> BTreeSet<String> {
self.values[field]
.split([' ', ':', ','])
.filter(|tag| !tag.is_empty())
.map(str::to_string)
.collect()
};
let (old_tags, new_tags) = (
self.original[TAGS]
.split(' ')
.filter(|tag| !tag.is_empty())
.map(str::to_string)
.collect::<BTreeSet<_>>(),
words(TAGS),
);
if self.changed(TITLE) == Some("") {
return Err("Title: a heading needs a title".to_string());
}
Ok(EditIntent {
title: text(TITLE),
state: text(STATE),
priority: text(PRIORITY),
tags_add: new_tags.difference(&old_tags).cloned().collect(),
tags_remove: old_tags.difference(&new_tags).cloned().collect(),
scheduled: date(SCHEDULED)?,
deadline: date(DEADLINE)?,
..EditIntent::default()
})
}
fn unchanged(&self) -> bool {
(0..LABELS.len()).all(|field| self.changed(field).is_none())
}
fn refresh(&mut self, app: &App) {
if self.unchanged() {
self.preview = Ok(String::new());
return;
}
self.preview = self.intent(app).and_then(|intent| {
WriteEngine::with_default_journal(app.settings.clone())
.and_then(|engine| engine.edit(&self.item_ref, &intent, true, &write_context()))
.map(|mutation| changed_lines(&mutation.diff))
.map_err(|error| error.to_string())
});
}
fn cycle(&mut self, step: isize) {
let choices = match self.field {
STATE => &self.keywords,
PRIORITY => &self.priorities,
_ => return,
};
let current = choices
.iter()
.position(|choice| *choice == self.values[self.field])
.unwrap_or(0) as isize;
let next = (current + step).rem_euclid(choices.len() as isize) as usize;
self.values[self.field] = choices[next].clone();
}
}
fn date_text(timestamp: &PlanningTimestamp) -> String {
[
Some(timestamp.date.clone()),
timestamp.time.clone(),
timestamp.repeater.clone(),
timestamp.warning.clone(),
]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join(" ")
}
fn changed_lines(diff: &str) -> String {
diff.lines()
.filter(|line| !line.starts_with("+++") && !line.starts_with("---"))
.filter(|line| line.starts_with('+') || line.starts_with('-'))
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn handle_key(app: &mut App, key: KeyEvent) -> Result<bool> {
let Overlay::Edit(mut form) = std::mem::replace(&mut app.overlay, Overlay::None) else {
return Ok(false);
};
if key.modifiers.contains(KeyModifiers::CONTROL) && key.code == KeyCode::Char('e') {
return Ok(true);
}
match key.code {
KeyCode::Esc => return Ok(false),
KeyCode::Enter => {
if !form.unchanged() {
commit(app, form)?;
}
return Ok(false);
}
KeyCode::Tab | KeyCode::Down => form.field = (form.field + 1) % LABELS.len(),
KeyCode::BackTab | KeyCode::Up => {
form.field = (form.field + LABELS.len() - 1) % LABELS.len()
}
KeyCode::Left => form.cycle(-1),
KeyCode::Right => form.cycle(1),
KeyCode::Backspace => {
form.values[form.field].pop();
}
KeyCode::Char(character) => {
let field = form.field;
if field == STATE || field == PRIORITY {
let choices = if field == STATE {
&form.keywords
} else {
&form.priorities
};
if let Some(choice) = choices.iter().find(|choice| {
choice
.to_lowercase()
.starts_with(&character.to_lowercase().to_string())
}) {
form.values[field] = choice.clone();
}
} else {
form.values[field].push(character);
}
}
_ => {}
}
form.refresh(app);
app.overlay = Overlay::Edit(form);
Ok(false)
}
fn commit(app: &mut App, form: Box<EditForm>) -> Result<()> {
let intent = match form.intent(app) {
Ok(intent) => intent,
Err(error) => {
app.set_status(error);
app.overlay = Overlay::Edit(form);
return Ok(());
}
};
let engine = WriteEngine::with_default_journal(app.settings.clone())?;
let mutation = engine.edit(&form.item_ref, &intent, false, &write_context())?;
app.index.refresh_path(Path::new(&mutation.item.file))?;
app.set_status(format!("{} edited · u undo", form.location));
app.reload_rows()
}
pub(super) fn draw(frame: &mut Frame<'_>, app: &App, area: Rect) {
let Overlay::Edit(form) = &app.overlay else {
return;
};
let theme = &app.theme;
frame
.buffer_mut()
.set_style(area, Style::default().add_modifier(Modifier::DIM));
let preview_lines: Vec<&str> = match &form.preview {
Ok(text) => text.lines().collect(),
Err(_) => Vec::new(),
};
let height = (LABELS.len() + preview_lines.len().max(1) + 6) as u16;
let popup = centered(area, area.width.saturating_sub(4).clamp(60, 100), height);
frame.render_widget(Clear, popup);
let block = theme
.panel(format!("Edit · {}", form.location))
.border_style(theme.fg(theme.accent));
let inner = block.inner(popup);
frame.render_widget(block, popup);
let mut lines = Vec::new();
for (field, label) in LABELS.iter().enumerate() {
let focused = field == form.field;
let value = &form.values[field];
let shown = if value.is_empty() && !focused {
Span::styled("—", theme.muted())
} else if focused {
Span::styled(value.clone(), theme.strong(theme.text))
} else {
Span::raw(value.clone())
};
let mut spans = vec![
Span::styled(
format!("{} {label:<10}", if focused { "›" } else { " " }),
if focused {
theme.fg(theme.accent)
} else {
theme.muted()
},
),
shown,
];
if focused && (field == STATE || field == PRIORITY) {
spans.push(Span::styled(" ←/→", theme.muted()));
} else if focused {
spans.push(Span::styled("▏", theme.fg(theme.accent)));
}
if form.changed(field).is_some() {
spans.push(Span::styled(" •", theme.fg(theme.due)));
}
lines.push(Line::from(spans));
}
lines.push(Line::default());
match &form.preview {
Ok(text) if text.is_empty() => {
lines.push(Line::from(Span::styled("no changes yet", theme.muted())));
}
Ok(_) => {
for line in &preview_lines {
let style = if line.starts_with('+') {
theme.fg(theme.done)
} else {
theme.fg(theme.slipped)
};
lines.push(Line::from(Span::styled(line.to_string(), style)));
}
}
Err(error) => lines.push(Line::from(Span::styled(
error.clone(),
theme.fg(theme.slipped),
))),
}
lines.push(Line::default());
lines.push(Line::from(
[
theme.key_hint("⏎", "save"),
theme.key_hint("tab", "next field"),
theme.key_hint("esc", "cancel"),
theme.key_hint("ctrl-e", "open in $EDITOR"),
]
.concat(),
));
frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}