diff --git a/.gitignore b/.gitignore index de42fbde..fa0a4b10 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ .DS_Store rustc-ice* *.orig - +*.db diff --git a/examples/todo-complex/Cargo.toml b/examples/todo-complex/Cargo.toml new file mode 100644 index 00000000..5ce7a550 --- /dev/null +++ b/examples/todo-complex/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "todo-complex" +version = "0.1.0" +edition = "2021" + +[dependencies] +confy = "0.6.1" +floem = { path = "../..", features = ["serde", "vello"], default-features = false } +im.workspace = true +rusqlite = { version = "0.32.1", features = ["bundled"] } +serde = {workspace = true} +# serde = { version = "1.0.210", features = ["derive"] } diff --git a/examples/todo-complex/src/app_config.rs b/examples/todo-complex/src/app_config.rs new file mode 100644 index 00000000..25168171 --- /dev/null +++ b/examples/todo-complex/src/app_config.rs @@ -0,0 +1,128 @@ +use floem::action::set_window_scale; +use floem::keyboard::Key; +use floem::reactive::provide_context; +use floem::views::Decorators; +use floem::{ + event::{Event, EventListener}, + kurbo::{Point, Size}, + reactive::{create_updater, RwSignal, SignalGet, SignalUpdate, SignalWith}, + window::WindowConfig, + Application, IntoView, +}; +use serde::{Deserialize, Serialize}; + +use crate::OS_MOD; + +#[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Debug)] +pub enum AppTheme { + FollowSystem, + DarkMode, + LightMode, +} + +#[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Debug)] +pub struct AppThemeState { + pub system: floem::window::Theme, + pub theme: AppTheme, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct AppConfig { + pub position: Point, + pub size: Size, + pub app_theme: AppThemeState, + pub window_scale: f64, +} + +impl std::default::Default for AppConfig { + fn default() -> Self { + Self { + position: Point { x: 500.0, y: 500.0 }, + size: Size { + width: 350.0, + height: 650.0, + }, + app_theme: AppThemeState { + system: floem::window::Theme::Dark, + theme: AppTheme::FollowSystem, + }, + window_scale: 1., + } + } +} + +pub fn launch_with_track(app_view: impl FnOnce() -> V + 'static) { + let config: AppConfig = confy::load("my_app", "floem-defaults").unwrap_or_default(); + + let app = Application::new(); + + // modifying this will rewrite app config to disk + let app_config = RwSignal::new(config); + + provide_context(app_config); + + // todo: debounce this + create_updater( + move || app_config.get(), + |config| { + let _ = confy::store("my_app", "floem-defaults", config); + }, + ); + + let window_config = WindowConfig::default() + .size(app_config.with(|ac| ac.size)) + .position(app_config.with(|ac| ac.position)); + + app.window( + move |_| { + set_window_scale(app_config.with(|c| c.window_scale)); + app_view() + .on_key_down( + Key::Character("=".into()), + |m| m == OS_MOD, + move |_| { + app_config.update(|ac| { + ac.window_scale *= 1.1; + floem::action::set_window_scale(ac.window_scale); + }); + }, + ) + .on_key_down( + Key::Character("-".into()), + |m| m == OS_MOD, + move |_| { + app_config.update(|ac| { + ac.window_scale /= 1.1; + floem::action::set_window_scale(ac.window_scale); + }); + }, + ) + .on_key_down( + Key::Character("0".into()), + |m| m == OS_MOD, + move |_| { + app_config.update(|ac| { + ac.window_scale = 1.; + floem::action::set_window_scale(ac.window_scale); + }); + }, + ) + .on_event_stop(EventListener::WindowMoved, move |event| { + if let Event::WindowMoved(position) = event { + app_config.update(|val| { + val.position = *position; + }) + } + }) + .on_event_stop(EventListener::WindowResized, move |event| { + if let Event::WindowResized(size) = event { + app_config.update(|val| { + val.size = *size; + }) + } + }) + }, + Some(window_config), + ) + .run(); +} diff --git a/examples/todo-complex/src/main.rs b/examples/todo-complex/src/main.rs new file mode 100644 index 00000000..adfbfc41 --- /dev/null +++ b/examples/todo-complex/src/main.rs @@ -0,0 +1,405 @@ +use std::time::Instant; + +use floem::{ + action::{exec_after, inspect}, + keyboard::{Key, Modifiers, NamedKey}, + prelude::*, +}; +use todo::TodoState; +use todo_state::TODOS_STATE; +mod app_config; +mod todo; +mod todo_state; + +pub const OS_MOD: Modifiers = if cfg!(target_os = "macos") { + Modifiers::META +} else { + Modifiers::CONTROL +}; + +fn todos() -> impl IntoView { + let todos = TODOS_STATE.with(|s| s.todos); + dyn_stack(move || todos.get(), |todo| *todo, |todo| todo) + .style(|s| s.flex_col().min_size(0, 0)) + .debug_name("Todos Stack") +} + +fn app_view() -> impl IntoView { + let todos_scroll = todos() + .style(|s| s.max_width_full().width_full()) + .scroll() + .style(|s| s.padding(10).padding_right(14)) + .scroll_style(|s| s.shrink_to_fit().handle_thickness(8)); + + let new_button = button("New To-Do") + .action(|| AppCommand::NewTodo.execute()) + .style(|s| s.margin_horiz(10)); + + (todos_scroll, new_button) + .v_stack() + .debug_name("Todos scroll list and new button") + .style(|s| { + s.gap(10) + .max_width(75.pct()) + .width(75.pct()) + .max_height_pct(75.) + }) + .container() + .debug_name("App container view (for center the items list and new button)") + .style(|s| { + s.items_center() + .justify_center() + .size_full() + .max_width_full() + .font_size(15.) + }) + .on_click_stop(move |_| { + AppCommand::Escape.execute(); + }) + .on_key_down( + Key::Named(NamedKey::F11), + |m| m.is_empty(), + |_| { + inspect(); + }, + ) + .on_key_down( + Key::Named(NamedKey::Escape), + |m| m.is_empty(), + move |_| { + AppCommand::Escape.execute(); + }, + ) + .on_key_down( + Key::Character("n".into()), + |m| m == OS_MOD, + move |_| AppCommand::NewTodo.execute(), + ) + .on_key_down( + Key::Character("r".into()), + |m| m == OS_MOD, + move |_| AppCommand::RefreshDB.execute(), + ) + .on_key_down( + Key::Character("a".into()), + |m| m == OS_MOD, + move |_| AppCommand::SelectAll.execute(), + ) + .on_key_down( + Key::Named(NamedKey::Enter), + |m| m.is_empty(), + |_| { + AppCommand::AppAction.execute(); + }, + ) + .on_key_down( + Key::Named(NamedKey::Space), + |m| m.is_empty(), + |_| { + AppCommand::AppAction.execute(); + }, + ) + .on_key_down( + Key::Named(NamedKey::Backspace), + // empty, shift, or OS_MOD or OS_MOD + shift + move |m| !m.intersects((OS_MOD | Modifiers::SHIFT).complement()), + move |_| AppCommand::DeleteSelected.execute(), + ) + .on_key_down( + Key::Named(NamedKey::ArrowDown), + |m| m == Modifiers::empty(), + |_| { + AppCommand::SelectDown.execute(); + }, + ) + .on_key_down( + Key::Named(NamedKey::ArrowUp), + |m| m == Modifiers::empty(), + |_| { + AppCommand::SelectUp.execute(); + }, + ) +} + +fn main() { + app_config::launch_with_track(app_view); +} + +#[must_use] +enum AppCommand<'a> { + Delete(&'a [TodoState]), + DeleteSelected, + SelectAll, + SetActive(TodoState), + #[allow(unused)] + ChangeActive(TodoState), + Escape, + #[allow(unused)] + FocusLost, + RefreshDB, + NewTodo, + UpdateDone(TodoState), + UpdateDescription(TodoState), + CommitTodo(TodoState), + InsertSelected(TodoState), + ToggleSelected(TodoState), + SelectRange(TodoState), + /// an action for when the user does something that should + /// update the UI but what happens is dependent on context + AppAction, + SetSelected(TodoState), + SelectUp, + SelectDown, +} +impl<'a> AppCommand<'a> { + fn execute(self) { + let (active, selected, todos) = TODOS_STATE.with(|s| (s.active, s.selected, s.todos)); + TODOS_STATE.with(|s| { + match self { + AppCommand::Delete(vec) => { + let mut deleted_selected = false; + TODOS_STATE.with(|s| { + for todo in vec { + s.delete(todo.db_id.get_untracked(), todo.unique_id); + selected.update(|s| { + deleted_selected |= s.remove(todo).is_some(); + }); + } + }); + if deleted_selected { + AppCommand::SelectUp.execute(); + } + } + AppCommand::DeleteSelected => { + let initial_selected_len = selected.with(|sel| sel.len()); + s.selected.with(|sel| { + for todo in sel.iter() { + s.delete(todo.db_id.get_untracked(), todo.unique_id); + } + }); + s.selected.update(|sel| sel.clear()); + if initial_selected_len == 1 { + AppCommand::SelectUp.execute(); + } + } + AppCommand::SelectAll => { + todos.with(|t| { + for todo in t.into_iter() { + selected.update(|s| { + s.insert(*todo); + }); + } + }); + } + AppCommand::SetActive(todo_state) => { + active.update(|a| a.set(Some(todo_state))); + selected.update(|s| s.clear()); + } + AppCommand::ChangeActive(todo_state) => { + if active.get().active.is_some() { + active.update(|a| a.set(Some(todo_state))); + selected.update(|s| s.clear()); + } + } + AppCommand::FocusLost => { + // some of this would be less complicated to + // handle if we add more control over what is allowed to steal focus per view + let active_todo = active.get(); + // handle the case where it was just set, don't clear anything + if Instant::now().duration_since(active_todo.time_set) < 50.millis() { + return; + } + // else handle the case where some time goes by without it being set + exec_after(50.millis(), move |_| { + if Instant::now().duration_since(active.get().time_set) < 50.millis() { + return; + } + let old_active = active.get(); + active.update(|a| a.set(None)); + if let Some(active) = old_active.active { + selected.update(|s| { + s.insert(active); + }); + } + }); + } + AppCommand::Escape => { + let active_todo = active.get(); + if Instant::now().duration_since(active_todo.time_set) < 200.millis() { + return; + } + selected.update(|s| s.clear()); + if let Some(active) = active_todo.active { + selected.update(|s| { + s.insert(active); + }); + } + active.update(|s| s.set(None)); + floem::action::clear_app_focus(); + } + AppCommand::RefreshDB => { + s.refresh_db(); + } + AppCommand::NewTodo => { + s.new_todo(); + } + AppCommand::UpdateDone(todo) => { + if let Some(db_id) = todo.db_id.with_untracked(|opt_id| *opt_id) { + s.update_done(db_id, todo.done.get_untracked()); + } else { + AppCommand::CommitTodo(todo).execute(); + } + } + AppCommand::UpdateDescription(todo) => { + if let Some(db_id) = todo.db_id.with_untracked(|opt_id| *opt_id) { + s.update_description(db_id, &todo.description.get_untracked()); + } else { + AppCommand::CommitTodo(todo).execute(); + } + } + AppCommand::CommitTodo(todo) => { + let new_db_id = + s.create(&todo.description.get_untracked(), todo.done.get_untracked()); + todo.db_id.set(Some(new_db_id)); + } + AppCommand::InsertSelected(todo) => { + active.update(|a| a.set(None)); + selected.update(|s| { + s.insert(todo); + }); + } + AppCommand::ToggleSelected(todo) => { + active.update(|a| a.set(None)); + selected.update(|s| { + if s.contains(&todo) { + s.remove(&todo); + } else { + s.insert(todo); + } + }); + } + AppCommand::SetSelected(todo) => { + active.update(|a| a.set(None)); + selected.update(|s| { + s.clear(); + s.insert(todo); + }); + } + AppCommand::SelectUp => { + let todos = todos.get(); + + selected.update(|sel| { + // If nothing is selected, select the last item + if sel.is_empty() { + if let Some(last_todo) = todos.last() { + sel.insert(*last_todo); + return; + } + return; + } + + // Find the highest selected index + if let Some(highest_selected_idx) = + todos.iter().position(|todo| sel.contains(todo)) + { + // If we're not at the top, select the item above + if highest_selected_idx > 0 { + sel.clear(); + sel.insert(todos[highest_selected_idx - 1]); + } else if sel.len() > 1 { + sel.clear(); + sel.insert(todos[highest_selected_idx]); + } + } + }); + } + + AppCommand::SelectDown => { + let todos = todos.get(); + + selected.update(|sel| { + // If nothing is selected, select the first item + if sel.is_empty() { + if let Some(first_todo) = todos.iter().next() { + sel.insert(*first_todo); + return; + } + return; + } + + // Find the lowest selected index + if let Some(lowest_selected_idx) = + todos.iter().rposition(|todo| sel.contains(todo)) + { + // If we're not at the bottom, select the item below + if lowest_selected_idx < todos.len() - 1 { + sel.clear(); + sel.insert(todos[lowest_selected_idx + 1]); + } else if sel.len() > 1 { + sel.clear(); + sel.insert(todos[lowest_selected_idx]); + } + } + }); + } + AppCommand::AppAction => { + let selected = selected.get(); + if selected.is_empty() { + AppCommand::SelectUp.execute(); + return; + } + if selected.len() != 1 { + return; + } + let selected = selected.iter().next().unwrap(); + active.update(|a| a.set(Some(*selected))); + } + AppCommand::SelectRange(todo) => { + active.update(|a| a.set(None)); + selected.update(|s| { + if s.is_empty() { + s.insert(todo); + return; + } + // Get ordered list of todos + let todos = todos.get(); + + // Find indices of the last selected todo and the newly clicked todo + let mut start_idx = None; + let mut end_idx = None; + + for (idx, child_todo) in todos.iter().enumerate() { + // Find the last selected todo + if s.contains(child_todo) { + start_idx = Some(idx); + } + // Find the newly clicked todo + if child_todo == &todo { + end_idx = Some(idx); + } + } + + // If we found both todos, select everything between them + if let (Some(start), Some(end)) = (start_idx, end_idx) { + let (start, end) = if start <= end { + (start, end) + } else { + (end, start) + }; + + // Clear existing selection + s.clear(); + + // Select all todos in the range + for idx in start..=end { + if let Some(child_todo) = todos.get(idx) { + s.insert(*child_todo); + } + } + } + }); + } + } + }); + } +} diff --git a/examples/todo-complex/src/todo.rs b/examples/todo-complex/src/todo.rs new file mode 100644 index 00000000..75f13501 --- /dev/null +++ b/examples/todo-complex/src/todo.rs @@ -0,0 +1,268 @@ +use std::{hash::Hasher, sync::atomic::AtomicU64, time::Instant}; + +use floem::{ + action::{debounce_action, exec_after}, + easing::Spring, + event::{Event, EventListener}, + keyboard::{Modifiers, NamedKey}, + kurbo::Stroke, + menu::{Menu, MenuEntry, MenuItem}, + prelude::*, + reactive::{create_effect, create_memo, Trigger}, + style::{BoxShadowProp, CursorStyle, MinHeight, Transition}, + taffy::AlignItems, + views::Checkbox, + AnyView, +}; + +use crate::{todo_state::TODOS_STATE, AppCommand, OS_MOD}; + +/// this state macro is unnecessary but convenient. It just produces the original struct and a new struct ({StructName}State) with all of the same fields but wrapped in Signal types. +#[derive(Clone)] +pub struct Todo { + pub db_id: Option, + pub unique_id: u64, + pub done: bool, + pub description: String, +} +static UNIQUE_COUNTER: AtomicU64 = AtomicU64::new(0); +impl Todo { + pub fn new_from_db(db_id: i64, done: bool, description: impl Into) -> Self { + Self { + db_id: Some(db_id), + unique_id: UNIQUE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + done, + description: description.into(), + } + } + pub fn new(done: bool, description: impl Into) -> Self { + Self { + db_id: None, + unique_id: UNIQUE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed), + done, + description: description.into(), + } + } +} +#[derive(Clone, Copy, Eq, Debug)] +pub struct TodoState { + pub db_id: RwSignal>, + pub unique_id: u64, + pub done: RwSignal, + pub description: RwSignal, +} +impl From for TodoState { + fn from(value: Todo) -> Self { + Self { + db_id: RwSignal::new(value.db_id), + unique_id: value.unique_id, + done: RwSignal::new(value.done), + description: RwSignal::new(value.description), + } + } +} +impl IntoView for TodoState { + type V = AnyView; + + fn into_view(self) -> Self::V { + // when the done status changes, commit the change to the db + debounce_action(self.done, 300.millis(), move || { + AppCommand::UpdateDone(self).execute() + }); + + // when the description changes, debounce a commit to the db + debounce_action(self.description, 300.millis(), move || { + AppCommand::UpdateDescription(self).execute() + }); + + let (active, selected) = TODOS_STATE.with(|s| (s.active, s.selected)); + let is_active = + create_memo(move |_| active.with(|a| a.active.map_or(false, |v| v == self))); + let is_selected = create_memo(move |_| selected.with(|s| s.contains(&self))); + + let todo_action_menu = move || { + // would be better to have actions that can operate on multiple selections. + AppCommand::Escape.execute(); + AppCommand::InsertSelected(self).execute(); + let delete = MenuItem::new("Delete").action(move || { + AppCommand::Delete(&[self]).execute(); + }); + let done = self.done.get(); + let action_name = if done { + "Mark as Incomplete" + } else { + "Mark as Complete" + }; + let toggle_done = MenuItem::new(action_name).action(move || { + if done { + self.done.set(false); + } else { + self.done.set(true); + } + }); + Menu::new("todo action") + .entry(MenuEntry::Item(toggle_done)) + .entry(MenuEntry::Item(delete)) + }; + + let input_focused = Trigger::new(); + let done_check = Checkbox::new_rw(self.done) + .style(|s| { + s.flex_shrink(0.) + .max_height_pct(70.) + .aspect_ratio(1.) + .border( + Stroke::new(1.) + .with_dashes(0.2, [1., 2.]) + .with_caps(floem::kurbo::Cap::Round), + ) + .class(SvgClass, |s| s.size_pct(50., 50.)) + }) + .on_key_down( + floem::keyboard::Key::Named(NamedKey::Enter), + |_| true, + |_| {}, + ) + .on_event_stop(EventListener::PointerDown, move |_| {}); + + let input = text_input(self.description) + .placeholder("New To-Do") + .into_view(); + let input_id = input.id(); + let input = input + .disable_default_event(move || (EventListener::PointerDown, !is_active)) + .style(move |s| { + s.width_full() + .apply_if(!is_active.get(), |s| s.cursor(CursorStyle::Default)) + .background(Color::TRANSPARENT) + .transition_background(Transition::ease_in_out(600.millis())) + .border(0) + .hover(|s| s.background(Color::TRANSPARENT)) + .focus(|s| { + s.hover(|s| s.background(Color::TRANSPARENT)) + .border(0.) + .border_color(Color::TRANSPARENT) + }) + .disabled(|s| s.background(Color::TRANSPARENT).color(Color::BLACK)) + .class(PlaceholderTextClass, |s| s.color(Color::GRAY)) + }) + .on_key_down( + floem::keyboard::Key::Named(NamedKey::Enter), + |m| m.is_empty(), + move |_| { + AppCommand::Escape.execute(); + }, + ) + .on_event_stop(EventListener::PointerDown, move |_| { + AppCommand::SetSelected(self).execute(); + }) + .on_event_stop(EventListener::DoubleClick, move |_| { + AppCommand::SetActive(self).execute(); + input_id.request_focus(); + }) + .on_event_stop(EventListener::FocusGained, move |_| { + AppCommand::SetActive(self).execute(); + input_focused.notify(); + }); + + // if this todo is being created after the app has already been initialized, focus the input + if Instant::now().duration_since(TODOS_STATE.with(|s| s.time_stated)) > 50.millis() { + input_id.request_focus(); + } + create_effect(move |_| { + if is_active.get() { + input_id.request_focus(); + } + }); + + let main_controls = (done_check, input) + .h_stack() + .debug_name("Todo Checkbox and text input (main controls)") + .style(|s| s.gap(10).width_full().items_center()) + .container() + .on_double_click_stop(move |_| { + AppCommand::SetActive(self).execute(); + }) + .on_click_stop(move |e| { + let Event::PointerUp(e) = e else { + return; + }; + if e.modifiers == OS_MOD { + AppCommand::ToggleSelected(self).execute(); + } else if e.modifiers.contains(Modifiers::SHIFT) { + AppCommand::SelectRange(self).execute(); + } else { + AppCommand::SetSelected(self).execute(); + } + }) + .style(|s| s.width_full().align_items(Some(AlignItems::FlexStart))); + + let container = main_controls.container(); + let final_view_id = container.id(); + create_effect(move |_| { + input_focused.track(); + // this is a super ugly hack... + // We should really figure out a way to make sure than an item that is focused + // can be scrolled to and then kept in view if it has an animation/transition + exec_after(25.millis(), move |_| final_view_id.scroll_to(None)); + exec_after(50.millis(), move |_| final_view_id.scroll_to(None)); + exec_after(75.millis(), move |_| final_view_id.scroll_to(None)); + exec_after(100.millis(), move |_| final_view_id.scroll_to(None)); + exec_after(125.millis(), move |_| final_view_id.scroll_to(None)); + exec_after(150.millis(), move |_| final_view_id.scroll_to(None)); + exec_after(170.millis(), move |_| final_view_id.scroll_to(None)); + exec_after(200.millis(), move |_| final_view_id.scroll_to(None)); + }); + + container + .style(move |s| { + s.width_full() + .min_height(0.) + .padding(5) + .border_radius(5.) + .transition(MinHeight, Transition::new(600.millis(), Spring::snappy())) + .box_shadow_blur(0.) + .box_shadow_color(Color::BLACK.multiply_alpha(0.0)) + .box_shadow_h_offset(0.) + .box_shadow_v_offset(0.) + .background(Color::TRANSPARENT) + .apply_if(is_selected.get(), |s| { + s.background(Color::LIGHT_BLUE.multiply_alpha(0.7)) + }) + .apply_if(is_active.get(), |s| { + s.min_height(100) + .background(Color::WHITE_SMOKE) + .box_shadow_blur(2.) + .box_shadow_color(Color::BLACK.multiply_alpha(0.7)) + .box_shadow_h_offset(1.) + .box_shadow_v_offset(2.) + .transition( + BoxShadowProp, + Transition::new(600.millis(), Spring::snappy()), + ) + }) + }) + .context_menu(todo_action_menu) + .into_any() + } +} +impl IntoView for Todo { + type V = ::V; + + fn into_view(self) -> Self::V { + let todo_state: TodoState = self.into(); + todo_state.into_view() + } +} + +impl PartialEq for TodoState { + fn eq(&self, other: &Self) -> bool { + self.unique_id == other.unique_id + } +} + +impl std::hash::Hash for TodoState { + fn hash(&self, state: &mut H) { + self.unique_id.hash(state); + } +} diff --git a/examples/todo-complex/src/todo_state.rs b/examples/todo-complex/src/todo_state.rs new file mode 100644 index 00000000..cf89627d --- /dev/null +++ b/examples/todo-complex/src/todo_state.rs @@ -0,0 +1,167 @@ +use std::{cell::LazyCell, time::Instant}; + +use floem::prelude::*; +use im::{HashSet, Vector}; +use rusqlite::{named_params, Connection}; + +use crate::todo::{Todo, TodoState}; + +thread_local! { + pub static TODOS_STATE: LazyCell = LazyCell::new(|| { + TodosState::new() + }); +} + +#[derive(Debug, Clone, Copy)] +pub struct ActiveTodo { + pub time_set: Instant, + pub active: Option, +} +impl ActiveTodo { + pub fn set(&mut self, todo: Option) { + self.time_set = Instant::now(); + self.active = todo; + } + + fn new() -> Self { + Self { + time_set: Instant::now(), + active: None, + } + } +} + +pub struct TodosState { + db: Connection, + pub todos: RwSignal>, + pub selected: RwSignal>, + pub active: RwSignal, + pub time_stated: Instant, +} +impl TodosState { + fn new() -> Self { + let conn = Connection::open("todos.db").unwrap(); + conn.execute( + "CREATE TABLE IF NOT EXISTS todo ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + done INTEGER NOT NULL, + description TEXT NOT NULL + )", + [], + ) + .unwrap(); + let mut stmt = conn + .prepare("SELECT id, done, description FROM todo") + .unwrap(); + let todos = RwSignal::new( + stmt.query_map([], |row| { + Ok(Todo::new_from_db( + row.get(0)?, + row.get::<_, i64>(1)? != 0, + row.get::<_, String>(2)?, + ) + .into()) + }) + .unwrap() + .collect::>>() + .unwrap(), + ); + drop(stmt); + + let selected = RwSignal::new(HashSet::new()); + let active = RwSignal::new(ActiveTodo::new()); + + Self { + db: conn, + todos, + selected, + active, + time_stated: Instant::now(), + } + } + + pub fn new_todo(&self) { + self.todos + .update(|todos| todos.push_back(Todo::new(false, "").into())); + } + + pub fn refresh_db(&self) { + let mut stmt = self + .db + .prepare("SELECT id, done, description FROM todo") + .unwrap(); + let todos = stmt + .query_map([], |row| { + Ok(Todo::new_from_db( + row.get(0)?, + row.get::<_, i64>(1)? != 0, + row.get::<_, String>(2)?, + ) + .into()) + }) + .unwrap() + .collect::>>() + .unwrap(); + self.todos.update(|old_todos| { + old_todos.retain(|todo| todo.db_id.get_untracked().is_some()); + + // Update existing todos and track which new todos were matched + let mut matched_new_todos = Vec::new(); + for (i, new_todo) in todos.iter().enumerate() { + if let Some(new_db_id) = new_todo.db_id.get_untracked() { + if let Some(old_todo) = old_todos.iter().find(|t| t.db_id == Some(new_db_id)) { + old_todo.done.set(new_todo.done.get_untracked()); + old_todo + .description + .set(new_todo.description.get_untracked()); + matched_new_todos.push(i); + } + } + } + // Add any new todos that weren't matched + for (i, new_todo) in todos.iter().enumerate() { + if !matched_new_todos.contains(&i) { + old_todos.push_back(*new_todo); + } + } + }); + } + + pub fn create(&self, description: &str, done: bool) -> i64 { + self.db + .execute( + "INSERT INTO todo (done, description) VALUES (:done, :description)", + named_params! { + ":done": done as i32, + ":description": description, + }, + ) + .unwrap(); + self.db.last_insert_rowid() + } + + pub fn update_done(&self, id: i64, done: bool) { + self.db + .execute("UPDATE todo SET done = ?1 WHERE id = ?2", [done as i64, id]) + .unwrap(); + } + + pub fn update_description(&self, id: i64, description: &str) { + self.db + .execute( + "UPDATE todo SET description = :desc WHERE id = :id", + named_params! {":desc": description, ":id": id}, + ) + .unwrap(); + } + + pub fn delete(&self, db_id: Option, view_id: u64) { + if let Some(db_id) = db_id { + self.db + .execute("DELETE FROM todo WHERE id = ?1", [db_id]) + .unwrap(); + } + self.todos + .update(|todos| todos.retain(|v| v.unique_id != view_id)); + } +}