Add docs to everything public. Rename some methods.Remove unused deps
This commit is contained in:
parent
9a779b1abb
commit
4beda4fae7
68
Cargo.lock
generated
68
Cargo.lock
generated
@ -301,13 +301,7 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "453ad9f582a441959e5f0d088b02ce04cfe8d51a8eaf077f12ac6d3e94164ca6"
|
||||
|
||||
[[package]]
|
||||
name = "kanban-tui"
|
||||
name = "kanban_tui"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
@ -316,9 +310,6 @@ dependencies = [
|
||||
"int-enum",
|
||||
"ratatui",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror",
|
||||
"tui-textarea",
|
||||
]
|
||||
|
||||
@ -492,49 +483,12 @@ dependencies = [
|
||||
"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]]
|
||||
name = "scopeguard"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "signal-hook"
|
||||
version = "0.3.15"
|
||||
@ -599,26 +553,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.2"
|
||||
|
@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "kanban-tui"
|
||||
name = "kanban_tui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
@ -9,10 +9,7 @@ edition = "2021"
|
||||
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 }
|
||||
crossterm = "0.26.1"
|
||||
serde = { version = "1.0.148" , features = [ "derive" ] }
|
||||
serde_json = "1.0.89"
|
||||
int-enum = "0.5.0"
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
clap = { version = "4.3.2" , features = [ "derive" ] }
|
||||
rusqlite = { version = "0.29", features = [ "bundled" ] }
|
113
src/app.rs
113
src/app.rs
@ -1,42 +1,65 @@
|
||||
use anyhow::Error;
|
||||
use int_enum::IntEnum;
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::cmp::min;
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
use crate::db;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
/// Represents a kanban column containing the tasks and other metadata.
|
||||
#[derive(Debug)]
|
||||
pub struct Column {
|
||||
/// Id provided by the database
|
||||
pub id: i64,
|
||||
/// The name used for the title in the UI
|
||||
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,
|
||||
/// The collection of [`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 {
|
||||
/// Id provided by the database
|
||||
pub id: i64,
|
||||
/// Title of the [`Task`]
|
||||
pub title: String,
|
||||
/// Description of the [`Task`]
|
||||
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;
|
||||
|
||||
/// Used to track the focus of the form field in the task edit window.
|
||||
#[repr(i8)]
|
||||
#[derive(Debug, IntEnum, Copy, Clone)]
|
||||
pub enum TaskEditFocus {
|
||||
/// Title text input line
|
||||
Title = 0,
|
||||
/// Description text input box
|
||||
Description = 1,
|
||||
/// Confirm changes button
|
||||
ConfirmBtn = 2,
|
||||
/// Cancel changes button
|
||||
CancelBtn = 3,
|
||||
}
|
||||
|
||||
/// Represents the transient state of a task while it is being editing
|
||||
/// by the user through the UI.
|
||||
pub struct TaskState<'a> {
|
||||
/// The title of the Task
|
||||
pub title: TextArea<'a>,
|
||||
/// The description of the Task
|
||||
pub description: TextArea<'a>,
|
||||
/// Where the current focus of the task edit form is
|
||||
pub focus: TaskEditFocus,
|
||||
/// Used to decide if the user is editing an existing task or
|
||||
/// creating a new one
|
||||
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> {
|
||||
/// The name of the project, currently derived from the name of
|
||||
/// the current working directory
|
||||
pub project_name: String,
|
||||
/// The index of the currently selected [`Column`]
|
||||
pub selected_column_idx: usize,
|
||||
/// A vec of all the [`Column`]s
|
||||
pub columns: Vec<Column>,
|
||||
/// The [`db::DBConn`] wrapping a [`rusqlite::Connection`]
|
||||
pub db_conn: db::DBConn,
|
||||
/// Flag to check on each loop whether we should exit the app
|
||||
pub quit: bool,
|
||||
/// If [`Some(TaskState)`] then we are in the task edit form window
|
||||
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]
|
||||
pub fn get_selected_column(&self) -> &Column {
|
||||
&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 {
|
||||
&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.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.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]
|
||||
pub fn get_selected_task(&self) -> Option<&Task> {
|
||||
let column = self.get_selected_column();
|
||||
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]
|
||||
pub fn get_previous_task(&self) -> Option<&Task> {
|
||||
pub fn get_task_above(&self) -> Option<&Task> {
|
||||
let column = self.get_selected_column();
|
||||
if column.selected_task_idx > 0 {
|
||||
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]
|
||||
pub fn get_next_task(&self) -> Option<&Task> {
|
||||
pub fn get_task_below(&self) -> Option<&Task> {
|
||||
let column = self.get_selected_column();
|
||||
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> {
|
||||
let column = self.get_selected_column_mut();
|
||||
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();
|
||||
column.selected_task_idx = column.selected_task_idx.saturating_sub(1);
|
||||
|
||||
@ -144,7 +194,9 @@ impl<'a> State<'a> {
|
||||
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();
|
||||
column.selected_task_idx = min(
|
||||
column.selected_task_idx + 1,
|
||||
@ -158,6 +210,8 @@ impl<'a> State<'a> {
|
||||
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> {
|
||||
let column = self.get_selected_column_mut();
|
||||
column.selected_task_idx = 0;
|
||||
@ -169,6 +223,8 @@ impl<'a> State<'a> {
|
||||
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> {
|
||||
let column = self.get_selected_column_mut();
|
||||
column.selected_task_idx = column.tasks.len().saturating_sub(1);
|
||||
@ -180,6 +236,9 @@ impl<'a> State<'a> {
|
||||
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]
|
||||
pub fn get_task_state_from_current(&self) -> Option<TaskState<'a>> {
|
||||
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> {
|
||||
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> {
|
||||
self.move_task(true)
|
||||
}
|
||||
|
||||
/// Returns the move task down of this [`State`].
|
||||
pub fn move_task(&mut self, is_down: bool) -> Result<(), Error> {
|
||||
/// Private function to handle saving the current [`Task`]'s
|
||||
/// state.
|
||||
fn move_task(&mut self, is_down: bool) -> Result<(), Error> {
|
||||
let other_task = if is_down {
|
||||
self.get_next_task()
|
||||
self.get_task_below()
|
||||
} else {
|
||||
self.get_previous_task()
|
||||
self.get_task_above()
|
||||
};
|
||||
if let (Some(task1), Some(task2)) = (self.get_selected_task(), other_task) {
|
||||
let t1_id = task1.id;
|
||||
@ -228,11 +292,15 @@ impl<'a> State<'a> {
|
||||
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)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -249,13 +317,13 @@ impl<'a> State<'a> {
|
||||
|
||||
// Only move it if it was the last task
|
||||
if first_col.selected_task_idx == first_col.tasks.len() {
|
||||
self.select_previous_task()?;
|
||||
self.select_task_above()?;
|
||||
}
|
||||
|
||||
if move_right {
|
||||
self.select_next_column()?;
|
||||
self.select_column_right()?;
|
||||
} else {
|
||||
self.select_previous_column()?;
|
||||
self.select_column_left()?;
|
||||
}
|
||||
|
||||
let col = self.get_selected_column_mut();
|
||||
@ -269,6 +337,8 @@ impl<'a> State<'a> {
|
||||
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> {
|
||||
let col_id = self.get_selected_column().id;
|
||||
let task = self.db_conn.create_new_task(title, description, col_id)?;
|
||||
@ -283,6 +353,8 @@ impl<'a> State<'a> {
|
||||
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> {
|
||||
if let Some(selected_task) = self.get_selected_task_mut() {
|
||||
selected_task.title = title;
|
||||
@ -294,7 +366,8 @@ impl<'a> State<'a> {
|
||||
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> {
|
||||
if let Some(task) = self.get_selected_task() {
|
||||
let task_id = task.id;
|
||||
@ -305,7 +378,7 @@ impl<'a> State<'a> {
|
||||
column.tasks.remove(task_idx);
|
||||
|
||||
if column.selected_task_idx >= column.tasks.len() {
|
||||
self.select_previous_task()?;
|
||||
self.select_task_above()?;
|
||||
task_idx = task_idx.saturating_sub(1);
|
||||
}
|
||||
|
||||
|
99
src/db.rs
99
src/db.rs
@ -3,7 +3,13 @@ use anyhow::Error;
|
||||
use rusqlite::{params, Connection, Result};
|
||||
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 {
|
||||
fn deref_mut(&mut self) -> &mut Self::Target {
|
||||
@ -24,22 +30,21 @@ impl DBConn {
|
||||
DBConn(conn)
|
||||
}
|
||||
|
||||
/// .
|
||||
/// Query tasks in a [`Column`] by using the column's [`Column::id`].
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function will return an error if something is wrong with the SQL
|
||||
pub fn get_tasks_by_column(&self, column_name: &String) -> Result<Vec<Task>> {
|
||||
/// Returns an error if something is wrong with the SQL.
|
||||
pub fn get_tasks_by_column(&self, column_id: i64) -> Result<Vec<Task>> {
|
||||
let mut stmt = self.prepare(
|
||||
r#"
|
||||
select task.id, title, description from task
|
||||
join kb_column on column_id = kb_column.id
|
||||
where kb_column.name = ?1
|
||||
where column_id = ?1
|
||||
order by sort_order
|
||||
"#,
|
||||
)?;
|
||||
let mut tasks = Vec::new();
|
||||
let rows = stmt.query_map([column_name], |row| {
|
||||
let rows = stmt.query_map([column_id], |row| {
|
||||
Ok(Task {
|
||||
id: row.get(0)?,
|
||||
title: row.get(1)?,
|
||||
@ -52,20 +57,22 @@ impl DBConn {
|
||||
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
|
||||
///
|
||||
/// 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>> {
|
||||
let mut stmt = self.prepare("select id, name, selected_task from kb_column")?;
|
||||
let columns = stmt
|
||||
.query_map((), |row| {
|
||||
let name = row.get(1)?;
|
||||
let id = row.get(0)?;
|
||||
Ok(Column {
|
||||
id: row.get(0)?,
|
||||
tasks: self.get_tasks_by_column(&name)?,
|
||||
name,
|
||||
id,
|
||||
tasks: self.get_tasks_by_column(id)?,
|
||||
name: row.get(1)?,
|
||||
selected_task_idx: row.get(2)?,
|
||||
})
|
||||
})?
|
||||
@ -74,11 +81,12 @@ impl DBConn {
|
||||
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(
|
||||
&self,
|
||||
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<()> {
|
||||
let mut stmt = self.prepare("delete from task where id = ?1")?;
|
||||
stmt.execute([task_id])?;
|
||||
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<()> {
|
||||
let mut stmt =
|
||||
self.prepare("update task set title = ?2, description = ?3 where id = ?1")?;
|
||||
@ -125,11 +133,11 @@ impl DBConn {
|
||||
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<()> {
|
||||
let mut stmt = self
|
||||
.prepare(
|
||||
@ -148,7 +156,7 @@ impl DBConn {
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
fn get_sort_order(&self) -> Result<Vec<(i32, String, usize)>> {
|
||||
let mut stmt = self.prepare(
|
||||
@ -162,11 +170,14 @@ impl DBConn {
|
||||
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<()> {
|
||||
let tx = self.transaction()?;
|
||||
|
||||
@ -194,9 +205,13 @@ impl DBConn {
|
||||
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> {
|
||||
let mut stmt =
|
||||
self.prepare("insert or replace into app_state(key, value) values (?1, ?2)")?;
|
||||
@ -204,9 +219,11 @@ impl DBConn {
|
||||
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> {
|
||||
let mut stmt = self.prepare("select value from app_state where key = ?1")?;
|
||||
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<()> {
|
||||
let mut stmt = self.prepare("update kb_column set selected_task = ?2 where id = ?1")?;
|
||||
stmt.execute((column_id, task_idx))?;
|
||||
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> {
|
||||
let mut stmt = self.prepare("select selected_task from kb_column where key = ?1")?;
|
||||
stmt.query_row([column_id], |row| row.get(0))
|
||||
|
30
src/input.rs
30
src/input.rs
@ -5,12 +5,11 @@ use crossterm::event::{Event, KeyCode};
|
||||
use int_enum::IntEnum;
|
||||
|
||||
pub fn cycle_focus(task: &mut TaskState<'_>, forward: bool) -> Result<(), Error> {
|
||||
let cycle;
|
||||
if forward {
|
||||
cycle = (task.focus.int_value() + 1) % EDIT_WINDOW_FOCUS_STATES;
|
||||
let cycle = if forward {
|
||||
(task.focus.int_value() + 1) % EDIT_WINDOW_FOCUS_STATES
|
||||
} 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)?;
|
||||
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> {
|
||||
match key.code {
|
||||
KeyCode::Char('q') => Ok(state.quit = true),
|
||||
KeyCode::Char('h') | KeyCode::Left => state.select_previous_column(),
|
||||
KeyCode::Char('j') | KeyCode::Down => state.select_next_task(),
|
||||
KeyCode::Char('k') | KeyCode::Up => state.select_previous_task(),
|
||||
KeyCode::Char('l') | KeyCode::Right => state.select_next_column(),
|
||||
KeyCode::Char('h') | KeyCode::Left => state.select_column_left(),
|
||||
KeyCode::Char('j') | KeyCode::Down => state.select_task_below(),
|
||||
KeyCode::Char('k') | KeyCode::Up => state.select_task_above(),
|
||||
KeyCode::Char('l') | KeyCode::Right => state.select_column_right(),
|
||||
KeyCode::Char('g') => state.select_first_task(),
|
||||
KeyCode::Char('G') => state.select_last_task(),
|
||||
KeyCode::Char('H') => state.move_task_previous_column(),
|
||||
KeyCode::Char('L') => state.move_task_next_column(),
|
||||
KeyCode::Char('H') => state.move_task_column_left(),
|
||||
KeyCode::Char('L') => state.move_task_column_right(),
|
||||
KeyCode::Char('J') => state.move_task_down(),
|
||||
KeyCode::Char('K') => state.move_task_up(),
|
||||
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
|
||||
///
|
||||
/// Crossterm `event::read()` might return an error
|
||||
pub fn handle(state: &mut State<'_>) -> Result<(), Error> {
|
||||
/// Most of the applications errors will be bubbled up to this layer,
|
||||
/// 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 state.task_edit_state.is_some() {
|
||||
handle_task_edit(state, key)?;
|
||||
|
18
src/lib.rs
18
src/lib.rs
@ -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)]
|
||||
mod app;
|
||||
mod db;
|
||||
@ -6,5 +20,5 @@ mod ui;
|
||||
|
||||
pub use app::*;
|
||||
pub use db::*;
|
||||
pub use input::handle;
|
||||
pub use ui::draw;
|
||||
pub use input::handle_user_keypress;
|
||||
pub use ui::draw_ui_from_state;
|
||||
|
@ -49,8 +49,8 @@ fn main() -> anyhow::Result<(), Box<dyn Error>> {
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
while !state.quit {
|
||||
terminal.draw(|f| kanban_tui::draw(f, &mut state))?;
|
||||
kanban_tui::handle(&mut state)?;
|
||||
terminal.draw(|f| kanban_tui::draw_ui_from_state(f, &mut state))?;
|
||||
kanban_tui::handle_user_keypress(&mut state)?;
|
||||
}
|
||||
|
||||
// restore terminal
|
||||
|
@ -216,14 +216,16 @@ fn draw_project_stats<B: Backend>(f: &mut Frame<'_, B>, area: Rect, state: &mut
|
||||
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 {
|
||||
(($first_a:literal, $first_b:literal), $(($a:literal, $b:literal)),*) => {
|
||||
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()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints(
|
||||
|
@ -25,10 +25,10 @@ mod app_tests {
|
||||
|
||||
state.add_new_task(String::from("T1"), String::from("D1"))?;
|
||||
state.add_new_task(String::from("T2"), String::from("D2"))?;
|
||||
state.select_next_column()?;
|
||||
state.select_next_column()?;
|
||||
state.select_column_right()?;
|
||||
state.select_column_right()?;
|
||||
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"))?;
|
||||
|
||||
assert_eq!(state.columns[0].tasks.len(), 2);
|
||||
@ -63,33 +63,33 @@ mod app_tests {
|
||||
state.move_task_down()?;
|
||||
state.move_task_down()?;
|
||||
for _ in 0..10 {
|
||||
state.select_next_column()?;
|
||||
state.select_column_right()?;
|
||||
}
|
||||
for _ in 0..10 {
|
||||
state.select_previous_column()?;
|
||||
state.select_column_left()?;
|
||||
}
|
||||
state.add_new_task(String::from("T1"), String::from("D1"))?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T1");
|
||||
state.add_new_task(String::from("T2"), String::from("D2"))?;
|
||||
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");
|
||||
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"))?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T3");
|
||||
assert_eq!(state.get_selected_column().name, "Done");
|
||||
for _ in 0..6 {
|
||||
state.select_next_column()?;
|
||||
state.select_column_right()?;
|
||||
}
|
||||
for _ in 0..4 {
|
||||
state.select_previous_column()?;
|
||||
state.select_column_left()?;
|
||||
}
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T1");
|
||||
state.select_next_column()?;
|
||||
state.select_next_column()?;
|
||||
state.select_column_right()?;
|
||||
state.select_column_right()?;
|
||||
|
||||
|
||||
// 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)?;
|
||||
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T3");
|
||||
state.select_next_task()?;
|
||||
state.select_next_task()?;
|
||||
state.select_task_below()?;
|
||||
state.select_task_below()?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T3");
|
||||
state.select_previous_column()?;
|
||||
state.select_previous_column()?;
|
||||
state.select_column_left()?;
|
||||
state.select_column_left()?;
|
||||
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");
|
||||
|
||||
Ok(())
|
||||
@ -128,8 +128,8 @@ mod app_tests {
|
||||
state.select_first_task()?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T2");
|
||||
state.select_last_task()?;
|
||||
state.select_previous_task()?;
|
||||
state.select_previous_task()?;
|
||||
state.select_task_above()?;
|
||||
state.select_task_above()?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T9");
|
||||
for _ in 0..10 {
|
||||
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("T2"), String::from("D2"))?;
|
||||
|
||||
state.select_previous_task()?;
|
||||
state.select_task_above()?;
|
||||
state.move_task_up()?;
|
||||
state.move_task_down()?;
|
||||
state.move_task_down()?;
|
||||
|
||||
assert_eq!(&state.columns[0].tasks[1].title, "T1");
|
||||
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.move_task_next_column()?;
|
||||
state.move_task_column_right()?;
|
||||
assert_eq!(state.columns[1].tasks.len(), 0);
|
||||
assert_eq!(state.columns[2].tasks.len(), 1);
|
||||
|
||||
for _ in 0..5 {
|
||||
state.move_task_next_column()?;
|
||||
state.move_task_column_right()?;
|
||||
}
|
||||
for _ in 0..4 {
|
||||
state.move_task_previous_column()?;
|
||||
state.move_task_column_left()?;
|
||||
}
|
||||
|
||||
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[3].tasks.len(), 0);
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T3");
|
||||
state.select_next_task()?;
|
||||
state.select_previous_task()?;
|
||||
state.select_task_below()?;
|
||||
state.select_task_above()?;
|
||||
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");
|
||||
state.select_first_task()?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T2");
|
||||
state.select_last_task()?;
|
||||
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
|
||||
// make sure everything was saved correctly
|
||||
let mut state = State::new(state.db_conn.0)?;
|
||||
|
||||
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");
|
||||
state.select_next_task()?;
|
||||
state.select_task_below()?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T3");
|
||||
state.select_first_task()?;
|
||||
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().description, "D1");
|
||||
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().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().description, "D3");
|
||||
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().description, "D3");
|
||||
@ -283,10 +283,10 @@ mod app_tests {
|
||||
state.delete_task()?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T2");
|
||||
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");
|
||||
state.delete_task()?;
|
||||
state.select_previous_task()?;
|
||||
state.select_task_above()?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T1");
|
||||
state.select_last_task()?;
|
||||
assert_eq!(state.get_selected_task().unwrap().title, "T3");
|
||||
@ -296,7 +296,7 @@ mod app_tests {
|
||||
}
|
||||
state.delete_task()?;
|
||||
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");
|
||||
for _ in 0..4 {
|
||||
state.delete_task()?;
|
||||
|
Loading…
x
Reference in New Issue
Block a user