First Version
This commit is contained in:
+944
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user