Add docs to everything public. Rename some methods.Remove unused deps

This commit is contained in:
Joseph Ferano 2023-06-17 22:47:54 +07:00
parent 9a779b1abb
commit 4beda4fae7
9 changed files with 231 additions and 182 deletions

68
Cargo.lock generated
View File

@ -301,13 +301,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "itoa" name = "kanban_tui"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
[[package]]
name = "kanban-tui"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
@ -316,9 +310,6 @@ dependencies = [
"int-enum", "int-enum",
"ratatui", "ratatui",
"rusqlite", "rusqlite",
"serde",
"serde_json",
"thiserror",
"tui-textarea", "tui-textarea",
] ]
@ -492,49 +483,12 @@ dependencies = [
"windows-sys 0.48.0", "windows-sys 0.48.0",
] ]
[[package]]
name = "ryu"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f91339c0467de62360649f8d3e185ca8de4224ff281f66000de5eb2a77a79041"
[[package]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.1.0" version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd" checksum = "d29ab0c6d3fc0ee92fe66e2d99f700eab17a8d57d1c1d3b748380fb20baa78cd"
[[package]]
name = "serde"
version = "1.0.163"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2113ab51b87a539ae008b5c6c02dc020ffa39afd2d83cffcb3f4eb2722cebec2"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.163"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c805777e3930c8883389c602315a24224bcc738b63905ef87cd1420353ea93e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
]
[[package]]
name = "serde_json"
version = "1.0.96"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "057d394a50403bcac12672b2b18fb387ab6d289d957dab67dd201875391e52f1"
dependencies = [
"itoa",
"ryu",
"serde",
]
[[package]] [[package]]
name = "signal-hook" name = "signal-hook"
version = "0.3.15" version = "0.3.15"
@ -599,26 +553,6 @@ dependencies = [
"unicode-ident", "unicode-ident",
] ]
[[package]]
name = "thiserror"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978c9a314bd8dc99be594bc3c175faaa9794be04a5a5e153caba6915336cebac"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9456a42c5b0d803c8cd86e73dd7cc9edd429499f37a3550d286d5e86720569f"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.18",
]
[[package]] [[package]]
name = "toml_datetime" name = "toml_datetime"
version = "0.6.2" version = "0.6.2"

View File

@ -1,5 +1,5 @@
[package] [package]
name = "kanban-tui" name = "kanban_tui"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
@ -9,10 +9,7 @@ edition = "2021"
tui = { package = "ratatui", version = "0.20.1" } tui = { package = "ratatui", version = "0.20.1" }
tui-textarea = { version = "0.2.0", git = "https://github.com/rhysd/tui-textarea.git", features = ["ratatui-crossterm"], default-features=false } tui-textarea = { version = "0.2.0", git = "https://github.com/rhysd/tui-textarea.git", features = ["ratatui-crossterm"], default-features=false }
crossterm = "0.26.1" crossterm = "0.26.1"
serde = { version = "1.0.148" , features = [ "derive" ] }
serde_json = "1.0.89"
int-enum = "0.5.0" int-enum = "0.5.0"
thiserror = "1"
anyhow = "1" anyhow = "1"
clap = { version = "4.3.2" , features = [ "derive" ] } clap = { version = "4.3.2" , features = [ "derive" ] }
rusqlite = { version = "0.29", features = [ "bundled" ] } rusqlite = { version = "0.29", features = [ "bundled" ] }

View File

