Added Todo parser, file open dialog
This commit is contained in:
Generated
+9428
File diff suppressed because it is too large
Load Diff
+5
-2
@@ -7,14 +7,17 @@ description = "A COSMIC todo.txt application"
|
||||
repository = "https://github.com/professorwaltwood/planit.git"
|
||||
|
||||
[dependencies]
|
||||
cosmic-files = { git = "https://github.com/pop-os/cosmic-files.git", version = "1.0.8" }
|
||||
futures-util = "0.3.31"
|
||||
i18n-embed = { version = "0.16", features = [
|
||||
"fluent-system",
|
||||
"desktop-requester",
|
||||
] }
|
||||
i18n-embed-fl = "0.10"
|
||||
log = "0.4.29"
|
||||
open = "5.3.2"
|
||||
rust-embed = "8.8.0"
|
||||
time = { version = "0.3.47", features = ["macros", "parsing"] }
|
||||
tokio = { version = "1.48.0", features = ["full"] }
|
||||
|
||||
[dependencies.libcosmic]
|
||||
@@ -22,7 +25,7 @@ git = "https://github.com/pop-os/libcosmic.git"
|
||||
# See https://github.com/pop-os/libcosmic/blob/master/Cargo.toml for available features.
|
||||
features = [
|
||||
# Accessibility support
|
||||
"a11y",
|
||||
#"a11y",
|
||||
# About widget for the app
|
||||
"about",
|
||||
# Uses cosmic-settings-daemon to watch for config file changes
|
||||
@@ -30,7 +33,7 @@ features = [
|
||||
# Support creating additional application windows.
|
||||
"multi-window",
|
||||
# On app startup, focuses an existing instance if the app is already open
|
||||
"single-instance",
|
||||
#"single-instance",
|
||||
# Uses tokio as the executor for the runtime
|
||||
"tokio",
|
||||
# Windowing support for X11, Windows, Mac, & Redox
|
||||
|
||||
+3
-1
@@ -2,6 +2,8 @@ app-title = Planit
|
||||
about = About
|
||||
repository = Repository
|
||||
view = View
|
||||
welcome = Welcome to COSMIC! ✨
|
||||
welcome = Welcome to Planit!
|
||||
page-id = Page { $num }
|
||||
git-description = Git commit {$hash} on {$date}
|
||||
file = File
|
||||
open = Open File
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
test todo list +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
x 2020-01-01 2020-01-02 test having lots of items +planit @tests
|
||||
2020-01-01 test having lots of items +planit @tests
|
||||
(B) test having lots of items +planit @tests
|
||||
x (A) test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests due:2020-02-01
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
test having lots of items +planit @tests
|
||||
+115
-114
@@ -5,13 +5,19 @@ use crate::fl;
|
||||
use cosmic::app::context_drawer;
|
||||
use cosmic::cosmic_config::{self, CosmicConfigEntry};
|
||||
use cosmic::iced::alignment::{Horizontal, Vertical};
|
||||
use cosmic::iced::futures::channel::mpsc::Sender;
|
||||
use cosmic::iced::{Alignment, Length, Subscription};
|
||||
use cosmic::widget::{self, about::About, icon, menu, nav_bar};
|
||||
use cosmic::{iced_futures, prelude::*};
|
||||
use cosmic::widget::{self, about::About, menu};
|
||||
use cosmic::{Theme, iced_futures, prelude::*};
|
||||
use futures_util::SinkExt;
|
||||
use std::collections::HashMap;
|
||||
use std::path;
|
||||
use std::time::Duration;
|
||||
|
||||
use cosmic_files::dialog::{Dialog, DialogKind, DialogMessage, DialogResult, DialogSettings};
|
||||
|
||||
use crate::todo::{Todo, TodoPart};
|
||||
|
||||
const REPOSITORY: &str = env!("CARGO_PKG_REPOSITORY");
|
||||
const APP_ICON: &[u8] = include_bytes!("../resources/icons/hicolor/scalable/apps/icon.svg");
|
||||
|
||||
@@ -24,8 +30,6 @@ pub struct AppModel {
|
||||
context_page: ContextPage,
|
||||
/// The about page for this app.
|
||||
about: About,
|
||||
/// Contains items assigned to the nav bar panel.
|
||||
nav: nav_bar::Model,
|
||||
/// Key bindings for the application's menu bar.
|
||||
key_binds: HashMap<menu::KeyBind, MenuAction>,
|
||||
/// Configuration data that persists between application runs.
|
||||
@@ -34,6 +38,9 @@ pub struct AppModel {
|
||||
time: u32,
|
||||
/// Toggle the watch subscription
|
||||
watch_is_active: bool,
|
||||
/// Current Todo data
|
||||
todo: Option<Todo>,
|
||||
dialog_opt: Option<Dialog<Message>>,
|
||||
}
|
||||
|
||||
/// Messages emitted by the application and its widgets.
|
||||
@@ -41,9 +48,11 @@ pub struct AppModel {
|
||||
pub enum Message {
|
||||
LaunchUrl(String),
|
||||
ToggleContextPage(ContextPage),
|
||||
ToggleWatch,
|
||||
UpdateConfig(Config),
|
||||
WatchTick(u32),
|
||||
OpenFileDialog,
|
||||
DialogMessage(DialogMessage),
|
||||
OpenFileResult(DialogResult),
|
||||
}
|
||||
|
||||
/// Create a COSMIC application from the app model
|
||||
@@ -58,7 +67,7 @@ impl cosmic::Application for AppModel {
|
||||
type Message = Message;
|
||||
|
||||
/// Unique identifier in RDNN (reverse domain name notation) format.
|
||||
const APP_ID: &'static str = "dev.mmurphy.Test";
|
||||
const APP_ID: &'static str = "dev.waltwood.planit";
|
||||
|
||||
fn core(&self) -> &cosmic::Core {
|
||||
&self.core
|
||||
@@ -73,25 +82,6 @@ impl cosmic::Application for AppModel {
|
||||
core: cosmic::Core,
|
||||
_flags: Self::Flags,
|
||||
) -> (Self, Task<cosmic::Action<Self::Message>>) {
|
||||
// Create a nav bar with three page items.
|
||||
let mut nav = nav_bar::Model::default();
|
||||
|
||||
nav.insert()
|
||||
.text(fl!("page-id", num = 1))
|
||||
.data::<Page>(Page::Page1)
|
||||
.icon(icon::from_name("applications-science-symbolic"))
|
||||
.activate();
|
||||
|
||||
nav.insert()
|
||||
.text(fl!("page-id", num = 2))
|
||||
.data::<Page>(Page::Page2)
|
||||
.icon(icon::from_name("applications-system-symbolic"));
|
||||
|
||||
nav.insert()
|
||||
.text(fl!("page-id", num = 3))
|
||||
.data::<Page>(Page::Page3)
|
||||
.icon(icon::from_name("applications-games-symbolic"));
|
||||
|
||||
// Create the about widget
|
||||
let about = About::default()
|
||||
.name(fl!("app-title"))
|
||||
@@ -105,7 +95,6 @@ impl cosmic::Application for AppModel {
|
||||
core,
|
||||
context_page: ContextPage::default(),
|
||||
about,
|
||||
nav,
|
||||
key_binds: HashMap::new(),
|
||||
// Optional configuration file for an application.
|
||||
config: cosmic_config::Config::new(Self::APP_ID, Config::VERSION)
|
||||
@@ -122,6 +111,8 @@ impl cosmic::Application for AppModel {
|
||||
.unwrap_or_default(),
|
||||
time: 0,
|
||||
watch_is_active: false,
|
||||
todo: None,
|
||||
dialog_opt: None,
|
||||
};
|
||||
|
||||
// Create a startup command that sets the window title.
|
||||
@@ -132,22 +123,26 @@ impl cosmic::Application for AppModel {
|
||||
|
||||
/// Elements to pack at the start of the header bar.
|
||||
fn header_start(&self) -> Vec<Element<'_, Self::Message>> {
|
||||
let menu_bar = menu::bar(vec![menu::Tree::with_children(
|
||||
menu::root(fl!("view")).apply(Element::from),
|
||||
menu::items(
|
||||
&self.key_binds,
|
||||
vec![menu::Item::Button(fl!("about"), None, MenuAction::About)],
|
||||
let menu_bar = menu::bar(vec![
|
||||
menu::Tree::with_children(
|
||||
menu::root(fl!("file")).apply(Element::from),
|
||||
menu::items(
|
||||
&self.key_binds,
|
||||
vec![menu::Item::Button(fl!("open"), None, MenuAction::OpenFile)],
|
||||
),
|
||||
),
|
||||
)]);
|
||||
menu::Tree::with_children(
|
||||
menu::root(fl!("view")).apply(Element::from),
|
||||
menu::items(
|
||||
&self.key_binds,
|
||||
vec![menu::Item::Button(fl!("about"), None, MenuAction::About)],
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
vec![menu_bar.into()]
|
||||
}
|
||||
|
||||
/// Enables the COSMIC application to create a nav bar with this model.
|
||||
fn nav_model(&self) -> Option<&nav_bar::Model> {
|
||||
Some(&self.nav)
|
||||
}
|
||||
|
||||
/// Display a context drawer if the context page is requested.
|
||||
fn context_drawer(&self) -> Option<context_drawer::ContextDrawer<'_, Self::Message>> {
|
||||
if !self.core.window.show_context {
|
||||
@@ -169,62 +164,29 @@ impl cosmic::Application for AppModel {
|
||||
/// events received by widgets will be passed to the update method.
|
||||
fn view(&self) -> Element<'_, Self::Message> {
|
||||
let space_s = cosmic::theme::spacing().space_s;
|
||||
let content: Element<_> = match self.nav.active_data::<Page>().unwrap() {
|
||||
Page::Page1 => {
|
||||
let header = widget::row::with_capacity(2)
|
||||
.push(widget::text::title1(fl!("welcome")))
|
||||
.push(widget::text::title3(fl!("page-id", num = 1)))
|
||||
.align_y(Alignment::End)
|
||||
.spacing(space_s);
|
||||
|
||||
let counter_label = ["Watch: ", self.time.to_string().as_str()].concat();
|
||||
let section = cosmic::widget::settings::section().add(
|
||||
cosmic::widget::settings::item::builder(counter_label).control(
|
||||
widget::button::text(if self.watch_is_active {
|
||||
"Stop"
|
||||
} else {
|
||||
"Start"
|
||||
})
|
||||
.on_press(Message::ToggleWatch),
|
||||
),
|
||||
);
|
||||
let header = widget::row::with_capacity(2)
|
||||
.push(widget::text::title1(fl!("welcome")))
|
||||
.align_y(Alignment::End)
|
||||
.spacing(space_s);
|
||||
|
||||
widget::column::with_capacity(2)
|
||||
.push(header)
|
||||
.push(section)
|
||||
.spacing(space_s)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
let section;
|
||||
|
||||
Page::Page2 => {
|
||||
let header = widget::row::with_capacity(2)
|
||||
.push(widget::text::title1(fl!("welcome")))
|
||||
.push(widget::text::title3(fl!("page-id", num = 2)))
|
||||
.align_y(Alignment::End)
|
||||
.spacing(space_s);
|
||||
if let Some(todo) = self.todo.as_ref() {
|
||||
section = widget::Column::with_children(
|
||||
todo.todos.iter().map(|item| view_todo(item.get_str())),
|
||||
)
|
||||
.spacing(20);
|
||||
} else {
|
||||
section = widget::Column::new()
|
||||
}
|
||||
|
||||
widget::column::with_capacity(1)
|
||||
.push(header)
|
||||
.spacing(space_s)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
|
||||
Page::Page3 => {
|
||||
let header = widget::row::with_capacity(2)
|
||||
.push(widget::text::title1(fl!("welcome")))
|
||||
.push(widget::text::title3(fl!("page-id", num = 3)))
|
||||
.align_y(Alignment::End)
|
||||
.spacing(space_s);
|
||||
|
||||
widget::column::with_capacity(1)
|
||||
.push(header)
|
||||
.spacing(space_s)
|
||||
.height(Length::Fill)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
let content: Element<_> = widget::column::with_capacity(2)
|
||||
.push(header)
|
||||
.push(section)
|
||||
.spacing(space_s)
|
||||
.height(Length::Fill)
|
||||
.into();
|
||||
|
||||
widget::container(content)
|
||||
.width(600)
|
||||
@@ -260,7 +222,7 @@ impl cosmic::Application for AppModel {
|
||||
// Conditionally enables a timer that emits a message every second.
|
||||
if self.watch_is_active {
|
||||
subscriptions.push(Subscription::run(|| {
|
||||
iced_futures::stream::channel(1, |mut emitter| async move {
|
||||
iced_futures::stream::channel(1, |mut emitter: Sender<Message>| async move {
|
||||
let mut time = 1;
|
||||
let mut interval = tokio::time::interval(Duration::from_secs(1));
|
||||
|
||||
@@ -285,11 +247,6 @@ impl cosmic::Application for AppModel {
|
||||
Message::WatchTick(time) => {
|
||||
self.time = time;
|
||||
}
|
||||
|
||||
Message::ToggleWatch => {
|
||||
self.watch_is_active = !self.watch_is_active;
|
||||
}
|
||||
|
||||
Message::ToggleContextPage(context_page) => {
|
||||
if self.context_page == context_page {
|
||||
// Close the context drawer if the toggled context page is the same.
|
||||
@@ -300,39 +257,71 @@ impl cosmic::Application for AppModel {
|
||||
self.core.window.show_context = true;
|
||||
}
|
||||
}
|
||||
|
||||
Message::UpdateConfig(config) => {
|
||||
self.config = config;
|
||||
}
|
||||
|
||||
Message::LaunchUrl(url) => match open::that_detached(&url) {
|
||||
Ok(()) => {}
|
||||
Err(err) => {
|
||||
eprintln!("failed to open {url:?}: {err}");
|
||||
}
|
||||
},
|
||||
Message::OpenFileDialog => {
|
||||
if self.dialog_opt.is_none() {
|
||||
let (dialog, command) = Dialog::new(
|
||||
DialogSettings::new().kind(DialogKind::OpenFile),
|
||||
Message::DialogMessage,
|
||||
Message::OpenFileResult,
|
||||
);
|
||||
self.dialog_opt = Some(dialog);
|
||||
return command;
|
||||
}
|
||||
}
|
||||
Message::DialogMessage(dialog_message) => {
|
||||
if let Some(dialog) = &mut self.dialog_opt {
|
||||
return dialog.update(dialog_message);
|
||||
}
|
||||
}
|
||||
Message::OpenFileResult(dialog_result) => match dialog_result {
|
||||
DialogResult::Open(paths) => {
|
||||
for path in paths {
|
||||
let cpath = match std::fs::canonicalize(&path) {
|
||||
Ok(ok) => ok,
|
||||
Err(err) => match path::absolute(&path) {
|
||||
Ok(ok) => ok,
|
||||
Err(_) => {
|
||||
log::error!("failed to canonicalise path {:?}: {}", path, err);
|
||||
return Task::none();
|
||||
}
|
||||
},
|
||||
};
|
||||
let mut todo = Todo::new(cpath);
|
||||
// Todo: this might need to be more async
|
||||
if let Err(err) = todo.load() {
|
||||
log::error!("failed to load file {:?} {:?}", path, err);
|
||||
}
|
||||
|
||||
self.todo = Some(todo);
|
||||
}
|
||||
}
|
||||
DialogResult::Cancel => todo!(),
|
||||
},
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
|
||||
/// Called when a nav item is selected.
|
||||
fn on_nav_select(&mut self, id: nav_bar::Id) -> Task<cosmic::Action<Self::Message>> {
|
||||
// Activate the page in the model.
|
||||
self.nav.activate(id);
|
||||
|
||||
self.update_title()
|
||||
fn view_window(&self, id: cosmic::iced::window::Id) -> Element<'_, Self::Message> {
|
||||
match &self.dialog_opt {
|
||||
Some(dialog) => dialog.view(id),
|
||||
None => widget::text("Unknown window ID").into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppModel {
|
||||
/// Updates the header and window titles.
|
||||
pub fn update_title(&mut self) -> Task<cosmic::Action<Message>> {
|
||||
let mut window_title = fl!("app-title");
|
||||
|
||||
if let Some(page) = self.nav.text(self.nav.active()) {
|
||||
window_title.push_str(" — ");
|
||||
window_title.push_str(page);
|
||||
}
|
||||
let window_title = fl!("app-title");
|
||||
|
||||
if let Some(id) = self.core.main_window_id() {
|
||||
self.set_window_title(window_title, id)
|
||||
@@ -342,11 +331,21 @@ impl AppModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// The page to display in the application.
|
||||
pub enum Page {
|
||||
Page1,
|
||||
Page2,
|
||||
Page3,
|
||||
fn view_todo<'a>(todo: Vec<TodoPart<'a>>) -> Element<'a, Message> {
|
||||
widget::row::with_children(todo.iter().map(|part| view_text(*part).into())).into()
|
||||
}
|
||||
|
||||
fn view_text<'a>(text: TodoPart<'a>) -> widget::Text<'a, Theme, Renderer> {
|
||||
let theme = &cosmic::theme::COSMIC_DARK;
|
||||
let colour = match text {
|
||||
TodoPart::Context(_) => theme.accent_color(),
|
||||
TodoPart::Description(_) => theme.on_primary_component_color(),
|
||||
TodoPart::Project(_) => theme.palette.accent_green,
|
||||
TodoPart::Tag(_) => theme.palette.accent_purple,
|
||||
};
|
||||
let colour = cosmic::iced::Color::from(colour);
|
||||
let text: &str = text.into();
|
||||
widget::text(text).class(colour)
|
||||
}
|
||||
|
||||
/// The context page to display in the context drawer.
|
||||
@@ -359,6 +358,7 @@ pub enum ContextPage {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub enum MenuAction {
|
||||
About,
|
||||
OpenFile,
|
||||
}
|
||||
|
||||
impl menu::action::MenuAction for MenuAction {
|
||||
@@ -367,6 +367,7 @@ impl menu::action::MenuAction for MenuAction {
|
||||
fn message(&self) -> Self::Message {
|
||||
match self {
|
||||
MenuAction::About => Message::ToggleContextPage(ContextPage::About),
|
||||
MenuAction::OpenFile => Message::OpenFileDialog,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ pub static LANGUAGE_LOADER: LazyLock<FluentLanguageLoader> = LazyLock::new(|| {
|
||||
loader
|
||||
});
|
||||
|
||||
|
||||
/// Request a localized string by ID from the i18n/ directory.
|
||||
#[macro_export]
|
||||
macro_rules! fl {
|
||||
@@ -49,4 +48,3 @@ macro_rules! fl {
|
||||
i18n_embed_fl::fl!($crate::i18n::LANGUAGE_LOADER, $message_id, $($args), *)
|
||||
}};
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
mod app;
|
||||
mod config;
|
||||
mod i18n;
|
||||
mod todo;
|
||||
|
||||
fn main() -> cosmic::iced::Result {
|
||||
// Get the system's preferred languages.
|
||||
|
||||
+643
@@ -0,0 +1,643 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
hash::{BuildHasher, DefaultHasher, Hash, Hasher},
|
||||
io::{self, BufRead, BufReader},
|
||||
path::PathBuf,
|
||||
pin::Pin,
|
||||
ptr::NonNull,
|
||||
};
|
||||
use time::{Date, macros::format_description};
|
||||
|
||||
const DATE_FMT: time::format_description::StaticFormatDescription =
|
||||
format_description!("[year]-[month]-[day]");
|
||||
|
||||
pub struct Todo {
|
||||
/// Location of todo.txt file
|
||||
location: PathBuf,
|
||||
|
||||
/// Vec of todo items (i.e., parsed lines of the todo.txt)
|
||||
pub todos: Vec<TodoItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TodoError {
|
||||
Parse,
|
||||
Io(io::Error),
|
||||
}
|
||||
|
||||
impl From<io::Error> for TodoError {
|
||||
fn from(value: io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl Todo {
|
||||
pub fn new(loc: PathBuf) -> Self {
|
||||
Self {
|
||||
location: loc,
|
||||
todos: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(&mut self) -> Result<(), TodoError> {
|
||||
self.reload()
|
||||
}
|
||||
|
||||
pub fn reload(&mut self) -> Result<(), TodoError> {
|
||||
let file = fs::File::open(&self.location)?;
|
||||
let reader = BufReader::new(file);
|
||||
|
||||
let mut todos = Vec::new();
|
||||
for line in reader.lines() {
|
||||
// Pin the string so it can be referenced but cruicially not modified
|
||||
let line = Pin::new(line?);
|
||||
todos.push(line.try_into()?);
|
||||
}
|
||||
|
||||
self.todos = todos;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum TodoPart<'a> {
|
||||
Description(&'a str),
|
||||
Project(&'a str),
|
||||
Context(&'a str),
|
||||
Tag(&'a str),
|
||||
}
|
||||
|
||||
impl<'a> Into<&'a str> for TodoPart<'a> {
|
||||
fn into(self) -> &'a str {
|
||||
match self {
|
||||
TodoPart::Description(s) => s,
|
||||
TodoPart::Project(s) => s,
|
||||
TodoPart::Context(s) => s,
|
||||
TodoPart::Tag(s) => s,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
enum TodoPartRaw {
|
||||
Description(NonNull<str>),
|
||||
Project(NonNull<str>),
|
||||
Context(NonNull<str>),
|
||||
Tag(NonNull<str>),
|
||||
}
|
||||
|
||||
impl<'a> Into<TodoPart<'a>> for TodoPartRaw {
|
||||
fn into<'b>(self) -> TodoPart<'a> {
|
||||
match self {
|
||||
TodoPartRaw::Description(s) => TodoPart::Description(unsafe { s.as_ref() }),
|
||||
TodoPartRaw::Project(s) => TodoPart::Project(unsafe { s.as_ref() }),
|
||||
TodoPartRaw::Context(s) => TodoPart::Context(unsafe { s.as_ref() }),
|
||||
TodoPartRaw::Tag(s) => TodoPart::Tag(unsafe { s.as_ref() }),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TodoPartRaw {
|
||||
fn as_str(&self) -> &str {
|
||||
match self {
|
||||
TodoPartRaw::Description(s) => unsafe { s.as_ref() },
|
||||
TodoPartRaw::Project(s) => unsafe { s.as_ref() },
|
||||
TodoPartRaw::Context(s) => unsafe { s.as_ref() },
|
||||
TodoPartRaw::Tag(s) => unsafe { s.as_ref() },
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)]
|
||||
pub struct TodoItemKey(u64);
|
||||
|
||||
impl From<&str> for TodoItemKey {
|
||||
fn from(value: &str) -> Self {
|
||||
let mut s = DefaultHasher::new();
|
||||
value.hash(&mut s);
|
||||
Self(s.finish())
|
||||
}
|
||||
}
|
||||
|
||||
impl Hash for TodoItemKey {
|
||||
fn hash<H: Hasher>(&self, state: &mut H) {
|
||||
state.write_u64(self.0);
|
||||
}
|
||||
}
|
||||
|
||||
struct TodoHasher(u64);
|
||||
|
||||
impl Hasher for TodoHasher {
|
||||
fn finish(&self) -> u64 {
|
||||
self.0
|
||||
}
|
||||
fn write(&mut self, _: &[u8]) {}
|
||||
|
||||
fn write_u64(&mut self, i: u64) {
|
||||
self.0 = i;
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct TodoBuildHasher;
|
||||
|
||||
impl BuildHasher for TodoBuildHasher {
|
||||
type Hasher = TodoHasher;
|
||||
|
||||
fn build_hasher(&self) -> Self::Hasher {
|
||||
TodoHasher(0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TodoItem {
|
||||
/// TodoItem owns/stores the whole here
|
||||
#[allow(unused)]
|
||||
raw: Pin<String>,
|
||||
/// Optional - Marks Completion x
|
||||
done: bool,
|
||||
/// Optional - Marks Priority (A)
|
||||
prio: Option<char>,
|
||||
/// Optional - Completion Date
|
||||
date_done: Option<Date>,
|
||||
/// Optional - Creation Date (must be sepecified if completion date is)
|
||||
date_made: Option<Date>,
|
||||
/// Description - raw str slice for description
|
||||
raw_desc: NonNull<str>,
|
||||
/// Formatted Description - list of description components
|
||||
desc: Vec<TodoPartRaw>,
|
||||
/// Project tags
|
||||
projects: HashSet<TodoItemKey, TodoBuildHasher>,
|
||||
/// Context tags
|
||||
contexts: HashSet<TodoItemKey, TodoBuildHasher>,
|
||||
/// Special Key/Value tags
|
||||
tags: HashMap<TodoItemKey, NonNull<str>, TodoBuildHasher>,
|
||||
}
|
||||
|
||||
impl TodoItem {
|
||||
pub fn is_done(&self) -> bool {
|
||||
self.done
|
||||
}
|
||||
|
||||
pub fn complete(&mut self) {
|
||||
self.done = true;
|
||||
}
|
||||
|
||||
pub fn get_priority(&self) -> Option<char> {
|
||||
self.prio
|
||||
}
|
||||
|
||||
pub fn set_priority(&mut self, prio: char) {
|
||||
self.prio = Some(prio);
|
||||
}
|
||||
|
||||
pub fn clear_priority(&mut self) {
|
||||
self.prio = None;
|
||||
}
|
||||
|
||||
pub fn get_date_completed(&self) -> Option<Date> {
|
||||
self.date_done
|
||||
}
|
||||
|
||||
pub fn set_date_completed(&mut self, date: Date) {
|
||||
self.date_done = Some(date);
|
||||
}
|
||||
|
||||
pub fn clear_date_completed(&mut self) {
|
||||
self.date_done = None;
|
||||
}
|
||||
|
||||
pub fn get_date_created(&self) -> Option<Date> {
|
||||
self.date_made
|
||||
}
|
||||
|
||||
pub fn set_date_created(&mut self, date: Date) {
|
||||
self.date_made = Some(date);
|
||||
}
|
||||
|
||||
pub fn clear_date_created(&mut self) {
|
||||
self.date_made = None;
|
||||
}
|
||||
|
||||
pub fn get_str<'a>(&'a self) -> Vec<TodoPart<'a>> {
|
||||
self.desc.iter().map(|p| (*p).into()).collect()
|
||||
}
|
||||
|
||||
fn get_raw_str(&self) -> &str {
|
||||
// Safety
|
||||
// ensure that the pointer is convertible to a reference.
|
||||
// The pointee will always be valid for the pointer's lifetime
|
||||
// as it points to a pinned string owned by the same object.
|
||||
// i.e., the String will only be dropped when the pointer is
|
||||
// Additionally the String is never mutated.
|
||||
unsafe { self.raw_desc.as_ref() }
|
||||
}
|
||||
|
||||
fn has_project(&self, project: TodoItemKey) -> bool {
|
||||
self.projects.contains(&project)
|
||||
}
|
||||
|
||||
pub fn get_tag<T>(&self, tag: T) -> Option<&str>
|
||||
where
|
||||
T: Into<TodoItemKey>,
|
||||
{
|
||||
let tag_pointer = self.tags.get(&tag.into())?;
|
||||
// Safety: see get_raw_str
|
||||
Some(unsafe { tag_pointer.as_ref() })
|
||||
}
|
||||
}
|
||||
|
||||
impl TryFrom<Pin<String>> for TodoItem {
|
||||
type Error = TodoError;
|
||||
|
||||
fn try_from(value: Pin<String>) -> Result<Self, Self::Error> {
|
||||
let raw = value;
|
||||
|
||||
let mut pos = 0;
|
||||
|
||||
let done = parse_done(&raw[pos..], &mut pos);
|
||||
let prio = parse_prio(&raw[pos..], &mut pos);
|
||||
let (date_done, date_made) = parse_dates(&raw[pos..], &mut pos);
|
||||
|
||||
if !raw[pos..].contains(|c: char| !c.is_whitespace()) {
|
||||
return Err(TodoError::Parse);
|
||||
}
|
||||
|
||||
let mut projects = HashSet::with_hasher(TodoBuildHasher);
|
||||
let mut contexts = HashSet::with_hasher(TodoBuildHasher);
|
||||
let mut tags = HashMap::with_hasher(TodoBuildHasher);
|
||||
let raw_desc = NonNull::from_ref(&raw[pos..]);
|
||||
let mut desc = Vec::new();
|
||||
|
||||
while let Some((size, word)) = get_word(&raw[pos..]) {
|
||||
if word.starts_with('@') {
|
||||
if word.len() > 1 {
|
||||
contexts.insert(word.into());
|
||||
desc.push(TodoPartRaw::Context(NonNull::from_ref(word)));
|
||||
}
|
||||
} else if word.starts_with('+') {
|
||||
if word.len() > 1 {
|
||||
projects.insert(word.into());
|
||||
desc.push(TodoPartRaw::Project(NonNull::from_ref(word)));
|
||||
}
|
||||
} else if let Some((k, v)) = word.split_once(':') {
|
||||
if !(k.contains(':') || v.contains(':') || k.len() == 0 || v.len() == 0) {
|
||||
tags.insert(k.into(), NonNull::from_ref(v));
|
||||
desc.push(TodoPartRaw::Tag(NonNull::from_ref(word)));
|
||||
}
|
||||
} else {
|
||||
desc.push(TodoPartRaw::Description(NonNull::from_ref(word)));
|
||||
}
|
||||
pos += size;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
raw,
|
||||
done,
|
||||
prio,
|
||||
date_done,
|
||||
date_made,
|
||||
raw_desc,
|
||||
desc,
|
||||
projects,
|
||||
contexts,
|
||||
tags,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn get_word(raw: &str) -> Option<(usize, &str)> {
|
||||
let mut chars = raw.char_indices();
|
||||
let (next_char, _) = chars.find(|(_, c)| !c.is_whitespace())?;
|
||||
let next_ws = chars.find(|(_, c)| c.is_whitespace());
|
||||
|
||||
match next_ws {
|
||||
Some((end, _)) => {
|
||||
let next_word = chars.find(|(_, c)| !c.is_whitespace());
|
||||
match next_word {
|
||||
Some((nw_start, _)) => Some((nw_start, &raw[next_char..end])),
|
||||
None => Some((end, &raw[next_char..end])),
|
||||
}
|
||||
}
|
||||
None => Some((raw.len(), &raw[next_char..])),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_done(raw: &str, pos: &mut usize) -> bool {
|
||||
let next_word = get_word(raw);
|
||||
|
||||
match next_word {
|
||||
Some((size, word)) => {
|
||||
let done = word == "x";
|
||||
if done {
|
||||
*pos += size;
|
||||
}
|
||||
done
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_prio(raw: &str, pos: &mut usize) -> Option<char> {
|
||||
let next_word = get_word(raw)?;
|
||||
let (size, word) = next_word;
|
||||
let mut word = word.chars();
|
||||
|
||||
if word.next()? == '('
|
||||
&& let char = word.next()?
|
||||
&& char.is_ascii_uppercase()
|
||||
&& word.next()? == ')'
|
||||
&& word.next().is_none()
|
||||
{
|
||||
*pos += size;
|
||||
Some(char)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dates(raw: &str, pos: &mut usize) -> (Option<Date>, Option<Date>) {
|
||||
let mut date_pos = 0;
|
||||
let first = parse_date(raw, &mut date_pos);
|
||||
if first.is_some() {
|
||||
let second = parse_date(&raw[date_pos..], &mut date_pos);
|
||||
*pos += date_pos;
|
||||
if second.is_some() {
|
||||
(first, second)
|
||||
} else {
|
||||
(None, first)
|
||||
}
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_date(raw: &str, pos: &mut usize) -> Option<Date> {
|
||||
let (size, word) = get_word(raw)?;
|
||||
|
||||
if let Ok(date) = Date::parse(word, &DATE_FMT) {
|
||||
*pos += size;
|
||||
Some(date)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use time::macros::date;
|
||||
|
||||
#[test]
|
||||
fn parse_from_file() {
|
||||
let mut todo = Todo::new("samples/todo.txt".into());
|
||||
todo.load().expect("Error loading todo file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_todo_incorrect_done() {
|
||||
let todo_str = Pin::new("X (A) 2020-05-01 2020-01-01 help me please".to_string());
|
||||
let todo: TodoItem = todo_str.try_into().expect("todo parse");
|
||||
|
||||
assert_eq!(todo.done, false);
|
||||
assert_eq!(todo.prio, None);
|
||||
assert_eq!(todo.date_done, None);
|
||||
assert_eq!(todo.date_made, None);
|
||||
assert_eq!(
|
||||
*todo.raw,
|
||||
"X (A) 2020-05-01 2020-01-01 help me please".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
todo.get_raw_str(),
|
||||
"X (A) 2020-05-01 2020-01-01 help me please"
|
||||
);
|
||||
|
||||
assert!(todo.projects.is_empty());
|
||||
assert!(todo.contexts.is_empty());
|
||||
assert!(todo.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_todo_invalid_tags() {
|
||||
let todo_str =
|
||||
Pin::new("x (A) 2020-05-01 2020-01-01 help me please artist: :music".to_string());
|
||||
let todo: TodoItem = todo_str.try_into().expect("todo parse");
|
||||
|
||||
assert_eq!(todo.done, true);
|
||||
assert_eq!(todo.prio, Some('A'));
|
||||
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
|
||||
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
|
||||
assert_eq!(
|
||||
*todo.raw,
|
||||
"x (A) 2020-05-01 2020-01-01 help me please artist: :music".to_string()
|
||||
);
|
||||
assert_eq!(todo.get_raw_str(), "help me please artist: :music");
|
||||
|
||||
assert!(todo.projects.is_empty());
|
||||
assert!(todo.contexts.is_empty());
|
||||
assert!(todo.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_todo_invalid_projects() {
|
||||
let todo_str =
|
||||
Pin::new("x (A) 2020-05-01 2020-01-01 help me please a+beatles +\tmusic".to_string());
|
||||
let todo: TodoItem = todo_str.try_into().expect("todo parse");
|
||||
|
||||
assert_eq!(todo.done, true);
|
||||
assert_eq!(todo.prio, Some('A'));
|
||||
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
|
||||
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
|
||||
assert_eq!(
|
||||
*todo.raw,
|
||||
"x (A) 2020-05-01 2020-01-01 help me please a+beatles +\tmusic".to_string()
|
||||
);
|
||||
assert_eq!(todo.get_raw_str(), "help me please a+beatles +\tmusic");
|
||||
|
||||
assert!(todo.projects.is_empty());
|
||||
assert!(todo.contexts.is_empty());
|
||||
assert!(todo.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_todo_invalid_contexts() {
|
||||
let todo_str =
|
||||
Pin::new("x (A) 2020-05-01 2020-01-01 help me please a@beatles @\tmusic".to_string());
|
||||
let todo: TodoItem = todo_str.try_into().expect("todo parse");
|
||||
|
||||
assert_eq!(todo.done, true);
|
||||
assert_eq!(todo.prio, Some('A'));
|
||||
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
|
||||
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
|
||||
assert_eq!(
|
||||
*todo.raw,
|
||||
"x (A) 2020-05-01 2020-01-01 help me please a@beatles @\tmusic".to_string()
|
||||
);
|
||||
assert_eq!(todo.get_raw_str(), "help me please a@beatles @\tmusic");
|
||||
|
||||
assert!(todo.projects.is_empty());
|
||||
assert!(todo.contexts.is_empty());
|
||||
assert!(todo.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_todo_valid_tags() {
|
||||
let todo_str = Pin::new(
|
||||
"x (A) 2020-05-01 2020-01-01 help me please artist:beatles category:music".to_string(),
|
||||
);
|
||||
let todo: TodoItem = todo_str.try_into().expect("todo parse");
|
||||
|
||||
assert_eq!(todo.done, true);
|
||||
assert_eq!(todo.prio, Some('A'));
|
||||
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
|
||||
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
|
||||
assert_eq!(
|
||||
*todo.raw,
|
||||
"x (A) 2020-05-01 2020-01-01 help me please artist:beatles category:music".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
todo.get_raw_str(),
|
||||
"help me please artist:beatles category:music"
|
||||
);
|
||||
|
||||
assert!(todo.projects.is_empty());
|
||||
assert!(todo.contexts.is_empty());
|
||||
|
||||
assert_eq!(todo.get_tag("artist").expect("artist in tags"), "beatles");
|
||||
assert_eq!(todo.get_tag("category").expect("category in tags"), "music");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_todo_valid_projects() {
|
||||
let todo_str =
|
||||
Pin::new("x (A) 2020-05-01 2020-01-01 help me please +beatles +music".to_string());
|
||||
let todo: TodoItem = todo_str.try_into().expect("todo parse");
|
||||
|
||||
assert_eq!(todo.done, true);
|
||||
assert_eq!(todo.prio, Some('A'));
|
||||
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
|
||||
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
|
||||
assert_eq!(
|
||||
*todo.raw,
|
||||
"x (A) 2020-05-01 2020-01-01 help me please +beatles +music".to_string()
|
||||
);
|
||||
assert_eq!(todo.get_raw_str(), "help me please +beatles +music");
|
||||
|
||||
let mut projects = HashSet::with_hasher(TodoBuildHasher);
|
||||
projects.insert("+beatles".into());
|
||||
projects.insert("+music".into());
|
||||
|
||||
assert_eq!(todo.projects, projects);
|
||||
assert!(todo.contexts.is_empty());
|
||||
assert!(todo.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_todo_valid_contexts() {
|
||||
let todo_str =
|
||||
Pin::new("x (A) 2020-05-01 2020-01-01 help me please @beatles @music".to_string());
|
||||
let todo: TodoItem = todo_str.try_into().expect("todo parse");
|
||||
|
||||
assert_eq!(todo.done, true);
|
||||
assert_eq!(todo.prio, Some('A'));
|
||||
assert_eq!(todo.date_done, Some(date!(2020 - 05 - 01)));
|
||||
assert_eq!(todo.date_made, Some(date!(2020 - 01 - 01)));
|
||||
assert_eq!(
|
||||
*todo.raw,
|
||||
"x (A) 2020-05-01 2020-01-01 help me please @beatles @music".to_string()
|
||||
);
|
||||
|
||||
assert_eq!(todo.get_raw_str(), "help me please @beatles @music");
|
||||
let mut contexts = HashSet::with_hasher(TodoBuildHasher);
|
||||
contexts.insert("@beatles".into());
|
||||
contexts.insert("@music".into());
|
||||
|
||||
assert_eq!(todo.contexts, contexts);
|
||||
assert!(todo.projects.is_empty());
|
||||
assert!(todo.tags.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prio_invalid() {
|
||||
let todo = "(a) hello".to_string();
|
||||
let mut pos = 0;
|
||||
let prio = parse_prio(&todo, &mut pos);
|
||||
|
||||
assert_eq!(prio, None);
|
||||
assert_eq!(pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_prio_valid() {
|
||||
let todo = "(A) hello".to_string();
|
||||
let mut pos = 0;
|
||||
let prio = parse_prio(&todo, &mut pos);
|
||||
|
||||
assert_eq!(prio, Some('A'));
|
||||
assert_eq!(get_word(&todo[pos..]), Some((5, "hello")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dates_not_done_invalid() {
|
||||
let dates = "2020-05-2 hello there".to_string();
|
||||
let mut pos = 0;
|
||||
let (completed, created) = parse_dates(&dates, &mut pos);
|
||||
|
||||
assert_eq!(completed, None);
|
||||
assert_eq!(created, None);
|
||||
assert_eq!(pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dates_not_done_valid() {
|
||||
let dates = "2020-05-02 hello there";
|
||||
let mut pos = 0;
|
||||
let (completed, created) = parse_dates(&dates, &mut pos);
|
||||
|
||||
assert_eq!(completed, None);
|
||||
assert_eq!(created, Some(date!(2020 - 05 - 02)));
|
||||
assert_eq!(get_word(&dates[pos..]), Some((6, "hello")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dates_done_valid() {
|
||||
let dates = "2020-05-03 2020-05-02";
|
||||
let mut pos = 0;
|
||||
let (completed, created) = parse_dates(&dates, &mut pos);
|
||||
|
||||
assert_eq!(completed, Some(date!(2020 - 05 - 03)));
|
||||
assert_eq!(created, Some(date!(2020 - 05 - 02)));
|
||||
assert!(get_word(&dates[pos..]).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_dates_done_invalid() {
|
||||
let dates = "d2020-05-03 2020-05-02";
|
||||
let mut pos = 0;
|
||||
let (completed, created) = parse_dates(&dates, &mut pos);
|
||||
|
||||
assert_eq!(completed, None);
|
||||
assert_eq!(created, None);
|
||||
assert_eq!(pos, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_date_valid() {
|
||||
let date = "2020-05-02";
|
||||
let mut pos = 0;
|
||||
let parsed = parse_date(&date, &mut pos);
|
||||
|
||||
assert_eq!(parsed, Some(date!(2020 - 05 - 02)));
|
||||
assert_eq!(get_word(&date[pos..]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_date_invalid() {
|
||||
let date = "2020-05-2";
|
||||
let mut pos = 0;
|
||||
let parsed = parse_date(&date, &mut pos);
|
||||
|
||||
assert_eq!(parsed, None);
|
||||
assert_eq!(pos, 0);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user