use super::{
render::{PopupKind, PopupRow},
tag_input,
};
use crate::bulk::{Actions, Report};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::collections::BTreeSet;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
const ITEMS: [(char, &str); 7] = [
('t', "Add tags"),
('r', "Remove tags"),
('s', "Replace tags"),
('p', "Priority"),
('a', "Archive"),
('u', "Unarchive"),
('x', "Clear selection"),
];
#[derive(Clone, Copy)]
enum InputKind {
Add,
Replace,
Priority,
}
enum Stage {
Menu {
selected: usize,
},
Input {
kind: InputKind,
value: String,
cursor: tag_input::Cursor,
error: String,
},
RemoveTags {
tags: Vec<String>,
selected: usize,
marked: BTreeSet<usize>,
error: String,
},
RangeSelect {
visible_ids: Vec<i64>,
value: String,
cursor: tag_input::Cursor,
error: String,
},
Preview(Report),
Error(String),
}
pub(super) struct View {
ids: Vec<i64>,
tags: Vec<String>,
stage: Stage,
top: usize,
page: usize,
}
pub(super) enum Intent {
None,
Close,
CancelSelection,
Clear,
Select(Vec<i64>),
RemoveTags,
Preview(Actions),
Apply(Report),
}
impl View {
pub fn menu(ids: Vec<i64>) -> Self {
Self {
ids,
tags: Vec::new(),
stage: Stage::Menu { selected: 0 },
top: 0,
page: 1,
}
}
pub fn select_range(visible_ids: Vec<i64>) -> Self {
Self {
ids: Vec::new(),
tags: Vec::new(),
stage: Stage::RangeSelect {
visible_ids,
value: String::new(),
cursor: tag_input::Cursor::at_end(""),
error: String::new(),
},
top: 0,
page: 1,
}
}
pub fn show_preview(&mut self, report: Report) {
self.stage = Stage::Preview(report);
self.top = 0;
}
pub fn with_tags(mut self, tags: Vec<String>) -> Self {
self.tags = tags;
self
}
pub fn show_remove_tags(&mut self, mut tags: Vec<String>) {
tags.sort();
tags.dedup();
self.stage = Stage::RemoveTags {
tags,
selected: 0,
marked: BTreeSet::new(),
error: String::new(),
};
self.top = 0;
}
pub fn show_error(&mut self, text: String) {
self.stage = Stage::Error(text);
self.top = 0;
}
pub fn ids(&self) -> &[i64] {
&self.ids
}
pub fn paste(&mut self, text: &str) {
if let Stage::Input {
value,
cursor,
error,
..
}
| Stage::RangeSelect {
value,
cursor,
error,
..
} = &mut self.stage
{
cursor.insert(value, &text.replace("\r\n", "\n"));
error.clear();
}
}
pub fn key(&mut self, key: KeyEvent) -> Intent {
let key = if matches!(self.stage, Stage::Input { .. } | Stage::RangeSelect { .. }) {
key
} else {
super::escape_alias(key)
};
if key.code == KeyCode::Esc
|| (key.code == KeyCode::Char('c') && key.modifiers.contains(KeyModifiers::CONTROL))
{
return if matches!(self.stage, Stage::RangeSelect { .. }) {
Intent::CancelSelection
} else {
Intent::Close
};
}
if key.modifiers.contains(KeyModifiers::SUPER) {
return Intent::None;
}
if key.modifiers.contains(KeyModifiers::ALT) {
if let Stage::Input {
value,
cursor,
error,
..
}
| Stage::RangeSelect {
value,
cursor,
error,
..
} = &mut self.stage
{
cursor.edit(value, error, key);
}
return Intent::None;
}
let control = key.modifiers.contains(KeyModifiers::CONTROL);
match &mut self.stage {
Stage::Menu { selected } => {
if control {
return Intent::None;
}
match key.code {
KeyCode::Up => {
*selected = selected.saturating_sub(1);
return Intent::None;
}
KeyCode::Down => {
*selected = (*selected + 1).min(ITEMS.len() - 1);
return Intent::None;
}
_ => {}
}
let activation = if key.code == KeyCode::Enter {
KeyCode::Char(ITEMS[*selected].0)
} else {
key.code
};
let kind = match activation {
KeyCode::Char('x') => return Intent::Clear,
KeyCode::Char('a') => {
return Intent::Preview(Actions {
archived: Some(true),
..Default::default()
});
}
KeyCode::Char('u') => {
return Intent::Preview(Actions {
archived: Some(false),
..Default::default()
});
}
KeyCode::Char('t') => InputKind::Add,
KeyCode::Char('r') => return Intent::RemoveTags,
KeyCode::Char('s') => InputKind::Replace,
KeyCode::Char('p') => InputKind::Priority,
_ => return Intent::None,
};
self.stage = Stage::Input {
kind,
value: String::new(),
cursor: if matches!(kind, InputKind::Priority) {
tag_input::Cursor::at_end("")
} else {
tag_input::Cursor::at_end("").with_tags(self.tags.clone())
},
error: String::new(),
};
}
Stage::Input {
kind,
value,
cursor,
error,
} => {
let preview = (control && key.code == KeyCode::Char('s'))
|| (matches!(kind, InputKind::Priority)
&& !control
&& key.code == KeyCode::Enter);
if preview {
let actions = match kind {
InputKind::Priority => value
.trim()
.parse::<i64>()
.map(|priority| Actions {
priority: Some(priority),
..Default::default()
})
.map_err(|_| "Priority must be -100..100".to_owned()),
InputKind::Add | InputKind::Replace => crate::tags::parse_lines(value)
.map(|tags| match kind {
InputKind::Add => Actions {
add_tags: tags,
..Default::default()
},
InputKind::Replace => Actions {
set_tags: Some(tags),
..Default::default()
},
InputKind::Priority => unreachable!(),
})
.map_err(|failure| failure.to_string()),
}
.and_then(|actions| actions.normalize().map_err(|failure| failure.to_string()));
match actions {
Ok(actions) => return Intent::Preview(actions),
Err(failure) => *error = failure,
}
} else {
cursor.edit(value, error, key);
}
}
Stage::RemoveTags {
tags,
selected,
marked,
error,
} => {
if (control && key.code == KeyCode::Char('s'))
|| (!control && key.code == KeyCode::Enter)
{
if marked.is_empty() {
*error = "Select at least one tag".to_owned();
} else {
return Intent::Preview(Actions {
remove_tags: tags
.iter()
.enumerate()
.filter(|(index, _)| marked.contains(index))
.map(|(_, tag)| tag.clone())
.collect(),
..Default::default()
});
}
} else if !control {
let last = tags.len().saturating_sub(1);
match key.code {
KeyCode::Up => *selected = selected.saturating_sub(1),
KeyCode::Down => *selected = selected.saturating_add(1).min(last),
KeyCode::PageUp => *selected = selected.saturating_sub(self.page),
KeyCode::PageDown => {
*selected = selected.saturating_add(self.page).min(last)
}
KeyCode::Home => *selected = 0,
KeyCode::End => *selected = last,
KeyCode::Char(' ') if !tags.is_empty() => {
if !marked.insert(*selected) {
marked.remove(selected);
}
}
_ => return Intent::None,
}
error.clear();
}
}
Stage::RangeSelect {
visible_ids,
value,
cursor,
error,
} => {
if !control && key.code == KeyCode::Enter {
match parse_id_range(value) {
Some((start, end)) => {
let ids: Vec<_> = visible_ids
.iter()
.copied()
.filter(|id| (*id >= start) && (*id <= end))
.collect();
if ids.is_empty() {
*error = "No visible tasks match that range".to_owned();
} else {
return Intent::Select(ids);
}
}
None => {
*error = "Enter a range like 1-5, -5, or 100-".to_owned();
}
}
} else {
cursor.edit(value, error, key);
}
}
Stage::Preview(report) => {
if !control {
match key.code {
KeyCode::Char('y' | 'Y') => return Intent::Apply(report.clone()),
KeyCode::Char('n' | 'N') | KeyCode::Enter => return Intent::Close,
_ => self.scroll(key.code),
}
}
}
Stage::Error(_) => {
if !control && matches!(key.code, KeyCode::Enter | KeyCode::Char('n' | 'N')) {
return Intent::Close;
}
if !control {
self.scroll(key.code);
}
}
}
Intent::None
}
fn scroll(&mut self, key: KeyCode) {
self.top = match key {
KeyCode::Up => self.top.saturating_sub(1),
KeyCode::Down => self.top.saturating_add(1),
KeyCode::PageUp => self.top.saturating_sub(self.page),
KeyCode::PageDown => self.top.saturating_add(self.page),
KeyCode::Home => 0,
KeyCode::End => usize::MAX,
_ => self.top,
};
}
pub fn rows(&mut self, width: usize, height: usize) -> Vec<PopupRow> {
if let Stage::RangeSelect {
value,
cursor,
error,
..
} = &self.stage
{
let mut rows = tag_input::rows(None, value, cursor, error, width, height);
if let Some(heading) = rows
.first_mut()
.filter(|row| row.kind == PopupKind::Heading)
{
heading.text = "Select task IDs".to_owned();
}
let hints: Vec<usize> = rows
.iter()
.enumerate()
.filter_map(|(index, row)| (row.kind == PopupKind::Hint).then_some(index))
.collect();
if hints.len() > 1 {
rows[hints[0]].text = if width >= 26 {
"Range: 1-5, -5, or 100-".to_owned()
} else if width >= 16 {
"Range: 1-5, -5".to_owned()
} else {
"ID range".to_owned()
};
}
if let Some(index) = hints.last() {
rows[*index].text = if "Enter select Esc cancel".width() <= width {
"Enter select Esc cancel".to_owned()
} else if "↵ select Esc".width() <= width {
"↵ select Esc".to_owned()
} else {
"Esc".to_owned()
};
}
return rows;
}
if let Stage::Input {
kind,
value,
cursor,
error,
} = &self.stage
{
let title = match kind {
InputKind::Priority => "Bulk priority",
InputKind::Add => "Bulk add tags",
InputKind::Replace => "Bulk replace tags",
};
let mut rows = tag_input::rows(None, value, cursor, error, width, height);
if let Some(first) = rows
.first_mut()
.filter(|row| row.kind == PopupKind::Heading)
{
first.text = format!("{title} ({})", self.ids.len());
}
let last = rows.len().saturating_sub(1);
for (index, row) in rows.iter_mut().enumerate() {
if row.kind == PopupKind::Hint {
row.text = if index != last {
if matches!(kind, InputKind::Priority) {
"Priority -100..100"
} else if cursor.has_matches(value) {
continue;
} else {
"One tag per line"
}
} else if matches!(kind, InputKind::Priority) {
if width >= 18 {
"Enter preview Esc"
} else {
"↵ preview"
}
} else if width >= 45 {
"Enter newline Ctrl-S preview Ctrl-U delete Esc"
} else if width >= 30 {
"↵ line ^S preview ^U delete Esc"
} else if width >= 21 {
"^S preview ^U del Esc"
} else {
"^S preview"
}
.to_owned();
}
}
return rows;
}
let (heading, body, hint) = match &self.stage {
Stage::Menu { selected } => {
let mut rows: Vec<_> = wrap(&format!("IDs: {}", ids(&self.ids)), width)
.into_iter()
.map(|text| PopupRow::new(text, PopupKind::Body))
.collect();
for (index, (key, label)) in ITEMS.iter().enumerate() {
if matches!(key, 'p' | 'a' | 'x') {
rows.push(PopupRow::new("", PopupKind::Body));
}
rows.push(PopupRow::new(
format!("{key} {label}"),
if index == *selected {
PopupKind::SelectedAction
} else {
PopupKind::Action
},
));
}
(
format!("Bulk actions ({})", self.ids.len()),
rows,
"Up/Down Enter Esc cancel",
)
}
Stage::RemoveTags {
tags,
selected,
marked,
..
} => {
let mut rows: Vec<_> = tags
.iter()
.enumerate()
.map(|(index, tag)| {
PopupRow::new(
format!(
"[{}] {tag}",
if marked.contains(&index) { "x" } else { " " }
),
if index == *selected {
PopupKind::SelectedChoice
} else {
PopupKind::Choice
},
)
})
.collect();
if tags.is_empty() {
rows.push(PopupRow::new("No tags on selected tasks", PopupKind::Body));
}
let hint = [
"Up/Down pick Space toggle Enter/Ctrl-S preview Esc cancel",
"↑↓ pick Space toggle Enter/^S preview Esc",
"↑↓ Space toggle ↵ preview Esc",
"↑↓ Space ↵ Esc",
"↑↓ Spc ↵ Esc",
"␣ ↵ Esc",
"Esc",
]
.into_iter()
.find(|hint| hint.width() <= width)
.unwrap_or("");
(format!("Bulk remove tags ({})", self.ids.len()), rows, hint)
}
Stage::Preview(report) => {
let mut lines = vec![
format!("IDs: {}", ids(&report.selected_ids)),
format!(
"{} changed, {} actions",
report.changed_count, report.action_count
),
];
for row in &report.tasks {
if row.before == row.after {
continue;
}
lines.push(format!("#{}", row.id));
if row.before.tags != row.after.tags {
lines.push(format!(
"Tags: {} -> {}",
serde_json::to_string(&row.before.tags).expect("tags serializable"),
serde_json::to_string(&row.after.tags).expect("tags serializable")
));
}
if row.before.priority != row.after.priority {
lines.push(format!(
"Priority: {} -> {}",
row.before.priority, row.after.priority
));
}
if row.before.archived != row.after.archived {
lines.push(format!(
"Archived: {} -> {}",
row.before.archived, row.after.archived
));
}
}
(
format!("Bulk preview ({})", self.ids.len()),
lines
.into_iter()
.flat_map(|text| wrap(&text, width))
.map(|text| PopupRow::new(text, PopupKind::Body))
.collect(),
if width >= 21 {
"y apply n/Esc cancel"
} else if width >= 16 {
"y apply n cancel"
} else {
"y apply n no"
},
)
}
Stage::Error(text) => (
"Bulk error".to_owned(),
wrap(text, width)
.into_iter()
.map(|text| PopupRow::new(text, PopupKind::Error))
.collect(),
"Regenerate preview Esc close Up/Down",
),
Stage::RangeSelect { .. } => unreachable!(),
Stage::Input { .. } => unreachable!(),
};
let error = match &self.stage {
Stage::RemoveTags { error, .. } if !error.is_empty() && height >= 3 => {
Some(PopupRow::new(error, PopupKind::Error))
}
_ => None,
};
let visible = height.saturating_sub(2 + usize::from(error.is_some()));
self.page = visible.max(1);
let mut top = self.top.min(body.len().saturating_sub(visible));
if visible > 0
&& matches!(self.stage, Stage::Menu { .. } | Stage::RemoveTags { .. })
&& let Some(row) = body.iter().position(|row| {
matches!(
row.kind,
PopupKind::SelectedAction | PopupKind::SelectedChoice
)
})
{
if row < top {
top = row;
} else if row >= top + visible {
top = row + 1 - visible;
}
}
self.top = top;
let mut rows = Vec::new();
if height >= 2 {
rows.push(PopupRow::new(heading, PopupKind::Heading));
rows.extend(error);
rows.extend(body.into_iter().skip(top).take(visible));
}
if height > 0 {
rows.push(PopupRow::new(hint, PopupKind::Hint));
}
rows
}
}
fn parse_id_range(value: &str) -> Option<(i64, i64)> {
let value = value.trim();
if value.is_empty() {
return None;
}
let parse_id = |text: &str| text.trim().parse::<i64>().ok().filter(|id| *id > 0);
let (start, end) = match value.split_once('-') {
Some((start, end)) => {
if start.trim().is_empty() && end.trim().is_empty() {
return None;
}
let start = if start.trim().is_empty() {
1
} else {
parse_id(start)?
};
let end = if end.trim().is_empty() {
i64::MAX
} else {
parse_id(end)?
};
(start, end)
}
None => {
let id = parse_id(value)?;
(id, id)
}
};
(start <= end).then_some((start, end))
}
fn ids(ids: &[i64]) -> String {
ids.iter()
.map(|id| format!("#{id}"))
.collect::<Vec<_>>()
.join(", ")
}
fn wrap(text: &str, width: usize) -> Vec<String> {
let mut rows = Vec::new();
let mut line = String::new();
let mut used = 0;
for grapheme in text.graphemes(true) {
let cells = grapheme.width();
if !line.is_empty() && used + cells > width.max(1) {
rows.push(std::mem::take(&mut line));
used = 0;
}
line.push_str(grapheme);
used += cells;
}
rows.push(line);
rows
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remove_tags_offer_unique_labels_and_preview_only_checked_choices() {
let mut ui = View::menu(vec![1, 2]);
assert!(matches!(
ui.key(KeyEvent::new(KeyCode::Char('r'), KeyModifiers::NONE)),
Intent::RemoveTags
));
ui.show_remove_tags(vec![
"界面".into(),
"bug".into(),
"Bug".into(),
"bug".into(),
]);
let rows = ui.rows(60, 12);
let choices: Vec<_> = rows
.iter()
.filter(|row| matches!(row.kind, PopupKind::Choice | PopupKind::SelectedChoice))
.map(|row| row.text.as_str())
.collect();
assert_eq!(choices, ["[ ] Bug", "[ ] bug", "[ ] 界面"]);
ui.paste("not an existing tag");
ui.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
ui.key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
ui.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let Intent::Preview(actions) =
ui.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL))
else {
panic!("checked tags should generate a preview");
};
assert_eq!(actions.remove_tags, ["Bug", "界面"]);
assert!(actions.add_tags.is_empty());
assert!(actions.set_tags.is_none());
assert!(matches!(
ui.key(KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE)),
Intent::Close
));
}
#[test]
fn remove_tag_list_keeps_focus_and_validation_visible_after_scrolling() {
for width in [8, 12, 18, 28, 46] {
for height in 0..=18 {
let mut ui = View::menu(vec![1, 2]);
ui.show_remove_tags((0..40).map(|index| format!("tag {index:02} 界")).collect());
ui.key(KeyEvent::new(KeyCode::End, KeyModifiers::NONE));
let rows = ui.rows(width, height);
assert!(rows.len() <= height);
if height >= 3 {
assert!(rows.iter().any(|row| {
row.kind == PopupKind::SelectedChoice && row.text == "[ ] tag 39 界"
}));
}
if height > 0 {
assert!(rows.last().unwrap().text.width() <= width);
}
assert!(matches!(
ui.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
Intent::None
));
let rows = ui.rows(width, height);
assert!(rows.len() <= height);
if height >= 3 {
assert!(rows.iter().any(|row| {
row.kind == PopupKind::Error && row.text == "Select at least one tag"
}));
}
ui.key(KeyEvent::new(KeyCode::PageUp, KeyModifiers::NONE));
ui.key(KeyEvent::new(KeyCode::Char(' '), KeyModifiers::NONE));
let Intent::Preview(actions) =
ui.key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE))
else {
panic!("a checked tag should preview after paging");
};
assert_eq!(actions.remove_tags.len(), 1);
assert_ne!(actions.remove_tags[0], "tag 39 界");
}
}
}
#[test]
fn remove_tags_empty_union_cannot_preview() {
let mut ui = View::menu(vec![1, 2]);
ui.show_remove_tags(Vec::new());
assert!(
ui.rows(60, 8)
.iter()
.any(|row| row.text == "No tags on selected tasks")
);
for key in [KeyCode::Char(' '), KeyCode::Enter, KeyCode::End] {
assert!(matches!(
ui.key(KeyEvent::new(key, KeyModifiers::NONE)),
Intent::None
));
}
assert!(matches!(
ui.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)),
Intent::None
));
assert!(matches!(
ui.key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
Intent::Close
));
}
#[test]
fn preview_hides_unchanged_tasks_and_fields() {
let report: Report = serde_json::from_value(serde_json::json!({
"version": 1, "applied": false, "database": "/test.db",
"actions": {"add_tags": [], "remove_tags": [], "set_tags": null,
"priority": 1, "archived": null},
"selected_ids": [1, 2], "selected_count": 2, "changed_count": 1,
"action_count": 1,
"tasks": [
{"id": 1, "fingerprint": "0".repeat(64),
"before": {"tags": [], "priority": 0, "archived": false},
"after": {"tags": [], "priority": 1, "archived": false}},
{"id": 2, "fingerprint": "0".repeat(64),
"before": {"tags": [], "priority": 1, "archived": false},
"after": {"tags": [], "priority": 1, "archived": false}}
]
}))
.unwrap();
let mut ui = View::menu(vec![1, 2]);
ui.show_preview(report);
let rows = ui.rows(80, 20);
assert!(rows.iter().any(|row| row.text == "#1"));
assert!(rows.iter().any(|row| row.text == "Priority: 0 -> 1"));
assert!(!rows.iter().any(|row| row.text == "#2"));
assert!(!rows.iter().any(|row| row.text.starts_with("Tags:")));
assert!(!rows.iter().any(|row| row.text.starts_with("Archived:")));
assert!(rows.last().unwrap().text.contains("y apply"));
}
#[test]
fn non_input_popups_reserve_confirmation_footer_in_short_screens() {
let report: Report = serde_json::from_value(serde_json::json!({
"version": 1, "applied": false, "database": "/test.db",
"actions": {"add_tags": [], "remove_tags": [], "set_tags": null,
"priority": 1, "archived": null},
"selected_ids": [1], "selected_count": 1, "changed_count": 1,
"action_count": 1,
"tasks": [{"id": 1, "fingerprint": "0".repeat(64),
"before": {"tags": [], "priority": 0, "archived": false},
"after": {"tags": [], "priority": 1, "archived": false}}]
}))
.unwrap();
for width in [12, 18, 28, 38, 39, 40, 46] {
for height in (2..=18).chain(0..=1) {
for stage in [0, 1, 2] {
let mut ui = View::menu(vec![1]);
match stage {
1 => ui.show_preview(report.clone()),
2 => ui.show_error("Conflict; regenerate preview".into()),
_ => (),
}
let rows = ui.rows(width, height);
assert!(rows.len() <= height, "stage={stage} height={height}");
if height >= 2 {
assert_eq!(rows[0].kind, PopupKind::Heading);
assert_eq!(rows.last().unwrap().kind, PopupKind::Hint);
}
if stage == 1 && height > 0 {
assert!(rows.last().unwrap().text.contains("y apply"));
assert!(rows.last().unwrap().text.width() <= width);
}
}
}
}
}
#[test]
fn short_input_popups_keep_value_cursor_and_preview_hint() {
for width in [12, 18, 28, 46] {
for height in 1..=18 {
let mut ui = View::menu(vec![1, 2]);
ui.key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
ui.paste("first\n界面");
let rows = ui.rows(width, height);
let input = rows
.iter()
.rfind(|row| row.kind == PopupKind::Input)
.unwrap();
assert_eq!(input.text, " 界面", "height={height}");
assert_eq!(input.cursor, Some(6));
if height >= 2 {
let hint = &rows.last().unwrap().text;
assert!(hint.contains("preview"));
assert!(hint.width() <= width);
}
}
}
}
#[test]
fn tag_input_keeps_alt_word_navigation_from_single_task_editor() {
let mut ui = View::menu(vec![1]);
ui.key(KeyEvent::new(KeyCode::Char('t'), KeyModifiers::NONE));
ui.paste("alpha beta");
ui.key(KeyEvent::new(KeyCode::Left, KeyModifiers::ALT));
ui.key(KeyEvent::new(KeyCode::Char('X'), KeyModifiers::NONE));
match ui.key(KeyEvent::new(KeyCode::Char('s'), KeyModifiers::CONTROL)) {
Intent::Preview(actions) => assert_eq!(actions.add_tags, ["alpha Xbeta"]),
_ => panic!("tag input should preview edited label"),
}
}
}