First Version

This commit is contained in:
2026-07-26 23:13:53 +02:00
commit 4bfa9692a1
13 changed files with 5315 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/target
Generated
+1669
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "kanban"
version = "0.1.0"
edition = "2024"
[dependencies]
crossterm = "0.29.0"
ratatui = "0.30.2"
+7
View File
@@ -0,0 +1,7 @@
# To Do
# Active
- Kekse backen
# Done
+7
View File
@@ -0,0 +1,7 @@
# To Do
# Active
- Board 2!!!
# Done
+944
View File
@@ -0,0 +1,944 @@
//! Anwendungszustand und Tastaturbelegung.
//!
//! Die eigentliche Arbeit passiert in [`crate::ops`] und [`crate::transfer`];
//! hier wird nur zugeordnet, welche Taste was ausloest, und das Ergebnis in
//! Cursor-Position, Dirty-Flag und Statusmeldung uebersetzt.
use std::path::PathBuf;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::history::{History, Snapshot};
use crate::markdown;
use crate::model::{Area, AreaKind, Board, Item};
/// Normal- oder Edit-Modus.
pub enum Mode {
Normal,
Edit(Edit),
}
/// Zustand des Edit-Modus. Der Text landet erst beim Bestaetigen im Modell.
pub struct Edit {
pub text: String,
/// Byte-Index in `text`.
pub cursor: usize,
/// Per `a`/`A` frisch angelegt — bei Abbruch wieder zu entfernen.
pub is_new: bool,
/// Zustand vor dem Anlegen bzw. vor der Bearbeitung. Anlegen und Benennen
/// gehoeren zusammen und bilden deshalb einen einzigen Undo-Schritt.
before: Snapshot,
}
impl Edit {
fn new(text: String, is_new: bool, before: Snapshot) -> Self {
Edit {
cursor: text.len(),
text,
is_new,
before,
}
}
fn insert(&mut self, c: char) {
self.text.insert(self.cursor, c);
self.cursor += c.len_utf8();
}
fn backspace(&mut self) {
if let Some(prev) = self.prev_boundary() {
self.text.remove(prev);
self.cursor = prev;
}
}
fn delete(&mut self) {
if self.cursor < self.text.len() {
self.text.remove(self.cursor);
}
}
fn left(&mut self) {
if let Some(prev) = self.prev_boundary() {
self.cursor = prev;
}
}
fn right(&mut self) {
if let Some(c) = self.text[self.cursor..].chars().next() {
self.cursor += c.len_utf8();
}
}
/// Byte-Index des vorherigen Zeichens — `String::remove` braucht eine
/// Zeichengrenze, kein Byte-Offset.
fn prev_boundary(&self) -> Option<usize> {
let c = self.text[..self.cursor].chars().next_back()?;
Some(self.cursor - c.len_utf8())
}
}
/// Meldung in der Statuszeile.
pub struct Status {
pub text: String,
pub is_error: bool,
}
pub struct App {
pub board: Board,
pub path: PathBuf,
pub focus: AreaKind,
pub mode: Mode,
pub status: Option<Status>,
/// Ungespeicherte Aenderungen.
pub dirty: bool,
/// `q` wurde bei ungespeicherten Aenderungen einmal gedrueckt.
quit_armed: bool,
pub should_quit: bool,
/// Terminal beherrscht das Kitty-Keyboard-Protocol, Shift+Enter ist also
/// von Enter unterscheidbar.
pub enhanced_keys: bool,
history: History,
}
impl App {
pub fn new(board: Board, path: PathBuf, enhanced_keys: bool) -> Self {
App {
board,
path,
focus: AreaKind::Active,
mode: Mode::Normal,
status: None,
dirty: false,
quit_armed: false,
should_quit: false,
enhanced_keys,
history: History::default(),
}
}
pub fn info(&mut self, text: impl Into<String>) {
self.status = Some(Status {
text: text.into(),
is_error: false,
});
}
pub fn error(&mut self, text: impl Into<String>) {
self.status = Some(Status {
text: text.into(),
is_error: true,
});
}
pub fn focused_area(&self) -> &Area {
self.board.area(self.focus)
}
fn focused_area_mut(&mut self) -> &mut Area {
self.board.area_mut(self.focus)
}
pub fn on_key(&mut self, key: KeyEvent) {
self.status = None;
match self.mode {
Mode::Normal => self.on_key_normal(key),
Mode::Edit(_) => self.on_key_edit(key),
}
}
fn on_key_normal(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
let shift = key.modifiers.contains(KeyModifiers::SHIFT);
// Jede andere Taste entschaerft ein angefangenes Beenden wieder.
let was_armed = std::mem::take(&mut self.quit_armed);
match key.code {
KeyCode::Char('q') if !ctrl => self.quit(was_armed),
KeyCode::Char('c') if ctrl => self.quit(was_armed),
KeyCode::Char('s') if ctrl => self.save(),
// Fokus zwischen den Areas
KeyCode::Tab if !shift => self.focus = rotate(self.focus, true),
KeyCode::Tab | KeyCode::BackTab => self.focus = rotate(self.focus, false),
// Cursor
KeyCode::Down | KeyCode::Char('j') if !shift => self.focused_area_mut().cursor_down(),
KeyCode::Up | KeyCode::Char('k') if !shift => self.focused_area_mut().cursor_up(),
// Punkt verschieben
KeyCode::Down | KeyCode::Char('J') => self.reorder(true),
KeyCode::Up | KeyCode::Char('K') => self.reorder(false),
// Ein- und Ausruecken
KeyCode::Right if shift => self.reparent(true),
KeyCode::Left if shift => self.reparent(false),
KeyCode::Char('L') => self.reparent(true),
KeyCode::Char('H') => self.reparent(false),
// Auf- und Zuklappen
KeyCode::Right | KeyCode::Char('l') => self.fold(false),
KeyCode::Left | KeyCode::Char('h') => self.fold(true),
// Markieren. Esc als Zweitbelegung, weil Shift+Space ohne
// Kitty-Protocol nicht von Space zu unterscheiden ist.
KeyCode::Esc => self.focused_area_mut().clear_selection(),
KeyCode::Char(' ') if shift => self.focused_area_mut().clear_selection(),
KeyCode::Char(' ') => {
let area = self.focused_area_mut();
if let Some(i) = area.cursor_item() {
area.toggle_selection(i);
}
}
// Zwischen den Areas verschieben
KeyCode::Enter if !shift => self.transfer(true),
KeyCode::Enter | KeyCode::Backspace => self.transfer(false),
// Rueckgaengig
KeyCode::Char('u') if !ctrl => self.undo(),
KeyCode::Char('z') if ctrl => self.undo(),
KeyCode::Char('U') => self.redo(),
KeyCode::Char('y') if ctrl => self.redo(),
// Bearbeiten
KeyCode::Char('i') => self.start_edit(),
KeyCode::Char('a') => self.add(false),
KeyCode::Char('A') => self.add(true),
KeyCode::Char('d') => self.delete(),
_ => {}
}
}
fn on_key_edit(&mut self, key: KeyEvent) {
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
match key.code {
KeyCode::Char('c') if ctrl => return self.abort_edit(),
KeyCode::Esc | KeyCode::Enter => return self.commit_edit(),
_ => {}
}
let Mode::Edit(edit) = &mut self.mode else {
return;
};
match key.code {
KeyCode::Backspace => edit.backspace(),
KeyCode::Delete => edit.delete(),
KeyCode::Left => edit.left(),
KeyCode::Right => edit.right(),
KeyCode::Home => edit.cursor = 0,
KeyCode::End => edit.cursor = edit.text.len(),
KeyCode::Char(c) if !ctrl => edit.insert(c),
_ => {}
}
}
// --- Aktionen -------------------------------------------------------
fn quit(&mut self, was_armed: bool) {
if self.dirty && !was_armed {
self.quit_armed = true;
self.error("Ungespeicherte Änderungen — nochmal für Verwerfen, Strg+S zum Speichern");
return;
}
self.should_quit = true;
}
pub fn save(&mut self) {
match markdown::save(&self.path, &self.board) {
Ok(()) => {
self.dirty = false;
self.info(format!("Gespeichert: {}", self.path.display()));
}
Err(err) => self.error(format!("Speichern fehlgeschlagen: {err}")),
}
}
/// Zustand fuer die Historie festhalten.
fn snapshot(&self) -> Snapshot {
Snapshot {
board: self.board.clone(),
focus: self.focus,
dirty: self.dirty,
}
}
fn restore(&mut self, snapshot: Snapshot) {
self.board = snapshot.board;
self.focus = snapshot.focus;
self.dirty = snapshot.dirty;
}
/// Traegt einen Rueckkehrpunkt ein — nach jeder Aenderung aufzurufen.
fn commit(&mut self, before: Snapshot) {
self.history.record(before);
self.dirty = true;
}
fn undo(&mut self) {
let current = self.snapshot();
match self.history.undo(current) {
Some(previous) => {
self.restore(previous);
// Der wiederhergestellte Zustand weicht von der Datei ab, auch
// wenn zwischendurch gespeichert wurde.
self.dirty = true;
self.info("Rückgängig");
}
None => self.info("Nichts rückgängig zu machen"),
}
}
fn redo(&mut self) {
let current = self.snapshot();
match self.history.redo(current) {
Some(next) => {
self.restore(next);
self.dirty = true;
self.info("Wiederhergestellt");
}
None => self.info("Nichts wiederherzustellen"),
}
}
fn reorder(&mut self, down: bool) {
let before = self.snapshot();
let area = self.focused_area_mut();
let Some(i) = area.cursor_item() else { return };
let moved = if down { area.move_down(i) } else { area.move_up(i) };
if let Some(new) = moved {
area.cursor = new;
self.commit(before);
}
}
fn reparent(&mut self, indent: bool) {
let before = self.snapshot();
let area = self.focused_area_mut();
let Some(i) = area.cursor_item() else { return };
let result = if indent { area.indent(i) } else { area.outdent(i) };
match result {
Ok(new) => {
area.cursor = new;
self.commit(before);
}
Err(err) => self.error(err.to_string()),
}
}
fn fold(&mut self, collapse: bool) {
let area = self.focused_area_mut();
let Some(i) = area.cursor_item() else { return };
let next = if collapse {
area.collapse_or_parent(i)
} else {
area.expand_or_child(i)
};
area.cursor = next;
}
fn transfer(&mut self, down: bool) {
let target = if down {
self.focus.below()
} else {
self.focus.above()
};
let Some(target) = target else {
let edge = if down { "unterste" } else { "oberste" };
self.info(format!("{} ist bereits die {edge} Area", self.focus));
return;
};
let before = self.snapshot();
let report = self.board.move_selection(self.focus, target);
if report.is_empty() {
return;
}
self.commit(before);
let mut msg = format!("{}{target}", plural(report.moved));
if !report.created.is_empty() {
msg.push_str(&format!(", Pfad angelegt: {}", report.created.join("/")));
}
if !report.merged.is_empty() {
msg.push_str(&format!(
", zusammengeführt: {}",
report.merged.join(", ")
));
}
if !report.stubs.is_empty() {
msg.push_str(&format!(
", zurückgeblieben: {}",
report.stubs.join(", ")
));
}
self.info(msg);
}
fn start_edit(&mut self) {
let area = self.focused_area();
let Some(i) = area.cursor_item() else {
self.info("Kein Punkt zum Bearbeiten — mit a einen anlegen");
return;
};
let text = area.items[i].text.clone();
let before = self.snapshot();
self.focused_area_mut().cursor = i;
self.mode = Mode::Edit(Edit::new(text, false, before));
}
/// Legt einen leeren Punkt an und springt in den Edit-Modus.
///
/// `as_child` haengt ihn als letztes Kind unter den Cursor, sonst wird er
/// dessen naechstes Geschwister. Beides landet an derselben Stelle in der
/// flachen Liste — nur die Tiefe unterscheidet sich.
fn add(&mut self, as_child: bool) {
let before = self.snapshot();
let area = self.focused_area_mut();
let (index, depth) = match area.cursor_item() {
None => (area.len(), 0),
Some(i) => {
if as_child {
area.items[i].collapsed = false;
(area.subtree_end(i), area.items[i].depth + 1)
} else {
(area.subtree_end(i), area.items[i].depth)
}
}
};
area.items.insert(index, Item::new("", depth));
area.cursor = index;
self.dirty = true;
self.mode = Mode::Edit(Edit::new(String::new(), true, before));
}
fn delete(&mut self) {
let before = self.snapshot();
let area = self.focused_area_mut();
let Some(i) = area.cursor_item() else { return };
let removed = area.remove_subtree(i);
area.clamp_cursor();
self.commit(before);
self.info(format!("{} gelöscht — u macht es rückgängig", plural(removed.len())));
}
fn commit_edit(&mut self) {
let Mode::Edit(edit) = &self.mode else { return };
let text = edit.text.trim().to_string();
let is_new = edit.is_new;
let area = self.board.area(self.focus);
let Some(i) = area.cursor_item() else {
self.mode = Mode::Normal;
return;
};
// Erst pruefen: bei einem Konflikt bleibt der Edit-Modus offen, der
// Undo-Punkt darf also noch nicht verbraucht werden.
if !text.is_empty()
&& let Err(err) = area.validate_rename(i, &text)
{
self.error(format!("{err} — Strg+C bricht ab"));
return;
}
let unchanged = !is_new && area.items[i].text == text;
let Mode::Edit(edit) = std::mem::replace(&mut self.mode, Mode::Normal) else {
unreachable!("oben schon geprueft");
};
let area = self.board.area_mut(self.focus);
if text.is_empty() {
if area.has_children(i) {
// Ein Parent ohne Namen wuerde seine Kinder unauffindbar
// machen; der alte Name bleibt stehen.
self.error("Punkt mit Unterpunkten braucht einen Namen — unverändert");
return;
}
area.remove_subtree(i);
area.clamp_cursor();
if is_new {
// Anlegen und Abbrechen heben sich auf, kein Undo-Schritt.
self.restore(edit.before);
} else {
self.commit(edit.before);
self.info("Leerer Punkt gelöscht");
}
return;
}
if unchanged {
return;
}
area.items[i].text = text;
self.commit(edit.before);
}
fn abort_edit(&mut self) {
let Mode::Edit(edit) = std::mem::replace(&mut self.mode, Mode::Normal) else {
return;
};
// Der Schnappschuss macht sowohl einen frisch angelegten Punkt als auch
// eine begonnene Umbenennung zurueck.
self.restore(edit.before);
self.info("Abgebrochen");
}
}
/// Naechste bzw. vorherige Area im Kreis.
fn rotate(focus: AreaKind, forward: bool) -> AreaKind {
let order = AreaKind::ALL;
let i = focus.index();
let next = if forward {
(i + 1) % order.len()
} else {
(i + order.len() - 1) % order.len()
};
order[next]
}
fn plural(n: usize) -> String {
if n == 1 {
"1 Punkt".to_string()
} else {
format!("{n} Punkte")
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::{KeyEventKind, KeyEventState};
fn app(src: &str) -> App {
let board = markdown::parse(src).expect("parse").board;
let mut app = App::new(board, PathBuf::from("board.md"), true);
app.focus = AreaKind::ToDo;
app
}
fn press(app: &mut App, code: KeyCode, mods: KeyModifiers) {
app.on_key(KeyEvent {
code,
modifiers: mods,
kind: KeyEventKind::Press,
state: KeyEventState::NONE,
});
}
fn key(app: &mut App, c: char) {
let mods = if c.is_uppercase() {
KeyModifiers::SHIFT
} else {
KeyModifiers::NONE
};
press(app, KeyCode::Char(c), mods);
}
fn dump(app: &App, kind: AreaKind) -> String {
app.board
.area(kind)
.items
.iter()
.map(|i| format!("{}{}", " ".repeat(i.depth), i.text))
.collect::<Vec<_>>()
.join("\n")
}
const BOARD: &str = "# To Do\n- a\n - a1\n - a2\n- b\n# Active\n# Done\n";
#[test]
fn navigation_and_reordering() {
let mut a = app(BOARD);
key(&mut a, 'j');
assert_eq!(a.focused_area().cursor, 1); // "a1"
key(&mut a, 'J'); // hinter "a2"
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a2\n a1\nb");
assert!(a.dirty);
}
#[test]
fn tab_rotates_through_the_areas() {
let mut a = app(BOARD);
press(&mut a, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(a.focus, AreaKind::Active);
press(&mut a, KeyCode::BackTab, KeyModifiers::SHIFT);
assert_eq!(a.focus, AreaKind::ToDo);
press(&mut a, KeyCode::BackTab, KeyModifiers::SHIFT);
assert_eq!(a.focus, AreaKind::Done, "rotiert im Kreis");
}
#[test]
fn enter_moves_down_backspace_moves_up() {
let mut a = app(BOARD);
key(&mut a, 'j'); // "a1"
press(&mut a, KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::Active), "a\n a1");
assert_eq!(a.focus, AreaKind::ToDo, "Fokus bleibt in der Quell-Area");
a.focus = AreaKind::Active;
press(&mut a, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a2\n a1\nb");
}
#[test]
fn shift_enter_moves_up_as_well() {
let mut a = app(BOARD);
a.focus = AreaKind::Done;
a.board.area_mut(AreaKind::Done).items.push(Item::new("x", 0));
press(&mut a, KeyCode::Enter, KeyModifiers::SHIFT);
assert_eq!(dump(&a, AreaKind::Active), "x");
}
#[test]
fn transfer_at_the_edge_only_reports() {
let mut a = app(BOARD);
press(&mut a, KeyCode::Backspace, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
assert!(!a.dirty);
assert!(a.status.is_some());
}
#[test]
fn space_selects_and_shift_space_clears() {
let mut a = app(BOARD);
key(&mut a, ' ');
assert_eq!(a.focused_area().selected_items(), vec![0, 1, 2]);
press(&mut a, KeyCode::Char(' '), KeyModifiers::SHIFT);
assert!(!a.focused_area().has_selection());
// Esc tut dasselbe, fuer Terminals ohne Kitty-Protocol.
key(&mut a, ' ');
assert!(a.focused_area().has_selection());
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert!(!a.focused_area().has_selection());
}
#[test]
fn indent_and_outdent() {
let mut a = app(BOARD);
a.focused_area_mut().cursor = 3; // "b"
key(&mut a, 'L');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\n b");
key(&mut a, 'H');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
}
#[test]
fn indent_conflict_is_reported_without_changing_anything() {
let mut a = app("# To Do\n- p\n - x\n- x\n# Active\n# Done\n");
a.focused_area_mut().cursor = 2; // "x" auf oberster Ebene
key(&mut a, 'L');
assert_eq!(dump(&a, AreaKind::ToDo), "p\n x\nx");
assert!(a.status.as_ref().unwrap().is_error);
}
#[test]
fn h_and_l_fold_without_moving_items() {
let mut a = app(BOARD);
key(&mut a, 'h');
assert!(a.focused_area().items[0].collapsed);
key(&mut a, 'j');
assert_eq!(a.focused_area().cursor, 3, "Cursor springt ueber die Kinder");
key(&mut a, 'k');
key(&mut a, 'l');
assert!(!a.focused_area().items[0].collapsed);
assert!(!a.dirty, "Falten aendert das Board nicht");
}
#[test]
fn edit_replaces_the_text() {
let mut a = app(BOARD);
key(&mut a, 'i');
press(&mut a, KeyCode::Backspace, KeyModifiers::NONE);
key(&mut a, 'z');
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "z\n a1\n a2\nb");
assert!(matches!(a.mode, Mode::Normal));
}
#[test]
fn edit_rejects_a_duplicate_name_and_stays_open() {
let mut a = app(BOARD);
a.focused_area_mut().cursor = 1; // "a1"
key(&mut a, 'i');
press(&mut a, KeyCode::Backspace, KeyModifiers::NONE);
key(&mut a, '2'); // -> "a2"
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert!(matches!(a.mode, Mode::Edit(_)), "bleibt im Edit-Modus");
assert!(a.status.as_ref().unwrap().is_error);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(matches!(a.mode, Mode::Normal));
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
}
#[test]
fn a_adds_a_sibling_after_the_whole_subtree() {
let mut a = app(BOARD);
key(&mut a, 'a'); // Cursor auf "a", das Kinder hat
for c in "neu".chars() {
key(&mut a, c);
}
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nneu\nb");
}
#[test]
fn shift_a_adds_a_child() {
let mut a = app(BOARD);
key(&mut a, 'A');
for c in "a3".chars() {
key(&mut a, c);
}
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\n a3\nb");
}
#[test]
fn new_item_left_empty_disappears() {
let mut a = app(BOARD);
key(&mut a, 'a');
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
key(&mut a, 'a');
press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
}
#[test]
fn emptying_a_parent_keeps_its_name() {
let mut a = app(BOARD);
key(&mut a, 'i');
press(&mut a, KeyCode::Backspace, KeyModifiers::NONE);
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
assert!(a.status.as_ref().unwrap().is_error);
}
#[test]
fn add_into_an_empty_area() {
let mut a = app(BOARD);
a.focus = AreaKind::Active;
key(&mut a, 'a');
key(&mut a, 'x');
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::Active), "x");
}
#[test]
fn delete_removes_the_whole_subtree() {
let mut a = app(BOARD);
key(&mut a, 'd');
assert_eq!(dump(&a, AreaKind::ToDo), "b");
assert_eq!(a.focused_area().cursor, 0);
}
#[test]
fn editing_unicode_text() {
let mut a = app("# To Do\n- ä\n# Active\n# Done\n");
key(&mut a, 'i');
key(&mut a, 'ö'); // "äö"
press(&mut a, KeyCode::Left, KeyModifiers::NONE);
press(&mut a, KeyCode::Backspace, KeyModifiers::NONE);
key(&mut a, 'ü'); // "üö"
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "üö");
}
// --- Undo / Redo ----------------------------------------------------
#[test]
fn u_undoes_a_deletion_and_shift_u_redoes_it() {
let mut a = app(BOARD);
key(&mut a, 'd');
assert_eq!(dump(&a, AreaKind::ToDo), "b");
key(&mut a, 'u');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
key(&mut a, 'U');
assert_eq!(dump(&a, AreaKind::ToDo), "b");
}
#[test]
fn ctrl_z_and_ctrl_y_work_as_well() {
let mut a = app(BOARD);
key(&mut a, 'd');
press(&mut a, KeyCode::Char('z'), KeyModifiers::CONTROL);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
press(&mut a, KeyCode::Char('y'), KeyModifiers::CONTROL);
assert_eq!(dump(&a, AreaKind::ToDo), "b");
}
#[test]
fn several_steps_unwind_in_order() {
let mut a = app(BOARD);
key(&mut a, 'J'); // "a" hinter "b"
key(&mut a, 'd'); // "a" samt Kindern weg
assert_eq!(dump(&a, AreaKind::ToDo), "b");
key(&mut a, 'u');
assert_eq!(dump(&a, AreaKind::ToDo), "b\na\n a1\n a2");
key(&mut a, 'u');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
assert!(!a.status.as_ref().unwrap().is_error);
}
#[test]
fn undo_restores_both_areas_of_a_transfer() {
let mut a = app(BOARD);
a.focused_area_mut().cursor = 1; // "a1"
press(&mut a, KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::Active), "a\n a1");
key(&mut a, 'u');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
assert_eq!(dump(&a, AreaKind::Active), "");
}
#[test]
fn undo_returns_to_the_area_the_change_happened_in() {
let mut a = app(BOARD);
key(&mut a, 'd');
press(&mut a, KeyCode::Tab, KeyModifiers::NONE);
assert_eq!(a.focus, AreaKind::Active);
key(&mut a, 'u');
assert_eq!(a.focus, AreaKind::ToDo, "Fokus zeigt, was sich aendert");
}
#[test]
fn adding_and_naming_is_a_single_undo_step() {
let mut a = app(BOARD);
key(&mut a, 'a');
for c in "neu".chars() {
key(&mut a, c);
}
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nneu\nb");
key(&mut a, 'u');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
}
#[test]
fn an_abandoned_new_item_leaves_no_undo_step() {
let mut a = app(BOARD);
key(&mut a, 'd'); // ein echter Schritt zum Zurueckgehen
key(&mut a, 'a');
press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL);
key(&mut a, 'u');
assert_eq!(
dump(&a, AreaKind::ToDo),
"a\n a1\n a2\nb",
"das u muss beim Loeschen landen, nicht beim leeren Punkt"
);
}
#[test]
fn an_edit_that_changes_nothing_leaves_no_undo_step() {
let mut a = app(BOARD);
key(&mut a, 'd');
key(&mut a, 'i');
press(&mut a, KeyCode::Esc, KeyModifiers::NONE);
key(&mut a, 'u');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
}
#[test]
fn aborting_an_edit_restores_the_old_name() {
let mut a = app(BOARD);
key(&mut a, 'i');
for c in "xyz".chars() {
key(&mut a, c);
}
press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
assert!(!a.dirty);
}
#[test]
fn folding_and_selecting_are_not_undoable_steps() {
let mut a = app(BOARD);
key(&mut a, 'd');
key(&mut a, ' ');
key(&mut a, 'h');
key(&mut a, 'u');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
}
#[test]
fn a_new_change_discards_the_redo_chain() {
let mut a = app(BOARD);
key(&mut a, 'd');
key(&mut a, 'u');
key(&mut a, 'J'); // "a" hinter "b" — zweigt ab
key(&mut a, 'U');
assert_eq!(
dump(&a, AreaKind::ToDo),
"b\na\n a1\n a2",
"nichts wiederherzustellen"
);
}
#[test]
fn undo_without_history_only_reports() {
let mut a = app(BOARD);
key(&mut a, 'u');
assert_eq!(dump(&a, AreaKind::ToDo), "a\n a1\n a2\nb");
assert!(a.status.is_some());
assert!(!a.dirty);
}
#[test]
fn quit_asks_once_when_there_are_unsaved_changes() {
let mut a = app(BOARD);
key(&mut a, 'd');
assert!(a.dirty);
key(&mut a, 'q');
assert!(!a.should_quit);
assert!(a.status.as_ref().unwrap().is_error);
key(&mut a, 'q');
assert!(a.should_quit);
}
#[test]
fn any_other_key_disarms_quitting() {
let mut a = app(BOARD);
key(&mut a, 'd');
key(&mut a, 'q');
key(&mut a, 'j');
key(&mut a, 'q');
assert!(!a.should_quit, "erneut nachfragen");
}
#[test]
fn clean_board_quits_immediately() {
let mut a = app(BOARD);
key(&mut a, 'q');
assert!(a.should_quit);
}
#[test]
fn ctrl_c_quits_in_normal_mode() {
let mut a = app(BOARD);
press(&mut a, KeyCode::Char('c'), KeyModifiers::CONTROL);
assert!(a.should_quit);
}
#[test]
fn keys_on_an_empty_area_do_nothing() {
let mut a = app("# To Do\n# Active\n# Done\n");
for c in "jkJKhlHLd i".chars() {
key(&mut a, c);
}
press(&mut a, KeyCode::Enter, KeyModifiers::NONE);
assert!(a.board.is_empty());
assert!(!a.should_quit);
}
}
+135
View File
@@ -0,0 +1,135 @@
//! Undo/Redo ueber Schnappschuesse.
//!
//! Statt fuer jede Operation eine Inverse zu pflegen, wird vor jeder Aenderung
//! der komplette Zustand kopiert. Das ist bei einer Stichpunktliste billig und
//! spart die unangenehmen Faelle: die Inverse eines Cross-Area-Moves muesste
//! wissen, welche Ancestors neu entstanden und welche nur mitbenutzt wurden,
//! und die eines Merges, welche Kinder vorher wo hingen.
//!
//! Preis dieser Entscheidung: Undo stellt auch Cursor-Position und
//! Klapp-Zustand von damals wieder her, nicht nur die Struktur.
use std::collections::VecDeque;
use crate::model::{AreaKind, Board};
/// So viele Schritte lassen sich zurueckgehen.
const LIMIT: usize = 100;
/// Vollstaendiger Zustand zu einem Zeitpunkt.
#[derive(Debug, Clone)]
pub struct Snapshot {
pub board: Board,
pub focus: AreaKind,
pub dirty: bool,
}
#[derive(Debug, Default)]
pub struct History {
undo: VecDeque<Snapshot>,
redo: Vec<Snapshot>,
}
impl History {
/// Merkt den Zustand *vor* einer Aenderung als Rueckkehrpunkt.
pub fn record(&mut self, before: Snapshot) {
// Ein neuer Zweig macht die alte Redo-Kette gegenstandslos.
self.redo.clear();
self.undo.push_back(before);
while self.undo.len() > LIMIT {
self.undo.pop_front();
}
}
/// Tauscht den aktuellen Zustand gegen den davor.
pub fn undo(&mut self, current: Snapshot) -> Option<Snapshot> {
let previous = self.undo.pop_back()?;
self.redo.push(current);
Some(previous)
}
/// Macht ein Undo wieder rueckgaengig.
pub fn redo(&mut self, current: Snapshot) -> Option<Snapshot> {
let next = self.redo.pop()?;
self.undo.push_back(current);
Some(next)
}
pub fn can_undo(&self) -> bool {
!self.undo.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.redo.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::Item;
fn snap(name: &str) -> Snapshot {
let mut board = Board::new();
board
.area_mut(AreaKind::ToDo)
.items
.push(Item::new(name, 0));
Snapshot {
board,
focus: AreaKind::ToDo,
dirty: true,
}
}
fn name(snap: &Snapshot) -> String {
snap.board.area(AreaKind::ToDo).items[0].text.clone()
}
#[test]
fn undo_and_redo_walk_the_chain() {
let mut h = History::default();
h.record(snap("a")); // a -> b
h.record(snap("b")); // b -> c
let back = h.undo(snap("c")).expect("undo");
assert_eq!(name(&back), "b");
let back = h.undo(snap("b")).expect("undo");
assert_eq!(name(&back), "a");
assert!(!h.can_undo());
let forward = h.redo(snap("a")).expect("redo");
assert_eq!(name(&forward), "b");
let forward = h.redo(snap("b")).expect("redo");
assert_eq!(name(&forward), "c");
assert!(!h.can_redo());
}
#[test]
fn a_new_change_discards_the_redo_chain() {
let mut h = History::default();
h.record(snap("a"));
h.undo(snap("b"));
assert!(h.can_redo());
h.record(snap("x"));
assert!(!h.can_redo(), "abgezweigt");
}
#[test]
fn nothing_to_undo_returns_none() {
let mut h = History::default();
assert!(h.undo(snap("a")).is_none());
assert!(h.redo(snap("a")).is_none());
}
#[test]
fn the_oldest_steps_fall_out_at_the_limit() {
let mut h = History::default();
for i in 0..LIMIT + 10 {
h.record(snap(&i.to_string()));
}
assert_eq!(h.undo.len(), LIMIT);
assert_eq!(name(h.undo.front().unwrap()), "10", "aelteste verworfen");
}
}
+88
View File
@@ -0,0 +1,88 @@
// Die Modell- und Historien-API ist bewusst vollstaendig; einzelne Zugriffe
// darauf braucht bisher nur die Testsuite.
#![allow(dead_code)]
mod app;
mod history;
mod markdown;
mod model;
mod ops;
mod transfer;
mod ui;
use std::io::{self, stdout};
use std::path::PathBuf;
use std::process::ExitCode;
use ratatui::DefaultTerminal;
use ratatui::crossterm::event::{
self, Event, KeyEventKind, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
PushKeyboardEnhancementFlags,
};
use ratatui::crossterm::execute;
use ratatui::crossterm::terminal::supports_keyboard_enhancement;
use app::App;
/// Board-Datei, wenn kein Pfad uebergeben wurde.
const DEFAULT_PATH: &str = "board.md";
fn main() -> ExitCode {
let path: PathBuf = std::env::args_os()
.nth(1)
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_PATH));
let parsed = match markdown::load(&path) {
Ok(parsed) => parsed,
Err(err) => {
eprintln!("Fehler: {err}");
return ExitCode::FAILURE;
}
};
// Shift+Enter ist von Enter nur unterscheidbar, wenn das Terminal das
// Kitty-Keyboard-Protocol beherrscht. Backspace tut es sonst auch.
let enhanced = supports_keyboard_enhancement().unwrap_or(false);
let mut app = App::new(parsed.board, path, enhanced);
if let Some(warning) = parsed.warnings.first() {
app.error(warning.clone());
}
let mut terminal = ratatui::init();
if enhanced {
let _ = execute!(
stdout(),
PushKeyboardEnhancementFlags(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES)
);
}
let result = run(&mut terminal, &mut app);
if enhanced {
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
}
ratatui::restore();
match result {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
eprintln!("Fehler: {err}");
ExitCode::FAILURE
}
}
}
fn run(terminal: &mut DefaultTerminal, app: &mut App) -> io::Result<()> {
while !app.should_quit {
terminal.draw(|frame| ui::draw(frame, app))?;
// Nur Press auswerten: mit aktivem Kitty-Protocol liefert das Terminal
// sonst zusaetzlich Release-Events und jede Taste feuerte doppelt.
if let Event::Key(key) = event::read()?
&& key.kind == KeyEventKind::Press
{
app.on_key(key);
}
}
Ok(())
}
+502
View File
@@ -0,0 +1,502 @@
//! Markdown als Persistenzformat.
//!
//! Die drei Areas sind L1-Header, die Stichpunkte eine ungeordnete Liste, deren
//! Verschachtelung ueber die Einrueckung abgebildet wird:
//!
//! ```markdown
//! # To Do
//!
//! - Hausaufgaben
//! - Mathe
//!
//! # Active
//!
//! # Done
//! ```
//!
//! Geschrieben wird immer mit [`INDENT`] Leerzeichen pro Ebene; gelesen wird
//! toleranter (beliebige, auch inkonsistente Einrueckung sowie Tabs), damit von
//! Hand editierte Dateien nicht verloren gehen.
use std::fmt;
use std::io;
use std::path::{Path, PathBuf};
use crate::model::{Area, AreaKind, Board, Item};
/// Leerzeichen pro Verschachtelungsebene beim Schreiben.
pub const INDENT: usize = 2;
/// Ein Tab zaehlt beim Lesen als so viele Spalten.
const TAB_WIDTH: usize = 4;
/// Strukturfehler, die ein Laden unmoeglich machen.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseError {
/// 1-basierte Zeilennummer.
pub line: usize,
pub kind: ParseErrorKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParseErrorKind {
/// L1-Header, der keiner der drei Areas entspricht.
UnknownHeader(String),
/// Dieselbe Area taucht ein zweites Mal auf.
DuplicateHeader(AreaKind),
/// Stichpunkt, bevor ein Header die Area festgelegt hat.
ItemBeforeHeader,
/// Weder Leerzeile, noch Header, noch Listenpunkt.
UnexpectedLine(String),
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Zeile {}: ", self.line)?;
match &self.kind {
ParseErrorKind::UnknownHeader(name) => {
write!(f, "unbekannter Header {name:?} (erwartet: To Do, Active, Done)")
}
ParseErrorKind::DuplicateHeader(kind) => write!(f, "Header {kind} kommt mehrfach vor"),
ParseErrorKind::ItemBeforeHeader => {
write!(f, "Stichpunkt ohne vorangehenden Area-Header")
}
ParseErrorKind::UnexpectedLine(text) => write!(f, "unerwarteter Inhalt: {text:?}"),
}
}
}
impl std::error::Error for ParseError {}
/// Ergebnis des Parsens: das Board plus nicht-fatale Auffaelligkeiten.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Parsed {
pub board: Board,
/// Z.B. Namensgleichheit unter Geschwistern in einer handeditierten Datei.
pub warnings: Vec<String>,
}
/// Liest ein Board aus Markdown.
pub fn parse(input: &str) -> Result<Parsed, ParseError> {
let mut board = Board::new();
let mut current: Option<AreaKind> = None;
let mut seen: Vec<AreaKind> = Vec::new();
// Einrueckungsbreite je Ebene; `stack.len() - 1` ist die aktuelle Tiefe.
let mut indents: Vec<usize> = Vec::new();
for (idx, raw) in input.lines().enumerate() {
let line_no = idx + 1;
let line = raw.trim_end();
if line.trim().is_empty() {
continue;
}
if let Some(rest) = header_body(line) {
let kind = parse_header(rest).ok_or_else(|| ParseError {
line: line_no,
kind: ParseErrorKind::UnknownHeader(rest.to_string()),
})?;
if seen.contains(&kind) {
return Err(ParseError {
line: line_no,
kind: ParseErrorKind::DuplicateHeader(kind),
});
}
seen.push(kind);
current = Some(kind);
indents.clear();
continue;
}
let Some((indent, text)) = split_bullet(line) else {
return Err(ParseError {
line: line_no,
kind: ParseErrorKind::UnexpectedLine(line.trim().to_string()),
});
};
let Some(kind) = current else {
return Err(ParseError {
line: line_no,
kind: ParseErrorKind::ItemBeforeHeader,
});
};
let depth = depth_for(&mut indents, indent);
board.area_mut(kind).items.push(Item::new(text, depth));
}
let mut warnings = Vec::new();
for (kind, area) in board.areas() {
for (_, name) in area.duplicate_siblings() {
warnings.push(format!(
"{kind}: {name:?} kommt mehrfach auf derselben Ebene vor"
));
}
}
Ok(Parsed { board, warnings })
}
/// Schreibt das Board als Markdown. Roundtrip-stabil zu [`parse`].
pub fn render(board: &Board) -> String {
let mut out = String::new();
for (kind, area) in board.areas() {
if !out.is_empty() {
out.push('\n');
}
out.push_str("# ");
out.push_str(kind.title());
out.push('\n');
if !area.is_empty() {
out.push('\n');
render_area(area, &mut out);
}
}
out
}
fn render_area(area: &Area, out: &mut String) {
for item in &area.items {
for _ in 0..item.depth * INDENT {
out.push(' ');
}
out.push('-');
if !item.text.is_empty() {
out.push(' ');
out.push_str(&item.text);
}
out.push('\n');
}
}
/// Fehler beim Laden von der Platte.
#[derive(Debug)]
pub enum LoadError {
Io { path: PathBuf, source: io::Error },
Parse { path: PathBuf, source: ParseError },
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LoadError::Io { path, source } => write!(f, "{}: {source}", path.display()),
LoadError::Parse { path, source } => write!(f, "{}: {source}", path.display()),
}
}
}
impl std::error::Error for LoadError {}
/// Laedt ein Board. Eine nicht existierende Datei ergibt ein leeres Board.
pub fn load(path: &Path) -> Result<Parsed, LoadError> {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => {
return Ok(Parsed {
board: Board::new(),
warnings: Vec::new(),
});
}
Err(source) => {
return Err(LoadError::Io {
path: path.to_path_buf(),
source,
});
}
};
parse(&text).map_err(|source| LoadError::Parse {
path: path.to_path_buf(),
source,
})
}
/// Speichert das Board. Schreibt erst daneben und benennt dann um, damit ein
/// abgebrochener Schreibvorgang die bestehende Datei nicht zerstoert.
pub fn save(path: &Path, board: &Board) -> io::Result<()> {
let tmp = path.with_extension("md.tmp");
std::fs::write(&tmp, render(board))?;
std::fs::rename(&tmp, path)
}
/// Inhalt hinter einem L1-Header (`# ...`), sonst `None`.
fn header_body(line: &str) -> Option<&str> {
let rest = line.strip_prefix('#')?;
if rest.starts_with('#') {
return None; // L2+ interessiert uns nicht als Area-Header
}
Some(rest.trim())
}
fn parse_header(name: &str) -> Option<AreaKind> {
let normalized: String = name
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
match normalized.as_str() {
"todo" => Some(AreaKind::ToDo),
"active" => Some(AreaKind::Active),
"done" => Some(AreaKind::Done),
_ => None,
}
}
/// Zerlegt eine Listenzeile in (Einrueckungsbreite, Text).
fn split_bullet(line: &str) -> Option<(usize, &str)> {
let mut indent = 0;
let mut rest = line;
for (i, ch) in line.char_indices() {
match ch {
' ' => indent += 1,
'\t' => indent += TAB_WIDTH - indent % TAB_WIDTH,
_ => {
rest = &line[i..];
break;
}
}
}
let marker = rest.chars().next()?;
if !matches!(marker, '-' | '*' | '+') {
return None;
}
let after = &rest[marker.len_utf8()..];
match after.chars().next() {
None => Some((indent, "")),
Some(c) if c.is_whitespace() => Some((indent, after.trim())),
// "-foo" ist kein Listenpunkt, sondern Fliesstext.
Some(_) => None,
}
}
/// Bildet eine Einrueckungsbreite auf eine Tiefe ab und pflegt dabei den Stack.
///
/// Groessere Einrueckung als bisher oeffnet genau eine Ebene; kleinere schliesst
/// so viele, bis es wieder passt. Damit bleibt die Tiefen-Invariante des Modells
/// auch bei krummer Einrueckung erhalten.
fn depth_for(indents: &mut Vec<usize>, indent: usize) -> usize {
match indents.last() {
None => {
indents.push(indent);
0
}
Some(&top) if indent > top => {
indents.push(indent);
indents.len() - 1
}
_ => {
while indents.len() > 1 && indent < indents[indents.len() - 1] {
indents.pop();
}
let last = indents.len() - 1;
// Auf die tatsaechliche Breite dieser Ebene einschnappen.
indents[last] = indents[last].min(indent);
last
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn depths(area: &Area) -> Vec<(usize, &str)> {
area.items
.iter()
.map(|i| (i.depth, i.text.as_str()))
.collect()
}
fn parse_ok(input: &str) -> Board {
let parsed = parse(input).expect("parse");
assert!(parsed.warnings.is_empty(), "{:?}", parsed.warnings);
for (kind, area) in parsed.board.areas() {
area.check_invariants().unwrap_or_else(|e| panic!("{kind}: {e}"));
}
parsed.board
}
const SAMPLE: &str = "\
# To Do
- Einkaufen
- Milch
- Brot
- Vollkorn
- Steuer
# Active
- Kanban-Board
- Datenmodell
# Done
- Rust lernen
";
#[test]
fn parses_areas_and_nesting() {
let board = parse_ok(SAMPLE);
assert_eq!(
depths(board.area(AreaKind::ToDo)),
vec![
(0, "Einkaufen"),
(1, "Milch"),
(1, "Brot"),
(2, "Vollkorn"),
(0, "Steuer"),
]
);
assert_eq!(
depths(board.area(AreaKind::Active)),
vec![(0, "Kanban-Board"), (1, "Datenmodell")]
);
assert_eq!(depths(board.area(AreaKind::Done)), vec![(0, "Rust lernen")]);
}
#[test]
fn roundtrip_is_stable() {
let board = parse_ok(SAMPLE);
let rendered = render(&board);
assert_eq!(rendered, SAMPLE);
assert_eq!(parse_ok(&rendered), board);
}
#[test]
fn empty_board_renders_all_headers() {
let rendered = render(&Board::new());
assert_eq!(rendered, "# To Do\n\n# Active\n\n# Done\n");
assert!(parse_ok(&rendered).is_empty());
}
#[test]
fn missing_areas_are_empty() {
let board = parse_ok("# Done\n- fertig\n");
assert!(board.area(AreaKind::ToDo).is_empty());
assert!(board.area(AreaKind::Active).is_empty());
assert_eq!(depths(board.area(AreaKind::Done)), vec![(0, "fertig")]);
}
#[test]
fn header_spellings() {
for (text, expected) in [
("To Do", AreaKind::ToDo),
("todo", AreaKind::ToDo),
("TO-DO", AreaKind::ToDo),
("Active", AreaKind::Active),
("DONE", AreaKind::Done),
] {
assert_eq!(parse_header(text), Some(expected), "{text}");
}
assert_eq!(parse_header("Backlog"), None);
}
#[test]
fn tolerates_odd_indentation_and_markers() {
// Ein Tab zaehlt als 4 Spalten, "c" liegt also auf derselben Ebene wie "b".
let board = parse_ok("# To Do\n* a\n * b\n\t* c\n\t\t* d\n * e\n+ f\n");
assert_eq!(
depths(board.area(AreaKind::ToDo)),
vec![(0, "a"), (1, "b"), (1, "c"), (2, "d"), (0, "e"), (0, "f")]
);
}
#[test]
fn ambiguous_indent_snaps_to_enclosing_level() {
// "b" liegt mit 3 Spalten zwischen den Ebenen 0 und 4 -> aeussere Ebene.
let board = parse_ok("# To Do\n- a\n - a1\n - b\n");
assert_eq!(
depths(board.area(AreaKind::ToDo)),
vec![(0, "a"), (1, "a1"), (0, "b")]
);
}
#[test]
fn indent_never_jumps_more_than_one_level() {
let board = parse_ok("# To Do\n- a\n - b\n- c\n");
assert_eq!(
depths(board.area(AreaKind::ToDo)),
vec![(0, "a"), (1, "b"), (0, "c")]
);
}
#[test]
fn indent_stack_resets_between_areas() {
let board = parse_ok("# To Do\n - a\n# Active\n- b\n - c\n");
assert_eq!(depths(board.area(AreaKind::ToDo)), vec![(0, "a")]);
assert_eq!(depths(board.area(AreaKind::Active)), vec![(0, "b"), (1, "c")]);
}
#[test]
fn empty_bullet_roundtrips() {
let board = parse_ok("# To Do\n-\n- x\n");
assert_eq!(depths(board.area(AreaKind::ToDo)), vec![(0, ""), (0, "x")]);
assert_eq!(render(&board), "# To Do\n\n-\n- x\n\n# Active\n\n# Done\n");
}
#[test]
fn duplicate_siblings_warn_but_load() {
let parsed = parse("# To Do\n- a\n- a\n").expect("parse");
assert_eq!(parsed.board.area(AreaKind::ToDo).len(), 2);
assert_eq!(parsed.warnings.len(), 1);
assert!(parsed.warnings[0].contains("To Do"));
}
#[test]
fn rejects_unknown_header() {
let err = parse("# Backlog\n- a\n").unwrap_err();
assert_eq!(err.line, 1);
assert!(matches!(err.kind, ParseErrorKind::UnknownHeader(_)));
}
#[test]
fn rejects_duplicate_header() {
let err = parse("# To Do\n# To Do\n").unwrap_err();
assert_eq!(err.kind, ParseErrorKind::DuplicateHeader(AreaKind::ToDo));
}
#[test]
fn rejects_item_before_header() {
let err = parse("- a\n").unwrap_err();
assert_eq!(err.kind, ParseErrorKind::ItemBeforeHeader);
}
#[test]
fn rejects_free_text() {
let err = parse("# To Do\nblabla\n").unwrap_err();
assert_eq!(err.line, 2);
assert!(matches!(err.kind, ParseErrorKind::UnexpectedLine(_)));
}
#[test]
fn text_with_markdown_chars_survives_roundtrip() {
let input = "# To Do\n\n- - nicht wirklich ein bullet\n- #1 mit Raute\n";
let board = parse_ok(input);
assert_eq!(
depths(board.area(AreaKind::ToDo)),
vec![(0, "- nicht wirklich ein bullet"), (0, "#1 mit Raute")]
);
}
#[test]
fn load_of_missing_file_yields_empty_board() {
let path = Path::new("/nonexistent-kanban-dir-xyz/board.md");
let parsed = load(path).expect("load");
assert!(parsed.board.is_empty());
}
#[test]
fn save_then_load_roundtrips() {
let dir = std::env::temp_dir().join(format!("kanban-test-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("board.md");
let board = parse_ok(SAMPLE);
save(&path, &board).expect("save");
let parsed = load(&path).expect("load");
assert_eq!(parsed.board, board);
std::fs::remove_dir_all(&dir).ok();
}
}
+429
View File
@@ -0,0 +1,429 @@
//! Datenmodell des Boards.
//!
//! Der Baum wird bewusst *flach* gehalten: jede Area ist ein `Vec<Item>`, die
//! Verschachtelung steckt allein in `Item::depth`. Ein Subtree ist damit ein
//! zusammenhaengender Slice, was Reorder-, Indent- und Move-Operationen auf
//! simple Slice-Manipulationen reduziert.
//!
//! Invarianten (siehe [`Area::check_invariants`]):
//! - `items[0].depth == 0`
//! - `items[i].depth <= items[i - 1].depth + 1`
use std::fmt;
/// Die drei Areas, von oben nach unten so, wie sie dargestellt werden.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum AreaKind {
ToDo,
Active,
Done,
}
impl AreaKind {
pub const ALL: [AreaKind; 3] = [AreaKind::ToDo, AreaKind::Active, AreaKind::Done];
/// Titel, wie er als L1-Header in der Markdown-Datei steht.
pub fn title(self) -> &'static str {
match self {
AreaKind::ToDo => "To Do",
AreaKind::Active => "Active",
AreaKind::Done => "Done",
}
}
pub fn index(self) -> usize {
match self {
AreaKind::ToDo => 0,
AreaKind::Active => 1,
AreaKind::Done => 2,
}
}
/// Die Area eine Stufe weiter Richtung `Done`.
pub fn below(self) -> Option<AreaKind> {
match self {
AreaKind::ToDo => Some(AreaKind::Active),
AreaKind::Active => Some(AreaKind::Done),
AreaKind::Done => None,
}
}
/// Die Area eine Stufe zurueck Richtung `To Do`.
pub fn above(self) -> Option<AreaKind> {
match self {
AreaKind::ToDo => None,
AreaKind::Active => Some(AreaKind::ToDo),
AreaKind::Done => Some(AreaKind::Active),
}
}
}
impl fmt::Display for AreaKind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.title())
}
}
/// Ein einzelner Stichpunkt.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub text: String,
pub depth: usize,
/// Per Leertaste markiert. Nicht persistiert.
pub selected: bool,
/// Subtree eingeklappt. Nicht persistiert.
pub collapsed: bool,
}
impl Item {
pub fn new(text: impl Into<String>, depth: usize) -> Self {
Item {
text: text.into(),
depth,
selected: false,
collapsed: false,
}
}
}
/// Eine Area mit ihrer Stichpunktliste und dem zugehoerigen UI-Zustand.
///
/// Cursor und Markierungen werden pro Area gehalten, bleiben beim Wechsel per
/// Tab also erhalten.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Area {
pub items: Vec<Item>,
/// Index in `items`. Bei leerer Liste bedeutungslos (0).
pub cursor: usize,
/// Index der obersten sichtbaren Zeile.
pub scroll: usize,
}
impl Area {
pub fn new() -> Self {
Self::default()
}
pub fn from_items(items: Vec<Item>) -> Self {
Area {
items,
cursor: 0,
scroll: 0,
}
}
pub fn is_empty(&self) -> bool {
self.items.is_empty()
}
pub fn len(&self) -> usize {
self.items.len()
}
/// Exklusives Ende des Subtrees, der bei `i` beginnt.
///
/// `[i, subtree_end(i))` umfasst den Punkt selbst und alle Nachfahren.
pub fn subtree_end(&self, i: usize) -> usize {
let depth = self.items[i].depth;
let mut end = i + 1;
while end < self.items.len() && self.items[end].depth > depth {
end += 1;
}
end
}
/// Anzahl Zeilen des Subtrees bei `i` (>= 1).
pub fn subtree_len(&self, i: usize) -> usize {
self.subtree_end(i) - i
}
pub fn has_children(&self, i: usize) -> bool {
self.subtree_len(i) > 1
}
/// Index des Parents von `i`, sofern `i` nicht auf oberster Ebene liegt.
pub fn parent_of(&self, i: usize) -> Option<usize> {
let depth = self.items[i].depth;
if depth == 0 {
return None;
}
(0..i).rev().find(|&j| self.items[j].depth < depth)
}
/// Ancestor-Indizes von der Wurzel bis zum direkten Parent von `i`.
///
/// Basis fuer den Cross-Area-Move: daraus entsteht der Pfad, der in der
/// Ziel-Area ggf. neu angelegt werden muss.
pub fn ancestors(&self, i: usize) -> Vec<usize> {
let mut path = Vec::new();
let mut cur = i;
while let Some(parent) = self.parent_of(cur) {
path.push(parent);
cur = parent;
}
path.reverse();
path
}
/// Ancestor-Namen von der Wurzel bis zum direkten Parent von `i`.
pub fn ancestor_names(&self, i: usize) -> Vec<String> {
self.ancestors(i)
.into_iter()
.map(|j| self.items[j].text.clone())
.collect()
}
/// Direkte Kinder von `i` (bzw. alle Wurzelpunkte fuer `None`).
pub fn children_of(&self, parent: Option<usize>) -> Vec<usize> {
let (start, end, depth) = match parent {
Some(p) => (p + 1, self.subtree_end(p), self.items[p].depth + 1),
None => (0, self.items.len(), 0),
};
(start..end)
.filter(|&i| self.items[i].depth == depth)
.collect()
}
/// Geschwister von `i` inklusive `i` selbst.
pub fn siblings_of(&self, i: usize) -> Vec<usize> {
self.children_of(self.parent_of(i))
}
/// Vorheriges Geschwister von `i`, falls vorhanden.
pub fn prev_sibling(&self, i: usize) -> Option<usize> {
let depth = self.items[i].depth;
(0..i)
.rev()
.take_while(|&j| self.items[j].depth >= depth)
.find(|&j| self.items[j].depth == depth)
}
/// Naechstes Geschwister von `i`, falls vorhanden.
pub fn next_sibling(&self, i: usize) -> Option<usize> {
let end = self.subtree_end(i);
match self.items.get(end) {
Some(item) if item.depth == self.items[i].depth => Some(end),
_ => None,
}
}
/// Sucht unter den direkten Kindern von `parent` einen Punkt mit `name`.
///
/// `parent == None` sucht auf oberster Ebene. `ignore` blendet einen Index
/// aus, damit man beim Umbenennen nicht gegen sich selbst kollidiert.
pub fn find_child(&self, parent: Option<usize>, name: &str, ignore: Option<usize>) -> Option<usize> {
self.children_of(parent)
.into_iter()
.find(|&i| Some(i) != ignore && self.items[i].text == name)
}
/// Prueft, ob `name` unter `parent` bereits vergeben ist.
pub fn name_taken(&self, parent: Option<usize>, name: &str, ignore: Option<usize>) -> bool {
self.find_child(parent, name, ignore).is_some()
}
/// Alle Faelle von Namensgleichheit unter Geschwistern.
///
/// Beim Laden einer von Hand editierten Datei wird das als Warnung
/// gemeldet statt hart abgelehnt.
pub fn duplicate_siblings(&self) -> Vec<(usize, String)> {
let mut dupes = Vec::new();
for i in 0..self.items.len() {
let name = &self.items[i].text;
if let Some(prev) = self.prev_sibling(i)
&& self
.siblings_of(i)
.into_iter()
.take_while(|&j| j <= prev)
.any(|j| &self.items[j].text == name)
{
dupes.push((i, name.clone()));
}
}
dupes
}
/// Verletzt die Liste die Tiefen-Invariante? Nur fuer Tests/Debug.
pub fn check_invariants(&self) -> Result<(), String> {
for (i, item) in self.items.iter().enumerate() {
let max = if i == 0 { 0 } else { self.items[i - 1].depth + 1 };
if item.depth > max {
return Err(format!(
"Zeile {i} ({:?}) hat depth {}, erlaubt waere hoechstens {max}",
item.text, item.depth
));
}
}
Ok(())
}
}
/// Das gesamte Board.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Board {
areas: [Area; 3],
}
impl Board {
pub fn new() -> Self {
Self::default()
}
pub fn area(&self, kind: AreaKind) -> &Area {
&self.areas[kind.index()]
}
pub fn area_mut(&mut self, kind: AreaKind) -> &mut Area {
&mut self.areas[kind.index()]
}
/// Zwei Areas gleichzeitig ausleihen. Panics, wenn `a == b`.
pub fn area_pair_mut(&mut self, a: AreaKind, b: AreaKind) -> (&mut Area, &mut Area) {
assert_ne!(a, b, "area_pair_mut braucht zwei verschiedene Areas");
let (ai, bi) = (a.index(), b.index());
if ai < bi {
let (left, right) = self.areas.split_at_mut(bi);
(&mut left[ai], &mut right[0])
} else {
let (left, right) = self.areas.split_at_mut(ai);
(&mut right[0], &mut left[bi])
}
}
pub fn areas(&self) -> impl Iterator<Item = (AreaKind, &Area)> {
AreaKind::ALL.into_iter().map(|kind| (kind, self.area(kind)))
}
pub fn is_empty(&self) -> bool {
self.areas.iter().all(|a| a.is_empty())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Baut eine Area aus `(depth, text)`-Paaren.
fn area(spec: &[(usize, &str)]) -> Area {
Area::from_items(
spec.iter()
.map(|&(depth, text)| Item::new(text, depth))
.collect(),
)
}
/// ```text
/// 0 a
/// 1 a1
/// 2 a1x
/// 1 a2
/// 0 b
/// ```
fn sample() -> Area {
area(&[
(0, "a"),
(1, "a1"),
(2, "a1x"),
(1, "a2"),
(0, "b"),
])
}
#[test]
fn subtree_spans_all_descendants() {
let a = sample();
assert_eq!(a.subtree_end(0), 4);
assert_eq!(a.subtree_end(1), 3);
assert_eq!(a.subtree_end(2), 3);
assert_eq!(a.subtree_end(3), 4);
assert_eq!(a.subtree_end(4), 5);
assert_eq!(a.subtree_len(0), 4);
assert!(a.has_children(0));
assert!(!a.has_children(4));
}
#[test]
fn parent_and_ancestors() {
let a = sample();
assert_eq!(a.parent_of(0), None);
assert_eq!(a.parent_of(1), Some(0));
assert_eq!(a.parent_of(2), Some(1));
assert_eq!(a.parent_of(3), Some(0));
assert_eq!(a.parent_of(4), None);
assert_eq!(a.ancestors(2), vec![0, 1]);
assert_eq!(a.ancestor_names(2), vec!["a".to_string(), "a1".to_string()]);
assert!(a.ancestors(0).is_empty());
}
#[test]
fn children_and_siblings() {
let a = sample();
assert_eq!(a.children_of(None), vec![0, 4]);
assert_eq!(a.children_of(Some(0)), vec![1, 3]);
assert_eq!(a.children_of(Some(1)), vec![2]);
assert!(a.children_of(Some(2)).is_empty());
assert_eq!(a.siblings_of(1), vec![1, 3]);
assert_eq!(a.prev_sibling(3), Some(1));
assert_eq!(a.prev_sibling(1), None);
assert_eq!(a.next_sibling(1), Some(3));
assert_eq!(a.next_sibling(3), None);
// Ueber Parent-Grenzen hinweg gibt es kein Geschwister.
assert_eq!(a.next_sibling(2), None);
assert_eq!(a.next_sibling(0), Some(4));
}
#[test]
fn name_lookup_is_scoped_to_siblings() {
let a = area(&[(0, "a"), (1, "x"), (0, "b"), (1, "x")]);
assert_eq!(a.find_child(Some(0), "x", None), Some(1));
assert_eq!(a.find_child(Some(2), "x", None), Some(3));
assert_eq!(a.find_child(None, "x", None), None);
assert!(a.name_taken(Some(0), "x", None));
// Sich selbst ignorieren: kein Konflikt beim Umbenennen.
assert!(!a.name_taken(Some(0), "x", Some(1)));
// Gleiche Namen unter verschiedenen Parents sind erlaubt.
assert!(a.duplicate_siblings().is_empty());
}
#[test]
fn duplicate_siblings_are_detected() {
let a = area(&[(0, "a"), (1, "x"), (1, "y"), (1, "x"), (0, "a")]);
assert_eq!(
a.duplicate_siblings(),
vec![(3, "x".to_string()), (4, "a".to_string())]
);
}
#[test]
fn invariants() {
assert!(sample().check_invariants().is_ok());
assert!(area(&[(1, "a")]).check_invariants().is_err());
assert!(area(&[(0, "a"), (2, "b")]).check_invariants().is_err());
assert!(Area::new().check_invariants().is_ok());
}
#[test]
fn area_order_and_navigation() {
assert_eq!(AreaKind::ToDo.below(), Some(AreaKind::Active));
assert_eq!(AreaKind::Active.below(), Some(AreaKind::Done));
assert_eq!(AreaKind::Done.below(), None);
assert_eq!(AreaKind::ToDo.above(), None);
assert_eq!(AreaKind::Done.above(), Some(AreaKind::Active));
}
#[test]
fn area_pair_mut_yields_both_orders() {
let mut board = Board::new();
let (todo, done) = board.area_pair_mut(AreaKind::ToDo, AreaKind::Done);
todo.items.push(Item::new("t", 0));
done.items.push(Item::new("d", 0));
assert_eq!(board.area(AreaKind::ToDo).items[0].text, "t");
assert_eq!(board.area(AreaKind::Done).items[0].text, "d");
let (done, todo) = board.area_pair_mut(AreaKind::Done, AreaKind::ToDo);
assert_eq!(done.items[0].text, "d");
assert_eq!(todo.items[0].text, "t");
}
}
+694
View File
@@ -0,0 +1,694 @@
//! Operationen auf einer einzelnen Area: Navigation, Umsortieren, Ein- und
//! Ausruecken, Markierungen, Collapse/Expand.
//!
//! Alle Funktionen arbeiten rein auf dem Modell und geben, wo sinnvoll, den
//! neuen Cursor-Index zurueck. Bewegungen enden immer an der Geschwister- bzw.
//! Area-Grenze: ein Punkt verlaesst seine Familie nur ueber Shift+H/L, und die
//! Area nur ueber Enter/Shift+Enter.
use std::fmt;
use crate::model::{Area, Item};
/// Gruende, aus denen eine Operation abgelehnt wird; landet als Meldung in der
/// Statuszeile.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpError {
/// Einruecken ohne vorangehendes Geschwister — es gaebe keinen Parent.
NoPreviousSibling,
/// Ausruecken auf oberster Ebene.
AlreadyTopLevel,
/// Der Zielplatz hat bereits ein Geschwister dieses Namens.
DuplicateName(String),
}
impl fmt::Display for OpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
OpError::NoPreviousSibling => {
f.write_str("Kein Punkt darüber, unter den eingerückt werden könnte")
}
OpError::AlreadyTopLevel => f.write_str("Punkt ist bereits auf oberster Ebene"),
OpError::DuplicateName(name) => {
write!(f, "{name:?} existiert dort bereits auf derselben Ebene")
}
}
}
}
impl std::error::Error for OpError {}
/// Sichtbarkeit und Cursor-Navigation.
impl Area {
/// Oberster eingeklappter Ancestor von `i`, falls es einen gibt.
fn collapsed_ancestor(&self, i: usize) -> Option<usize> {
self.ancestors(i)
.into_iter()
.find(|&a| self.items[a].collapsed)
}
/// Ist `i` sichtbar, d.h. steckt es in keinem eingeklappten Subtree?
pub fn is_visible(&self, i: usize) -> bool {
self.collapsed_ancestor(i).is_none()
}
/// Alle sichtbaren Indizes in Darstellungsreihenfolge.
pub fn visible_items(&self) -> Vec<usize> {
let mut out = Vec::new();
let mut i = 0;
while i < self.len() {
out.push(i);
i = if self.items[i].collapsed {
self.subtree_end(i)
} else {
i + 1
};
}
out
}
/// Naechster sichtbarer Punkt unterhalb von `i`.
pub fn next_visible(&self, i: usize) -> Option<usize> {
let next = if self.items[i].collapsed {
self.subtree_end(i)
} else {
i + 1
};
(next < self.len()).then_some(next)
}
/// Naechster sichtbarer Punkt oberhalb von `i`.
pub fn prev_visible(&self, i: usize) -> Option<usize> {
let prev = i.checked_sub(1)?;
// Liegt der Vorgaenger in einem eingeklappten Subtree, springen wir auf
// dessen sichtbare Wurzel.
Some(self.collapsed_ancestor(prev).unwrap_or(prev))
}
/// Bewegt den Cursor eine sichtbare Zeile nach unten.
pub fn cursor_down(&mut self) {
if let Some(next) = self.cursor_item().and_then(|i| self.next_visible(i)) {
self.cursor = next;
}
}
/// Bewegt den Cursor eine sichtbare Zeile nach oben.
pub fn cursor_up(&mut self) {
if let Some(prev) = self.cursor_item().and_then(|i| self.prev_visible(i)) {
self.cursor = prev;
}
}
/// Aktueller Cursor-Index, sofern die Area nicht leer ist.
pub fn cursor_item(&self) -> Option<usize> {
(!self.is_empty()).then(|| self.cursor.min(self.len() - 1))
}
/// Holt den Cursor zurueck in einen gueltigen, sichtbaren Zustand.
///
/// Noetig nach Loeschen, Einfuegen oder Collapse, wo der Cursor aus dem
/// Bereich laufen oder in einem eingeklappten Subtree landen kann.
pub fn clamp_cursor(&mut self) {
if self.is_empty() {
self.cursor = 0;
return;
}
self.cursor = self.cursor.min(self.len() - 1);
if let Some(root) = self.collapsed_ancestor(self.cursor) {
self.cursor = root;
}
}
}
/// Umsortieren innerhalb der Geschwister.
impl Area {
/// Schiebt den Subtree bei `i` hinter sein naechstes Geschwister.
///
/// Gibt den neuen Index zurueck, oder `None` am Ende der Geschwisterliste —
/// der Punkt verlaesst seine Familie dabei nie.
pub fn move_down(&mut self, i: usize) -> Option<usize> {
let next = self.next_sibling(i)?;
let end = self.subtree_end(next);
let len = self.subtree_len(i);
// Der eigene Subtree wandert hinter den des Geschwisters.
self.items[i..end].rotate_left(len);
Some(end - len)
}
/// Schiebt den Subtree bei `i` vor sein vorheriges Geschwister.
pub fn move_up(&mut self, i: usize) -> Option<usize> {
let prev = self.prev_sibling(i)?;
let end = self.subtree_end(i);
let len = self.subtree_len(i);
self.items[prev..end].rotate_right(len);
Some(prev)
}
/// Macht `i` zum letzten Kind seines vorherigen Geschwisters.
///
/// Der eigene Subtree kommt mit; ein eingeklappter neuer Parent wird
/// aufgeklappt, damit der Punkt nicht verschwindet.
pub fn indent(&mut self, i: usize) -> Result<usize, OpError> {
let parent = self.prev_sibling(i).ok_or(OpError::NoPreviousSibling)?;
let name = self.items[i].text.clone();
if self.name_taken(Some(parent), &name, None) {
return Err(OpError::DuplicateName(name));
}
let end = self.subtree_end(i);
for item in &mut self.items[i..end] {
item.depth += 1;
}
self.items[parent].collapsed = false;
// Der Punkt steht bereits direkt hinter dem Subtree des Parents und ist
// damit automatisch dessen letztes Kind — nur die Tiefe musste sich
// aendern.
Ok(i)
}
/// Macht `i` zum naechsten Geschwister seines Parents.
///
/// Der Subtree wandert hinter den des bisherigen Parents; nachfolgende
/// Geschwister bleiben dort, wo sie sind, statt adoptiert zu werden.
pub fn outdent(&mut self, i: usize) -> Result<usize, OpError> {
let parent = self.parent_of(i).ok_or(OpError::AlreadyTopLevel)?;
let name = self.items[i].text.clone();
let grandparent = self.parent_of(parent);
if self.name_taken(grandparent, &name, None) {
return Err(OpError::DuplicateName(name));
}
let end = self.subtree_end(i);
let len = end - i;
let parent_end = self.subtree_end(parent);
self.items[i..parent_end].rotate_left(len);
let new_index = parent_end - len;
for item in &mut self.items[new_index..parent_end] {
item.depth -= 1;
}
Ok(new_index)
}
/// Prueft, ob `i` auf `new_name` umbenannt werden darf.
///
/// Wird beim Verlassen des Edit-Mode und beim Anlegen neuer Punkte genutzt.
pub fn validate_rename(&self, i: usize, new_name: &str) -> Result<(), OpError> {
let parent = self.parent_of(i);
if self.name_taken(parent, new_name, Some(i)) {
return Err(OpError::DuplicateName(new_name.to_string()));
}
Ok(())
}
/// Entfernt den Subtree bei `i` und gibt ihn zurueck.
///
/// Basis fuer `d` und fuer den Move zwischen Areas.
pub fn remove_subtree(&mut self, i: usize) -> Vec<Item> {
let end = self.subtree_end(i);
self.items.drain(i..end).collect()
}
}
/// Markierungen.
impl Area {
/// (Ent-)markiert `i` samt aller Nachfahren.
///
/// Der neue Zustand ist die Umkehrung des Zustands von `i` selbst, sodass
/// ein Parent seine Kinder immer mitnimmt. Kinder lassen sich unabhaengig
/// davon einzeln markieren.
pub fn toggle_selection(&mut self, i: usize) {
let target = !self.items[i].selected;
let end = self.subtree_end(i);
for item in &mut self.items[i..end] {
item.selected = target;
}
}
/// Hebt alle Markierungen dieser Area auf (Shift+Space).
pub fn clear_selection(&mut self) {
for item in &mut self.items {
item.selected = false;
}
}
pub fn selected_items(&self) -> Vec<usize> {
(0..self.len()).filter(|&i| self.items[i].selected).collect()
}
pub fn has_selection(&self) -> bool {
self.items.iter().any(|i| i.selected)
}
}
/// Ein- und Ausklappen.
impl Area {
/// Klappt `i` ein, sofern es Kinder hat. Gibt zurueck, ob sich etwas aenderte.
pub fn collapse(&mut self, i: usize) -> bool {
let changed = self.has_children(i) && !self.items[i].collapsed;
if changed {
self.items[i].collapsed = true;
}
changed
}
pub fn expand(&mut self, i: usize) -> bool {
let changed = self.items[i].collapsed;
self.items[i].collapsed = false;
changed
}
/// `h` / Left: erst einklappen, sonst zum Parent springen.
pub fn collapse_or_parent(&mut self, i: usize) -> usize {
if self.collapse(i) {
i
} else {
self.parent_of(i).unwrap_or(i)
}
}
/// `l` / Right: erst aufklappen, sonst zum ersten Kind springen.
pub fn expand_or_child(&mut self, i: usize) -> usize {
if self.expand(i) {
i
} else if self.has_children(i) {
i + 1
} else {
i
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Baut eine Area aus `(depth, text)`-Paaren.
fn area(spec: &[(usize, &str)]) -> Area {
Area::from_items(
spec.iter()
.map(|&(d, t)| Item::new(t, d))
.collect(),
)
}
/// Struktur als `(depth, text)` zum Vergleichen.
fn shape(a: &Area) -> Vec<(usize, &str)> {
a.items.iter().map(|i| (i.depth, i.text.as_str())).collect()
}
fn names<'a>(a: &'a Area, idx: &[usize]) -> Vec<&'a str> {
idx.iter().map(|&i| a.items[i].text.as_str()).collect()
}
/// ```text
/// a
/// a1
/// a1x
/// a2
/// b
/// b1
/// c
/// ```
fn sample() -> Area {
area(&[
(0, "a"),
(1, "a1"),
(2, "a1x"),
(1, "a2"),
(0, "b"),
(1, "b1"),
(0, "c"),
])
}
fn assert_valid(a: &Area) {
a.check_invariants().expect("Tiefen-Invariante");
}
// --- Umsortieren ----------------------------------------------------
#[test]
fn move_down_takes_children_along() {
let mut a = sample();
assert_eq!(a.move_down(0), Some(2)); // "a" hinter "b"
assert_eq!(
shape(&a),
vec![
(0, "b"),
(1, "b1"),
(0, "a"),
(1, "a1"),
(2, "a1x"),
(1, "a2"),
(0, "c"),
]
);
assert_valid(&a);
}
#[test]
fn move_up_takes_children_along() {
let mut a = sample();
assert_eq!(a.move_up(4), Some(0)); // "b" vor "a"
assert_eq!(
shape(&a),
vec![
(0, "b"),
(1, "b1"),
(0, "a"),
(1, "a1"),
(2, "a1x"),
(1, "a2"),
(0, "c"),
]
);
assert_valid(&a);
}
#[test]
fn children_reorder_within_their_parent() {
let mut a = sample();
assert_eq!(a.move_down(1), Some(2)); // "a1" hinter "a2"
assert_eq!(
shape(&a),
vec![
(0, "a"),
(1, "a2"),
(1, "a1"),
(2, "a1x"),
(0, "b"),
(1, "b1"),
(0, "c"),
]
);
assert_valid(&a);
}
#[test]
fn move_stops_at_family_boundary() {
let mut a = sample();
// "a2" ist das letzte Kind von "a" und rutscht nicht zu "b" hinueber.
assert_eq!(a.move_down(3), None);
// "a1" ist das erste Kind und verlaesst "a" auch nach oben nicht.
assert_eq!(a.move_up(1), None);
// Ebenso an den Raendern der Area.
assert_eq!(a.move_up(0), None);
assert_eq!(a.move_down(6), None);
assert_eq!(shape(&a), shape(&sample()));
}
#[test]
fn move_skips_over_whole_sibling_subtree() {
let mut a = area(&[(0, "x"), (0, "y"), (1, "y1"), (1, "y2"), (0, "z")]);
assert_eq!(a.move_down(0), Some(3)); // "x" landet hinter "y2"
assert_eq!(
shape(&a),
vec![(0, "y"), (1, "y1"), (1, "y2"), (0, "x"), (0, "z")]
);
}
#[test]
fn move_is_reversible() {
let original = sample();
for i in 0..original.len() {
let mut a = original.clone();
if let Some(moved) = a.move_down(i) {
assert_eq!(a.move_up(moved), Some(i), "Index {i}");
assert_eq!(shape(&a), shape(&original), "Index {i}");
}
}
}
// --- Ein-/Ausruecken ------------------------------------------------
#[test]
fn indent_makes_item_last_child_of_previous_sibling() {
let mut a = sample();
assert_eq!(a.indent(4), Ok(4)); // "b" unter "a"
assert_eq!(
shape(&a),
vec![
(0, "a"),
(1, "a1"),
(2, "a1x"),
(1, "a2"),
(1, "b"),
(2, "b1"),
(0, "c"),
]
);
assert_valid(&a);
}
#[test]
fn indent_needs_a_previous_sibling() {
let mut a = sample();
assert_eq!(a.indent(0), Err(OpError::NoPreviousSibling));
assert_eq!(a.indent(1), Err(OpError::NoPreviousSibling)); // erstes Kind
assert_eq!(shape(&a), shape(&sample()));
}
#[test]
fn indent_rejects_name_clash_in_new_parent() {
let mut a = area(&[(0, "a"), (1, "x"), (0, "x")]);
assert_eq!(a.indent(2), Err(OpError::DuplicateName("x".into())));
assert_eq!(shape(&a), vec![(0, "a"), (1, "x"), (0, "x")]);
}
#[test]
fn indent_expands_collapsed_new_parent() {
let mut a = sample();
a.collapse(0);
assert!(a.items[0].collapsed);
a.indent(4).unwrap();
assert!(!a.items[0].collapsed, "neuer Parent muss sichtbar werden");
assert!(a.is_visible(4));
}
#[test]
fn outdent_places_item_after_its_parent() {
let mut a = sample();
assert_eq!(a.outdent(1), Ok(2)); // "a1" wird Geschwister von "a"
assert_eq!(
shape(&a),
vec![
(0, "a"),
(1, "a2"),
(0, "a1"),
(1, "a1x"),
(0, "b"),
(1, "b1"),
(0, "c"),
]
);
assert_valid(&a);
}
#[test]
fn outdent_does_not_adopt_following_siblings() {
let mut a = area(&[(0, "p"), (1, "x"), (1, "y"), (1, "z")]);
assert_eq!(a.outdent(1), Ok(3)); // "x" raus, "y"/"z" bleiben bei "p"
assert_eq!(shape(&a), vec![(0, "p"), (1, "y"), (1, "z"), (0, "x")]);
assert_valid(&a);
}
#[test]
fn outdent_needs_a_parent() {
let mut a = sample();
assert_eq!(a.outdent(0), Err(OpError::AlreadyTopLevel));
assert_eq!(shape(&a), shape(&sample()));
}
#[test]
fn outdent_rejects_name_clash_with_new_siblings() {
let mut a = area(&[(0, "p"), (1, "q"), (0, "q")]);
assert_eq!(a.outdent(1), Err(OpError::DuplicateName("q".into())));
assert_eq!(shape(&a), vec![(0, "p"), (1, "q"), (0, "q")]);
}
#[test]
fn indent_and_outdent_are_inverse_when_last_child() {
let mut a = sample();
let i = a.indent(4).unwrap();
assert_eq!(a.outdent(i), Ok(4));
assert_eq!(shape(&a), shape(&sample()));
}
#[test]
fn validate_rename_ignores_the_item_itself() {
let a = area(&[(0, "a"), (1, "x"), (1, "y")]);
assert_eq!(a.validate_rename(1, "x"), Ok(()));
assert_eq!(a.validate_rename(1, "z"), Ok(()));
assert_eq!(a.validate_rename(1, "y"), Err(OpError::DuplicateName("y".into())));
// Gleicher Name unter anderem Parent ist erlaubt.
assert_eq!(a.validate_rename(0, "x"), Ok(()));
}
#[test]
fn remove_subtree_returns_the_whole_family() {
let mut a = sample();
let removed = a.remove_subtree(1);
assert_eq!(
removed.iter().map(|i| i.text.as_str()).collect::<Vec<_>>(),
vec!["a1", "a1x"]
);
assert_eq!(
shape(&a),
vec![(0, "a"), (1, "a2"), (0, "b"), (1, "b1"), (0, "c")]
);
assert_valid(&a);
}
// --- Markierungen ---------------------------------------------------
#[test]
fn selecting_a_parent_selects_its_children() {
let mut a = sample();
a.toggle_selection(0);
assert_eq!(names(&a, &a.selected_items()), vec!["a", "a1", "a1x", "a2"]);
a.toggle_selection(0);
assert!(!a.has_selection());
}
#[test]
fn children_can_be_selected_on_their_own() {
let mut a = sample();
a.toggle_selection(2);
assert_eq!(names(&a, &a.selected_items()), vec!["a1x"]);
a.toggle_selection(5);
assert_eq!(names(&a, &a.selected_items()), vec!["a1x", "b1"]);
}
#[test]
fn toggling_a_parent_overrides_partial_child_selection() {
let mut a = sample();
a.toggle_selection(2); // nur "a1x"
a.toggle_selection(0); // "a" war nicht markiert -> alles markieren
assert_eq!(names(&a, &a.selected_items()), vec!["a", "a1", "a1x", "a2"]);
}
#[test]
fn clear_selection_empties_the_area() {
let mut a = sample();
a.toggle_selection(0);
a.toggle_selection(4);
a.clear_selection();
assert!(!a.has_selection());
}
#[test]
fn selection_survives_reordering() {
let mut a = sample();
a.toggle_selection(4); // "b" + "b1"
let moved = a.move_up(4).unwrap();
assert_eq!(a.items[moved].text, "b");
assert_eq!(names(&a, &a.selected_items()), vec!["b", "b1"]);
}
// --- Collapse / Expand ----------------------------------------------
#[test]
fn collapse_hides_descendants() {
let mut a = sample();
assert!(a.collapse(0));
assert_eq!(names(&a, &a.visible_items()), vec!["a", "b", "b1", "c"]);
assert!(!a.is_visible(1));
assert!(a.is_visible(4));
// Zweites Einklappen aendert nichts.
assert!(!a.collapse(0));
}
#[test]
fn leaves_cannot_be_collapsed() {
let mut a = sample();
assert!(!a.collapse(6));
assert!(!a.items[6].collapsed);
}
#[test]
fn nested_collapse_is_remembered() {
let mut a = sample();
a.collapse(1); // "a1" einklappen
a.collapse(0); // "a" einklappen
assert_eq!(names(&a, &a.visible_items()), vec!["a", "b", "b1", "c"]);
a.expand(0);
// "a1" ist weiterhin eingeklappt.
assert_eq!(names(&a, &a.visible_items()), vec!["a", "a1", "a2", "b", "b1", "c"]);
}
#[test]
fn navigation_skips_collapsed_subtrees() {
let mut a = sample();
a.collapse(0);
a.cursor = 0;
a.cursor_down();
assert_eq!(a.items[a.cursor].text, "b");
a.cursor_up();
assert_eq!(a.items[a.cursor].text, "a");
}
#[test]
fn prev_visible_jumps_to_collapsed_root() {
let mut a = sample();
a.collapse(0);
// Von "b" aus ist der Vorgaenger nicht "a2", sondern "a".
assert_eq!(a.prev_visible(4), Some(0));
}
#[test]
fn cursor_stops_at_the_ends() {
let mut a = sample();
a.cursor = 0;
a.cursor_up();
assert_eq!(a.cursor, 0);
a.cursor = a.len() - 1;
a.cursor_down();
assert_eq!(a.cursor, a.len() - 1);
}
#[test]
fn cursor_helpers_handle_empty_area() {
let mut a = Area::new();
assert_eq!(a.cursor_item(), None);
a.cursor_down();
a.cursor_up();
a.clamp_cursor();
assert_eq!(a.cursor, 0);
assert!(a.visible_items().is_empty());
}
#[test]
fn clamp_cursor_pulls_it_out_of_hidden_and_out_of_range() {
let mut a = sample();
a.collapse(0);
a.cursor = 2; // in einem eingeklappten Subtree
a.clamp_cursor();
assert_eq!(a.cursor, 0);
a.cursor = 99;
a.clamp_cursor();
assert_eq!(a.cursor, a.len() - 1);
}
#[test]
fn h_collapses_then_walks_to_parent() {
let mut a = sample();
assert_eq!(a.collapse_or_parent(0), 0); // klappt "a" ein
assert!(a.items[0].collapsed);
assert_eq!(a.collapse_or_parent(0), 0); // ohne Parent bleibt es stehen
assert_eq!(a.collapse_or_parent(2), 1); // Blatt -> Parent "a1"
assert_eq!(a.collapse_or_parent(1), 1); // "a1" hat Kinder -> einklappen
assert_eq!(a.collapse_or_parent(1), 0); // dann zum Parent "a"
}
#[test]
fn l_expands_then_walks_to_first_child() {
let mut a = sample();
a.collapse(0);
assert_eq!(a.expand_or_child(0), 0); // klappt auf
assert_eq!(a.expand_or_child(0), 1); // dann zum ersten Kind "a1"
assert_eq!(a.expand_or_child(6), 6); // Blatt bleibt stehen
}
}
+432
View File
@@ -0,0 +1,432 @@
//! Verschieben von Punkten zwischen Areas (Enter / Shift+Enter / Backspace).
//!
//! Kernregel: Punkte behalten ihren Pfad. Wird ein Child ohne seinen Parent
//! verschoben, entsteht der Ancestor-Pfad in der Ziel-Area neu; existiert er
//! dort schon, wird der Punkt in diese Family eingesetzt. Umgekehrt bleibt in
//! der Quelle stehen, was fuer die zurueckbleibenden Punkte noch gebraucht wird
//! — und Parents bleiben ohnehin immer stehen, weil sie eigene Punkte sind.
//!
//! Zusammengefuehrt wird ueber Namensgleichheit auf derselben Ebene. Dadurch
//! kann die Operation nie scheitern und die Eindeutigkeit von Geschwisternamen
//! bleibt in der Ziel-Area erhalten.
use crate::model::{Area, AreaKind, Board, Item};
/// Was ein Move bewirkt hat — Grundlage fuer die Statuszeile.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MoveReport {
/// Anzahl tatsaechlich verschobener Punkte (ohne angelegte Ancestors).
pub moved: usize,
/// Namen, die in der Ziel-Area als Ancestor-Pfad neu angelegt wurden.
pub created: Vec<String>,
/// Namen, die mit einem dort bereits vorhandenen Punkt verschmolzen sind.
pub merged: Vec<String>,
/// Namen, die in der Quelle als Struktur-Stub zurueckbleiben mussten, weil
/// noch unverschobene Kinder an ihnen haengen.
pub stubs: Vec<String>,
/// Index des ersten verschobenen Punkts in der Ziel-Area.
pub focus: Option<usize>,
}
impl MoveReport {
pub fn is_empty(&self) -> bool {
self.moved == 0
}
}
/// Pfad-Operationen auf einer Area.
impl Area {
/// Index des Punkts, auf den der Namenspfad zeigt.
pub fn find_path(&self, path: &[String]) -> Option<usize> {
let mut parent = None;
for name in path {
parent = Some(self.find_child(parent, name, None)?);
}
parent
}
/// Voller Namenspfad von der Wurzel bis `i` einschliesslich.
pub fn path_of(&self, i: usize) -> Vec<String> {
let mut path = self.ancestor_names(i);
path.push(self.items[i].text.clone());
path
}
/// Liefert das Kind `name` von `parent` und legt es an, falls es fehlt.
///
/// Neue Punkte werden ans Ende der Kinderliste gehaengt. Rueckgabe ist der
/// Index und ob der Punkt neu entstanden ist.
fn ensure_child(&mut self, parent: Option<usize>, name: &str) -> (usize, bool) {
if let Some(existing) = self.find_child(parent, name, None) {
return (existing, false);
}
let (pos, depth) = match parent {
// Hinter den letzten Nachfahren des Parents = letztes Kind.
Some(p) => (self.subtree_end(p), self.items[p].depth + 1),
None => (self.len(), 0),
};
self.items.insert(pos, Item::new(name, depth));
(pos, true)
}
}
impl Board {
/// Verschiebt die markierten Punkte von `from` nach `to`.
///
/// Ohne Markierung wandert der Punkt unter dem Cursor samt seiner Kinder.
pub fn move_selection(&mut self, from: AreaKind, to: AreaKind) -> MoveReport {
if from == to {
return MoveReport::default();
}
let mask = move_mask(self.area(from));
let (src, dst) = self.area_pair_mut(from, to);
let mut report = MoveReport::default();
let mut first_path: Option<Vec<String>> = None;
// Quell-Reihenfolge ist Pre-Order: ein mitverschobener Ancestor ist
// immer schon angelegt, bevor seine Kinder drankommen.
for i in 0..src.len() {
if !mask[i] {
continue;
}
let mut parent = None;
for ancestor in src.ancestors(i) {
let name = src.items[ancestor].text.clone();
let (idx, created) = dst.ensure_child(parent, &name);
// Ein Ancestor, der selbst mitwandert, behaelt seinen
// Klapp-Zustand; ein fremder wird aufgeklappt, damit der Punkt
// nicht unsichtbar ankommt.
if !mask[ancestor] {
dst.items[idx].collapsed = false;
}
if created {
report.created.push(name);
}
parent = Some(idx);
}
let name = src.items[i].text.clone();
let (idx, created) = dst.ensure_child(parent, &name);
if created {
dst.items[idx].collapsed = src.items[i].collapsed;
} else {
report.merged.push(name.clone());
}
dst.items[idx].selected = false;
report.moved += 1;
if first_path.is_none() {
first_path = Some(src.path_of(i));
}
}
// Ein verschobener Punkt bleibt als Stub stehen, wenn unter ihm noch
// etwas Unverschobenes haengt — sonst verloere das seinen Pfad.
let keep: Vec<bool> = (0..src.len())
.map(|i| !mask[i] || (i + 1..src.subtree_end(i)).any(|j| !mask[j]))
.collect();
for i in 0..src.len() {
if mask[i] && keep[i] {
report.stubs.push(src.items[i].text.clone());
}
}
let mut i = 0;
src.items.retain(|_| {
let keep = keep[i];
i += 1;
keep
});
src.clear_selection();
src.clamp_cursor();
dst.clamp_cursor();
report.focus = first_path.and_then(|path| dst.find_path(&path));
report
}
}
/// Welche Punkte wandern? Die Markierung, sonst der Subtree unter dem Cursor.
fn move_mask(area: &Area) -> Vec<bool> {
if area.has_selection() {
return area.items.iter().map(|item| item.selected).collect();
}
match area.cursor_item() {
Some(cursor) => {
let end = area.subtree_end(cursor);
(0..area.len()).map(|i| i >= cursor && i < end).collect()
}
None => Vec::new(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown;
/// Baut ein Board aus Markdown.
fn board(src: &str) -> Board {
markdown::parse(src).expect("parse").board
}
/// Stellt eine Area als eingerueckten Text dar, damit Erwartungen lesbar
/// bleiben.
fn dump(area: &Area) -> String {
area.items
.iter()
.map(|i| format!("{}{}", " ".repeat(i.depth), i.text))
.collect::<Vec<_>>()
.join("\n")
}
fn assert_area(board: &Board, kind: AreaKind, expected: &str) {
let area = board.area(kind);
area.check_invariants()
.unwrap_or_else(|e| panic!("{kind}: {e}"));
assert!(
area.duplicate_siblings().is_empty(),
"{kind}: doppelte Geschwister {:?}",
area.duplicate_siblings()
);
assert_eq!(dump(area), expected.trim_matches('\n'), "Area {kind}");
}
const EINKAUF: &str = "\
# To Do
- Einkaufen
- Milch
- Brot
- Steuer
# Active
# Done
";
#[test]
fn moving_a_child_recreates_its_parent() {
let mut b = board(EINKAUF);
b.area_mut(AreaKind::ToDo).cursor = 1; // "Milch"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert_eq!(report.moved, 1);
assert_eq!(report.created, vec!["Einkaufen"]);
assert_area(&b, AreaKind::ToDo, "Einkaufen\n Brot\nSteuer");
assert_area(&b, AreaKind::Active, "Einkaufen\n Milch");
}
#[test]
fn second_child_joins_the_existing_family() {
let mut b = board(EINKAUF);
b.area_mut(AreaKind::ToDo).cursor = 1; // "Milch"
b.move_selection(AreaKind::ToDo, AreaKind::Active);
b.area_mut(AreaKind::ToDo).cursor = 1; // jetzt "Brot"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert!(report.created.is_empty(), "Einkaufen existiert dort schon");
assert_eq!(report.moved, 1);
// Der kinderlose Parent bleibt als eigener Punkt stehen.
assert_area(&b, AreaKind::ToDo, "Einkaufen\nSteuer");
assert_area(&b, AreaKind::Active, "Einkaufen\n Milch\n Brot");
}
#[test]
fn moving_a_parent_takes_its_children() {
let mut b = board(EINKAUF);
b.area_mut(AreaKind::ToDo).cursor = 0; // "Einkaufen"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert_eq!(report.moved, 3);
assert!(report.stubs.is_empty());
assert_area(&b, AreaKind::ToDo, "Steuer");
assert_area(&b, AreaKind::Active, "Einkaufen\n Milch\n Brot");
}
#[test]
fn deep_ancestor_paths_are_recreated_completely() {
let mut b = board("# To Do\n- a\n - b\n - c\n# Active\n# Done\n");
b.area_mut(AreaKind::ToDo).cursor = 2; // "c"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert_eq!(report.created, vec!["a", "b"]);
assert_area(&b, AreaKind::ToDo, "a\n b");
assert_area(&b, AreaKind::Active, "a\n b\n c");
}
#[test]
fn existing_ancestors_are_reused_at_every_level() {
let mut b = board(
"# To Do\n- a\n - b\n - c2\n# Active\n- a\n - b\n - c1\n# Done\n",
);
b.area_mut(AreaKind::ToDo).cursor = 2; // "c2"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert!(report.created.is_empty());
assert_area(&b, AreaKind::Active, "a\n b\n c1\n c2");
}
#[test]
fn same_name_under_different_parents_does_not_merge() {
let mut b = board("# To Do\n- p\n - x\n# Active\n- x\n- q\n - x\n# Done\n");
b.area_mut(AreaKind::ToDo).cursor = 1; // "x" unter "p"
b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert_area(&b, AreaKind::Active, "x\nq\n x\np\n x");
}
#[test]
fn colliding_subtrees_merge_by_name() {
let mut b = board("# To Do\n- a\n - y\n# Active\n- a\n - x\n# Done\n");
b.area_mut(AreaKind::ToDo).cursor = 0; // ganzes "a"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert_eq!(report.merged, vec!["a"]);
assert_area(&b, AreaKind::ToDo, "");
assert_area(&b, AreaKind::Active, "a\n x\n y");
}
#[test]
fn selection_moves_instead_of_cursor() {
let mut b = board(EINKAUF);
let todo = b.area_mut(AreaKind::ToDo);
todo.cursor = 0;
todo.toggle_selection(3); // "Steuer"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert_eq!(report.moved, 1);
assert_area(&b, AreaKind::ToDo, "Einkaufen\n Milch\n Brot");
assert_area(&b, AreaKind::Active, "Steuer");
}
#[test]
fn multiple_roots_keep_their_order() {
let mut b = board("# To Do\n- a\n- b\n- c\n# Active\n# Done\n");
let todo = b.area_mut(AreaKind::ToDo);
todo.toggle_selection(2); // "c" zuerst markiert ...
todo.toggle_selection(0); // ... "a" danach
b.move_selection(AreaKind::ToDo, AreaKind::Active);
// Ausschlaggebend ist die Reihenfolge im Board, nicht die des Markierens.
assert_area(&b, AreaKind::Active, "a\nc");
assert_area(&b, AreaKind::ToDo, "b");
}
#[test]
fn partially_selected_parent_leaves_a_stub() {
let mut b = board("# To Do\n- a\n - a1\n - a2\n# Active\n# Done\n");
let todo = b.area_mut(AreaKind::ToDo);
todo.toggle_selection(0); // "a" samt Kindern
todo.toggle_selection(1); // "a1" wieder abwaehlen
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert_eq!(report.moved, 2);
assert_eq!(report.stubs, vec!["a"]);
// "a" bleibt zurueck, damit "a1" seinen Pfad behaelt.
assert_area(&b, AreaKind::ToDo, "a\n a1");
assert_area(&b, AreaKind::Active, "a\n a2");
}
#[test]
fn moving_back_up_works_the_same_way() {
let mut b = board("# To Do\n# Active\n# Done\n- a\n - b\n - c\n");
b.area_mut(AreaKind::Done).cursor = 1; // "b"
b.move_selection(AreaKind::Done, AreaKind::Active);
assert_area(&b, AreaKind::Done, "a\n c");
assert_area(&b, AreaKind::Active, "a\n b");
}
#[test]
fn round_trip_through_all_areas_preserves_the_tree() {
let original = board(EINKAUF);
let mut b = original.clone();
for (from, to) in [
(AreaKind::ToDo, AreaKind::Active),
(AreaKind::Active, AreaKind::Done),
] {
b.area_mut(from).toggle_selection(0); // "Einkaufen" samt Kindern
b.area_mut(from).toggle_selection(3); // "Steuer"
b.move_selection(from, to);
}
assert_area(&b, AreaKind::Done, "Einkaufen\n Milch\n Brot\nSteuer");
b.area_mut(AreaKind::Done).toggle_selection(0);
b.area_mut(AreaKind::Done).toggle_selection(3);
b.move_selection(AreaKind::Done, AreaKind::Active);
b.area_mut(AreaKind::Active).toggle_selection(0);
b.area_mut(AreaKind::Active).toggle_selection(3);
b.move_selection(AreaKind::Active, AreaKind::ToDo);
assert_eq!(dump(b.area(AreaKind::ToDo)), dump(original.area(AreaKind::ToDo)));
assert!(b.area(AreaKind::Active).is_empty());
assert!(b.area(AreaKind::Done).is_empty());
}
#[test]
fn empty_source_is_a_no_op() {
let mut b = board("# To Do\n# Active\n- a\n# Done\n");
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert!(report.is_empty());
assert_area(&b, AreaKind::Active, "a");
}
#[test]
fn moving_into_itself_is_a_no_op() {
let mut b = board(EINKAUF);
let report = b.move_selection(AreaKind::ToDo, AreaKind::ToDo);
assert!(report.is_empty());
assert_area(&b, AreaKind::ToDo, "Einkaufen\n Milch\n Brot\nSteuer");
}
#[test]
fn target_ancestors_are_expanded_so_the_item_stays_visible() {
let mut b = board("# To Do\n- a\n - y\n# Active\n- a\n - x\n# Done\n");
b.area_mut(AreaKind::Active).collapse(0);
b.area_mut(AreaKind::ToDo).cursor = 1; // "y"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
let active = b.area(AreaKind::Active);
assert!(!active.items[0].collapsed);
assert!(active.is_visible(report.focus.unwrap()));
}
#[test]
fn moved_items_arrive_unselected() {
let mut b = board(EINKAUF);
b.area_mut(AreaKind::ToDo).toggle_selection(0);
b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert!(!b.area(AreaKind::Active).has_selection());
assert!(!b.area(AreaKind::ToDo).has_selection());
}
#[test]
fn focus_points_at_the_first_moved_item() {
let mut b = board(EINKAUF);
b.area_mut(AreaKind::ToDo).cursor = 2; // "Brot"
let report = b.move_selection(AreaKind::ToDo, AreaKind::Active);
let active = b.area(AreaKind::Active);
assert_eq!(active.items[report.focus.unwrap()].text, "Brot");
}
#[test]
fn source_cursor_stays_valid_after_the_move() {
let mut b = board(EINKAUF);
b.area_mut(AreaKind::ToDo).cursor = 3; // "Steuer", letzter Punkt
b.move_selection(AreaKind::ToDo, AreaKind::Active);
let todo = b.area(AreaKind::ToDo);
assert!(todo.cursor < todo.len());
assert!(todo.is_visible(todo.cursor));
}
#[test]
fn collapsed_state_travels_with_the_item() {
let mut b = board("# To Do\n- a\n - b\n# Active\n# Done\n");
b.area_mut(AreaKind::ToDo).collapse(0);
b.area_mut(AreaKind::ToDo).cursor = 0;
b.move_selection(AreaKind::ToDo, AreaKind::Active);
assert!(b.area(AreaKind::Active).items[0].collapsed);
}
}
+399
View File
@@ -0,0 +1,399 @@
//! Darstellung.
//!
//! Vertikales Layout: To Do oben, Active in der Mitte, Done unten. Das bildet
//! den Weg eines Akteurs ab — von vorne kommen die Huerden, in der Mitte steht
//! er selbst, hinter ihm liegt das Erledigte.
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Position, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use crate::app::{App, Mode};
use crate::model::{Area, AreaKind};
const ACCENT: Color = Color::Cyan;
const SELECTED: Color = Color::Yellow;
pub fn draw(frame: &mut Frame, app: &mut App) {
let [top, middle, bottom, status] = Layout::vertical([
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Fill(1),
Constraint::Length(1),
])
.areas(frame.area());
let mut cursor = None;
for (kind, rect) in [
(AreaKind::ToDo, top),
(AreaKind::Active, middle),
(AreaKind::Done, bottom),
] {
if let Some(pos) = draw_area(frame, app, kind, rect) {
cursor = Some(pos);
}
}
draw_status(frame, app, status);
// Nur im Edit-Modus zeigt das Terminal einen Cursor.
if let Some(pos) = cursor {
frame.set_cursor_position(pos);
}
}
/// Zeichnet eine Area und liefert im Edit-Modus die Cursor-Position.
fn draw_area(frame: &mut Frame, app: &mut App, kind: AreaKind, rect: Rect) -> Option<Position> {
let focused = app.focus == kind;
let editing = focused && matches!(app.mode, Mode::Edit(_));
let border = if focused {
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::DarkGray)
};
let count = app.board.area(kind).len();
let block = Block::bordered()
.border_style(border)
.title(Line::from(vec![
Span::raw(" "),
Span::styled(
kind.title(),
if focused {
Style::new().fg(ACCENT).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::Gray)
},
),
Span::styled(format!(" {count} "), Style::new().fg(Color::DarkGray)),
]));
let inner = block.inner(rect);
frame.render_widget(block, rect);
if inner.height == 0 || inner.width == 0 {
return None;
}
let area = app.board.area_mut(kind);
let visible = area.visible_items();
scroll_into_view(area, &visible, inner.height as usize);
let area = app.board.area(kind);
let edit = match &app.mode {
Mode::Edit(edit) if editing => Some(edit),
_ => None,
};
let mut cursor = None;
let mut lines = Vec::new();
for (row, &i) in visible
.iter()
.skip(area.scroll)
.take(inner.height as usize)
.enumerate()
{
let on_cursor = i == area.cursor;
let prefix = prefix_spans(area, i);
let text = match edit {
Some(edit) if on_cursor => {
let width: u16 = prefix.iter().map(|s| s.width() as u16).sum();
let before = display_width(&edit.text[..edit.cursor]) as u16;
cursor = Some(Position::new(
inner.x + width + before,
inner.y + row as u16,
));
Span::raw(edit.text.clone())
}
_ => Span::raw(area.items[i].text.clone()),
};
let mut spans = prefix;
spans.push(text);
let mut line = Line::from(spans);
if on_cursor && (focused || editing) {
line = line.style(Style::new().add_modifier(Modifier::REVERSED));
} else if on_cursor {
line = line.style(Style::new().bg(Color::DarkGray));
}
lines.push(line);
}
frame.render_widget(Paragraph::new(lines), inner);
cursor
}
/// Baut Markierungs-Gutter, Baum-Glyphen und Falt-Symbol vor dem Text.
///
/// Das Falt-Symbol sitzt dort, wo bei kinderlosen Punkten der Querstrich der
/// Glyphe steht — beides an dieselbe Stelle zu setzen wuerde doppelt einruecken.
fn prefix_spans(area: &Area, i: usize) -> Vec<Span<'static>> {
let item = &area.items[i];
let gutter = if item.selected {
Span::styled("", Style::new().fg(SELECTED))
} else {
Span::raw(" ")
};
let (marker, marker_style) = if !area.has_children(i) {
// Auf oberster Ebene gibt es keine Glyphe, die fortzusetzen waere.
let filler = if item.depth == 0 { " " } else { "" };
(filler, Style::new().fg(Color::DarkGray))
} else if item.collapsed {
("", Style::new().fg(ACCENT))
} else {
("", Style::new().fg(Color::DarkGray))
};
vec![
gutter,
Span::styled(tree_glyphs(area, i), Style::new().fg(Color::DarkGray)),
Span::styled(marker, marker_style),
Span::raw(" "),
]
}
/// Einrueckung im Stil von `tree`: eine Rinne `│` pro Vorfahr, der noch
/// Geschwister unter sich hat, und `├`/`└` fuer den Punkt selbst.
fn tree_glyphs(area: &Area, i: usize) -> String {
let depth = area.items[i].depth;
if depth == 0 {
return String::new();
}
let mut out = String::with_capacity(depth * 3);
for (level, ancestor) in area.ancestors(i).into_iter().enumerate() {
out.push(if area.next_sibling(ancestor).is_some() {
'│'
} else {
' '
});
// Ebene 0 belegt nur Falt-Symbol und Leerzeichen, tiefere zusaetzlich
// die Astglyphe.
out.push_str(if level == 0 { " " } else { " " });
}
out.push(if area.next_sibling(i).is_some() {
'├'
} else {
'└'
});
out
}
/// Haelt den Cursor im sichtbaren Fenster.
fn scroll_into_view(area: &mut Area, visible: &[usize], height: usize) {
let Some(pos) = visible.iter().position(|&i| i == area.cursor) else {
area.scroll = 0;
return;
};
if pos < area.scroll {
area.scroll = pos;
} else if height > 0 && pos >= area.scroll + height {
area.scroll = pos + 1 - height;
}
area.scroll = area.scroll.min(visible.len().saturating_sub(height));
}
fn draw_status(frame: &mut Frame, app: &App, rect: Rect) {
let line = match &app.status {
Some(status) if status.is_error => Line::from(status.text.clone()).fg(Color::Red),
Some(status) => Line::from(status.text.clone()).fg(Color::Green),
None => Line::from(vec![
Span::styled(
if app.dirty { "" } else { " " },
Style::new().fg(SELECTED),
),
Span::styled(hints(app), Style::new().fg(Color::DarkGray)),
]),
};
frame.render_widget(Paragraph::new(line), rect);
}
fn hints(app: &App) -> String {
if matches!(app.mode, Mode::Edit(_)) {
return "Esc/Enter übernehmen · Strg+C abbrechen".into();
}
// Ohne Kitty-Protocol ist Shift+Enter nicht von Enter zu unterscheiden.
let up = if app.enhanced_keys {
"Shift+Enter bzw. Backspace ↑"
} else {
"Backspace ↑"
};
format!(
"Tab Area · Space markieren · Enter ↓ · {up} · i/a/A/d bearbeiten · u/U zurück · Strg+S speichern · q Ende"
)
}
/// Darstellungsbreite in Terminalspalten.
fn display_width(text: &str) -> usize {
Line::raw(text).width()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown;
fn area(src: &str) -> Area {
markdown::parse(&format!("# To Do\n{src}"))
.expect("parse")
.board
.area(AreaKind::ToDo)
.clone()
}
/// Prefix und Text einer Zeile als reiner Text.
fn row(a: &Area, i: usize) -> String {
let prefix: String = prefix_spans(a, i)
.iter()
.map(|s| s.content.as_ref())
.collect();
format!("{prefix}{}", a.items[i].text)
}
#[test]
fn tree_glyphs_match_the_tree_layout() {
let a = area("- a\n - a1\n - x\n - y\n - a2\n- b\n");
let rendered: Vec<String> = (0..a.len()).map(|i| row(&a, i)).collect();
assert_eq!(
rendered,
vec![
" ▾ a",
" │ ├▾ a1",
" │ │ ├─ x",
" │ │ └─ y",
" │ └─ a2",
" b",
]
);
}
#[test]
fn a_last_child_ends_its_ancestors_channel() {
let a = area("- a\n - a1\n - x\n- b\n");
assert_eq!(row(&a, 2), " │ └─ x");
}
#[test]
fn collapsed_items_show_a_closed_marker() {
let mut a = area("- a\n - a1\n- b\n");
a.collapse(0);
assert_eq!(row(&a, 0), " ▸ a");
}
#[test]
fn scrolling_follows_the_cursor_in_both_directions() {
let mut a = area("- 1\n- 2\n- 3\n- 4\n- 5\n- 6\n");
let visible = a.visible_items();
a.cursor = 4;
scroll_into_view(&mut a, &visible, 3);
assert_eq!(a.scroll, 2, "Cursor am unteren Rand");
a.cursor = 1;
scroll_into_view(&mut a, &visible, 3);
assert_eq!(a.scroll, 1, "Cursor am oberen Rand");
}
#[test]
fn scrolling_does_not_run_past_the_end() {
let mut a = area("- 1\n- 2\n- 3\n");
let visible = a.visible_items();
a.scroll = 99;
a.cursor = 0;
scroll_into_view(&mut a, &visible, 10);
assert_eq!(a.scroll, 0, "alles passt, kein Scrollen noetig");
}
#[test]
fn scrolling_counts_visible_rows_not_items() {
let mut a = area("- a\n - a1\n - a2\n- b\n- c\n");
a.collapse(0);
let visible = a.visible_items();
a.cursor = 4; // "c"
scroll_into_view(&mut a, &visible, 2);
// Sichtbar sind nur a, b, c -> "c" steht auf der dritten Zeile.
assert_eq!(a.scroll, 1);
}
/// Rendert das komplette Frame als Text — praktisch zum Nachsehen, wie es
/// wirklich aussieht (`cargo test -- --nocapture whole_frame`).
fn render(app: &mut App, width: u16, height: u16) -> String {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
terminal.draw(|frame| draw(frame, app)).unwrap();
let buffer = terminal.backend().buffer();
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
.trim_end()
.to_string()
})
.collect::<Vec<_>>()
.join("\n")
}
fn demo_app() -> App {
let board = markdown::parse(
"# To Do\n- Steuer\n - Belege\n - Formular\n- Urlaub\n\
# Active\n- Kanban-Board\n - TUI\n\
# Done\n- Rust lernen\n",
)
.expect("parse")
.board;
let mut app = App::new(board, std::path::PathBuf::from("board.md"), true);
app.focus = AreaKind::Active;
app
}
#[test]
fn whole_frame() {
let mut app = demo_app();
app.board.area_mut(AreaKind::ToDo).toggle_selection(1);
app.board.area_mut(AreaKind::ToDo).cursor = 1;
let out = render(&mut app, 96, 19);
println!("{out}");
assert!(out.contains("├─ Belege"), "Baum-Glyphen fehlen:\n{out}");
assert!(out.contains("└─ Formular"));
assert!(out.contains("To Do 4"), "Titel mit Anzahl fehlt:\n{out}");
// Alle drei Areas plus Statuszeile sind da.
for title in ["To Do", "Active", "Done"] {
assert!(out.contains(title), "{title} fehlt:\n{out}");
}
assert!(out.contains("Tab Area"), "Tastenhilfe fehlt:\n{out}");
}
#[test]
fn edit_mode_places_the_terminal_cursor() {
let mut app = demo_app();
app.on_key(ratatui::crossterm::event::KeyEvent::new(
ratatui::crossterm::event::KeyCode::Char('i'),
ratatui::crossterm::event::KeyModifiers::NONE,
));
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(58, 19)).unwrap();
terminal.draw(|frame| draw(frame, &mut app)).unwrap();
use ratatui::backend::Backend;
let pos = terminal.backend_mut().get_cursor_position().unwrap();
// Hinter dem letzten Zeichen von "Kanban-Board" in der Active-Area,
// also weder am Zeilenanfang noch in der ersten Area.
assert!(pos.x > 12, "Cursor steht bei {pos:?}");
assert!(pos.y > 6, "Cursor steht bei {pos:?}");
}
#[test]
fn selection_and_fold_markers_appear_in_the_gutter() {
let mut a = area("- a\n - a1\n- b\n");
a.toggle_selection(0);
a.collapse(0);
let spans = prefix_spans(&a, 0);
assert_eq!(spans[0].content, "", "Markierung");
assert_eq!(spans[2].content, "", "eingeklappt");
let spans = prefix_spans(&a, 2);
assert_eq!(spans[0].content, " ");
assert_eq!(spans[2].content, " ", "Blatt auf oberster Ebene ohne Symbol");
}
}