@ -1,42 +1,65 @@
use anyhow::Error; use anyhow::Error;
use int_enum::IntEnum; use int_enum::IntEnum;
use rusqlite::Connection; use rusqlite::Connection;
use serde::{Deserialize, Serialize};
use std::cmp::min; use std::cmp::min;
use tui_textarea::TextArea; use tui_textarea::TextArea;
use crate::db; use crate::db;
#[derive(Debug, Serialize, Deserialize)] /// Represents a kanban column containing the tasks and other metadata.
#[derive(Debug)]
pub struct Column { pub struct Column {
/// Id provided by the database
pub id: i64, pub id: i64,
/// The name used for the title in the UI
pub name: String, pub name: String,
/// The currently selected [`Task`], which keeps track of the
/// user's position in a column when the go from one to another
pub selected_task_idx: usize, pub selected_task_idx: usize,
/// The collection of [`Task`]
pub tasks: Vec<Task>, pub tasks: Vec<Task>,
} }
#[derive(Clone, Default, Deserialize, Serialize, Debug)] /// Basic TODO task with a title and a description.
#[derive(Clone, Default, Debug)]
pub struct Task { pub struct Task {
/// Id provided by the database
pub id: i64, pub id: i64,
/// Title of the [`Task`]
pub title: String, pub title: String,
/// Description of the [`Task`]
pub description: String, pub description: String,
} }
/// The number of TaskEditFocus variants, used so we can "wrap around"
/// with modulo when cycling through tasks with Tab/Backtab.
pub const EDIT_WINDOW_FOCUS_STATES: i8 = 4; pub const EDIT_WINDOW_FOCUS_STATES: i8 = 4;
/// Used to track the focus of the form field in the task edit window.
#[repr(i8)] #[repr(i8)]
#[derive(Debug, IntEnum, Copy, Clone)] #[derive(Debug, IntEnum, Copy, Clone)]
pub enum TaskEditFocus { pub enum TaskEditFocus {
/// Title text input line
Title = 0, Title = 0,
/// Description text input box
Description = 1, Description = 1,
/// Confirm changes button
ConfirmBtn = 2, ConfirmBtn = 2,
/// Cancel changes button
CancelBtn = 3, CancelBtn = 3,
} }
/// Represents the transient state of a task while it is being editing
/// by the user through the UI.
pub struct TaskState<'a> { pub struct TaskState<'a> {
/// The title of the Task
pub title: TextArea<'a>, pub title: TextArea<'a>,
/// The description of the Task
pub description: TextArea<'a>, pub description: TextArea<'a>,
/// Where the current focus of the task edit form is
pub focus: TaskEditFocus, pub focus: TaskEditFocus,
/// Used to decide if the user is editing an existing task or
/// creating a new one
pub is_edit: bool, pub is_edit: bool,
} }
@ -51,12 +74,21 @@ impl Default for TaskState<'_> {
} }
} }
/// Holds the application's state, including all columns and the
/// database connection.
pub struct State<'a> { pub struct State<'a> {
/// The name of the project, currently derived from the name of
/// the current working directory
pub project_name: String, pub project_name: String,
/// The index of the currently selected [`Column`]
pub selected_column_idx: usize, pub selected_column_idx: usize,
/// A vec of all the [`Column`]s
pub columns: Vec<Column>, pub columns: Vec<Column>,
/// The [`db::DBConn`] wrapping a [`rusqlite::Connection`]
pub db_conn: db::DBConn, pub db_conn: db::DBConn,
/// Flag to check on each loop whether we should exit the app
pub quit: bool, pub quit: bool,
/// If [`Some(TaskState)`] then we are in the task edit form window
pub task_edit_state: Option<TaskState<'a>>, pub task_edit_state: Option<TaskState<'a>>,
} }
@ -87,33 +119,44 @@ impl<'a> State<'a> {
}) })
} }
/// Returns a reference to the currently selected [`Column`].
#[must_use] #[must_use]
pub fn get_selected_column(&self) -> &Column { pub fn get_selected_column(&self) -> &Column {
&self.columns[self.selected_column_idx] &self.columns[self.selected_column_idx]
} }
/// Returns a mutable reference to the currently selected
/// [`Column`].
pub fn get_selected_column_mut(&mut self) -> &mut Column { pub fn get_selected_column_mut(&mut self) -> &mut Column {
&mut self.columns[self.selected_column_idx] &mut self.columns[self.selected_column_idx]
} }
pub fn select_previous_column(&mut self) -> Result<(), Error> { /// Selects the [`Column`] on the left. Does nothing if on the
/// first column.
pub fn select_column_left(&mut self) -> Result<(), Error> {
self.selected_column_idx = self.selected_column_idx.saturating_sub(1); self.selected_column_idx = self.selected_column_idx.saturating_sub(1);
self.db_conn.set_selected_column(self.selected_column_idx) self.db_conn.set_selected_column(self.selected_column_idx)
} }
pub fn select_next_column(&mut self) -> Result<(), Error> { /// Selects the [`Column`] on the right. Does nothing if on the
/// last column.
pub fn select_column_right(&mut self) -> Result<(), Error> {
self.selected_column_idx = min(self.selected_column_idx + 1, self.columns.len() - 1); self.selected_column_idx = min(self.selected_column_idx + 1, self.columns.len() - 1);
self.db_conn.set_selected_column(self.selected_column_idx) self.db_conn.set_selected_column(self.selected_column_idx)
} }
/// Returns a reference to the currently selected [`Task`].
/// Returns `None` if the current [`Column::tasks`] is empty.
#[must_use] #[must_use]
pub fn get_selected_task(&self) -> Option<&Task> { pub fn get_selected_task(&self) -> Option<&Task> {
let column = self.get_selected_column(); let column = self.get_selected_column();
column.tasks.get(column.selected_task_idx) column.tasks.get(column.selected_task_idx)
} }
/// Returns a reference to the [`Task`] above the current one.
/// Returns `None` if it's the first task on the list
#[must_use] #[must_use]
pub fn get_previous_task(&self) -> Option<&Task> { pub fn get_task_above(&self) -> Option<&Task> {
let column = self.get_selected_column(); let column = self.get_selected_column();
if column.selected_task_idx > 0 { if column.selected_task_idx > 0 {
column.tasks.get(column.selected_task_idx - 1) column.tasks.get(column.selected_task_idx - 1)
@ -122,18 +165,25 @@ impl<'a> State<'a> {
} }
} }
/// Returns a reference to the [`Task`] below the current one.
/// Returns `None` if it's the last task on the list
#[must_use] #[must_use]
pub fn get_next_task(&self) -> Option<&Task> { pub fn get_task_below(&self) -> Option<&Task> {
let column = self.get_selected_column(); let column = self.get_selected_column();
column.tasks.get(column.selected_task_idx + 1) column.tasks.get(column.selected_task_idx + 1)
} }
/// Returns a mutable reference to the currently selected
/// [`Task`]. Returns `None` if the current [`Column::tasks`] is
/// empty.
pub fn get_selected_task_mut(&mut self) -> Option<&mut Task> { pub fn get_selected_task_mut(&mut self) -> Option<&mut Task> {
let column = self.get_selected_column_mut(); let column = self.get_selected_column_mut();
column.tasks.get_mut(column.selected_task_idx) column.tasks.get_mut(column.selected_task_idx)
} }
pub fn select_previous_task(&mut self) -> Result<(), Error> { /// Selects the [`Task`] above the current one. Does nothing if
/// it's the first task on the list
pub fn select_task_above(&mut self) -> Result<(), Error> {
let column = self.get_selected_column_mut(); let column = self.get_selected_column_mut();
column.selected_task_idx = column.selected_task_idx.saturating_sub(1); column.selected_task_idx = column.selected_task_idx.saturating_sub(1);
@ -144,7 +194,9 @@ impl<'a> State<'a> {
Ok(()) Ok(())
} }
pub fn select_next_task(&mut self) -> Result<(), Error> { /// Selects the [`Task`] below the current one. Does nothing if
/// it's the last task on the list
pub fn select_task_below(&mut self) -> Result<(), Error> {
let column = self.get_selected_column_mut(); let column = self.get_selected_column_mut();
column.selected_task_idx = min( column.selected_task_idx = min(
column.selected_task_idx + 1, column.selected_task_idx + 1,
@ -158,6 +210,8 @@ impl<'a> State<'a> {
Ok(()) Ok(())
} }
/// Selects the [`Task`] at the beginning of the list, no matter
/// where you are in the current [`Column`].
pub fn select_first_task(&mut self) -> Result<(), Error> { pub fn select_first_task(&mut self) -> Result<(), Error> {
let column = self.get_selected_column_mut(); let column = self.get_selected_column_mut();
column.selected_task_idx = 0; column.selected_task_idx = 0;
@ -169,6 +223,8 @@ impl<'a> State<'a> {
Ok(()) Ok(())
} }
/// Selects the [`Task`] at the end of the list, no matter
/// where you are in the current [`Column`].
pub fn select_last_task(&mut self) -> Result<(), Error> { pub fn select_last_task(&mut self) -> Result<(), Error> {
let column = self.get_selected_column_mut(); let column = self.get_selected_column_mut();
column.selected_task_idx = column.tasks.len().saturating_sub(1); column.selected_task_idx = column.tasks.len().saturating_sub(1);
@ -180,6 +236,9 @@ impl<'a> State<'a> {
Ok(()) Ok(())
} }
/// Helper method to construct a [`TaskState`]. Used when we are
/// going to edit an existing [`Task`]. Returns `None` if the
/// [`Column`] is empty.
#[must_use] #[must_use]
pub fn get_task_state_from_current(&self) -> Option<TaskState<'a>> { pub fn get_task_state_from_current(&self) -> Option<TaskState<'a>> {
self.get_selected_task().map(|t| TaskState { self.get_selected_task().map(|t| TaskState {
@ -190,20 +249,25 @@ impl<'a> State<'a> {
}) })
} }
/// Moves the current [`Task`] up the list towards the top. Does
/// nothing if it's the first task.
pub fn move_task_up(&mut self) -> Result<(), Error> { pub fn move_task_up(&mut self) -> Result<(), Error> {
self.move_task(false) self.move_task(false)
} }
/// Moves the current [`Task`] down the list towards the bottom. Does
/// nothing if it's the last task.
pub fn move_task_down(&mut self) -> Result<(), Error> { pub fn move_task_down(&mut self) -> Result<(), Error> {
self.move_task(true) self.move_task(true)
} }
/// Returns the move task down of this [`State`]. /// Private function to handle saving the current [`Task`]'s
pub fn move_task(&mut self, is_down: bool) -> Result<(), Error> { /// state.
fn move_task(&mut self, is_down: bool) -> Result<(), Error> {
let other_task = if is_down { let other_task = if is_down {
self.get_next_task() self.get_task_below()
} else { } else {
self.get_previous_task() self.get_task_above()
}; };
if let (Some(task1), Some(task2)) = (self.get_selected_task(), other_task) { if let (Some(task1), Some(task2)) = (self.get_selected_task(), other_task) {
let t1_id = task1.id; let t1_id = task1.id;
@ -228,11 +292,15 @@ impl<'a> State<'a> {
Ok(()) Ok(())
} }
pub fn move_task_previous_column(&mut self) -> Result<(), Error> { /// Moves the current [`Task`] to the [`Column`] on the left. Does
/// nothing if it's the first column.
pub fn move_task_column_left(&mut self) -> Result<(), Error> {
self.move_task_to_column(false) self.move_task_to_column(false)
} }
pub fn move_task_next_column(&mut self) -> Result<(), Error> { /// Moves the current [`Task`] to the [`Column`] on the right. Does
/// nothing if it's the last column.
pub fn move_task_column_right(&mut self) -> Result<(), Error> {
self.move_task_to_column(true) self.move_task_to_column(true)
} }
@ -249,13 +317,13 @@ impl<'a> State<'a> {
// Only move it if it was the last task // Only move it if it was the last task
if first_col.selected_task_idx == first_col.tasks.len() { if first_col.selected_task_idx == first_col.tasks.len() {
self.select_previous_task()?; self.select_task_above()?;
} }
if move_right { if move_right {
self.select_next_column()?; self.select_column_right()?;
} else { } else {
self.select_previous_column()?; self.select_column_left()?;
} }
let col = self.get_selected_column_mut(); let col = self.get_selected_column_mut();
@ -269,6 +337,8 @@ impl<'a> State<'a> {
Ok(()) Ok(())
} }
/// Inserts a new [`Task`] into [`Column::tasks`] at the bottom of
/// the list and saves the state to the DB.
pub fn add_new_task(&mut self, title: String, description: String) -> Result<(), Error> { pub fn add_new_task(&mut self, title: String, description: String) -> Result<(), Error> {
let col_id = self.get_selected_column().id; let col_id = self.get_selected_column().id;
let task = self.db_conn.create_new_task(title, description, col_id)?; let task = self.db_conn.create_new_task(title, description, col_id)?;
@ -283,6 +353,8 @@ impl<'a> State<'a> {
Ok(()) Ok(())
} }
/// Edits the selected [`Task`] changing only it's title and/or
/// description. Does nothing if the [`Column`] is empty.
pub fn edit_task(&mut self, title: String, description: String) -> Result<(), Error> { pub fn edit_task(&mut self, title: String, description: String) -> Result<(), Error> {
if let Some(selected_task) = self.get_selected_task_mut() { if let Some(selected_task) = self.get_selected_task_mut() {
selected_task.title = title; selected_task.title = title;
@ -294,7 +366,8 @@ impl<'a> State<'a> {
Ok(()) Ok(())
} }
/// Delete the currently selected task from the selected column /// Deletes the selected [`Task`] from the list. Does nothing if
/// the [`Column`] is empty.
pub fn delete_task(&mut self) -> Result<(), Error> { pub fn delete_task(&mut self) -> Result<(), Error> {
if let Some(task) = self.get_selected_task() { if let Some(task) = self.get_selected_task() {
let task_id = task.id; let task_id = task.id;
@ -305,7 +378,7 @@ impl<'a> State<'a> {
column.tasks.remove(task_idx); column.tasks.remove(task_idx);
if column.selected_task_idx >= column.tasks.len() { if column.selected_task_idx >= column.tasks.len() {
self.select_previous_task()?; self.select_task_above()?;
task_idx = task_idx.saturating_sub(1); task_idx = task_idx.saturating_sub(1);
} }

View File

@ -3,7 +3,13 @@ use anyhow::Error;
use rusqlite::{params, Connection, Result}; use rusqlite::{params, Connection, Result};
use std::ops::{Deref, DerefMut}; use std::ops::{Deref, DerefMut};
pub struct DBConn(pub Connection); /// Simple one field struct to wrap a [`rusqlite::Connection`] so we
/// can assign our own methods.
pub struct DBConn(
/// Unfortunately remains public for now so we can do integration tests
/// and reuse to simulate exiting and relaunching the app.
pub Connection
);
impl DerefMut for DBConn { impl DerefMut for DBConn {
fn deref_mut(&mut self) -> &mut Self::Target { fn deref_mut(&mut self) -> &mut Self::Target {
@ -24,22 +30,21 @@ impl DBConn {
DBConn(conn) DBConn(conn)
} }
/// . /// Query tasks in a [`Column`] by using the column's [`Column::id`].
/// ///
/// # Errors /// # Errors
/// ///
/// This function will return an error if something is wrong with the SQL /// Returns an error if something is wrong with the SQL.
pub fn get_tasks_by_column(&self, column_name: &String) -> Result<Vec<Task>> { pub fn get_tasks_by_column(&self, column_id: i64) -> Result<Vec<Task>> {
let mut stmt = self.prepare( let mut stmt = self.prepare(
r#" r#"
select task.id, title, description from task select task.id, title, description from task
join kb_column on column_id = kb_column.id where column_id = ?1
where kb_column.name = ?1
order by sort_order order by sort_order
"#, "#,
)?; )?;
let mut tasks = Vec::new(); let mut tasks = Vec::new();
let rows = stmt.query_map([column_name], |row| { let rows = stmt.query_map([column_id], |row| {
Ok(Task { Ok(Task {
id: row.get(0)?, id: row.get(0)?,
title: row.get(1)?, title: row.get(1)?,
@ -52,20 +57,22 @@ impl DBConn {
Ok(tasks) Ok(tasks)
} }
/// . /// Uses [get_tasks_by_column][`DBConn::get_tasks_by_column`] over
/// a loop to get all [`Column`] populated with the vec of.
/// [`Task`]
/// ///
/// # Errors /// # Errors
/// ///
/// This function will return an error if there are issues with the SQL /// Returns an error if something is wrong with the SQL.
pub fn get_all_columns(&self) -> Result<Vec<Column>> { pub fn get_all_columns(&self) -> Result<Vec<Column>> {
let mut stmt = self.prepare("select id, name, selected_task from kb_column")?; let mut stmt = self.prepare("select id, name, selected_task from kb_column")?;
let columns = stmt let columns = stmt
.query_map((), |row| { .query_map((), |row| {
let name = row.get(1)?; let id = row.get(0)?;
Ok(Column { Ok(Column {
id: row.get(0)?, id,
tasks: self.get_tasks_by_column(&name)?, tasks: self.get_tasks_by_column(id)?,
name, name: row.get(1)?,
selected_task_idx: row.get(2)?, selected_task_idx: row.get(2)?,
}) })
})? })?
@ -74,11 +81,12 @@ impl DBConn {
Ok(columns) Ok(columns)
} }
/// . /// Insert a new task into the DB given a title and description,
/// then return the [`Task`] with the ID provided by the DB.
/// ///
/// # Panics /// # Errors
/// ///
/// Panics if something goes wrong with the SQL /// Returns an error if something is wrong with the SQL.
pub fn create_new_task( pub fn create_new_task(
&self, &self,
title: String, title: String,
@ -102,22 +110,22 @@ impl DBConn {
}) })
} }
/// . /// Deletes a [`Task`] given it's ID.
/// ///
/// # Panics /// # Errors
/// ///
/// Panics if something goes wrong with the SQL /// Returns an error if something is wrong with the SQL.
pub fn delete_task(&self, task_id: i64) -> Result<()> { pub fn delete_task(&self, task_id: i64) -> Result<()> {
let mut stmt = self.prepare("delete from task where id = ?1")?; let mut stmt = self.prepare("delete from task where id = ?1")?;
stmt.execute([task_id])?; stmt.execute([task_id])?;
Ok(()) Ok(())
} }
/// . /// Updates an existing [`Task`]'s `title` and `description`.
/// ///
/// # Panics /// # Errors
/// ///
/// Panics if something goes wrong with the SQL /// Returns an error if something is wrong with the SQL.
pub fn update_task_text(&self, task: &Task) -> Result<()> { pub fn update_task_text(&self, task: &Task) -> Result<()> {
let mut stmt = let mut stmt =
self.prepare("update task set title = ?2, description = ?3 where id = ?1")?; self.prepare("update task set title = ?2, description = ?3 where id = ?1")?;
@ -125,11 +133,11 @@ impl DBConn {
Ok(()) Ok(())
} }
/// . /// Moves a [`Task`] to the target [`Column`] and updates the sorting order.
/// ///
/// # Panics /// # Errors
/// ///
/// Panics if something goes wrong with the SQL /// Returns an error if something is wrong with the SQL.
pub fn move_task_to_column(&self, task: &Task, target_column: &Column) -> Result<()> { pub fn move_task_to_column(&self, task: &Task, target_column: &Column) -> Result<()> {
let mut stmt = self let mut stmt = self
.prepare( .prepare(
@ -148,7 +156,7 @@ impl DBConn {
} }
/// This is a helper function in case we need to debug sort_order, because I ran into /// This is a helper function in case we need to debug sort_order, because I ran into
/// a bug when I forgot to insert the sort_order when creating a task /// a bug when I forgot to insert the sort_order when creating a task.
#[allow(dead_code)] #[allow(dead_code)]
fn get_sort_order(&self) -> Result<Vec<(i32, String, usize)>> { fn get_sort_order(&self) -> Result<Vec<(i32, String, usize)>> {
let mut stmt = self.prepare( let mut stmt = self.prepare(
@ -162,11 +170,14 @@ impl DBConn {
Ok(tasks) Ok(tasks)
} }
/// . /// The order of a [`Task`] in a [`Column`] needs to be saved to
/// the DB because SQLite doesn't have a way to handle the
/// ordering the internal [`Vec<Task>`] has. This takes the
/// current sorting order of two tasks and swaps them.
/// ///
/// # Panics /// # Errors
/// ///
/// Panics if something goes wrong with the SQL /// Returns an error if something is wrong with the SQL.
pub fn swap_task_order(&mut self, task1_id: i64, task2_id: i64) -> Result<()> { pub fn swap_task_order(&mut self, task1_id: i64, task2_id: i64) -> Result<()> {
let tx = self.transaction()?; let tx = self.transaction()?;
@ -194,9 +205,13 @@ impl DBConn {
Ok(()) Ok(())
} }
/// # Panics /// Saves the currently selected column's index to `app_state` so
/// when the user reloads the project, they start on the
/// [`Column`] they were last on.
/// ///
/// Panics if something goes wrong with the SQL /// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn set_selected_column(&self, column_id: usize) -> Result<(), Error> { pub fn set_selected_column(&self, column_id: usize) -> Result<(), Error> {
let mut stmt = let mut stmt =
self.prepare("insert or replace into app_state(key, value) values (?1, ?2)")?; self.prepare("insert or replace into app_state(key, value) values (?1, ?2)")?;
@ -204,9 +219,11 @@ impl DBConn {
Ok(()) Ok(())
} }
/// # Panics /// Get's the user's last selected [`Column`] before exiting.
/// ///
/// Panics if something goes wrong with the SQL /// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_selected_column(&self) -> Result<usize> { pub fn get_selected_column(&self) -> Result<usize> {
let mut stmt = self.prepare("select value from app_state where key = ?1")?; let mut stmt = self.prepare("select value from app_state where key = ?1")?;
stmt.query_row(["selected_column"], |row| { stmt.query_row(["selected_column"], |row| {
@ -216,18 +233,26 @@ impl DBConn {
}) })
} }
/// # Panics /// Saves the index currently selected [`Task`] in a [`Column`] so
/// when the user reloads the project, each column selects the has
/// the last selected task before switching to another column or
/// exiting the app.
/// ///
/// Panics if something goes wrong with the SQL /// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn set_selected_task_for_column(&self, task_idx: usize, column_id: i64) -> Result<()> { pub fn set_selected_task_for_column(&self, task_idx: usize, column_id: i64) -> Result<()> {
let mut stmt = self.prepare("update kb_column set selected_task = ?2 where id = ?1")?; let mut stmt = self.prepare("update kb_column set selected_task = ?2 where id = ?1")?;
stmt.execute((column_id, task_idx))?; stmt.execute((column_id, task_idx))?;
Ok(()) Ok(())
} }
/// # Panics /// Get's each [`Column`]'s 's last selected [`Task`] before
/// switching or exiting.
/// ///
/// Panics if something goes wrong with the SQL /// # Errors
///
/// Returns an error if something is wrong with the SQL.
pub fn get_selected_task_for_column(&self, column_id: i32) -> Result<usize> { pub fn get_selected_task_for_column(&self, column_id: i32) -> Result<usize> {
let mut stmt = self.prepare("select selected_task from kb_column where key = ?1")?; let mut stmt = self.prepare("select selected_task from kb_column where key = ?1")?;
stmt.query_row([column_id], |row| row.get(0)) stmt.query_row([column_id], |row| row.get(0))

View File

@ -5,12 +5,11 @@ use crossterm::event::{Event, KeyCode};
use int_enum::IntEnum; use int_enum::IntEnum;
pub fn cycle_focus(task: &mut TaskState<'_>, forward: bool) -> Result<(), Error> { pub fn cycle_focus(task: &mut TaskState<'_>, forward: bool) -> Result<(), Error> {
let cycle; let cycle = if forward {
if forward { (task.focus.int_value() + 1) % EDIT_WINDOW_FOCUS_STATES
cycle = (task.focus.int_value() + 1) % EDIT_WINDOW_FOCUS_STATES;
} else { } else {
cycle = (task.focus.int_value() - 1) % EDIT_WINDOW_FOCUS_STATES; (task.focus.int_value() - 1) % EDIT_WINDOW_FOCUS_STATES
} };
task.focus = TaskEditFocus::from_int(cycle)?; task.focus = TaskEditFocus::from_int(cycle)?;
Ok(()) Ok(())
} }
@ -66,14 +65,14 @@ pub fn handle_task_edit(state: &mut State<'_>, key: event::KeyEvent) -> Result<(
pub fn handle_main(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Error> { pub fn handle_main(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Error> {
match key.code { match key.code {
KeyCode::Char('q') => Ok(state.quit = true), KeyCode::Char('q') => Ok(state.quit = true),
KeyCode::Char('h') | KeyCode::Left => state.select_previous_column(), KeyCode::Char('h') | KeyCode::Left => state.select_column_left(),
KeyCode::Char('j') | KeyCode::Down => state.select_next_task(), KeyCode::Char('j') | KeyCode::Down => state.select_task_below(),
KeyCode::Char('k') | KeyCode::Up => state.select_previous_task(), KeyCode::Char('k') | KeyCode::Up => state.select_task_above(),
KeyCode::Char('l') | KeyCode::Right => state.select_next_column(), KeyCode::Char('l') | KeyCode::Right => state.select_column_right(),
KeyCode::Char('g') => state.select_first_task(), KeyCode::Char('g') => state.select_first_task(),
KeyCode::Char('G') => state.select_last_task(), KeyCode::Char('G') => state.select_last_task(),
KeyCode::Char('H') => state.move_task_previous_column(), KeyCode::Char('H') => state.move_task_column_left(),
KeyCode::Char('L') => state.move_task_next_column(), KeyCode::Char('L') => state.move_task_column_right(),
KeyCode::Char('J') => state.move_task_down(), KeyCode::Char('J') => state.move_task_down(),
KeyCode::Char('K') => state.move_task_up(), KeyCode::Char('K') => state.move_task_up(),
KeyCode::Char('n') => Ok(state.task_edit_state = Some(TaskState::default())), KeyCode::Char('n') => Ok(state.task_edit_state = Some(TaskState::default())),
@ -83,10 +82,15 @@ pub fn handle_main(state: &mut State<'_>, key: event::KeyEvent) -> Result<(), Er
} }
} }
/// Takes the app's [`State`] and uses [`event::read`] to get the current keypress.
///
/// # Errors /// # Errors
/// ///
/// Crossterm `event::read()` might return an error /// Most of the applications errors will be bubbled up to this layer,
pub fn handle(state: &mut State<'_>) -> Result<(), Error> { /// including all database related ones
///
/// Crossterm `event::read()` might return an error,
pub fn handle_user_keypress(state: &mut State<'_>) -> Result<(), Error> {
if let Event::Key(key) = event::read()? { if let Event::Key(key) = event::read()? {
if state.task_edit_state.is_some() { if state.task_edit_state.is_some() {
handle_task_edit(state, key)?; handle_task_edit(state, key)?;

View File

@ -1,3 +1,17 @@
//! Manage your TODOs with kanban columns right from the terminal.
//!
//! kanban-tui is a TUI based application using [`ratatui`] for
//! rendering the UI and capturing user input interaction, with
//! [`Crossterm`] as the lower-level backend system for terminal
//! text-based interfaces. The data is saved to a SQLite database
//! ideally placed in the root of your project. For this the
//! [`rusqlite`] crate provides the bindings to handle all the data
//! persistence.
//!
//! [`ratatui`]: https://crates.io/crates/ratatui
//! [`Crossterm`]: https://crates.io/crates/crossterm
//! [`rusqlite`]: https://crates.io/crates/rusqlite
#![deny(rust_2018_idioms)] #![deny(rust_2018_idioms)]
mod app; mod app;
mod db; mod db;
@ -6,5 +20,5 @@ mod ui;
pub use app::*; pub use app::*;
pub use db::*; pub use db::*;
pub use input::handle; pub use input::handle_user_keypress;
pub use ui::draw; pub use ui::draw_ui_from_state;

View File

@ -49,8 +49,8 @@ fn main() -> anyhow::Result<(), Box<dyn Error>> {
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
while !state.quit { while !state.quit {
terminal.draw(|f| kanban_tui::draw(f, &mut state))?; terminal.draw(|f| kanban_tui::draw_ui_from_state(f, &mut state))?;
kanban_tui::handle(&mut state)?; kanban_tui::handle_user_keypress(&mut state)?;
} }
// restore terminal // restore terminal

View File

@ -216,14 +216,16 @@ fn draw_project_stats<B: Backend>(f: &mut Frame<'_, B>, area: Rect, state: &mut
f.render_widget(list, area); f.render_widget(list, area);
} }
/// Macro to generate keybindings string at compile time /// Macro to generate the app's keybindings string at compile time
macro_rules! unroll { macro_rules! unroll {
(($first_a:literal, $first_b:literal), $(($a:literal, $b:literal)),*) => { (($first_a:literal, $first_b:literal), $(($a:literal, $b:literal)),*) => {
concat!(concat!($first_a, ": ", $first_b) $(," | ", concat!($a, ": ", $b))*) concat!(concat!($first_a, ": ", $first_b) $(," | ", concat!($a, ": ", $b))*)
}; };
} }
pub fn draw<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>) { /// Takes the app's [`State`] so [ratatui][`tui`] can render it to the
/// terminal screen
pub fn draw_ui_from_state<B: Backend>(f: &mut Frame<'_, B>, state: &mut State<'_>) {
let main_layout = Layout::default() let main_layout = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints( .constraints(

View File

@ -25,10 +25,10 @@ mod app_tests {
state.add_new_task(String::from("T1"), String::from("D1"))?; state.add_new_task(String::from("T1"), String::from("D1"))?;
state.add_new_task(String::from("T2"), String::from("D2"))?; state.add_new_task(String::from("T2"), String::from("D2"))?;
state.select_next_column()?; state.select_column_right()?;
state.select_next_column()?; state.select_column_right()?;
state.add_new_task(String::from("T3"), String::from("D3"))?; state.add_new_task(String::from("T3"), String::from("D3"))?;
state.select_previous_column()?; state.select_column_left()?;
state.add_new_task(String::from("T4"), String::from("D4"))?; state.add_new_task(String::from("T4"), String::from("D4"))?;
assert_eq!(state.columns[0].tasks.len(), 2); assert_eq!(state.columns[0].tasks.len(), 2);
@ -63,33 +63,33 @@ mod app_tests {
state.move_task_down()?; state.move_task_down()?;
state.move_task_down()?; state.move_task_down()?;
for _ in 0..10 { for _ in 0..10 {
state.select_next_column()?; state.select_column_right()?;
} }
for _ in 0..10 { for _ in 0..10 {
state.select_previous_column()?; state.select_column_left()?;
} }
state.add_new_task(String::from("T1"), String::from("D1"))?; state.add_new_task(String::from("T1"), String::from("D1"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T1"); assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.add_new_task(String::from("T2"), String::from("D2"))?; state.add_new_task(String::from("T2"), String::from("D2"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_previous_task()?; state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1"); assert_eq!(state.get_selected_task().unwrap().title, "T1");
for _ in 0..6 { for _ in 0..6 {
state.select_next_column()?; state.select_column_right()?;
} }
state.select_previous_column()?; state.select_column_left()?;
state.add_new_task(String::from("T3"), String::from("D3"))?; state.add_new_task(String::from("T3"), String::from("D3"))?;
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
assert_eq!(state.get_selected_column().name, "Done"); assert_eq!(state.get_selected_column().name, "Done");
for _ in 0..6 { for _ in 0..6 {
state.select_next_column()?; state.select_column_right()?;
} }
for _ in 0..4 { for _ in 0..4 {
state.select_previous_column()?; state.select_column_left()?;
} }
assert_eq!(state.get_selected_task().unwrap().title, "T1"); assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_next_column()?; state.select_column_right()?;
state.select_next_column()?; state.select_column_right()?;
// Reload the data from the database then rerun the asserts to // Reload the data from the database then rerun the asserts to
@ -97,13 +97,13 @@ mod app_tests {
let mut state = State::new(state.db_conn.0)?; let mut state = State::new(state.db_conn.0)?;
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_next_task()?; state.select_task_below()?;
state.select_next_task()?; state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_previous_column()?; state.select_column_left()?;
state.select_previous_column()?; state.select_column_left()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1"); assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_next_task()?; state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
Ok(()) Ok(())
@ -128,8 +128,8 @@ mod app_tests {
state.select_first_task()?; state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_last_task()?; state.select_last_task()?;
state.select_previous_task()?; state.select_task_above()?;
state.select_previous_task()?; state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T9"); assert_eq!(state.get_selected_task().unwrap().title, "T9");
for _ in 0..10 { for _ in 0..10 {
state.move_task_up()?; state.move_task_up()?;
@ -160,24 +160,24 @@ mod app_tests {
state.add_new_task(String::from("T1"), String::from("D1"))?; state.add_new_task(String::from("T1"), String::from("D1"))?;
state.add_new_task(String::from("T2"), String::from("D2"))?; state.add_new_task(String::from("T2"), String::from("D2"))?;
state.select_previous_task()?; state.select_task_above()?;
state.move_task_up()?; state.move_task_up()?;
state.move_task_down()?; state.move_task_down()?;
state.move_task_down()?; state.move_task_down()?;
assert_eq!(&state.columns[0].tasks[1].title, "T1"); assert_eq!(&state.columns[0].tasks[1].title, "T1");
assert_eq!(&state.columns[0].tasks[0].title, "T2"); assert_eq!(&state.columns[0].tasks[0].title, "T2");
state.select_next_column()?; state.select_column_right()?;
state.add_new_task(String::from("T3"), String::from("D3"))?; state.add_new_task(String::from("T3"), String::from("D3"))?;
state.move_task_next_column()?; state.move_task_column_right()?;
assert_eq!(state.columns[1].tasks.len(), 0); assert_eq!(state.columns[1].tasks.len(), 0);
assert_eq!(state.columns[2].tasks.len(), 1); assert_eq!(state.columns[2].tasks.len(), 1);
for _ in 0..5 { for _ in 0..5 {
state.move_task_next_column()?; state.move_task_column_right()?;
} }
for _ in 0..4 { for _ in 0..4 {
state.move_task_previous_column()?; state.move_task_column_left()?;
} }
assert_eq!(state.columns[0].tasks.len(), 3); assert_eq!(state.columns[0].tasks.len(), 3);
@ -185,25 +185,25 @@ mod app_tests {
assert_eq!(state.columns[2].tasks.len(), 0); assert_eq!(state.columns[2].tasks.len(), 0);
assert_eq!(state.columns[3].tasks.len(), 0); assert_eq!(state.columns[3].tasks.len(), 0);
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_next_task()?; state.select_task_below()?;
state.select_previous_task()?; state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1"); assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_previous_task()?; state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_first_task()?; state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.select_last_task()?; state.select_last_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_previous_task()?; state.select_task_above()?;
// Reload the data from the database then rerun the asserts to // Reload the data from the database then rerun the asserts to
// make sure everything was saved correctly // make sure everything was saved correctly
let mut state = State::new(state.db_conn.0)?; let mut state = State::new(state.db_conn.0)?;
assert_eq!(state.get_selected_task().unwrap().title, "T1"); assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_next_task()?; state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_next_task()?; state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
state.select_first_task()?; state.select_first_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
@ -231,7 +231,7 @@ mod app_tests {
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
assert_eq!(state.get_selected_task().unwrap().description, "D1"); assert_eq!(state.get_selected_task().unwrap().description, "D1");
for _ in 0..4 { for _ in 0..4 {
state.move_task_next_column()?; state.move_task_column_right()?;
} }
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
assert_eq!(state.get_selected_task().unwrap().description, "D1"); assert_eq!(state.get_selected_task().unwrap().description, "D1");
@ -239,7 +239,7 @@ mod app_tests {
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
assert_eq!(state.get_selected_task().unwrap().description, "D3"); assert_eq!(state.get_selected_task().unwrap().description, "D3");
for _ in 0..4 { for _ in 0..4 {
state.move_task_previous_column()?; state.move_task_column_left()?;
} }
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
assert_eq!(state.get_selected_task().unwrap().description, "D3"); assert_eq!(state.get_selected_task().unwrap().description, "D3");
@ -283,10 +283,10 @@ mod app_tests {
state.delete_task()?; state.delete_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.add_new_task(String::from("T3"), String::from("D3"))?; state.add_new_task(String::from("T3"), String::from("D3"))?;
state.select_previous_task()?; state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T2"); assert_eq!(state.get_selected_task().unwrap().title, "T2");
state.delete_task()?; state.delete_task()?;
state.select_previous_task()?; state.select_task_above()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1"); assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_last_task()?; state.select_last_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
@ -296,7 +296,7 @@ mod app_tests {
} }
state.delete_task()?; state.delete_task()?;
assert_eq!(state.get_selected_task().unwrap().title, "T1"); assert_eq!(state.get_selected_task().unwrap().title, "T1");
state.select_next_task()?; state.select_task_below()?;
assert_eq!(state.get_selected_task().unwrap().title, "T3"); assert_eq!(state.get_selected_task().unwrap().title, "T3");
for _ in 0..4 { for _ in 0..4 {
state.delete_task()?; state.delete_task()?;