use plushie::prelude::*;
use plushie::query::Query;
use plushie::route::Route;
use plushie::selection::{Selection, SelectionMode};
use plushie::undo::{UndoCommand, UndoStack};
#[derive(Clone)]
struct NoteContent {
title: String,
body: String,
}
#[derive(Clone)]
struct Note {
id: usize,
title: String,
body: String,
}
struct Notes {
notes: Vec<Note>,
next_id: usize,
search_query: String,
editing_id: Option<usize>,
selection: Selection,
undo: UndoStack<NoteContent>,
route: Route,
}
impl App for Notes {
type Model = Self;
fn init() -> (Self, Command) {
(
Notes {
notes: Vec::new(),
next_id: 1,
search_query: String::new(),
editing_id: None,
selection: Selection::new(SelectionMode::Multi, Vec::new()),
undo: UndoStack::new(NoteContent {
title: String::new(),
body: String::new(),
}),
route: Route::new("/list"),
},
Command::none(),
)
}
fn update(model: &mut Self, event: Event) -> Command {
match event.widget_match() {
Some(Click("new_note")) => {
let id = model.next_id;
model.next_id += 1;
model.notes.push(Note {
id,
title: String::new(),
body: String::new(),
});
model.editing_id = Some(id);
model.undo = UndoStack::new(NoteContent {
title: String::new(),
body: String::new(),
});
model.route.push("/edit");
update_selection_order(model);
}
Some(Click(id)) if id.starts_with("note:") => {
if let Ok(note_id) = id[5..].parse::<usize>()
&& let Some(note) = model.notes.iter().find(|n| n.id == note_id)
{
model.editing_id = Some(note_id);
model.undo = UndoStack::new(NoteContent {
title: note.title.clone(),
body: note.body.clone(),
});
model.route.push("/edit");
}
}
Some(Click("back")) => {
save_current_edit(model);
model.editing_id = None;
model.route.pop();
}
Some(Click("delete_selected")) => {
let selected = model.selection.selected().clone();
model
.notes
.retain(|n| !selected.contains(&n.id.to_string()));
model.selection.clear();
update_selection_order(model);
}
Some(Click("undo")) => {
model.undo.undo();
}
Some(Click("redo")) => {
model.undo.redo();
}
Some(Input("search", query)) => {
model.search_query = query.to_string();
}
Some(Input("title", value)) => {
let title = value.to_string();
model.undo.apply(
UndoCommand::new(
move |c: &NoteContent| NoteContent {
title: title.clone(),
body: c.body.clone(),
},
|c: &NoteContent| c.clone(),
)
.label("edit title")
.coalesce("typing", 500),
);
}
Some(Input("body", value)) => {
let body = value.to_string();
model.undo.apply(
UndoCommand::new(
move |c: &NoteContent| NoteContent {
title: c.title.clone(),
body: body.clone(),
},
|c: &NoteContent| c.clone(),
)
.label("edit body")
.coalesce("typing", 500),
);
}
Some(Toggle(id, _)) if id.starts_with("note_select:") => {
let note_id = &id["note_select:".len()..];
model.selection.toggle(note_id);
}
_ => {}
}
Command::none()
}
fn view(model: &Self, _widgets: &mut WidgetRegistrar) -> ViewList {
match model.route.current() {
"/list" => view_list(model).into(),
"/edit" => view_edit(model).into(),
_ => view_list(model).into(),
}
}
}
fn view_list(model: &Notes) -> View {
let q = model.search_query.to_lowercase();
let result = Query::new(&model.notes)
.filter(|note| {
q.is_empty()
|| note.title.to_lowercase().contains(&q)
|| note.body.to_lowercase().contains(&q)
})
.page_size(model.notes.len().max(1))
.run();
let mut note_list = column().spacing(4.0).width(Fill);
for note in &result.entries {
let id_str = note.id.to_string();
let label = if note.title.is_empty() {
"(untitled)"
} else {
¬e.title
};
note_list = note_list.child(
row()
.id(&id_str)
.spacing(8.0)
.width(Fill)
.child(
checkbox_for_selection(
&format!("note_select:{}", note.id),
&id_str,
&model.selection,
)
.label(label),
)
.child(button(&format!("note:{}", note.id), "Edit")),
);
}
window("main")
.title("Notes")
.child(
column()
.padding(16)
.spacing(12.0)
.width(Fill)
.child(text("Notes").id("heading").size(24.0))
.child(text_input("search", &model.search_query).placeholder("Search notes..."))
.child(scrollable().id("notes_list").height(Fill).child(note_list))
.child(
row()
.spacing(8.0)
.child(button("new_note", "New Note"))
.child(button("delete_selected", "Delete Selected")),
),
)
.into()
}
fn view_edit(model: &Notes) -> View {
let current = model.undo.current();
window("main")
.title("Edit Note")
.child(
column()
.padding(16)
.spacing(12.0)
.width(Fill)
.child(
row()
.spacing(8.0)
.child(button("back", "Back"))
.child(button("undo", "Undo"))
.child(button("redo", "Redo")),
)
.child(text_input("title", ¤t.title).placeholder("Note title"))
.child(
text_editor("body", ¤t.body)
.placeholder("Write your note...")
.width(Fill)
.height(Fill),
),
)
.into()
}
fn save_current_edit(model: &mut Notes) {
if let Some(editing_id) = model.editing_id {
let current = model.undo.current().clone();
if let Some(note) = model.notes.iter_mut().find(|n| n.id == editing_id) {
note.title = current.title;
note.body = current.body;
}
}
}
fn update_selection_order(model: &mut Notes) {
let order: Vec<String> = model.notes.iter().map(|n| n.id.to_string()).collect();
model.selection = Selection::new(SelectionMode::Multi, order);
}
fn main() -> plushie::Result {
plushie::run::<Notes>()
}
#[cfg(test)]
mod tests {
use super::*;
use plushie::test::TestSession;
#[test]
fn starts_with_empty_notes_and_list_route() {
let session = TestSession::<Notes>::start();
assert!(session.model().notes.is_empty());
assert_eq!(session.model().route.current(), "/list");
}
#[test]
fn heading_renders() {
let session = TestSession::<Notes>::start();
session.assert_text("heading", "Notes");
}
#[test]
fn list_view_has_buttons() {
let session = TestSession::<Notes>::start();
session.assert_exists("new_note");
session.assert_exists("delete_selected");
session.assert_exists("search");
}
#[test]
fn creating_note_navigates_to_edit() {
let mut session = TestSession::<Notes>::start();
session.click("new_note");
assert_eq!(session.model().notes.len(), 1);
assert_eq!(session.model().route.current(), "/edit");
}
#[test]
fn edit_view_has_controls() {
let mut session = TestSession::<Notes>::start();
session.click("new_note");
session.assert_exists("back");
session.assert_exists("undo");
session.assert_exists("redo");
session.assert_exists("title");
session.assert_exists("body");
}
#[test]
fn navigating_back_returns_to_list() {
let mut session = TestSession::<Notes>::start();
session.click("new_note");
session.click("back");
assert_eq!(session.model().route.current(), "/list");
}
#[test]
fn editing_title_updates_undo_state() {
let mut session = TestSession::<Notes>::start();
session.click("new_note");
session.type_text("title", "My Note");
assert_eq!(session.model().undo.current().title, "My Note");
}
#[test]
fn edits_saved_when_navigating_back() {
let mut session = TestSession::<Notes>::start();
session.click("new_note");
session.type_text("title", "Saved Title");
session.click("back");
assert_eq!(session.model().notes[0].title, "Saved Title");
}
#[test]
fn created_note_appears_in_list() {
let mut session = TestSession::<Notes>::start();
session.click("new_note");
session.type_text("title", "Test Note");
session.click("back");
session.assert_exists("note:1");
}
#[test]
fn search_updates_query() {
let mut session = TestSession::<Notes>::start();
session.type_text("search", "hello");
assert_eq!(session.model().search_query, "hello");
}
}