This commit is contained in:
Dheepak Krishnamurthy 2023-09-25 04:19:48 -04:00
parent 732c5b6f84
commit 1ec93c0913
46 changed files with 1233 additions and 7570 deletions

View file

@ -1,39 +0,0 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Action {
Tick,
Error(String),
Quit,
Refresh,
GotoBottom,
GotoTop,
GotoPageBottom,
GotoPageTop,
Down,
Up,
PageDown,
PageUp,
Delete,
Done,
ToggleStartStop,
ToggleMark,
ToggleMarkAll,
QuickTag,
Select,
SelectAll,
Undo,
Edit,
Shell,
Help,
ToggleZoom,
Context,
Next,
Previous,
Shortcut(usize),
Modify,
Log,
Annotate,
Filter,
Add,
}

2752
src/app.rs

File diff suppressed because it is too large Load diff

View file

@ -1,277 +0,0 @@
// Based on https://gist.github.com/diwic/5c20a283ca3a03752e1a27b0f3ebfa30
// See https://old.reddit.com/r/rust/comments/4xneq5/the_calendar_example_challenge_ii_why_eddyb_all/
use std::fmt;
const COL_WIDTH: usize = 21;
use std::cmp::min;
use chrono::{
format::Fixed, DateTime, Datelike, Duration, FixedOffset, Local, Month, NaiveDate, NaiveDateTime, TimeZone,
};
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
symbols,
widgets::{Block, Widget},
};
#[derive(Debug, Clone)]
pub struct Calendar<'a> {
pub block: Option<Block<'a>>,
pub year: i32,
pub month: u32,
pub style: Style,
pub months_per_row: usize,
pub date_style: Vec<(NaiveDate, Style)>,
pub today_style: Style,
pub start_on_monday: bool,
pub title_background_color: Color,
}
impl<'a> Default for Calendar<'a> {
fn default() -> Calendar<'a> {
let year = Local::now().year();
let month = Local::now().month();
Calendar {
block: None,
style: Style::default(),
months_per_row: 0,
year,
month,
date_style: vec![],
today_style: Style::default(),
start_on_monday: false,
title_background_color: Color::Reset,
}
}
}
impl<'a> Calendar<'a> {
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn style(mut self, style: Style) -> Self {
self.style = style;
self
}
pub fn year(mut self, year: i32) -> Self {
self.year = year;
if self.year < 0 {
self.year = 0;
}
self
}
pub fn month(mut self, month: u32) -> Self {
self.month = month;
self
}
pub fn date_style(mut self, date_style: Vec<(NaiveDate, Style)>) -> Self {
self.date_style = date_style;
self
}
pub fn today_style(mut self, today_style: Style) -> Self {
self.today_style = today_style;
self
}
pub fn months_per_row(mut self, months_per_row: usize) -> Self {
self.months_per_row = months_per_row;
self
}
pub fn start_on_monday(mut self, start_on_monday: bool) -> Self {
self.start_on_monday = start_on_monday;
self
}
}
impl<'a> Widget for Calendar<'a> {
fn render(mut self, area: Rect, buf: &mut Buffer) {
let month_names = Self::generate_month_names();
buf.set_style(area, self.style);
let area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
inner_area
}
None => area,
};
if area.height < 7 {
return;
}
let style = self.style;
let today = Local::now();
let year = self.year;
let month = self.month;
let months: Vec<_> = (0..12).collect();
let mut days: Vec<(NaiveDate, NaiveDate)> = months
.iter()
.map(|i| {
let first = NaiveDate::from_ymd_opt(year, i + 1, 1).unwrap();
let num_days = if self.start_on_monday {
first.weekday().num_days_from_monday()
} else {
first.weekday().num_days_from_sunday()
};
(first, first - Duration::days(i64::from(num_days)))
})
.collect();
let mut start_m = 0_usize;
if self.months_per_row > area.width as usize / 8 / 3 || self.months_per_row == 0 {
self.months_per_row = area.width as usize / 8 / 3;
}
let mut y = area.y;
y += 1;
let x = area.x;
let s = format!("{year:^width$}", year = year, width = area.width as usize);
let mut new_year = 0;
let style = Style::default().add_modifier(Modifier::UNDERLINED);
if self.year + new_year as i32 == today.year() {
buf.set_string(x, y, &s, self.today_style.add_modifier(Modifier::UNDERLINED));
} else {
buf.set_string(x, y, &s, style);
}
let start_x = (area.width - 3 * 7 * self.months_per_row as u16 - self.months_per_row as u16) / 2;
y += 2;
loop {
let endm = std::cmp::min(start_m + self.months_per_row, 12);
let mut x = area.x + start_x;
for (c, d) in days.iter_mut().enumerate().take(endm).skip(start_m) {
if c > start_m {
x += 1;
}
let m = d.0.month() as usize;
let s = format!("{:^20}", month_names[m - 1]);
let style = Style::default().bg(self.title_background_color);
if m == today.month() as usize && self.year + new_year as i32 == today.year() {
buf.set_string(x, y, &s, self.today_style);
} else {
buf.set_string(x, y, &s, style);
}
x += s.len() as u16 + 1;
}
y += 1;
let mut x = area.x + start_x;
for d in days.iter_mut().take(endm).skip(start_m) {
let m = d.0.month() as usize;
let style = Style::default().bg(self.title_background_color);
let days_string = if self.start_on_monday {
"Mo Tu We Th Fr Sa Su"
} else {
"Su Mo Tu We Th Fr Sa"
};
buf.set_string(x, y, days_string, style.add_modifier(Modifier::UNDERLINED));
x += 21 + 1;
}
y += 1;
loop {
let mut moredays = false;
let mut x = area.x + start_x;
for c in start_m..endm {
if c > start_m {
x += 1;
}
let d = &mut days[c + new_year * 12];
for _ in 0..7 {
let s = if d.0.month() == d.1.month() {
format!("{:>2}", d.1.day())
} else {
" ".to_string()
};
let mut style = Style::default();
let index = self.date_style.iter().position(|(date, style)| d.1 == *date);
if let Some(i) = index {
style = self.date_style[i].1;
}
if d.1 == Local::now().date_naive() {
buf.set_string(x, y, s, self.today_style);
} else {
buf.set_string(x, y, s, style);
}
x += 3;
d.1 += Duration::days(1);
}
moredays |= d.0.month() == d.1.month() || d.1 < d.0;
}
y += 1;
if !moredays {
break;
}
}
start_m += self.months_per_row;
y += 2;
if y + 8 > area.height {
break;
} else if start_m >= 12 {
start_m = 0;
new_year += 1;
days.append(
&mut months
.iter()
.map(|i| {
let first = NaiveDate::from_ymd_opt(self.year + new_year as i32, i + 1, 1).unwrap();
(
first,
first - Duration::days(i64::from(first.weekday().num_days_from_sunday())),
)
})
.collect::<Vec<_>>(),
);
let x = area.x;
let s = format!(
"{year:^width$}",
year = self.year as usize + new_year,
width = area.width as usize
);
let mut style = Style::default().add_modifier(Modifier::UNDERLINED);
if self.year + new_year as i32 == today.year() {
style = style.add_modifier(Modifier::BOLD);
}
buf.set_string(x, y, &s, style);
y += 1;
}
y += 1;
}
}
}
impl<'a> Calendar<'a> {
fn generate_month_names() -> [&'a str; 12] {
let month_names = [
Month::January.name(),
Month::February.name(),
Month::March.name(),
Month::April.name(),
Month::May.name(),
Month::June.name(),
Month::July.name(),
Month::August.name(),
Month::September.name(),
Month::October.name(),
Month::November.name(),
Month::December.name(),
];
month_names
}
}

View file

@ -1,52 +1,36 @@
use clap::Arg;
use std::path::PathBuf;
const APP_VERSION: &str = env!("CARGO_PKG_VERSION");
const APP_NAME: &str = env!("CARGO_PKG_NAME");
use clap::Parser;
pub fn generate_cli_app() -> clap::Command {
let mut app = clap::Command::new(APP_NAME)
.version(APP_VERSION)
.author("Dheepak Krishnamurthy <@kdheepak>")
.about("A taskwarrior terminal user interface")
.arg(
Arg::new("data")
.short('d')
.long("data")
.value_name("FOLDER")
.help("Sets the data folder for taskwarrior-tui")
.action(clap::ArgAction::Set),
)
.arg(
Arg::new("config")
.short('c')
.long("config")
.value_name("FOLDER")
.help("Sets the config folder for taskwarrior-tui")
.action(clap::ArgAction::Set),
)
.arg(
Arg::new("taskdata")
.long("taskdata")
.value_name("FOLDER")
.help("Sets the .task folder using the TASKDATA environment variable for taskwarrior")
.action(clap::ArgAction::Set),
)
.arg(
Arg::new("taskrc")
.long("taskrc")
.value_name("FILE")
.help("Sets the .taskrc file using the TASKRC environment variable for taskwarrior")
.action(clap::ArgAction::Set),
)
.arg(
Arg::new("report")
.short('r')
.long("report")
.value_name("STRING")
.help("Sets default report")
.action(clap::ArgAction::Set),
);
#[derive(Parser, Debug)]
#[command(author, version, about)]
pub struct Cli {
#[arg(short, long, value_name = "FOLDER", help = "Sets the data folder for taskwarrior-tui")]
pub data: Option<String>,
app.set_bin_name(APP_NAME);
app
#[arg(short, long, value_name = "FOLDER", help = "Sets the config folder for taskwarrior-tui")]
pub config: Option<String>,
#[arg(
long,
value_name = "FOLDER",
help = "Sets the .task folder using the TASKDATA environment variable for taskwarrior"
)]
pub taskdata: Option<PathBuf>,
#[arg(
long,
value_name = "FILE",
help = "Sets the .taskrc file using the TASKRC environment variable for taskwarrior"
)]
pub taskrc: Option<PathBuf>,
#[arg(value_name = "FLOAT", help = "Tick rate", default_value_t = 4.0)]
pub tick_rate: f64,
#[arg(value_name = "FLOAT", help = "Frame rate", default_value_t = 60.0)]
pub frame_rate: f64,
#[arg(short, long, value_name = "STRING", help = "Sets default report")]
pub report: Option<String>,
}

46
src/command.rs Normal file
View file

@ -0,0 +1,46 @@
use serde_derive::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Command {
Tick,
Render,
Resize(u16, u16),
Suspend,
Resume,
Quit,
Refresh,
Error(String),
Help,
MoveDown,
MoveUp,
MoveBottom,
MoveTop,
MoveLeft,
MoveRight,
MoveHome,
MoveEnd,
ToggleMark,
ToggleMarkAll,
Select,
SelectAll,
ToggleZoom,
Context,
RunShortcut(usize),
RunShell,
Task,
ShowTaskReport,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Task {
Undo,
Edit,
Tag,
Start,
Stop,
Modify,
Log,
Annotate,
Filter,
Add,
}

View file

@ -1,206 +0,0 @@
use std::{error::Error, io};
use log::{debug, error, info, log_enabled, trace, warn, Level, LevelFilter};
use ratatui::{
layout::{Constraint, Corner, Direction, Layout},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
Terminal,
};
use rustyline::{
error::ReadlineError,
highlight::{Highlighter, MatchingBracketHighlighter},
hint::Hinter,
history::FileHistory,
line_buffer::LineBuffer,
Context,
};
use unicode_segmentation::{Graphemes, UnicodeSegmentation};
use unicode_width::UnicodeWidthStr;
pub fn get_start_word_under_cursor(line: &str, cursor_pos: usize) -> usize {
let mut chars = line[..cursor_pos].chars();
let mut res = cursor_pos;
while let Some(c) = chars.next_back() {
if c == ' ' || c == '(' || c == ')' {
break;
}
res -= c.len_utf8();
}
// if iter == None, res == 0.
res
}
pub struct TaskwarriorTuiCompletionHelper {
pub candidates: Vec<(String, String)>,
pub context: String,
pub input: String,
}
type Completion = (String, String, String, String, String);
impl TaskwarriorTuiCompletionHelper {
fn complete(&self, word: &str, pos: usize, _ctx: &Context) -> rustyline::Result<(usize, Vec<Completion>)> {
let candidates: Vec<Completion> = self
.candidates
.iter()
.filter_map(|(context, candidate)| {
if context == &self.context
&& (candidate.starts_with(&word[..pos]) || candidate.to_lowercase().starts_with(&word[..pos].to_lowercase()))
&& (!self.input.contains(candidate) || !self.input.to_lowercase().contains(&candidate.to_lowercase()))
{
Some((
candidate.clone(), // display
candidate.to_string(), // replacement
word[..pos].to_string(), // original
candidate[..pos].to_string(),
candidate[pos..].to_string(),
))
} else {
None
}
})
.collect();
Ok((pos, candidates))
}
}
pub struct CompletionList {
pub state: ListState,
pub current: String,
pub pos: usize,
pub helper: TaskwarriorTuiCompletionHelper,
}
impl CompletionList {
pub fn new() -> CompletionList {
CompletionList {
state: ListState::default(),
current: String::new(),
pos: 0,
helper: TaskwarriorTuiCompletionHelper {
candidates: vec![],
context: String::new(),
input: String::new(),
},
}
}
pub fn with_items(items: Vec<(String, String)>) -> CompletionList {
let mut candidates = vec![];
for i in items {
if !candidates.contains(&i) {
candidates.push(i);
}
}
let context = String::new();
let input = String::new();
CompletionList {
state: ListState::default(),
current: String::new(),
pos: 0,
helper: TaskwarriorTuiCompletionHelper {
candidates,
context,
input,
},
}
}
pub fn insert(&mut self, item: (String, String)) {
if !self.helper.candidates.contains(&item) {
self.helper.candidates.push(item);
}
}
pub fn next(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i >= self.candidates().len() - 1 {
0
} else {
i + 1
}
}
None => 0,
};
self.state.select(Some(i));
}
pub fn previous(&mut self) {
let i = match self.state.selected() {
Some(i) => {
if i == 0 {
self.candidates().len() - 1
} else {
i - 1
}
}
None => 0,
};
self.state.select(Some(i));
}
pub fn unselect(&mut self) {
self.state.select(None);
}
pub fn clear(&mut self) {
self.helper.candidates.clear();
self.state.select(None);
}
pub fn len(&self) -> usize {
self.candidates().len()
}
pub fn max_width(&self) -> Option<usize> {
self.candidates().iter().map(|p| p.1.width() + 4).max()
}
pub fn get(&self, i: usize) -> Option<Completion> {
let candidates = self.candidates();
if i < candidates.len() {
Some(candidates[i].clone())
} else {
None
}
}
pub fn selected(&self) -> Option<(usize, Completion)> {
self.state.selected().and_then(|i| self.get(i)).map(|s| (self.pos, s))
}
pub fn is_empty(&self) -> bool {
self.candidates().is_empty()
}
pub fn candidates(&self) -> Vec<Completion> {
let hist = FileHistory::new();
let ctx = rustyline::Context::new(&hist);
let (pos, candidates) = self.helper.complete(&self.current, self.pos, &ctx).unwrap();
candidates
}
pub fn input(&mut self, current: String, i: String) {
self.helper.input = i;
if current.contains('.') && current.contains(':') {
self.current = current.split_once(':').unwrap().1.to_string();
self.helper.context = current.split_once('.').unwrap().0.to_string();
} else if current.contains('.') {
self.current = format!(".{}", current.split_once('.').unwrap().1);
self.helper.context = "modifier".to_string();
} else if current.contains(':') {
self.current = current.split_once(':').unwrap().1.to_string();
self.helper.context = current.split_once(':').unwrap().0.to_string();
} else if current.contains('+') {
self.current = format!("+{}", current.split_once('+').unwrap().1);
self.helper.context = "+".to_string();
} else {
self.current = current;
self.helper.context = "attribute".to_string();
}
self.pos = self.current.len();
}
}

42
src/components.rs Normal file
View file

@ -0,0 +1,42 @@
use color_eyre::eyre::Result;
use crossterm::event::{KeyEvent, MouseEvent};
use ratatui::layout::Rect;
use tokio::sync::mpsc::UnboundedSender;
use crate::{
command::Command,
tui::{Event, Frame},
};
pub mod app;
pub trait Component {
#[allow(unused_variables)]
fn register_command_handler(&mut self, tx: UnboundedSender<Command>) -> Result<()> {
Ok(())
}
fn init(&mut self) -> Result<()> {
Ok(())
}
fn handle_events(&mut self, event: Option<Event>) -> Result<Option<Command>> {
let r = match event {
Some(Event::Key(key_event)) => self.handle_key_events(key_event)?,
Some(Event::Mouse(mouse_event)) => self.handle_mouse_events(mouse_event)?,
_ => None,
};
Ok(r)
}
#[allow(unused_variables)]
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Command>> {
Ok(None)
}
#[allow(unused_variables)]
fn handle_mouse_events(&mut self, mouse: MouseEvent) -> Result<Option<Command>> {
Ok(None)
}
#[allow(unused_variables)]
fn update(&mut self, command: Command) -> Result<Option<Command>> {
Ok(None)
}
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()>;
}

141
src/components/app.rs Normal file
View file

@ -0,0 +1,141 @@
use std::{collections::HashMap, time::Duration};
use color_eyre::eyre::Result;
use crossterm::event::{KeyCode, KeyEvent};
use ratatui::{prelude::*, widgets::*};
use serde_derive::{Deserialize, Serialize};
use task_hookrs::{import::import, task::Task};
use tokio::sync::mpsc::UnboundedSender;
use tui_input::backend::crossterm::EventHandler;
use uuid::Uuid;
use super::{Component, Frame};
use crate::{command::Command, config::KeyBindings};
#[derive(Default, Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum Mode {
#[default]
TaskReport,
TaskContext,
Calendar,
Error,
}
#[derive(Default)]
pub struct App {
pub mode: Mode,
pub command_tx: Option<UnboundedSender<Command>>,
pub keybindings: KeyBindings,
pub last_export: Option<std::time::SystemTime>,
pub report: String,
pub filter: String,
pub current_context_filter: String,
pub tasks: Vec<Task>,
}
impl App {
pub fn new() -> Self {
Self::default()
}
pub fn keybindings(mut self, keybindings: KeyBindings) -> Self {
self.keybindings = keybindings;
self
}
pub fn refresh(&mut self) -> Result<()> {
self.last_export = Some(std::time::SystemTime::now());
Ok(())
}
pub fn send_command(&self, command: Command) -> Result<()> {
if let Some(ref tx) = self.command_tx {
tx.send(command)?;
}
Ok(())
}
pub fn task_export(&mut self) -> Result<()> {
let mut task = std::process::Command::new("task");
task
.arg("rc.json.array=on")
.arg("rc.confirmation=off")
.arg("rc.json.depends.array=on")
.arg("rc.color=off")
.arg("rc._forcecolor=off");
// .arg("rc.verbose:override=false");
if let Some(args) = shlex::split(format!(r#"rc.report.{}.filter='{}'"#, self.report, self.filter.trim()).trim()) {
for arg in args {
task.arg(arg);
}
}
if !self.current_context_filter.trim().is_empty() {
if let Some(args) = shlex::split(&self.current_context_filter) {
for arg in args {
task.arg(arg);
}
}
}
task.arg("export");
task.arg(&self.report);
log::info!("Running `{:?}`", task);
let output = task.output()?;
let data = String::from_utf8_lossy(&output.stdout);
let error = String::from_utf8_lossy(&output.stderr);
if output.status.success() {
if let Ok(imported) = import(data.as_bytes()) {
self.tasks = imported;
log::info!("Imported {} tasks", self.tasks.len());
if self.mode == Mode::Error {
self.send_command(Command::ShowTaskReport)?;
};
// } else {
// self.error = Some(format!("Unable to parse output of `{:?}`:\n`{:?}`", task, data));
// self.mode = Mode::Tasks(Action::Error);
// debug!("Unable to parse output: {:?}", data);
}
} else {
// self.error = Some(format!("Cannot run `{:?}` - ({}) error:\n{}", &task, output.status, error));
}
Ok(())
}
}
impl Component for App {
fn register_command_handler(&mut self, tx: UnboundedSender<Command>) -> Result<()> {
self.command_tx = Some(tx);
Ok(())
}
fn handle_key_events(&mut self, key: KeyEvent) -> Result<Option<Command>> {
let command = if let Some(keymap) = self.keybindings.get(&self.mode) {
if let Some(command) = keymap.get(&vec![key]) {
command
} else {
return Ok(None);
}
} else {
return Ok(None);
};
Ok(Some(command.clone()))
}
fn update(&mut self, command: Command) -> Result<Option<Command>> {
match command {
_ => (),
}
Ok(None)
}
fn draw(&mut self, f: &mut Frame<'_>, rect: Rect) -> Result<()> {
Ok(())
}
}

View file

@ -1,102 +1,322 @@
use std::{collections::HashMap, error::Error, path::PathBuf, str};
use std::{collections::HashMap, fmt, path::PathBuf};
use color_eyre::eyre::{eyre, Context, Result};
use color_eyre::eyre::Result;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
};
use ratatui::{
style::{Color, Modifier, Style},
symbols::line::DOUBLE_VERTICAL,
};
use serde::{
de::{self, Deserialize, Deserializer, MapAccess, Visitor},
ser::{self, Serialize, Serializer},
};
use serde_derive::{Deserialize, Serialize};
use derive_deref::{Deref, DerefMut};
use ratatui::style::{Color, Modifier, Style};
use serde::de::{self, Deserialize, Deserializer, MapAccess, Visitor};
use serde_derive::Deserialize;
use crate::{action::Action, keyevent::parse_key_sequence, keymap::KeyMap, utils::get_config_dir};
use crate::{command::Command, components::app::Mode};
#[derive(Default, Clone, Debug, Copy)]
pub struct SerdeStyle(pub Style);
const CONFIG: &'static str = include_str!("../.config/config.json5");
impl std::ops::Deref for SerdeStyle {
type Target = Style;
fn deref(&self) -> &Self::Target {
&self.0
}
#[derive(Clone, Debug, Deserialize, Default)]
pub struct AppConfig {
#[serde(default)]
pub _data_dir: PathBuf,
#[serde(default)]
pub _config_dir: PathBuf,
}
impl std::ops::DerefMut for SerdeStyle {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
#[derive(Clone, Debug, Default, Deserialize)]
pub struct Config {
#[serde(default, flatten)]
pub config: AppConfig,
#[serde(default)]
pub keybindings: KeyBindings,
#[serde(default)]
pub styles: Styles,
}
impl Config {
pub fn new() -> Result<Self, config::ConfigError> {
let default_config: Config = json5::from_str(&CONFIG).unwrap();
let data_dir = crate::utils::get_data_dir();
let config_dir = crate::utils::get_config_dir();
let mut builder = config::Config::builder()
.set_default("_data_dir", data_dir.to_str().unwrap())?
.set_default("_config_dir", config_dir.to_str().unwrap())?;
builder = builder
.add_source(config::File::from(config_dir.join("config.json5")).format(config::FileFormat::Json5).required(false))
.add_source(config::File::from(config_dir.join("config.json")).format(config::FileFormat::Json).required(false))
.add_source(config::File::from(config_dir.join("config.yaml")).format(config::FileFormat::Yaml).required(false))
.add_source(config::File::from(config_dir.join("config.toml")).format(config::FileFormat::Toml).required(false))
.add_source(config::File::from(config_dir.join("config.ini")).format(config::FileFormat::Ini).required(false));
let mut cfg: Self = builder.build()?.try_deserialize()?;
for (mode, default_bindings) in default_config.keybindings.iter() {
let user_bindings = cfg.keybindings.entry(*mode).or_default();
for (key, cmd) in default_bindings.iter() {
user_bindings.entry(key.clone()).or_insert_with(|| cmd.clone());
}
}
Ok(cfg)
}
}
impl<'de> Deserialize<'de> for SerdeStyle {
#[derive(Clone, Debug, Default, Deref, DerefMut)]
pub struct KeyBindings(pub HashMap<Mode, HashMap<Vec<KeyEvent>, Command>>);
impl<'de> Deserialize<'de> for KeyBindings {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct StyleVisitor;
let parsed_map = HashMap::<Mode, HashMap<String, Command>>::deserialize(deserializer)?;
impl<'de> Visitor<'de> for StyleVisitor {
type Value = SerdeStyle;
let keybindings = parsed_map
.into_iter()
.map(|(mode, inner_map)| {
let converted_inner_map =
inner_map.into_iter().map(|(key_str, cmd)| (parse_key_sequence(&key_str).unwrap(), cmd)).collect();
(mode, converted_inner_map)
})
.collect();
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string representation of tui::style::Style")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<SerdeStyle, E> {
Ok(SerdeStyle(get_tcolor(v)))
}
}
deserializer.deserialize_str(StyleVisitor)
Ok(KeyBindings(keybindings))
}
}
pub fn get_tcolor(line: &str) -> Style {
fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
let raw_lower = raw.to_ascii_lowercase();
let (remaining, modifiers) = extract_modifiers(&raw_lower);
parse_key_code_with_modifiers(remaining, modifiers)
}
fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
let mut modifiers = KeyModifiers::empty();
let mut current = raw;
loop {
match current {
rest if rest.starts_with("ctrl-") => {
modifiers.insert(KeyModifiers::CONTROL);
current = &rest[5..];
},
rest if rest.starts_with("alt-") => {
modifiers.insert(KeyModifiers::ALT);
current = &rest[4..];
},
rest if rest.starts_with("shift-") => {
modifiers.insert(KeyModifiers::SHIFT);
current = &rest[6..];
},
_ => break, // break out of the loop if no known prefix is detected
};
}
(current, modifiers)
}
fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
let c = match raw {
"esc" => KeyCode::Esc,
"enter" => KeyCode::Enter,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" => KeyCode::PageUp,
"pagedown" => KeyCode::PageDown,
"backtab" => {
modifiers.insert(KeyModifiers::SHIFT);
KeyCode::BackTab
},
"backspace" => KeyCode::Backspace,
"delete" => KeyCode::Delete,
"insert" => KeyCode::Insert,
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
"space" => KeyCode::Char(' '),
"hyphen" => KeyCode::Char('-'),
"minus" => KeyCode::Char('-'),
"tab" => KeyCode::Tab,
c if c.len() == 1 => {
let mut c = c.chars().next().unwrap();
if modifiers.contains(KeyModifiers::SHIFT) {
c = c.to_ascii_uppercase();
}
KeyCode::Char(c)
},
_ => return Err(format!("Unable to parse {raw}")),
};
Ok(KeyEvent::new(c, modifiers))
}
pub fn key_event_to_string(key_event: &KeyEvent) -> String {
let char;
let key_code = match key_event.code {
KeyCode::Backspace => "backspace",
KeyCode::Enter => "enter",
KeyCode::Left => "left",
KeyCode::Right => "right",
KeyCode::Up => "up",
KeyCode::Down => "down",
KeyCode::Home => "home",
KeyCode::End => "end",
KeyCode::PageUp => "pageup",
KeyCode::PageDown => "pagedown",
KeyCode::Tab => "tab",
KeyCode::BackTab => "backtab",
KeyCode::Delete => "delete",
KeyCode::Insert => "insert",
KeyCode::F(c) => {
char = format!("f({})", c.to_string());
&char
},
KeyCode::Char(c) if c == ' ' => "space",
KeyCode::Char(c) => {
char = c.to_string();
&char
},
KeyCode::Esc => "esc",
KeyCode::Null => "",
KeyCode::CapsLock => "",
KeyCode::Menu => "",
KeyCode::ScrollLock => "",
KeyCode::Media(_) => "",
KeyCode::NumLock => "",
KeyCode::PrintScreen => "",
KeyCode::Pause => "",
KeyCode::KeypadBegin => "",
KeyCode::Modifier(_) => "",
};
let mut modifiers = Vec::with_capacity(3);
if key_event.modifiers.intersects(KeyModifiers::CONTROL) {
modifiers.push("ctrl");
}
if key_event.modifiers.intersects(KeyModifiers::SHIFT) {
modifiers.push("shift");
}
if key_event.modifiers.intersects(KeyModifiers::ALT) {
modifiers.push("alt");
}
let mut key = modifiers.join("-");
if !key.is_empty() {
key.push('-');
}
key.push_str(key_code);
key
}
pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> {
if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
return Err(format!("Unable to parse `{}`", raw));
}
let raw = if !raw.contains("><") {
let raw = raw.strip_prefix("<").unwrap_or(raw);
let raw = raw.strip_prefix(">").unwrap_or(raw);
raw
} else {
raw
};
let sequences = raw
.split("><")
.map(|seq| {
if seq.starts_with('<') {
&seq[1..]
} else if seq.ends_with('>') {
&seq[..seq.len() - 1]
} else {
seq
}
})
.collect::<Vec<_>>();
sequences.into_iter().map(parse_key_event).collect()
}
#[derive(Clone, Debug, Default, Deref, DerefMut)]
pub struct Styles(pub HashMap<Mode, HashMap<String, Style>>);
impl<'de> Deserialize<'de> for Styles {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let parsed_map = HashMap::<Mode, HashMap<String, String>>::deserialize(deserializer)?;
let styles = parsed_map
.into_iter()
.map(|(mode, inner_map)| {
let converted_inner_map = inner_map.into_iter().map(|(str, style)| (str, parse_style(&style))).collect();
(mode, converted_inner_map)
})
.collect();
Ok(Styles(styles))
}
}
pub fn parse_style(line: &str) -> Style {
let (foreground, background) = line.split_at(line.to_lowercase().find("on ").unwrap_or(line.len()));
let (mut foreground, mut background) = (String::from(foreground), String::from(background));
background = background.replace("on ", "");
let mut modifiers = Modifier::empty();
if foreground.contains("bright") {
foreground = foreground.replace("bright ", "");
background = background.replace("bright ", "");
background.insert_str(0, "bright ");
}
foreground = foreground.replace("grey", "gray");
background = background.replace("grey", "gray");
if foreground.contains("underline") {
modifiers |= Modifier::UNDERLINED;
}
let foreground = foreground.replace("underline ", "");
// TODO: use bold, bright boolean flags
if foreground.contains("bold") {
modifiers |= Modifier::BOLD;
}
// let foreground = foreground.replace("bold ", "");
if foreground.contains("inverse") {
modifiers |= Modifier::REVERSED;
}
let foreground = foreground.replace("inverse ", "");
let foreground = process_color_string(foreground);
let background = process_color_string(&background.replace("on ", ""));
let mut style = Style::default();
if let Some(fg) = get_color_foreground(foreground.as_str()) {
if let Some(fg) = parse_color(&foreground.0) {
style = style.fg(fg);
}
if let Some(bg) = get_color_background(background.as_str()) {
if let Some(bg) = parse_color(&background.0) {
style = style.bg(bg);
}
style = style.add_modifier(modifiers);
style = style.add_modifier(foreground.1 | background.1);
style
}
fn get_color_foreground(s: &str) -> Option<Color> {
fn process_color_string(color_str: &str) -> (String, Modifier) {
let color = color_str
.replace("grey", "gray")
.replace("bright ", "")
.replace("bold ", "")
.replace("underline ", "")
.replace("inverse ", "");
let mut modifiers = Modifier::empty();
if color_str.contains("underline") {
modifiers |= Modifier::UNDERLINED;
}
if color_str.contains("bold") {
modifiers |= Modifier::BOLD;
}
if color_str.contains("inverse") {
modifiers |= Modifier::REVERSED;
}
(color, modifiers)
}
fn parse_color(s: &str) -> Option<Color> {
let s = s.trim_start();
let s = s.trim_end();
if s.contains("color") {
if s.contains("bright color") {
let s = s.trim_start_matches("bright ");
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default();
Some(Color::Indexed(c.wrapping_shl(8)))
} else if s.contains("color") {
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default();
Some(Color::Indexed(c))
} else if s.contains("gray") {
@ -145,372 +365,118 @@ fn get_color_foreground(s: &str) -> Option<Color> {
}
}
fn get_color_background(s: &str) -> Option<Color> {
let s = s.trim_start();
let s = s.trim_end();
if s.contains("bright color") {
let s = s.trim_start_matches("bright ");
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default();
Some(Color::Indexed(c.wrapping_shl(8)))
} else if s.contains("color") {
let c = s.trim_start_matches("color").parse::<u8>().unwrap_or_default();
Some(Color::Indexed(c))
} else if s.contains("gray") {
let s = s.trim_start_matches("bright ");
let c = 232 + s.trim_start_matches("gray").parse::<u8>().unwrap_or_default();
Some(Color::Indexed(c.wrapping_shl(8)))
} else if s.contains("rgb") {
let s = s.trim_start_matches("bright ");
let red = (s.as_bytes()[3] as char).to_digit(10).unwrap_or_default() as u8;
let green = (s.as_bytes()[4] as char).to_digit(10).unwrap_or_default() as u8;
let blue = (s.as_bytes()[5] as char).to_digit(10).unwrap_or_default() as u8;
let c = 16 + red * 36 + green * 6 + blue;
Some(Color::Indexed(c.wrapping_shl(8)))
} else if s == "bright black" {
Some(Color::Indexed(8))
} else if s == "bright red" {
Some(Color::Indexed(9))
} else if s == "bright green" {
Some(Color::Indexed(10))
} else if s == "bright yellow" {
Some(Color::Indexed(11))
} else if s == "bright blue" {
Some(Color::Indexed(12))
} else if s == "bright magenta" {
Some(Color::Indexed(13))
} else if s == "bright cyan" {
Some(Color::Indexed(14))
} else if s == "bright white" {
Some(Color::Indexed(15))
} else if s == "black" {
Some(Color::Indexed(0))
} else if s == "red" {
Some(Color::Indexed(1))
} else if s == "green" {
Some(Color::Indexed(2))
} else if s == "yellow" {
Some(Color::Indexed(3))
} else if s == "blue" {
Some(Color::Indexed(4))
} else if s == "magenta" {
Some(Color::Indexed(5))
} else if s == "cyan" {
Some(Color::Indexed(6))
} else if s == "white" {
Some(Color::Indexed(7))
} else {
None
}
}
impl Serialize for SerdeStyle {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// Getting the foreground color string
let fg_str = color_to_string(self.0.fg.unwrap_or_default());
// Getting the background color string
let mut bg_str = color_to_string(self.0.bg.unwrap_or_default());
// If the background is not default, prepend with "on "
if bg_str != "" {
bg_str.insert_str(0, "on ");
}
// Building the modifier string
let mut mod_str = String::new();
let mod_val = self.0.add_modifier;
if mod_val.contains(Modifier::BOLD) {
mod_str.push_str("bold ");
}
if mod_val.contains(Modifier::UNDERLINED) {
mod_str.push_str("underline ");
}
if mod_val.contains(Modifier::REVERSED) {
mod_str.push_str("inverse ");
}
// Constructing the final style string
let style_str = format!("{}{} {}", mod_str, fg_str, bg_str).trim().to_string();
serializer.serialize_str(&style_str)
}
}
fn color_to_string(color: Color) -> String {
match color {
Color::Black => "black".to_string(),
Color::Red => "red".to_string(),
Color::Green => "green".to_string(),
Color::Reset => "reset".to_string(),
Color::Yellow => "yellow".to_string(),
Color::Blue => "blue".to_string(),
Color::Magenta => "magenta".to_string(),
Color::Cyan => "cyan".to_string(),
Color::Gray => "gray".to_string(),
Color::DarkGray => "darkgray".to_string(),
Color::LightRed => "lightred".to_string(),
Color::LightGreen => "lightgreen".to_string(),
Color::LightYellow => "lightyellow".to_string(),
Color::LightBlue => "lightblue".to_string(),
Color::LightMagenta => "lightmagenta".to_string(),
Color::LightCyan => "lightcyan".to_string(),
Color::White => "white".to_string(),
Color::Rgb(r, g, b) => format!("#{}{}{}", r, g, b),
Color::Indexed(u) => format!("#{}", u),
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Config {
pub tick_rate: usize,
pub keymap: HashMap<String, KeyMap>,
pub enabled: bool,
pub color: HashMap<String, SerdeStyle>,
pub filter: String,
pub data_location: String,
pub obfuscate: bool,
pub print_empty_columns: bool,
pub due: usize,
pub weekstart: bool,
pub rule_precedence_color: Vec<String>,
pub uda_priority_values: Vec<String>,
pub uda_tick_rate: u64,
pub uda_auto_insert_double_quotes_on_add: bool,
pub uda_auto_insert_double_quotes_on_annotate: bool,
pub uda_auto_insert_double_quotes_on_log: bool,
pub uda_prefill_task_metadata: bool,
pub uda_reset_filter_on_esc: bool,
pub uda_task_detail_prefetch: usize,
pub uda_task_report_use_all_tasks_for_completion: bool,
pub uda_task_report_show_info: bool,
pub uda_task_report_looping: bool,
pub uda_task_report_jump_to_task_on_add: bool,
pub uda_selection_indicator: String,
pub uda_mark_indicator: String,
pub uda_unmark_indicator: String,
pub uda_scrollbar_indicator: String,
pub uda_scrollbar_area: String,
pub uda_style_report_scrollbar: SerdeStyle,
pub uda_style_report_scrollbar_area: SerdeStyle,
pub uda_selection_bold: bool,
pub uda_selection_italic: bool,
pub uda_selection_dim: bool,
pub uda_selection_blink: bool,
pub uda_selection_reverse: bool,
pub uda_calendar_months_per_row: usize,
pub uda_style_context_active: SerdeStyle,
pub uda_style_report_selection: SerdeStyle,
pub uda_style_calendar_title: SerdeStyle,
pub uda_style_calendar_today: SerdeStyle,
pub uda_style_navbar: SerdeStyle,
pub uda_style_command: SerdeStyle,
pub uda_style_report_completion_pane: SerdeStyle,
pub uda_style_report_completion_pane_highlight: SerdeStyle,
pub uda_shortcuts: Vec<String>,
pub uda_change_focus_rotate: bool,
pub uda_background_process: String,
pub uda_background_process_period: usize,
pub uda_quick_tag_name: String,
pub uda_task_report_prompt_on_undo: bool,
pub uda_task_report_prompt_on_delete: bool,
pub uda_task_report_prompt_on_done: bool,
pub uda_task_report_date_time_vague_more_precise: bool,
pub uda_context_menu_select_on_move: bool,
}
impl Default for Config {
fn default() -> Self {
let tick_rate = 250;
let mut task_report_keymap: KeyMap = Default::default();
task_report_keymap.insert(parse_key_sequence("q").unwrap(), Action::Quit);
task_report_keymap.insert(parse_key_sequence("r").unwrap(), Action::Refresh);
task_report_keymap.insert(parse_key_sequence("G").unwrap(), Action::GotoBottom);
task_report_keymap.insert(parse_key_sequence("<g><g>").unwrap(), Action::GotoTop);
task_report_keymap.insert(parse_key_sequence("<g><j>").unwrap(), Action::GotoPageBottom);
task_report_keymap.insert(parse_key_sequence("<g><k>").unwrap(), Action::GotoPageTop);
task_report_keymap.insert(parse_key_sequence("<g><G>").unwrap(), Action::GotoBottom);
task_report_keymap.insert(parse_key_sequence("j").unwrap(), Action::Down);
task_report_keymap.insert(parse_key_sequence("k").unwrap(), Action::Up);
task_report_keymap.insert(parse_key_sequence("J").unwrap(), Action::PageDown);
task_report_keymap.insert(parse_key_sequence("K").unwrap(), Action::PageUp);
task_report_keymap.insert(parse_key_sequence("<d><d>").unwrap(), Action::Delete);
task_report_keymap.insert(parse_key_sequence("<x><x>").unwrap(), Action::Done);
task_report_keymap.insert(parse_key_sequence("s").unwrap(), Action::ToggleStartStop);
task_report_keymap.insert(parse_key_sequence("t").unwrap(), Action::QuickTag);
task_report_keymap.insert(parse_key_sequence("v").unwrap(), Action::Select);
task_report_keymap.insert(parse_key_sequence("V").unwrap(), Action::SelectAll);
task_report_keymap.insert(parse_key_sequence("u").unwrap(), Action::Undo);
task_report_keymap.insert(parse_key_sequence("e").unwrap(), Action::Edit);
task_report_keymap.insert(parse_key_sequence("m").unwrap(), Action::Modify);
task_report_keymap.insert(parse_key_sequence("!").unwrap(), Action::Shell);
task_report_keymap.insert(parse_key_sequence("l").unwrap(), Action::Log);
task_report_keymap.insert(parse_key_sequence("a").unwrap(), Action::Add);
task_report_keymap.insert(parse_key_sequence("A").unwrap(), Action::Annotate);
task_report_keymap.insert(parse_key_sequence("?").unwrap(), Action::Help);
task_report_keymap.insert(parse_key_sequence("/").unwrap(), Action::Filter);
task_report_keymap.insert(parse_key_sequence("z").unwrap(), Action::ToggleZoom);
task_report_keymap.insert(parse_key_sequence("c").unwrap(), Action::Context);
task_report_keymap.insert(parse_key_sequence("]").unwrap(), Action::Next);
task_report_keymap.insert(parse_key_sequence("[").unwrap(), Action::Previous);
task_report_keymap.insert(parse_key_sequence("1").unwrap(), Action::Shortcut(1));
task_report_keymap.insert(parse_key_sequence("2").unwrap(), Action::Shortcut(2));
task_report_keymap.insert(parse_key_sequence("3").unwrap(), Action::Shortcut(3));
task_report_keymap.insert(parse_key_sequence("4").unwrap(), Action::Shortcut(4));
task_report_keymap.insert(parse_key_sequence("5").unwrap(), Action::Shortcut(5));
task_report_keymap.insert(parse_key_sequence("6").unwrap(), Action::Shortcut(6));
task_report_keymap.insert(parse_key_sequence("7").unwrap(), Action::Shortcut(7));
task_report_keymap.insert(parse_key_sequence("8").unwrap(), Action::Shortcut(8));
task_report_keymap.insert(parse_key_sequence("9").unwrap(), Action::Shortcut(9));
let mut keymap: HashMap<String, KeyMap> = Default::default();
keymap.insert("task-report".into(), task_report_keymap);
let enabled = true;
let color = Default::default();
let filter = Default::default();
let data_location = Default::default();
let obfuscate = false;
let print_empty_columns = false;
let due = 7; // due 7 days
let weekstart = true; // starts on monday
let rule_precedence_color = Default::default();
let uda_priority_values = Default::default();
let uda_tick_rate = 250;
let uda_auto_insert_double_quotes_on_add = true;
let uda_auto_insert_double_quotes_on_annotate = true;
let uda_auto_insert_double_quotes_on_log = true;
let uda_prefill_task_metadata = Default::default();
let uda_reset_filter_on_esc = true;
let uda_task_detail_prefetch = 10;
let uda_task_report_use_all_tasks_for_completion = Default::default();
let uda_task_report_show_info = true;
let uda_task_report_looping = true;
let uda_task_report_jump_to_task_on_add = true;
let uda_selection_indicator = "\u{2022} ".to_string();
let uda_mark_indicator = "\u{2714} ".to_string();
let uda_unmark_indicator = " ".to_string();
let uda_scrollbar_indicator = "".to_string();
let uda_scrollbar_area = "".to_string();
let uda_style_report_scrollbar = Default::default();
let uda_style_report_scrollbar_area = Default::default();
let uda_selection_bold = true;
let uda_selection_italic = Default::default();
let uda_selection_dim = Default::default();
let uda_selection_blink = Default::default();
let uda_selection_reverse = Default::default();
let uda_calendar_months_per_row = 4;
let uda_style_context_active = Default::default();
let uda_style_report_selection = Default::default();
let uda_style_calendar_title = Default::default();
let uda_style_calendar_today = Default::default();
let uda_style_navbar = Default::default();
let uda_style_command = Default::default();
let uda_style_report_completion_pane = Default::default();
let uda_style_report_completion_pane_highlight = Default::default();
let uda_shortcuts = Default::default();
let uda_change_focus_rotate = Default::default();
let uda_background_process = Default::default();
let uda_background_process_period = Default::default();
let uda_quick_tag_name = Default::default();
let uda_task_report_prompt_on_undo = Default::default();
let uda_task_report_prompt_on_delete = Default::default();
let uda_task_report_prompt_on_done = Default::default();
let uda_task_report_date_time_vague_more_precise = Default::default();
let uda_context_menu_select_on_move = Default::default();
Self {
tick_rate,
keymap,
enabled,
color,
filter,
data_location,
obfuscate,
print_empty_columns,
due,
weekstart,
rule_precedence_color,
uda_priority_values,
uda_tick_rate,
uda_auto_insert_double_quotes_on_add,
uda_auto_insert_double_quotes_on_annotate,
uda_auto_insert_double_quotes_on_log,
uda_prefill_task_metadata,
uda_reset_filter_on_esc,
uda_task_detail_prefetch,
uda_task_report_use_all_tasks_for_completion,
uda_task_report_show_info,
uda_task_report_looping,
uda_task_report_jump_to_task_on_add,
uda_selection_indicator,
uda_mark_indicator,
uda_unmark_indicator,
uda_scrollbar_indicator,
uda_scrollbar_area,
uda_style_report_scrollbar,
uda_style_report_scrollbar_area,
uda_selection_bold,
uda_selection_italic,
uda_selection_dim,
uda_selection_blink,
uda_selection_reverse,
uda_calendar_months_per_row,
uda_style_context_active,
uda_style_report_selection,
uda_style_calendar_title,
uda_style_calendar_today,
uda_style_navbar,
uda_style_command,
uda_style_report_completion_pane,
uda_style_report_completion_pane_highlight,
uda_shortcuts,
uda_change_focus_rotate,
uda_background_process,
uda_background_process_period,
uda_quick_tag_name,
uda_task_report_prompt_on_undo,
uda_task_report_prompt_on_delete,
uda_task_report_prompt_on_done,
uda_task_report_date_time_vague_more_precise,
uda_context_menu_select_on_move,
}
}
}
impl Config {
pub fn new() -> Result<Self> {
let config: Self = Figment::from(Serialized::defaults(Config::default()))
.merge(Toml::file(get_config_dir().join("config.toml")))
.merge(Env::prefixed("TASKWARRIOR_TUI_"))
.extract()?;
Ok(config)
}
pub fn write(&self, path: PathBuf) -> Result<()> {
let content = toml::to_string(&self)?;
std::fs::write(&path, content)?;
std::fs::File::open(&path)?.sync_data()?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
#[test]
fn test_read_config() {
let config = Config::new().unwrap();
dbg!(config);
fn test_parse_style_default() {
let style = parse_style("");
assert_eq!(style, Style::default());
}
// #[test]
// fn test_write_config() {
// let config: Config = Default::default();
// config.write("tests/data/test.toml".into()).unwrap();
// }
#[test]
fn test_parse_style_foreground() {
let style = parse_style("red");
assert_eq!(style.fg, Some(Color::Indexed(1)));
}
#[test]
fn test_parse_style_background() {
let style = parse_style("on blue");
assert_eq!(style.bg, Some(Color::Indexed(4)));
}
#[test]
fn test_parse_style_modifiers() {
let style = parse_style("underline red on blue");
assert_eq!(style.fg, Some(Color::Indexed(1)));
assert_eq!(style.bg, Some(Color::Indexed(4)));
}
#[test]
fn test_process_color_string() {
let (color, modifiers) = process_color_string("underline bold inverse gray");
assert_eq!(color, "gray");
assert!(modifiers.contains(Modifier::UNDERLINED));
assert!(modifiers.contains(Modifier::BOLD));
assert!(modifiers.contains(Modifier::REVERSED));
}
#[test]
fn test_parse_color_rgb() {
let color = parse_color("rgb123");
let expected = 16 + 1 * 36 + 2 * 6 + 3;
assert_eq!(color, Some(Color::Indexed(expected)));
}
#[test]
fn test_parse_color_unknown() {
let color = parse_color("unknown");
assert_eq!(color, None);
}
#[test]
fn test_config() -> Result<()> {
let c = Config::new()?;
assert_eq!(
c.keybindings.get(&Mode::TaskReport).unwrap().get(&parse_key_sequence("<q>").unwrap_or_default()).unwrap(),
&Command::Quit
);
Ok(())
}
#[test]
fn test_simple_keys() {
assert_eq!(parse_key_event("a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()));
assert_eq!(parse_key_event("enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::empty()));
assert_eq!(parse_key_event("esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::empty()));
}
#[test]
fn test_with_modifiers() {
assert_eq!(parse_key_event("ctrl-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert_eq!(parse_key_event("alt-enter").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT));
assert_eq!(parse_key_event("shift-esc").unwrap(), KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT));
}
#[test]
fn test_multiple_modifiers() {
assert_eq!(
parse_key_event("ctrl-alt-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
);
assert_eq!(
parse_key_event("ctrl-shift-enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
);
}
#[test]
fn test_reverse_multiple_modifiers() {
assert_eq!(
key_event_to_string(&KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)),
"ctrl-alt-a".to_string()
);
}
#[test]
fn test_invalid_keys() {
assert!(parse_key_event("invalid-key").is_err());
assert!(parse_key_event("ctrl-invalid-key").is_err());
}
#[test]
fn test_case_insensitivity() {
assert_eq!(parse_key_event("CTRL-a").unwrap(), KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL));
assert_eq!(parse_key_event("AlT-eNtEr").unwrap(), KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT));
}
}

View file

@ -1,2 +0,0 @@

View file

@ -1,49 +0,0 @@
use std::cmp;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, Clear, Paragraph, Widget},
};
const TEXT: &str = include_str!("../docs/keybindings.md");
pub struct Help {
pub title: String,
pub scroll: u16,
pub text_height: usize,
}
impl Help {
pub fn new() -> Self {
Self {
title: "Help".to_string(),
scroll: 0,
text_height: TEXT.lines().count(),
}
}
}
impl Default for Help {
fn default() -> Self {
Self::new()
}
}
impl Widget for &Help {
fn render(self, area: Rect, buf: &mut Buffer) {
let text: Vec<Line> = TEXT.lines().map(|line| Line::from(format!("{}\n", line))).collect();
Paragraph::new(text)
.block(
Block::default()
.title(Span::styled(&self.title, Style::default().add_modifier(Modifier::BOLD)))
.borders(Borders::ALL)
.border_type(BorderType::Rounded),
)
.alignment(Alignment::Left)
.scroll((self.scroll, 0))
.render(area, buf);
}
}

View file

@ -1,132 +0,0 @@
use std::{
fs::File,
path::{Path, PathBuf},
};
use color_eyre::eyre::{anyhow, Result};
use rustyline::{
error::ReadlineError,
history::{DefaultHistory, History, SearchDirection},
};
pub struct HistoryContext {
history: DefaultHistory,
history_index: Option<usize>,
data_path: PathBuf,
}
impl HistoryContext {
pub fn new(filename: &str, data_path: PathBuf) -> Self {
let history = DefaultHistory::new();
std::fs::create_dir_all(&data_path)
.unwrap_or_else(|_| panic!("Unable to create configuration directory in {:?}", &data_path));
let data_path = data_path.join(filename);
Self {
history,
history_index: None,
data_path,
}
}
pub fn load(&mut self) -> Result<()> {
if self.data_path.exists() {
self.history.load(&self.data_path)?;
} else {
self.history.save(&self.data_path)?;
}
self.history_index = None;
log::debug!("Loading history of length {}", self.history.len());
Ok(())
}
pub fn write(&mut self) -> Result<()> {
self.history.save(&self.data_path)?;
Ok(())
}
pub fn history(&self) -> &DefaultHistory {
&self.history
}
pub fn history_index(&self) -> Option<usize> {
self.history_index
}
pub fn history_search(&mut self, buf: &str, dir: SearchDirection) -> Option<String> {
log::debug!(
"Searching history for {:?} in direction {:?} with history index = {:?} and history len = {:?}",
buf,
dir,
self.history_index(),
self.history.len(),
);
if self.history.is_empty() {
log::debug!("History is empty");
return None;
}
let history_index = if self.history_index().is_none() {
log::debug!("History index is none");
match dir {
SearchDirection::Forward => return None,
SearchDirection::Reverse => self.history_index = Some(self.history_len().saturating_sub(1)),
}
self.history_index.unwrap()
} else {
let hi = self.history_index().unwrap();
if hi == self.history.len().saturating_sub(1) && dir == SearchDirection::Forward
|| hi == 0 && dir == SearchDirection::Reverse
{
return None;
}
match dir {
SearchDirection::Reverse => hi.saturating_sub(1),
SearchDirection::Forward => hi.saturating_add(1).min(self.history_len().saturating_sub(1)),
}
};
log::debug!("Using history index = {} for searching", history_index);
return if let Some(history_index) = self.history.starts_with(buf, history_index, dir).unwrap() {
log::debug!("Found index {:?}", history_index);
log::debug!("Previous index {:?}", self.history_index);
self.history_index = Some(history_index.idx);
Some(history_index.entry.to_string())
} else if buf.is_empty() {
self.history_index = Some(history_index);
Some(
self
.history
.get(history_index, SearchDirection::Forward)
.unwrap()
.unwrap()
.entry
.to_string(),
)
} else {
log::debug!("History index = {}. Found no match.", history_index);
None
};
}
pub fn add(&mut self, buf: &str) {
if let Ok(x) = self.history.add(buf) {
if x {
self.reset();
}
}
}
pub fn reset(&mut self) {
self.history_index = None
}
pub fn history_len(&self) -> usize {
self.history.len()
}
}

View file

@ -1,231 +0,0 @@
use std::{collections::HashSet, error::Error, hash::Hash};
use color_eyre::eyre::{anyhow, Result};
use crossterm::event::KeyCode;
use log::{error, info, warn};
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
pub struct KeyConfig {
pub quit: KeyCode,
pub refresh: KeyCode,
pub go_to_bottom: KeyCode,
pub go_to_top: KeyCode,
pub down: KeyCode,
pub up: KeyCode,
pub page_down: KeyCode,
pub page_up: KeyCode,
pub delete: KeyCode,
pub done: KeyCode,
pub start_stop: KeyCode,
pub quick_tag: KeyCode,
pub select: KeyCode,
pub select_all: KeyCode,
pub undo: KeyCode,
pub edit: KeyCode,
pub modify: KeyCode,
pub shell: KeyCode,
pub log: KeyCode,
pub add: KeyCode,
pub annotate: KeyCode,
pub help: KeyCode,
pub filter: KeyCode,
pub zoom: KeyCode,
pub context_menu: KeyCode,
pub next_tab: KeyCode,
pub previous_tab: KeyCode,
pub shortcut0: KeyCode,
pub shortcut1: KeyCode,
pub shortcut2: KeyCode,
pub shortcut3: KeyCode,
pub shortcut4: KeyCode,
pub shortcut5: KeyCode,
pub shortcut6: KeyCode,
pub shortcut7: KeyCode,
pub shortcut8: KeyCode,
pub shortcut9: KeyCode,
}
impl Default for KeyConfig {
fn default() -> Self {
Self {
quit: KeyCode::Char('q'),
refresh: KeyCode::Char('r'),
go_to_bottom: KeyCode::Char('G'),
go_to_top: KeyCode::Char('g'),
down: KeyCode::Char('j'),
up: KeyCode::Char('k'),
page_down: KeyCode::Char('J'),
page_up: KeyCode::Char('K'),
delete: KeyCode::Char('x'),
done: KeyCode::Char('d'),
start_stop: KeyCode::Char('s'),
quick_tag: KeyCode::Char('t'),
select: KeyCode::Char('v'),
select_all: KeyCode::Char('V'),
undo: KeyCode::Char('u'),
edit: KeyCode::Char('e'),
modify: KeyCode::Char('m'),
shell: KeyCode::Char('!'),
log: KeyCode::Char('l'),
add: KeyCode::Char('a'),
annotate: KeyCode::Char('A'),
help: KeyCode::Char('?'),
filter: KeyCode::Char('/'),
zoom: KeyCode::Char('z'),
context_menu: KeyCode::Char('c'),
next_tab: KeyCode::Char(']'),
previous_tab: KeyCode::Char('['),
shortcut0: KeyCode::Char('0'),
shortcut1: KeyCode::Char('1'),
shortcut2: KeyCode::Char('2'),
shortcut3: KeyCode::Char('3'),
shortcut4: KeyCode::Char('4'),
shortcut5: KeyCode::Char('5'),
shortcut6: KeyCode::Char('6'),
shortcut7: KeyCode::Char('7'),
shortcut8: KeyCode::Char('8'),
shortcut9: KeyCode::Char('9'),
}
}
}
impl KeyConfig {
pub fn new(data: &str) -> Result<Self> {
let mut kc = Self::default();
kc.update(data)?;
Ok(kc)
}
pub fn update(&mut self, data: &str) -> Result<()> {
let quit = Self::get_config("uda.taskwarrior-tui.keyconfig.quit", data);
let refresh = Self::get_config("uda.taskwarrior-tui.keyconfig.refresh", data);
let go_to_bottom = Self::get_config("uda.taskwarrior-tui.keyconfig.go-to-bottom", data);
let go_to_top = Self::get_config("uda.taskwarrior-tui.keyconfig.go-to-top", data);
let down = Self::get_config("uda.taskwarrior-tui.keyconfig.down", data);
let up = Self::get_config("uda.taskwarrior-tui.keyconfig.up", data);
let page_down = Self::get_config("uda.taskwarrior-tui.keyconfig.page-down", data);
let page_up = Self::get_config("uda.taskwarrior-tui.keyconfig.page-up", data);
let delete = Self::get_config("uda.taskwarrior-tui.keyconfig.delete", data);
let done = Self::get_config("uda.taskwarrior-tui.keyconfig.done", data);
let start_stop = Self::get_config("uda.taskwarrior-tui.keyconfig.start-stop", data);
let quick_tag = Self::get_config("uda.taskwarrior-tui.keyconfig.quick-tag", data);
let select = Self::get_config("uda.taskwarrior-tui.keyconfig.select", data);
let select_all = Self::get_config("uda.taskwarrior-tui.keyconfig.select-all", data);
let undo = Self::get_config("uda.taskwarrior-tui.keyconfig.undo", data);
let edit = Self::get_config("uda.taskwarrior-tui.keyconfig.edit", data);
let modify = Self::get_config("uda.taskwarrior-tui.keyconfig.modify", data);
let shell = Self::get_config("uda.taskwarrior-tui.keyconfig.shell", data);
let log = Self::get_config("uda.taskwarrior-tui.keyconfig.log", data);
let add = Self::get_config("uda.taskwarrior-tui.keyconfig.add", data);
let annotate = Self::get_config("uda.taskwarrior-tui.keyconfig.annotate", data);
let filter = Self::get_config("uda.taskwarrior-tui.keyconfig.filter", data);
let zoom = Self::get_config("uda.taskwarrior-tui.keyconfig.zoom", data);
let context_menu = Self::get_config("uda.taskwarrior-tui.keyconfig.context-menu", data);
let next_tab = Self::get_config("uda.taskwarrior-tui.keyconfig.next-tab", data);
let previous_tab = Self::get_config("uda.taskwarrior-tui.keyconfig.previous-tab", data);
self.quit = quit.unwrap_or(self.quit);
self.refresh = refresh.unwrap_or(self.refresh);
self.go_to_bottom = go_to_bottom.unwrap_or(self.go_to_bottom);
self.go_to_top = go_to_top.unwrap_or(self.go_to_top);
self.down = down.unwrap_or(self.down);
self.up = up.unwrap_or(self.up);
self.page_down = page_down.unwrap_or(self.page_down);
self.page_up = page_up.unwrap_or(self.page_up);
self.delete = delete.unwrap_or(self.delete);
self.done = done.unwrap_or(self.done);
self.start_stop = start_stop.unwrap_or(self.start_stop);
self.quick_tag = quick_tag.unwrap_or(self.quick_tag);
self.select = select.unwrap_or(self.select);
self.select_all = select_all.unwrap_or(self.select_all);
self.undo = undo.unwrap_or(self.undo);
self.edit = edit.unwrap_or(self.edit);
self.modify = modify.unwrap_or(self.modify);
self.shell = shell.unwrap_or(self.shell);
self.log = log.unwrap_or(self.log);
self.add = add.unwrap_or(self.add);
self.annotate = annotate.unwrap_or(self.annotate);
self.filter = filter.unwrap_or(self.filter);
self.zoom = zoom.unwrap_or(self.zoom);
self.context_menu = context_menu.unwrap_or(self.context_menu);
self.next_tab = next_tab.unwrap_or(self.next_tab);
self.previous_tab = previous_tab.unwrap_or(self.previous_tab);
self.check()
}
pub fn check(&self) -> Result<()> {
let mut elements = vec![
&self.quit,
&self.refresh,
&self.go_to_bottom,
&self.go_to_top,
&self.down,
&self.up,
&self.page_down,
&self.page_up,
&self.delete,
&self.done,
&self.select,
&self.select_all,
&self.start_stop,
&self.quick_tag,
&self.undo,
&self.edit,
&self.modify,
&self.shell,
&self.log,
&self.add,
&self.annotate,
&self.help,
&self.filter,
&self.zoom,
&self.context_menu,
&self.next_tab,
&self.previous_tab,
];
let l = elements.len();
elements.dedup();
if l == elements.len() {
Ok(())
} else {
Err(anyhow!("Duplicate keys found in key config"))
}
}
fn get_config(config: &str, data: &str) -> Option<KeyCode> {
for line in data.split('\n') {
if line.starts_with(config) {
let line = line.trim_start_matches(config).trim_start().trim_end().to_string();
if has_just_one_char(&line) {
return Some(KeyCode::Char(line.chars().next().unwrap()));
} else {
error!("Found multiple characters in {} for {}", line, config);
}
} else if line.starts_with(&config.replace('-', "_")) {
let line = line
.trim_start_matches(&config.replace('-', "_"))
.trim_start()
.trim_end()
.to_string();
if has_just_one_char(&line) {
return Some(KeyCode::Char(line.chars().next().unwrap()));
} else {
error!("Found multiple characters in {} for {}", line, config);
}
}
}
None
}
}
fn has_just_one_char(s: &str) -> bool {
let mut chars = s.chars();
chars.next().is_some() && chars.next().is_none()
}
#[cfg(test)]
mod tests {
use super::*;
}

View file

@ -1,307 +0,0 @@
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MediaKeyCode, ModifierKeyCode};
fn parse_key_event(raw: &str) -> Result<KeyEvent, String> {
let raw_lower = raw.to_ascii_lowercase();
let (remaining, modifiers) = extract_modifiers(&raw_lower);
parse_key_code_with_modifiers(remaining, modifiers)
}
fn extract_modifiers(raw: &str) -> (&str, KeyModifiers) {
let mut modifiers = KeyModifiers::empty();
let mut current = raw;
loop {
match current {
rest if rest.starts_with("ctrl-") => {
modifiers.insert(KeyModifiers::CONTROL);
current = &rest[5..];
}
rest if rest.starts_with("alt-") => {
modifiers.insert(KeyModifiers::ALT);
current = &rest[4..];
}
rest if rest.starts_with("shift-") => {
modifiers.insert(KeyModifiers::SHIFT);
current = &rest[6..];
}
_ => break, // break out of the loop if no known prefix is detected
};
}
(current, modifiers)
}
fn parse_key_code_with_modifiers(raw: &str, mut modifiers: KeyModifiers) -> Result<KeyEvent, String> {
let c = match raw {
"esc" => KeyCode::Esc,
"enter" => KeyCode::Enter,
"left" => KeyCode::Left,
"right" => KeyCode::Right,
"up" => KeyCode::Up,
"down" => KeyCode::Down,
"home" => KeyCode::Home,
"end" => KeyCode::End,
"pageup" => KeyCode::PageUp,
"pagedown" => KeyCode::PageDown,
"backtab" => {
modifiers.insert(KeyModifiers::SHIFT);
KeyCode::BackTab
}
"backspace" => KeyCode::Backspace,
"delete" => KeyCode::Delete,
"insert" => KeyCode::Insert,
"f1" => KeyCode::F(1),
"f2" => KeyCode::F(2),
"f3" => KeyCode::F(3),
"f4" => KeyCode::F(4),
"f5" => KeyCode::F(5),
"f6" => KeyCode::F(6),
"f7" => KeyCode::F(7),
"f8" => KeyCode::F(8),
"f9" => KeyCode::F(9),
"f10" => KeyCode::F(10),
"f11" => KeyCode::F(11),
"f12" => KeyCode::F(12),
"space" => KeyCode::Char(' '),
"tab" => KeyCode::Tab,
c if c.len() == 1 => {
let mut c = c.chars().next().unwrap();
if modifiers.contains(KeyModifiers::SHIFT) {
c = c.to_ascii_uppercase();
}
KeyCode::Char(c)
}
_ => return Err(format!("Unable to parse {raw}")),
};
Ok(KeyEvent::new(c, modifiers))
}
pub fn parse_key_sequence(raw: &str) -> Result<Vec<KeyEvent>, String> {
if raw.chars().filter(|c| *c == '>').count() != raw.chars().filter(|c| *c == '<').count() {
return Err(format!("Unable to parse `{}`", raw));
}
let raw = if !raw.contains("><") {
let raw = raw.strip_prefix("<").unwrap_or(raw);
let raw = raw.strip_prefix(">").unwrap_or(raw);
raw
} else {
raw
};
let sequences = raw
.split("><")
.map(|seq| {
if seq.starts_with('<') {
&seq[1..]
} else if seq.ends_with('>') {
&seq[..seq.len() - 1]
} else {
seq
}
})
.collect::<Vec<_>>();
sequences.into_iter().map(parse_key_event).collect()
}
pub fn key_event_to_string(event: KeyEvent) -> String {
let mut result = String::new();
result.push('<');
// Add modifiers
if event.modifiers.contains(KeyModifiers::CONTROL) {
result.push_str("ctrl-");
}
if event.modifiers.contains(KeyModifiers::ALT) {
result.push_str("alt-");
}
if event.modifiers.contains(KeyModifiers::SHIFT) {
result.push_str("shift-");
}
match event.code {
KeyCode::Char(' ') => result.push_str("space"),
KeyCode::Char(c) => result.push(c),
KeyCode::Enter => result.push_str("enter"),
KeyCode::Esc => result.push_str("esc"),
KeyCode::Left => result.push_str("left"),
KeyCode::Right => result.push_str("right"),
KeyCode::Up => result.push_str("up"),
KeyCode::Down => result.push_str("down"),
KeyCode::Home => result.push_str("home"),
KeyCode::End => result.push_str("end"),
KeyCode::PageUp => result.push_str("pageup"),
KeyCode::PageDown => result.push_str("pagedown"),
KeyCode::BackTab => result.push_str("backtab"),
KeyCode::Delete => result.push_str("delete"),
KeyCode::Insert => result.push_str("insert"),
KeyCode::F(n) => result.push_str(&format!("f{}", n)),
KeyCode::Backspace => result.push_str("backspace"),
KeyCode::Tab => result.push_str("tab"),
KeyCode::Null => result.push_str("null"),
KeyCode::CapsLock => result.push_str("capslock"),
KeyCode::ScrollLock => result.push_str("scrolllock"),
KeyCode::NumLock => result.push_str("numlock"),
KeyCode::PrintScreen => result.push_str("printscreen"),
KeyCode::Pause => result.push_str("pause"),
KeyCode::Menu => result.push_str("menu"),
KeyCode::KeypadBegin => result.push_str("keypadbegin"),
KeyCode::Media(media) => match media {
MediaKeyCode::Play => result.push_str("play"),
MediaKeyCode::Pause => result.push_str("pause"),
MediaKeyCode::PlayPause => result.push_str("playpause"),
MediaKeyCode::Reverse => result.push_str("reverse"),
MediaKeyCode::Stop => result.push_str("stop"),
MediaKeyCode::FastForward => result.push_str("fastforward"),
MediaKeyCode::Rewind => result.push_str("rewind"),
MediaKeyCode::TrackNext => result.push_str("tracknext"),
MediaKeyCode::TrackPrevious => result.push_str("trackprevious"),
MediaKeyCode::Record => result.push_str("record"),
MediaKeyCode::LowerVolume => result.push_str("lowervolume"),
MediaKeyCode::RaiseVolume => result.push_str("raisevolume"),
MediaKeyCode::MuteVolume => result.push_str("mutevolume"),
},
KeyCode::Modifier(keycode) => match keycode {
ModifierKeyCode::LeftShift => result.push_str("leftshift"),
ModifierKeyCode::LeftControl => result.push_str("leftcontrol"),
ModifierKeyCode::LeftAlt => result.push_str("leftalt"),
ModifierKeyCode::LeftSuper => result.push_str("leftsuper"),
ModifierKeyCode::LeftHyper => result.push_str("lefthyper"),
ModifierKeyCode::LeftMeta => result.push_str("leftmeta"),
ModifierKeyCode::RightShift => result.push_str("rightshift"),
ModifierKeyCode::RightControl => result.push_str("rightcontrol"),
ModifierKeyCode::RightAlt => result.push_str("rightalt"),
ModifierKeyCode::RightSuper => result.push_str("rightsuper"),
ModifierKeyCode::RightHyper => result.push_str("righthyper"),
ModifierKeyCode::RightMeta => result.push_str("rightmeta"),
ModifierKeyCode::IsoLevel3Shift => result.push_str("isolevel3shift"),
ModifierKeyCode::IsoLevel5Shift => result.push_str("isolevel5shift"),
},
}
result.push('>');
result
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
fn test_event_to_string() {
let event = KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL);
println!("{}", key_event_to_string(event)); // Outputs: ctrl-a
}
#[test]
fn test_single_key_sequence() {
let result = parse_key_sequence("a");
assert_eq!(
result.unwrap(),
vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())]
);
let result = parse_key_sequence("<a><b>");
assert_eq!(
result.unwrap(),
vec![
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty())
]
);
let result = parse_key_sequence("<Ctrl-a><Alt-b>");
assert_eq!(
result.unwrap(),
vec![
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::ALT)
]
);
let result = parse_key_sequence("<Ctrl-a>");
assert_eq!(
result.unwrap(),
vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL),]
);
let result = parse_key_sequence("<Ctrl-Alt-a>");
assert_eq!(
result.unwrap(),
vec![KeyEvent::new(
KeyCode::Char('a'),
KeyModifiers::CONTROL | KeyModifiers::ALT
),]
);
assert!(parse_key_sequence("Ctrl-a>").is_err());
assert!(parse_key_sequence("<Ctrl-a").is_err());
}
#[test]
fn test_simple_keys() {
assert_eq!(
parse_key_event("a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())
);
assert_eq!(
parse_key_event("enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::empty())
);
assert_eq!(
parse_key_event("esc").unwrap(),
KeyEvent::new(KeyCode::Esc, KeyModifiers::empty())
);
}
#[test]
fn test_with_modifiers() {
assert_eq!(
parse_key_event("ctrl-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
);
assert_eq!(
parse_key_event("alt-enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
);
assert_eq!(
parse_key_event("shift-esc").unwrap(),
KeyEvent::new(KeyCode::Esc, KeyModifiers::SHIFT)
);
}
#[test]
fn test_multiple_modifiers() {
assert_eq!(
parse_key_event("ctrl-alt-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL | KeyModifiers::ALT)
);
assert_eq!(
parse_key_event("ctrl-shift-enter").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL | KeyModifiers::SHIFT)
);
}
#[test]
fn test_invalid_keys() {
assert!(parse_key_event("invalid-key").is_err());
assert!(parse_key_event("ctrl-invalid-key").is_err());
}
#[test]
fn test_case_insensitivity() {
assert_eq!(
parse_key_event("CTRL-a").unwrap(),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)
);
assert_eq!(
parse_key_event("AlT-eNtEr").unwrap(),
KeyEvent::new(KeyCode::Enter, KeyModifiers::ALT)
);
}
}

View file

@ -1,220 +0,0 @@
use std::{
collections::HashMap,
error::Error,
ops::{Deref, DerefMut},
str,
};
use color_eyre::eyre::{eyre, Context, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{
de::{self, Deserialize, Deserializer, MapAccess, Visitor},
ser::{self, Serialize, SerializeMap, Serializer},
};
use crate::{
action::Action,
keyevent::{key_event_to_string, parse_key_sequence},
};
#[derive(Clone, Debug, Default)]
pub struct KeyMap(pub std::collections::HashMap<Vec<KeyEvent>, Action>);
impl Deref for KeyMap {
type Target = std::collections::HashMap<Vec<KeyEvent>, Action>;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for KeyMap {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl KeyMap {
pub fn validate(&self) -> Result<(), String> {
let mut sorted_sequences: Vec<_> = self.keys().collect();
sorted_sequences.sort_by_key(|seq| seq.len());
for i in 0..sorted_sequences.len() {
for j in i + 1..sorted_sequences.len() {
if sorted_sequences[j].starts_with(sorted_sequences[i]) {
return Err(format!(
"Conflict detected: Sequence {:?} is a prefix of sequence {:?}",
sorted_sequences[i], sorted_sequences[j]
));
}
}
}
Ok(())
}
}
impl Serialize for KeyMap {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
// Begin serializing a map.
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (key_sequence, action) in &self.0 {
let key_string = key_sequence
.iter()
.map(|key_event| key_event_to_string(*key_event))
.collect::<Vec<_>>()
.join("");
map.serialize_entry(&key_string, action)?;
}
// End serialization.
map.end()
}
}
impl<'de> Deserialize<'de> for KeyMap {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
struct KeyMapVisitor;
impl<'de> Visitor<'de> for KeyMapVisitor {
type Value = KeyMap;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a keymap with string representation of KeyEvent as key and Action as value")
}
fn visit_map<M>(self, mut access: M) -> Result<KeyMap, M::Error>
where
M: MapAccess<'de>,
{
let mut keymap = std::collections::HashMap::new();
// While there are entries in the map, read them
while let Some((key_sequence_str, action)) = access.next_entry::<String, Action>()? {
let key_sequence = parse_key_sequence(&key_sequence_str).map_err(de::Error::custom)?;
if let Some(old_action) = keymap.insert(key_sequence, action.clone()) {
if old_action != action {
return Err(format!(
"Found a {:?} for both {:?} and {:?}",
key_sequence_str, old_action, action
))
.map_err(de::Error::custom);
}
}
}
Ok(KeyMap(keymap))
}
}
deserializer.deserialize_map(KeyMapVisitor)
}
}
#[cfg(test)]
mod validate_tests {
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use super::*;
#[test]
fn test_no_conflict() {
let mut map = std::collections::HashMap::new();
map.insert(
vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())],
Action::Quit,
);
map.insert(
vec![KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty())],
Action::Quit,
);
let keymap = KeyMap(map);
assert!(keymap.validate().is_ok());
}
#[test]
fn test_conflict_prefix() {
let mut map = std::collections::HashMap::new();
map.insert(
vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty())],
Action::Quit,
);
map.insert(
vec![
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()),
],
Action::Quit,
);
let keymap = KeyMap(map);
assert!(keymap.validate().is_err());
}
#[test]
fn test_no_conflict_different_modifiers() {
let mut map = std::collections::HashMap::new();
map.insert(
vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::CONTROL)],
Action::Quit,
);
map.insert(vec![KeyEvent::new(KeyCode::Char('a'), KeyModifiers::ALT)], Action::Quit);
let keymap = KeyMap(map);
assert!(keymap.validate().is_ok());
}
#[test]
fn test_no_conflict_multiple_keys() {
let mut map = std::collections::HashMap::new();
map.insert(
vec![
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()),
],
Action::Quit,
);
map.insert(
vec![
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
],
Action::Quit,
);
let keymap = KeyMap(map);
assert!(keymap.validate().is_ok());
}
#[test]
fn test_conflict_three_keys() {
let mut map = std::collections::HashMap::new();
map.insert(
vec![
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('c'), KeyModifiers::empty()),
],
Action::Quit,
);
map.insert(
vec![
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::empty()),
KeyEvent::new(KeyCode::Char('b'), KeyModifiers::empty()),
],
Action::Quit,
);
let keymap = KeyMap(map);
assert!(keymap.validate().is_err());
}
}

View file

@ -1,111 +1,38 @@
#![allow(dead_code)]
#![allow(unused_imports)]
#![allow(unused_variables)]
#![allow(clippy::too_many_arguments)]
mod action;
mod app;
mod calendar;
mod cli;
mod completion;
mod config;
mod help;
mod history;
mod keyconfig;
mod keyevent;
mod keymap;
mod pane;
mod scrollbar;
mod table;
mod task_report;
mod traits;
mod tui;
mod ui;
mod utils;
pub mod cli;
pub mod command;
pub mod components;
pub mod config;
pub mod runner;
pub mod tui;
pub mod utils;
use std::{
env,
error::Error,
io::{self, Write},
panic,
path::{Path, PathBuf},
time::Duration,
use clap::Parser;
use cli::Cli;
use color_eyre::eyre::Result;
use crate::{
runner::Runner,
utils::{initialize_logging, initialize_panic_handler, version},
};
// use app::{Mode, TaskwarriorTui};
use color_eyre::eyre::Result;
use utils::{absolute_path, get_config_dir, get_data_dir, initialize_logging, initialize_panic_handler};
// use crossterm::{
// cursor,
// event::{DisableMouseCapture, EnableMouseCapture, EventStream},
// execute,
// terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType, EnterAlternateScreen, LeaveAlternateScreen},
// };
// use futures::stream::{FuturesUnordered, StreamExt};
// use log::{debug, error, info, log_enabled, trace, warn, Level, LevelFilter};
// use ratatui::{backend::CrosstermBackend, Terminal};
// use utils::{get_config_dir, get_data_dir};
async fn tokio_main() -> Result<()> {
initialize_logging()?;
// use crate::{
// action::Action,
// keyconfig::KeyConfig,
// utils::{initialize_logging, initialize_panic_handler},
// };
//
// const LOG_PATTERN: &str = "{d(%Y-%m-%d %H:%M:%S)} | {l} | {f}:{L} | {m}{n}";
initialize_panic_handler()?;
let args = Cli::parse();
let mut runner = Runner::new(args.tick_rate, args.frame_rate)?;
runner.run().await?;
Ok(())
}
#[tokio::main]
async fn main() -> Result<()> {
let matches = cli::generate_cli_app().get_matches();
let config = matches.get_one::<String>("config");
let data = matches.get_one::<String>("data");
let taskrc = matches.get_one::<String>("taskrc");
let taskdata = matches.get_one::<String>("taskdata");
let binding = String::from("next");
let report = matches.get_one::<String>("report").unwrap_or(&binding);
let config_dir = config.map(PathBuf::from).unwrap_or_else(get_config_dir);
let data_dir = data.map(PathBuf::from).unwrap_or_else(get_data_dir);
if let Some(e) = taskrc {
if env::var("TASKRC").is_err() {
// if environment variable is not set, this env::var returns an error
env::set_var(
"TASKRC",
absolute_path(PathBuf::from(e)).expect("Unable to get path for taskrc"),
)
} else {
log::warn!("TASKRC environment variable cannot be set.")
}
}
if let Some(e) = taskdata {
if env::var("TASKDATA").is_err() {
// if environment variable is not set, this env::var returns an error
env::set_var(
"TASKDATA",
absolute_path(PathBuf::from(e)).expect("Unable to get path for taskdata"),
)
} else {
log::warn!("TASKDATA environment variable cannot be set.")
}
}
initialize_logging()?;
initialize_panic_handler()?;
log::info!("getting matches from clap...");
log::debug!("report = {:?}", &report);
log::debug!("config = {:?}", &config);
let mut app = app::TaskwarriorTui::new(report)?;
let r = app.run().await;
if let Err(err) = r {
eprintln!("\x1b[0;31m[taskwarrior-tui error]\x1b[0m: {}\n\nIf you need additional help, please report as a github issue on https://github.com/kdheepak/taskwarrior-tui", err);
std::process::exit(1);
}
tokio_main().await.unwrap();
Ok(())
}

View file

@ -1,148 +0,0 @@
use std::fmt;
use color_eyre::eyre::{anyhow, Context as AnyhowContext, Result};
const NAME: &str = "Name";
const TYPE: &str = "Remaining";
const DEFINITION: &str = "Avg age";
const ACTIVE: &str = "Complete";
use std::{
cmp,
cmp::min,
collections::{HashMap, HashSet},
error::Error,
process::{Command, Output},
};
use chrono::{Datelike, Duration, Local, Month, NaiveDate, NaiveDateTime, TimeZone};
use itertools::Itertools;
use ratatui::{
buffer::Buffer,
layout::{Alignment, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span, Text},
widgets::{Block, BorderType, Borders, Clear, Paragraph, StatefulWidget, Widget},
};
use uuid::Uuid;
use crate::{
action::Action,
app::{Mode, TaskwarriorTui},
pane::Pane,
table::TableState,
};
#[derive(Debug, Clone, Default)]
pub struct ContextDetails {
pub name: String,
pub definition: String,
pub active: String,
pub type_: String,
}
impl ContextDetails {
pub fn new(name: String, definition: String, active: String, type_: String) -> Self {
Self {
name,
definition,
active,
type_,
}
}
}
pub struct ContextsState {
pub table_state: TableState,
pub report_height: u16,
pub columns: Vec<String>,
pub rows: Vec<ContextDetails>,
}
impl ContextsState {
pub(crate) fn new() -> Self {
Self {
table_state: TableState::default(),
report_height: 0,
columns: vec![
NAME.to_string(),
TYPE.to_string(),
DEFINITION.to_string(),
ACTIVE.to_string(),
],
rows: vec![],
}
}
pub fn simplified_view(&mut self) -> (Vec<Vec<String>>, Vec<String>) {
let rows = self
.rows
.iter()
.map(|c| vec![c.name.clone(), c.type_.clone(), c.definition.clone(), c.active.clone()])
.collect();
let headers = self.columns.clone();
(rows, headers)
}
pub fn len(&self) -> usize {
self.rows.len()
}
pub fn update_data(&mut self) -> Result<()> {
let output = Command::new("task").arg("context").output()?;
let data = String::from_utf8_lossy(&output.stdout);
self.rows = vec![];
for (i, line) in data.trim().split('\n').enumerate() {
if line.starts_with(" ") && line.trim().starts_with("write") {
continue;
}
if line.starts_with(" ") && !(line.trim().ends_with("yes") || line.trim().ends_with("no")) {
let definition = line.trim();
if let Some(c) = self.rows.last_mut() {
c.definition = format!("{} {}", c.definition, definition);
}
continue;
}
let line = line.trim();
if line.is_empty() || line == "Use 'task context none' to unset the current context." {
continue;
}
if i == 0 || i == 1 {
continue;
}
let mut s = line.split_whitespace();
let name = s.next().unwrap_or_default();
let typ = s.next().unwrap_or_default();
let active = s.last().unwrap_or_default();
let definition = line.replacen(name, "", 1);
let definition = definition.replacen(typ, "", 1);
let definition = definition.strip_suffix(active).unwrap_or_default();
let context = ContextDetails::new(
name.to_string(),
definition.trim().to_string(),
active.to_string(),
typ.to_string(),
);
self.rows.push(context);
}
if self.rows.iter().any(|r| r.active != "no") {
self.rows.insert(
0,
ContextDetails::new("none".to_string(), "".to_string(), "no".to_string(), "read".to_string()),
);
} else {
self.rows.insert(
0,
ContextDetails::new(
"none".to_string(),
"".to_string(),
"yes".to_string(),
"read".to_string(),
),
);
}
Ok(())
}
}

View file

@ -1,41 +0,0 @@
use std::ops::Index;
use color_eyre::eyre::Result;
use crossterm::event::KeyEvent;
use crate::{
action::Action,
app::{Mode, TaskwarriorTui},
tui::Event,
};
pub mod context;
pub mod project;
pub trait Pane {
fn handle_input(app: &mut TaskwarriorTui, input: KeyEvent) -> Result<()>;
fn change_focus_to_left_pane(app: &mut TaskwarriorTui) {
match app.mode {
Mode::Projects => app.mode = Mode::TaskReport,
Mode::Calendar => {
app.mode = Mode::Projects;
}
_ => {
if app.config.uda_change_focus_rotate {
app.mode = Mode::Calendar;
}
}
}
}
fn change_focus_to_right_pane(app: &mut TaskwarriorTui) {
match app.mode {
Mode::Projects => app.mode = Mode::Calendar,
Mode::Calendar => {
if app.config.uda_change_focus_rotate {
app.mode = Mode::TaskReport;
}
}
_ => app.mode = Mode::Projects,
}
}
}

View file

@ -1,202 +0,0 @@
use std::fmt;
use color_eyre::eyre::{anyhow, Context as AnyhowContext, Result};
use crossterm::event::{KeyCode, KeyEvent};
const COL_WIDTH: usize = 21;
const PROJECT_HEADER: &str = "Name";
const REMAINING_TASK_HEADER: &str = "Remaining";
const AVG_AGE_HEADER: &str = "Avg age";
const COMPLETE_HEADER: &str = "Complete";
use std::{
cmp::min,
collections::{HashMap, HashSet},
error::Error,
process::{Command, Output},
};
use chrono::{Datelike, Duration, Local, Month, NaiveDate, NaiveDateTime, TimeZone};
use itertools::Itertools;
use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
symbols,
widgets::{Block, Widget},
};
use task_hookrs::project::Project;
use uuid::Uuid;
use crate::{
action::Action,
app::{Mode, TaskwarriorTui},
pane::Pane,
table::TableState,
utils::Changeset,
};
pub struct ProjectsState {
pub(crate) list: Vec<Project>,
pub table_state: TableState,
pub current_selection: usize,
pub marked: HashSet<Project>,
pub columns: Vec<String>,
pub rows: Vec<ProjectDetails>,
pub data: String,
}
#[derive(Debug, Clone, Default)]
pub struct ProjectDetails {
name: Project,
remaining: usize,
avg_age: String,
complete: String,
}
impl ProjectsState {
pub(crate) fn new() -> Self {
Self {
list: Vec::default(),
table_state: TableState::default(),
current_selection: 0,
marked: HashSet::default(),
columns: vec![
PROJECT_HEADER.to_string(),
REMAINING_TASK_HEADER.to_string(),
AVG_AGE_HEADER.to_string(),
COMPLETE_HEADER.to_string(),
],
data: Default::default(),
rows: vec![],
}
}
fn pattern_by_marked(app: &mut TaskwarriorTui) -> String {
let mut project_pattern = String::new();
if !app.projects.marked.is_empty() {
for (idx, project) in app.projects.marked.clone().iter().enumerate() {
let mut input: String = String::from(project);
if input.as_str() == "(none)" {
input = " ".to_string();
}
if idx == 0 {
project_pattern = format!("\'(project:{}", input);
} else {
project_pattern = format!("{} or project:{}", project_pattern, input);
}
}
project_pattern = format!("{})\'", project_pattern);
}
project_pattern
}
pub fn toggle_mark(&mut self) {
if !self.list.is_empty() {
let selected = self.current_selection;
if !self.marked.insert(self.list[selected].clone()) {
self.marked.remove(self.list[selected].as_str());
}
}
}
pub fn simplified_view(&mut self) -> (Vec<Vec<String>>, Vec<String>) {
let rows = self
.rows
.iter()
.map(|c| {
vec![
c.name.clone(),
c.remaining.to_string(),
c.avg_age.to_string(),
c.complete.clone(),
]
})
.collect();
let headers = self.columns.clone();
(rows, headers)
}
pub fn last_line(&self, line: &str) -> bool {
let words = line.trim().split(' ').map(|s| s.trim()).collect::<Vec<&str>>();
return words.len() == 2
&& words[0].chars().map(|c| c.is_numeric()).all(|b| b)
&& (words[1] == "project" || words[1] == "projects");
}
pub fn update_data(&mut self) -> Result<()> {
self.list.clear();
self.rows.clear();
let output = Command::new("task")
.arg("summary")
.output()
.context("Unable to run `task summary`")
.unwrap();
let data = String::from_utf8_lossy(&output.stdout);
self.data = data.into();
Ok(())
}
fn update_table_state(&mut self) {
self.table_state.select(Some(self.current_selection));
if self.marked.is_empty() {
self.table_state.single_selection();
} else {
self.table_state.multiple_selection();
self.table_state.clear();
for project in &self.marked {
let index = self.list.iter().position(|x| x == project);
self.table_state.mark(index);
}
}
}
}
impl Pane for ProjectsState {
fn handle_input(app: &mut TaskwarriorTui, input: KeyEvent) -> Result<()> {
if input.code == app.keyconfig.quit {
// || input == KeyCode::Ctrl('c') {
app.should_quit = true;
} else if input.code == app.keyconfig.next_tab {
Self::change_focus_to_right_pane(app);
} else if input.code == app.keyconfig.previous_tab {
Self::change_focus_to_left_pane(app);
} else if input.code == KeyCode::Down || input.code == app.keyconfig.down {
self::focus_on_next_project(app);
} else if input.code == KeyCode::Up || input.code == app.keyconfig.up {
self::focus_on_previous_project(app);
} else if input.code == app.keyconfig.select {
self::update_task_filter_by_selection(app)?;
}
app.projects.update_table_state();
Ok(())
}
}
fn focus_on_next_project(app: &mut TaskwarriorTui) {
if app.projects.current_selection < app.projects.list.len().saturating_sub(1) {
app.projects.current_selection += 1;
app.projects.table_state.select(Some(app.projects.current_selection));
}
}
fn focus_on_previous_project(app: &mut TaskwarriorTui) {
if app.projects.current_selection >= 1 {
app.projects.current_selection -= 1;
app.projects.table_state.select(Some(app.projects.current_selection));
}
}
fn update_task_filter_by_selection(app: &mut TaskwarriorTui) -> Result<()> {
app.projects.table_state.multiple_selection();
let last_project_pattern = ProjectsState::pattern_by_marked(app);
app.projects.toggle_mark();
let new_project_pattern = ProjectsState::pattern_by_marked(app);
let current_filter = app.filter.value();
app.filter_history.add(current_filter);
let mut filter = current_filter.replace(&last_project_pattern, "");
filter = format!("{}{}", filter, new_project_pattern);
app.filter = app.filter.clone().with_value(filter);
Ok(())
}

109
src/runner.rs Normal file
View file

@ -0,0 +1,109 @@
use color_eyre::eyre::Result;
use tokio::sync::mpsc;
use crate::{
command::Command,
components::{app::App, Component},
config::Config,
tui,
};
pub struct Runner {
pub config: Config,
pub tick_rate: f64,
pub frame_rate: f64,
pub components: Vec<Box<dyn Component>>,
pub should_quit: bool,
pub should_suspend: bool,
}
impl Runner {
pub fn new(tick_rate: f64, frame_rate: f64) -> Result<Self> {
let app = App::new();
let config = Config::new()?;
let app = app.keybindings(config.keybindings.clone());
Ok(Self {
tick_rate,
frame_rate,
components: vec![Box::new(app)],
should_quit: false,
should_suspend: false,
config,
})
}
pub async fn run(&mut self) -> Result<()> {
let (command_tx, mut command_rx) = mpsc::unbounded_channel();
let mut tui = tui::Tui::new()?;
tui.tick_rate(self.tick_rate);
tui.frame_rate(self.frame_rate);
tui.enter()?;
for component in self.components.iter_mut() {
component.register_command_handler(command_tx.clone())?;
}
for component in self.components.iter_mut() {
component.init()?;
}
loop {
if let Some(e) = tui.next().await {
match e {
tui::Event::Quit => command_tx.send(Command::Quit)?,
tui::Event::Tick => command_tx.send(Command::Tick)?,
tui::Event::Render => command_tx.send(Command::Render)?,
tui::Event::Resize(x, y) => command_tx.send(Command::Resize(x, y))?,
e => {
for component in self.components.iter_mut() {
if let Some(command) = component.handle_events(Some(e.clone()))? {
command_tx.send(command)?;
}
}
},
}
}
while let Ok(command) = command_rx.try_recv() {
if command != Command::Tick && command != Command::Render {
log::debug!("{command:?}");
}
match command {
Command::Quit => self.should_quit = true,
Command::Suspend => self.should_suspend = true,
Command::Resume => self.should_suspend = false,
Command::Render => {
tui.draw(|f| {
for component in self.components.iter_mut() {
let r = component.draw(f, f.size());
if let Err(e) = r {
command_tx.send(Command::Error(format!("Failed to draw: {:?}", e))).unwrap();
}
}
})?;
},
_ => {},
}
for component in self.components.iter_mut() {
if let Some(command) = component.update(command.clone())? {
command_tx.send(command)?
};
}
}
if self.should_suspend {
tui.suspend()?;
command_tx.send(Command::Resume)?;
tui = tui::Tui::new()?;
tui.tick_rate(self.tick_rate);
tui.frame_rate(self.frame_rate);
tui.enter()?;
} else if self.should_quit {
tui.stop()?;
break;
}
}
tui.exit()?;
Ok(())
}
}

View file

@ -1,63 +0,0 @@
use ratatui::{
backend::Backend,
buffer::Buffer,
layout::{Margin, Rect},
style::{Color, Style},
symbols::{block::FULL, line::DOUBLE_VERTICAL},
widgets::Widget,
Frame,
};
pub struct Scrollbar {
pub pos: u16,
pub len: u16,
pub pos_style: Style,
pub pos_symbol: String,
pub area_style: Style,
pub area_symbol: String,
}
impl Scrollbar {
pub fn new(pos: usize, len: usize) -> Self {
Self {
pos: pos as u16,
len: len as u16,
pos_style: Style::default(),
pos_symbol: FULL.to_string(),
area_style: Style::default(),
area_symbol: DOUBLE_VERTICAL.to_string(),
}
}
}
impl Widget for Scrollbar {
fn render(self, area: Rect, buf: &mut Buffer) {
if area.height <= 2 {
return;
}
if self.len == 0 {
return;
}
let right = area.right().saturating_sub(1);
if right <= area.left() {
return;
};
let (top, height) = { (area.top() + 3, area.height.saturating_sub(4)) };
for y in top..(top + height) {
buf.set_string(right, y, self.area_symbol.clone(), self.area_style);
}
let progress = self.pos as f64 / self.len as f64;
let progress = if progress > 1.0 { 1.0 } else { progress };
let pos = height as f64 * progress;
let pos = pos as i64 as u16;
buf.set_string(right, top + pos, self.pos_symbol, self.pos_style);
}
}

View file

@ -1,537 +0,0 @@
use std::{
collections::{HashMap, HashSet},
fmt::Display,
iter::{self, Iterator},
};
use cassowary::{
strength::{MEDIUM, REQUIRED, WEAK},
Expression, Solver,
WeightedRelation::{EQ, GE, LE},
};
use ratatui::{
buffer::Buffer,
layout::{Constraint, Rect},
style::Style,
widgets::{Block, StatefulWidget, Widget},
};
use unicode_segmentation::{Graphemes, UnicodeSegmentation};
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
pub enum TableMode {
SingleSelection,
MultipleSelection,
}
#[derive(Clone)]
pub struct TableState {
offset: usize,
current_selection: Option<usize>,
marked: HashSet<usize>,
mode: TableMode,
}
impl Default for TableState {
fn default() -> TableState {
TableState {
offset: 0,
current_selection: Some(0),
marked: HashSet::new(),
mode: TableMode::SingleSelection,
}
}
}
impl TableState {
pub fn mode(&self) -> TableMode {
self.mode.clone()
}
pub fn multiple_selection(&mut self) {
self.mode = TableMode::MultipleSelection;
}
pub fn single_selection(&mut self) {
self.mode = TableMode::SingleSelection;
}
pub fn current_selection(&self) -> Option<usize> {
self.current_selection
}
pub fn select(&mut self, index: Option<usize>) {
self.current_selection = index;
if index.is_none() {
self.offset = 0;
}
}
pub fn mark(&mut self, index: Option<usize>) {
if let Some(i) = index {
self.marked.insert(i);
}
}
pub fn unmark(&mut self, index: Option<usize>) {
if let Some(i) = index {
self.marked.remove(&i);
}
}
pub fn toggle_mark(&mut self, index: Option<usize>) {
if let Some(i) = index {
if !self.marked.insert(i) {
self.marked.remove(&i);
}
}
}
pub fn marked(&self) -> std::collections::hash_set::Iter<usize> {
self.marked.iter()
}
pub fn clear(&mut self) {
self.marked.drain().for_each(drop);
}
}
/// Holds data to be displayed in a Table widget
#[derive(Debug, Clone)]
pub enum Row<D>
where
D: Iterator,
D::Item: Display,
{
Data(D),
StyledData(D, Style),
}
/// A widget to display data in formatted columns
///
/// # Examples
///
/// ```rust
/// # use ratatui::widgets::{Block, Borders, Table, Row};
/// # use ratatui::layout::Constraint;
/// # use ratatui::style::{Style, Color};
/// let row_style = Style::default().fg(Color::White);
/// Table::new(
/// ["Col1", "Col2", "Col3"].into_iter(),
/// vec![
/// Row::StyledData(["Row11", "Row12", "Row13"].into_iter(), row_style),
/// Row::StyledData(["Row21", "Row22", "Row23"].into_iter(), row_style),
/// Row::StyledData(["Row31", "Row32", "Row33"].into_iter(), row_style),
/// Row::Data(["Row41", "Row42", "Row43"].into_iter())
/// ].into_iter()
/// )
/// .block(Block::default().title("Table"))
/// .header_style(Style::default().fg(Color::Yellow))
/// .widths(&[Constraint::Length(5), Constraint::Length(5), Constraint::Length(10)])
/// .style(Style::default().fg(Color::White))
/// .column_spacing(1);
/// ```
#[derive(Debug, Clone)]
pub struct Table<'a, H, R> {
/// A block to wrap the widget in
block: Option<Block<'a>>,
/// Base style for the widget
style: Style,
/// Header row for all columns
header: H,
/// Style for the header
header_style: Style,
/// Width constraints for each column
widths: &'a [Constraint],
/// Space between each column
column_spacing: u16,
/// Space between the header and the rows
header_gap: u16,
/// Style used to render the selected row
highlight_style: Style,
/// Symbol in front of the selected row
highlight_symbol: Option<&'a str>,
/// Symbol in front of the marked row
mark_symbol: Option<&'a str>,
/// Symbol in front of the unmarked row
unmark_symbol: Option<&'a str>,
/// Symbol in front of the marked and selected row
mark_highlight_symbol: Option<&'a str>,
/// Symbol in front of the unmarked and selected row
unmark_highlight_symbol: Option<&'a str>,
/// Data to display in each row
rows: R,
}
impl<'a, H, R> Default for Table<'a, H, R>
where
H: Iterator + Default,
R: Iterator + Default,
{
fn default() -> Table<'a, H, R> {
Table {
block: None,
style: Style::default(),
header: H::default(),
header_style: Style::default(),
widths: &[],
column_spacing: 1,
header_gap: 1,
highlight_style: Style::default(),
highlight_symbol: None,
mark_symbol: None,
unmark_symbol: None,
mark_highlight_symbol: None,
unmark_highlight_symbol: None,
rows: R::default(),
}
}
}
impl<'a, H, D, R> Table<'a, H, R>
where
H: Iterator,
D: Iterator,
D::Item: Display,
R: Iterator<Item = Row<D>>,
{
pub fn new(header: H, rows: R) -> Table<'a, H, R> {
Table {
block: None,
style: Style::default(),
header,
header_style: Style::default(),
widths: &[],
column_spacing: 1,
header_gap: 1,
highlight_style: Style::default(),
highlight_symbol: None,
mark_symbol: None,
unmark_symbol: None,
mark_highlight_symbol: None,
unmark_highlight_symbol: None,
rows,
}
}
pub fn block(mut self, block: Block<'a>) -> Table<'a, H, R> {
self.block = Some(block);
self
}
pub fn header<II>(mut self, header: II) -> Table<'a, H, R>
where
II: IntoIterator<Item = H::Item, IntoIter = H>,
{
self.header = header.into_iter();
self
}
pub fn header_style(mut self, style: Style) -> Table<'a, H, R> {
self.header_style = style;
self
}
pub fn widths(mut self, widths: &'a [Constraint]) -> Table<'a, H, R> {
let between_0_and_100 = |&w| match w {
Constraint::Percentage(p) => p <= 100,
_ => true,
};
assert!(
widths.iter().all(between_0_and_100),
"Percentages should be between 0 and 100 inclusively."
);
self.widths = widths;
self
}
pub fn rows<II>(mut self, rows: II) -> Table<'a, H, R>
where
II: IntoIterator<Item = Row<D>, IntoIter = R>,
{
self.rows = rows.into_iter();
self
}
pub fn style(mut self, style: Style) -> Table<'a, H, R> {
self.style = style;
self
}
pub fn mark_symbol(mut self, mark_symbol: &'a str) -> Table<'a, H, R> {
self.mark_symbol = Some(mark_symbol);
self
}
pub fn unmark_symbol(mut self, unmark_symbol: &'a str) -> Table<'a, H, R> {
self.unmark_symbol = Some(unmark_symbol);
self
}
pub fn mark_highlight_symbol(mut self, mark_highlight_symbol: &'a str) -> Table<'a, H, R> {
self.mark_highlight_symbol = Some(mark_highlight_symbol);
self
}
pub fn unmark_highlight_symbol(mut self, unmark_highlight_symbol: &'a str) -> Table<'a, H, R> {
self.unmark_highlight_symbol = Some(unmark_highlight_symbol);
self
}
pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Table<'a, H, R> {
self.highlight_symbol = Some(highlight_symbol);
self
}
pub fn highlight_style(mut self, highlight_style: Style) -> Table<'a, H, R> {
self.highlight_style = highlight_style;
self
}
pub fn column_spacing(mut self, spacing: u16) -> Table<'a, H, R> {
self.column_spacing = spacing;
self
}
pub fn header_gap(mut self, gap: u16) -> Table<'a, H, R> {
self.header_gap = gap;
self
}
}
impl<'a, H, D, R> StatefulWidget for Table<'a, H, R>
where
H: Iterator,
H::Item: Display,
D: Iterator,
D::Item: Display,
R: Iterator<Item = Row<D>>,
{
type State = TableState;
fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
buf.set_style(area, self.style);
// Render block if necessary and get the drawing area
let table_area = match self.block.take() {
Some(b) => {
let inner_area = b.inner(area);
b.render(area, buf);
inner_area
}
None => area,
};
let mut solver = Solver::new();
let mut var_indices = HashMap::new();
let mut ccs = Vec::new();
let mut variables = Vec::new();
for i in 0..self.widths.len() {
let var = cassowary::Variable::new();
variables.push(var);
var_indices.insert(var, i);
}
for (i, constraint) in self.widths.iter().enumerate() {
ccs.push(variables[i] | GE(WEAK) | 0.);
ccs.push(match *constraint {
Constraint::Length(v) => variables[i] | EQ(MEDIUM) | f64::from(v),
Constraint::Percentage(v) => variables[i] | EQ(WEAK) | (f64::from(v * area.width) / 100.0),
Constraint::Ratio(n, d) => variables[i] | EQ(WEAK) | (f64::from(area.width) * f64::from(n) / f64::from(d)),
Constraint::Min(v) => variables[i] | GE(WEAK) | f64::from(v),
Constraint::Max(v) => variables[i] | LE(WEAK) | f64::from(v),
});
}
solver
.add_constraint(
variables.iter().fold(Expression::from_constant(0.), |acc, v| acc + *v)
| LE(REQUIRED)
| f64::from(area.width - 2 - (self.column_spacing * (variables.len() as u16 - 1))),
)
.unwrap();
solver.add_constraints(&ccs).unwrap();
let mut solved_widths = vec![0; variables.len()];
for &(var, value) in solver.fetch_changes() {
let index = var_indices[&var];
let value = if value.is_sign_negative() { 0 } else { value as u16 };
solved_widths[index] = value;
}
let mut y = table_area.top();
let mut x = table_area.left();
// Draw header
let mut header_index = usize::MAX;
let mut index = 0;
if y < table_area.bottom() {
for (w, t) in solved_widths.iter().zip(self.header.by_ref()) {
buf.set_stringn(
x,
y,
format!("{symbol:>width$}", symbol = " ", width = *w as usize),
*w as usize,
self.header_style,
);
if t.to_string() == "ID" {
buf.set_stringn(
x,
y,
format!("{symbol:>width$}", symbol = t, width = *w as usize),
*w as usize,
self.header_style,
);
header_index = index;
} else {
buf.set_stringn(x, y, format!("{}", t), *w as usize, self.header_style);
}
x += *w + self.column_spacing;
index += 1;
}
}
y += 1 + self.header_gap;
// Use highlight_style only if something is selected
let (selected, highlight_style) = if state.current_selection().is_some() {
(state.current_selection(), self.highlight_style)
} else {
(None, self.style)
};
let highlight_symbol = match state.mode {
TableMode::MultipleSelection => {
let s = self.highlight_symbol.unwrap_or("\u{2022}").trim_end();
format!("{} ", s)
}
TableMode::SingleSelection => self.highlight_symbol.unwrap_or("").to_string(),
};
let mark_symbol = match state.mode {
TableMode::MultipleSelection => {
let s = self.mark_symbol.unwrap_or("\u{2714}").trim_end();
format!("{} ", s)
}
TableMode::SingleSelection => self.highlight_symbol.unwrap_or("").to_string(),
};
let blank_symbol = match state.mode {
TableMode::MultipleSelection => {
let s = self.unmark_symbol.unwrap_or(" ").trim_end();
format!("{} ", s)
}
TableMode::SingleSelection => " ".repeat(highlight_symbol.width()),
};
let mark_highlight_symbol = {
let s = self.mark_highlight_symbol.unwrap_or("\u{29bf}").trim_end();
format!("{} ", s)
};
let unmark_highlight_symbol = {
let s = self.unmark_highlight_symbol.unwrap_or("\u{29be}").trim_end();
format!("{} ", s)
};
// Draw rows
let default_style = Style::default();
if y < table_area.bottom() {
let remaining = (table_area.bottom() - y) as usize;
// Make sure the table shows the selected item
state.offset = selected.map_or(0, |s| {
if s >= remaining + state.offset - 1 {
s + 1 - remaining
} else if s < state.offset {
s
} else {
state.offset
}
});
for (i, row) in self.rows.skip(state.offset).take(remaining).enumerate() {
let (data, style, symbol) = match row {
Row::Data(d) | Row::StyledData(d, _) if Some(i) == state.current_selection().map(|s| s - state.offset) => {
match state.mode {
TableMode::MultipleSelection => {
if state.marked.contains(&(i + state.offset)) {
(d, highlight_style, mark_highlight_symbol.to_string())
} else {
(d, highlight_style, unmark_highlight_symbol.to_string())
}
}
TableMode::SingleSelection => (d, highlight_style, highlight_symbol.to_string()),
}
}
Row::Data(d) => {
if state.marked.contains(&(i + state.offset)) {
(d, default_style, mark_symbol.to_string())
} else {
(d, default_style, blank_symbol.to_string())
}
}
Row::StyledData(d, s) => {
if state.marked.contains(&(i + state.offset)) {
(d, s, mark_symbol.to_string())
} else {
(d, s, blank_symbol.to_string())
}
}
};
x = table_area.left();
for (c, (w, elt)) in solved_widths.iter().zip(data).enumerate() {
let s = if c == 0 {
buf.set_stringn(
x,
y + i as u16,
format!("{symbol:^width$}", symbol = "", width = area.width as usize),
*w as usize,
style,
);
if c == header_index {
let symbol = match state.mode {
TableMode::SingleSelection | TableMode::MultipleSelection => &symbol,
};
format!(
"{symbol}{elt:>width$}",
symbol = symbol,
elt = elt,
width = (*w as usize).saturating_sub(symbol.to_string().width())
)
} else {
format!(
"{symbol}{elt:<width$}",
symbol = symbol,
elt = elt,
width = (*w as usize).saturating_sub(symbol.to_string().width())
)
}
} else {
buf.set_stringn(
x - 1,
y + i as u16,
format!("{symbol:^width$}", symbol = "", width = area.width as usize),
*w as usize + 1,
style,
);
if c == header_index {
format!("{elt:>width$}", elt = elt, width = *w as usize)
} else {
format!("{elt:<width$}", elt = elt, width = *w as usize)
}
};
buf.set_stringn(x, y + i as u16, s, *w as usize, style);
x += *w + self.column_spacing;
}
}
}
}
}
impl<'a, H, D, R> Widget for Table<'a, H, R>
where
H: Iterator,
H::Item: Display,
D: Iterator,
D::Item: Display,
R: Iterator<Item = Row<D>>,
{
fn render(self, area: Rect, buf: &mut Buffer) {
let mut state = TableState::default();
StatefulWidget::render(self, area, buf, &mut state);
}
}

View file

@ -1,481 +0,0 @@
use std::{error::Error, process::Command};
use chrono::{DateTime, Datelike, Local, NaiveDate, NaiveDateTime, TimeZone};
use color_eyre::eyre::Result;
use itertools::join;
use task_hookrs::{task::Task, uda::UDAValue};
use unicode_truncate::UnicodeTruncateStr;
use unicode_width::UnicodeWidthStr;
pub fn format_date_time(dt: NaiveDateTime) -> String {
let dt = Local.from_local_datetime(&dt).unwrap();
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
pub fn format_date(dt: NaiveDateTime) -> String {
let offset = Local.offset_from_utc_datetime(&dt);
let dt = DateTime::<Local>::from_naive_utc_and_offset(dt, offset);
dt.format("%Y-%m-%d").to_string()
}
pub fn vague_format_date_time(from_dt: NaiveDateTime, to_dt: NaiveDateTime, with_remainder: bool) -> String {
let to_dt = Local.from_local_datetime(&to_dt).unwrap();
let from_dt = Local.from_local_datetime(&from_dt).unwrap();
let mut seconds = (to_dt - from_dt).num_seconds();
let minus = if seconds < 0 {
seconds *= -1;
"-"
} else {
""
};
let year = 60 * 60 * 24 * 365;
let month = 60 * 60 * 24 * 30;
let week = 60 * 60 * 24 * 7;
let day = 60 * 60 * 24;
let hour = 60 * 60;
let minute = 60;
if seconds >= 60 * 60 * 24 * 365 {
return if with_remainder {
format!(
"{}{}y{}mo",
minus,
seconds / year,
(seconds - year * (seconds / year)) / month
)
} else {
format!("{}{}y", minus, seconds / year)
};
} else if seconds >= 60 * 60 * 24 * 90 {
return if with_remainder {
format!(
"{}{}mo{}w",
minus,
seconds / month,
(seconds - month * (seconds / month)) / week
)
} else {
format!("{}{}mo", minus, seconds / month)
};
} else if seconds >= 60 * 60 * 24 * 14 {
return if with_remainder {
format!(
"{}{}w{}d",
minus,
seconds / week,
(seconds - week * (seconds / week)) / day
)
} else {
format!("{}{}w", minus, seconds / week)
};
} else if seconds >= 60 * 60 * 24 {
return if with_remainder {
format!(
"{}{}d{}h",
minus,
seconds / day,
(seconds - day * (seconds / day)) / hour
)
} else {
format!("{}{}d", minus, seconds / day)
};
} else if seconds >= 60 * 60 {
return if with_remainder {
format!(
"{}{}h{}min",
minus,
seconds / hour,
(seconds - hour * (seconds / hour)) / minute
)
} else {
format!("{}{}h", minus, seconds / hour)
};
} else if seconds >= 60 {
return if with_remainder {
format!(
"{}{}min{}s",
minus,
seconds / minute,
(seconds - minute * (seconds / minute))
)
} else {
format!("{}{}min", minus, seconds / minute)
};
}
format!("{}{}s", minus, seconds)
}
pub struct TaskReportTable {
pub labels: Vec<String>,
pub columns: Vec<String>,
pub tasks: Vec<Vec<String>>,
pub virtual_tags: Vec<String>,
pub description_width: usize,
pub date_time_vague_precise: bool,
}
impl TaskReportTable {
pub fn new(data: &str, report: &str) -> Result<Self> {
let virtual_tags = vec![
"PROJECT",
"BLOCKED",
"UNBLOCKED",
"BLOCKING",
"DUE",
"DUETODAY",
"TODAY",
"OVERDUE",
"WEEK",
"MONTH",
"QUARTER",
"YEAR",
"ACTIVE",
"SCHEDULED",
"PARENT",
"CHILD",
"UNTIL",
"WAITING",
"ANNOTATED",
"READY",
"YESTERDAY",
"TOMORROW",
"TAGGED",
"PENDING",
"COMPLETED",
"DELETED",
"UDA",
"ORPHAN",
"PRIORITY",
"PROJECT",
"LATEST",
"RECURRING",
"INSTANCE",
"TEMPLATE",
];
let mut task_report_table = Self {
labels: vec![],
columns: vec![],
tasks: vec![vec![]],
virtual_tags: virtual_tags.iter().map(ToString::to_string).collect::<Vec<_>>(),
description_width: 100,
date_time_vague_precise: false,
};
task_report_table.export_headers(Some(data), report)?;
Ok(task_report_table)
}
pub fn export_headers(&mut self, data: Option<&str>, report: &str) -> Result<()> {
self.columns = vec![];
self.labels = vec![];
let data = if let Some(s) = data {
s.to_string()
} else {
let output = Command::new("task")
.arg("show")
.arg("rc.defaultwidth=0")
.arg(format!("report.{}.columns", report))
.output()?;
String::from_utf8_lossy(&output.stdout).into_owned()
};
for line in data.split('\n') {
if line.starts_with(format!("report.{}.columns", report).as_str()) {
let column_names = line.split_once(' ').unwrap().1;
for column in column_names.split(',') {
self.columns.push(column.to_string());
}
}
}
let output = Command::new("task")
.arg("show")
.arg("rc.defaultwidth=0")
.arg(format!("report.{}.labels", report))
.output()?;
let data = String::from_utf8_lossy(&output.stdout);
for line in data.split('\n') {
if line.starts_with(format!("report.{}.labels", report).as_str()) {
let label_names = line.split_once(' ').unwrap().1;
for label in label_names.split(',') {
self.labels.push(label.to_string());
}
}
}
if self.labels.is_empty() {
for label in &self.columns {
let label = label.split('.').collect::<Vec<&str>>()[0];
let label = if label == "id" { "ID" } else { label };
let mut c = label.chars();
let label = match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
};
if !label.is_empty() {
self.labels.push(label);
}
}
}
let num_labels = self.labels.len();
let num_columns = self.columns.len();
assert!(num_labels == num_columns, "Must have the same number of labels (currently {}) and columns (currently {}). Compare their values as shown by \"task show report.{}.\" and fix your taskwarrior config.", num_labels, num_columns, report);
Ok(())
}
pub fn generate_table(&mut self, tasks: &[Task]) {
self.tasks = vec![];
// get all tasks as their string representation
for task in tasks {
if self.columns.is_empty() {
break;
}
let mut item = vec![];
for name in &self.columns {
let s = self.get_string_attribute(name, task, tasks);
item.push(s);
}
self.tasks.push(item);
}
}
pub fn simplify_table(&mut self) -> (Vec<Vec<String>>, Vec<String>) {
// find which columns are empty
if self.tasks.is_empty() {
return (vec![], vec![]);
}
let mut null_columns = vec![0; self.tasks[0].len()];
for task in &self.tasks {
for (i, s) in task.iter().enumerate() {
null_columns[i] += s.len();
}
}
// filter out columns where everything is empty
let mut tasks = vec![];
for task in &self.tasks {
let t = task.clone();
let t: Vec<String> = t
.iter()
.enumerate()
.filter(|&(i, _)| null_columns[i] != 0)
.map(|(_, e)| e.clone())
.collect();
tasks.push(t);
}
// filter out header where all columns are empty
let headers: Vec<String> = self
.labels
.iter()
.enumerate()
.filter(|&(i, _)| null_columns[i] != 0)
.map(|(_, e)| e.clone())
.collect();
(tasks, headers)
}
pub fn get_string_attribute(&self, attribute: &str, task: &Task, tasks: &[Task]) -> String {
match attribute {
"id" => task.id().unwrap_or_default().to_string(),
"scheduled.relative" => match task.scheduled() {
Some(v) => vague_format_date_time(
Local::now().naive_utc(),
NaiveDateTime::new(v.date(), v.time()),
self.date_time_vague_precise,
),
None => "".to_string(),
},
"due.relative" => match task.due() {
Some(v) => vague_format_date_time(
Local::now().naive_utc(),
NaiveDateTime::new(v.date(), v.time()),
self.date_time_vague_precise,
),
None => "".to_string(),
},
"due" => match task.due() {
Some(v) => format_date(NaiveDateTime::new(v.date(), v.time())),
None => "".to_string(),
},
"until.remaining" => match task.until() {
Some(v) => vague_format_date_time(
Local::now().naive_utc(),
NaiveDateTime::new(v.date(), v.time()),
self.date_time_vague_precise,
),
None => "".to_string(),
},
"until" => match task.until() {
Some(v) => format_date(NaiveDateTime::new(v.date(), v.time())),
None => "".to_string(),
},
"entry.age" => vague_format_date_time(
NaiveDateTime::new(task.entry().date(), task.entry().time()),
Local::now().naive_utc(),
self.date_time_vague_precise,
),
"entry" => format_date(NaiveDateTime::new(task.entry().date(), task.entry().time())),
"start.age" => match task.start() {
Some(v) => vague_format_date_time(
NaiveDateTime::new(v.date(), v.time()),
Local::now().naive_utc(),
self.date_time_vague_precise,
),
None => "".to_string(),
},
"start" => match task.start() {
Some(v) => format_date(NaiveDateTime::new(v.date(), v.time())),
None => "".to_string(),
},
"end.age" => match task.end() {
Some(v) => vague_format_date_time(
NaiveDateTime::new(v.date(), v.time()),
Local::now().naive_utc(),
self.date_time_vague_precise,
),
None => "".to_string(),
},
"end" => match task.end() {
Some(v) => format_date(NaiveDateTime::new(v.date(), v.time())),
None => "".to_string(),
},
"status.short" => task.status().to_string().chars().next().unwrap().to_string(),
"status" => task.status().to_string(),
"priority" => match task.priority() {
Some(p) => p.clone(),
None => "".to_string(),
},
"project" => match task.project() {
Some(p) => p.to_string(),
None => "".to_string(),
},
"depends.count" => match task.depends() {
Some(v) => {
if v.is_empty() {
"".to_string()
} else {
format!("{}", v.len())
}
}
None => "".to_string(),
},
"depends" => match task.depends() {
Some(v) => {
if v.is_empty() {
"".to_string()
} else {
let mut dt = vec![];
for u in v {
if let Some(t) = tasks.iter().find(|t| t.uuid() == u) {
dt.push(t.id().unwrap());
}
}
join(dt.iter().map(ToString::to_string), " ")
}
}
None => "".to_string(),
},
"tags.count" => match task.tags() {
Some(v) => {
let t = v.iter().filter(|t| !self.virtual_tags.contains(t)).count();
if t == 0 {
"".to_string()
} else {
t.to_string()
}
}
None => "".to_string(),
},
"tags" => match task.tags() {
Some(v) => v
.iter()
.filter(|t| !self.virtual_tags.contains(t))
.cloned()
.collect::<Vec<_>>()
.join(","),
None => "".to_string(),
},
"recur" => match task.recur() {
Some(v) => v.clone(),
None => "".to_string(),
},
"wait" => match task.wait() {
Some(v) => vague_format_date_time(
NaiveDateTime::new(v.date(), v.time()),
Local::now().naive_utc(),
self.date_time_vague_precise,
),
None => "".to_string(),
},
"wait.remaining" => match task.wait() {
Some(v) => vague_format_date_time(
Local::now().naive_utc(),
NaiveDateTime::new(v.date(), v.time()),
self.date_time_vague_precise,
),
None => "".to_string(),
},
"description.count" => {
let c = if let Some(a) = task.annotations() {
format!("[{}]", a.len())
} else {
Default::default()
};
format!("{} {}", task.description(), c)
}
"description.truncated_count" => {
let c = if let Some(a) = task.annotations() {
format!("[{}]", a.len())
} else {
Default::default()
};
let d = task.description().to_string();
let mut available_width = self.description_width;
if self.description_width >= c.len() {
available_width = self.description_width - c.len();
}
let (d, _) = d.unicode_truncate(available_width);
let mut d = d.to_string();
if d != *task.description() {
d = format!("{}\u{2026}", d);
}
format!("{}{}", d, c)
}
"description.truncated" => {
let d = task.description().to_string();
let available_width = self.description_width;
let (d, _) = d.unicode_truncate(available_width);
let mut d = d.to_string();
if d != *task.description() {
d = format!("{}\u{2026}", d);
}
d
}
"description.desc" | "description" => task.description().to_string(),
"urgency" => match &task.urgency() {
Some(f) => format!("{:.2}", *f),
None => "0.00".to_string(),
},
s => {
let u = &task.uda();
let v = u.get(s);
if v.is_none() {
return "".to_string();
}
match v.unwrap() {
UDAValue::Str(s) => s.to_string(),
UDAValue::F64(f) => f.to_string(),
UDAValue::U64(u) => u.to_string(),
}
}
}
}
}

View file

@ -1,24 +0,0 @@
use task_hookrs::task::Task;
pub trait TaskwarriorTuiTask {
fn add_tag(&mut self, tag: String);
fn remove_tag(&mut self, tag: &str);
}
impl TaskwarriorTuiTask for Task {
fn add_tag(&mut self, tag: String) {
match self.tags_mut() {
Some(t) => t.push(tag),
None => self.set_tags(Some(vec![tag])),
}
}
fn remove_tag(&mut self, tag: &str) {
if let Some(t) = self.tags_mut() {
if let Some(index) = t.iter().position(|x| *x == tag) {
t.remove(index);
}
}
}
}

View file

@ -1,4 +1,3 @@
use std::{
ops::{Deref, DerefMut},
time::Duration,
@ -23,6 +22,7 @@ pub type Frame<'a> = ratatui::Frame<'a, Backend<std::io::Stderr>>;
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum Event {
Init,
Quit,
Error,
Closed,
@ -42,36 +42,43 @@ pub struct Tui {
pub cancellation_token: CancellationToken,
pub event_rx: UnboundedReceiver<Event>,
pub event_tx: UnboundedSender<Event>,
pub tick_rate: (usize, usize),
pub frame_rate: f64,
pub tick_rate: f64,
}
impl Tui {
pub fn new() -> Result<Self> {
let tick_rate = (1000, 100);
let tick_rate = 4.0;
let frame_rate = 60.0;
let terminal = ratatui::Terminal::new(Backend::new(std::io::stderr()))?;
let (event_tx, event_rx) = mpsc::unbounded_channel();
let cancellation_token = CancellationToken::new();
let task = tokio::spawn(async {});
Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, tick_rate })
Ok(Self { terminal, task, cancellation_token, event_rx, event_tx, frame_rate, tick_rate })
}
pub fn tick_rate(&mut self, tick_rate: (usize, usize)) {
pub fn tick_rate(&mut self, tick_rate: f64) {
self.tick_rate = tick_rate;
}
pub fn frame_rate(&mut self, frame_rate: f64) {
self.frame_rate = frame_rate;
}
pub fn start(&mut self) {
let tick_rate = std::time::Duration::from_millis(self.tick_rate.0 as u64);
let render_tick_rate = std::time::Duration::from_millis(self.tick_rate.1 as u64);
let tick_delay = std::time::Duration::from_secs_f64(1.0 / self.tick_rate);
let render_delay = std::time::Duration::from_secs_f64(1.0 / self.frame_rate);
self.cancel();
self.cancellation_token = CancellationToken::new();
let _cancellation_token = self.cancellation_token.clone();
let _event_tx = self.event_tx.clone();
self.task = tokio::spawn(async move {
let mut reader = crossterm::event::EventStream::new();
let mut interval = tokio::time::interval(tick_rate);
let mut render_interval = tokio::time::interval(render_tick_rate);
let mut tick_interval = tokio::time::interval(tick_delay);
let mut render_interval = tokio::time::interval(render_delay);
_event_tx.send(Event::Init).unwrap();
loop {
let delay = interval.tick();
let tick_delay = tick_interval.tick();
let render_delay = render_interval.tick();
let crossterm_event = reader.next().fuse();
tokio::select! {
@ -110,7 +117,7 @@ impl Tui {
None => {},
}
},
_ = delay => {
_ = tick_delay => {
_event_tx.send(Event::Tick).unwrap();
},
_ = render_delay => {
@ -132,7 +139,7 @@ impl Tui {
}
if counter > 100 {
log::error!("Failed to abort task in 100 milliseconds for unknown reason");
return Err(color_eyre::eyre::eyre!("Unable to abort task"));
break;
}
}
Ok(())
@ -145,10 +152,13 @@ impl Tui {
Ok(())
}
pub fn exit(&self) -> Result<()> {
pub fn exit(&mut self) -> Result<()> {
self.stop()?;
crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?;
if crossterm::terminal::is_raw_mode_enabled()? {
self.flush()?;
crossterm::execute!(std::io::stderr(), LeaveAlternateScreen, cursor::Show)?;
crossterm::terminal::disable_raw_mode()?;
}
Ok(())
}
@ -156,7 +166,7 @@ impl Tui {
self.cancellation_token.cancel();
}
pub fn suspend(&self) -> Result<()> {
pub fn suspend(&mut self) -> Result<()> {
self.exit()?;
#[cfg(not(windows))]
signal_hook::low_level::raise(signal_hook::consts::signal::SIGTSTP)?;

View file

@ -1,37 +0,0 @@
use ratatui::{
backend::Backend,
layout::{Alignment, Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
symbols,
text::{Line, Span},
widgets::{Block, BorderType, Borders, Cell, LineGauge, Paragraph, Row, Table},
Frame,
};
use crate::app::TaskwarriorTui;
pub fn draw<B>(rect: &mut Frame<B>, app: &TaskwarriorTui)
where
B: Backend,
{
let size = rect.size();
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(10), Constraint::Length(3)].as_ref())
.split(size);
let title = draw_title();
rect.render_widget(title, chunks[0]);
}
fn draw_title<'a>() -> Paragraph<'a> {
Paragraph::new("Taskwarrior TUI")
.style(Style::default().fg(Color::LightCyan))
.alignment(Alignment::Center)
.block(
Block::default()
.borders(Borders::ALL)
.style(Style::default().fg(Color::White))
.border_type(BorderType::Plain),
)
}

View file

@ -1,92 +1,64 @@
use path_clean::PathClean;
use rustyline::line_buffer::{ChangeListener, DeleteListener, Direction};
/// Undo manager
#[derive(Default)]
pub struct Changeset {}
impl DeleteListener for Changeset {
fn delete(&mut self, idx: usize, string: &str, _: Direction) {}
}
impl ChangeListener for Changeset {
fn insert_char(&mut self, idx: usize, c: char) {}
fn insert_str(&mut self, idx: usize, string: &str) {}
fn replace(&mut self, idx: usize, old: &str, new: &str) {}
}
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use color_eyre::eyre::Result;
use directories::ProjectDirs;
use lazy_static::lazy_static;
use tracing::error;
use tracing_error::ErrorLayer;
use tracing_subscriber::{
self, filter::EnvFilter, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, Layer,
};
use crate::tui::Tui;
use tracing_subscriber::{self, prelude::__tracing_subscriber_SubscriberExt, util::SubscriberInitExt, Layer};
lazy_static! {
pub static ref PROJECT_NAME: String = env!("CARGO_CRATE_NAME").to_uppercase().to_string();
pub static ref DATA_FOLDER: Option<PathBuf> = std::env::var(format!("{}_DATA", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
pub static ref CONFIG_FOLDER: Option<PathBuf> = std::env::var(format!("{}_CONFIG", PROJECT_NAME.clone()))
.ok()
.map(PathBuf::from);
pub static ref DATA_FOLDER: Option<PathBuf> =
std::env::var(format!("{}_DATA", PROJECT_NAME.clone())).ok().map(PathBuf::from);
pub static ref CONFIG_FOLDER: Option<PathBuf> =
std::env::var(format!("{}_CONFIG", PROJECT_NAME.clone())).ok().map(PathBuf::from);
pub static ref GIT_COMMIT_HASH: String =
std::env::var(format!("{}_GIT_INFO", PROJECT_NAME.clone())).unwrap_or_else(|_| String::from("Unknown"));
pub static ref LOG_LEVEL: String = std::env::var(format!("{}_LOG_LEVEL", PROJECT_NAME.clone())).unwrap_or_default();
pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME").to_lowercase());
std::env::var(format!("{}_GIT_INFO", PROJECT_NAME.clone())).unwrap_or_else(|_| String::from("UNKNOWN"));
pub static ref LOG_ENV: String = format!("{}_LOGLEVEL", PROJECT_NAME.clone());
pub static ref LOG_FILE: String = format!("{}.log", env!("CARGO_PKG_NAME"));
}
fn project_directory() -> Option<ProjectDirs> {
ProjectDirs::from("com", "kdheepak", PROJECT_NAME.clone().to_lowercase().as_str())
ProjectDirs::from("com", "kdheepak", env!("CARGO_PKG_NAME"))
}
pub fn initialize_panic_handler() -> Result<()> {
let (panic_hook, eyre_hook) = color_eyre::config::HookBuilder::default()
.panic_section(format!(
"This is a bug. Consider reporting it at {}",
env!("CARGO_PKG_REPOSITORY")
))
.display_location_section(true)
.display_env_section(true)
.issue_url(concat!(env!("CARGO_PKG_REPOSITORY"), "/issues/new"))
.add_issue_metadata("version", env!("CARGO_PKG_VERSION"))
.add_issue_metadata("os", std::env::consts::OS)
.add_issue_metadata("arch", std::env::consts::ARCH)
.panic_section(format!("This is a bug. Consider reporting it at {}", env!("CARGO_PKG_REPOSITORY")))
.capture_span_trace_by_default(false)
.display_location_section(false)
.display_env_section(false)
.into_hooks();
eyre_hook.install()?;
std::panic::set_hook(Box::new(move |panic_info| {
if let Ok(t) = Tui::new() {
if let Ok(mut t) = crate::tui::Tui::new() {
if let Err(r) = t.exit() {
error!("Unable to exit Terminal: {:?}", r);
}
}
#[cfg(not(debug_assertions))]
{
use human_panic::{handle_dump, print_msg, Metadata};
let meta = Metadata {
version: env!("CARGO_PKG_VERSION").into(),
name: env!("CARGO_PKG_NAME").into(),
authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(),
homepage: env!("CARGO_PKG_HOMEPAGE").into(),
};
let file_path = handle_dump(&meta, panic_info);
// prints human-panic message
print_msg(file_path, &meta).expect("human-panic: printing error message to console failed");
eprintln!("{}", panic_hook.panic_report(panic_info)); // prints color-eyre stack trace to stderr
}
let msg = format!("{}", panic_hook.panic_report(panic_info));
eprintln!("{}", msg);
log::error!("Error: {}", strip_ansi_escapes::strip_str(msg));
use human_panic::{handle_dump, print_msg, Metadata};
let meta = Metadata {
version: env!("CARGO_PKG_VERSION").into(),
name: env!("CARGO_PKG_NAME").into(),
authors: env!("CARGO_PKG_AUTHORS").replace(':', ", ").into(),
homepage: env!("CARGO_PKG_HOMEPAGE").into(),
};
let file_path = handle_dump(&meta, panic_info);
print_msg(file_path, &meta).expect("human-panic: printing error message to console failed");
// Better Panic. Only enabled *when* debugging.
#[cfg(debug_assertions)]
{
// Better Panic stacktrace that is only enabled when debugging.
better_panic::Settings::auto()
.most_recent_first(false)
.lineno_suffix(true)
@ -126,30 +98,20 @@ pub fn initialize_logging() -> Result<()> {
std::fs::create_dir_all(directory.clone())?;
let log_path = directory.join(LOG_FILE.clone());
let log_file = std::fs::File::create(log_path)?;
std::env::set_var(
"RUST_LOG",
std::env::var("RUST_LOG")
.or_else(|_| std::env::var(LOG_ENV.clone()))
.unwrap_or_else(|_| format!("{}=info", env!("CARGO_CRATE_NAME"))),
);
let file_subscriber = tracing_subscriber::fmt::layer()
.with_file(true)
.with_line_number(true)
.with_writer(log_file)
.with_target(false)
.with_ansi(false)
.with_filter(EnvFilter::from_default_env());
tracing_subscriber::registry()
.with(file_subscriber)
// .with(tui_logger::tracing_subscriber_layer())
.with(ErrorLayer::default())
.init();
// let default_level = match LOG_LEVEL.clone().to_lowercase().as_str() {
// "off" => log::LevelFilter::Off,
// "error" => log::LevelFilter::Error,
// "warn" => log::LevelFilter::Warn,
// "info" => log::LevelFilter::Info,
// "debug" => log::LevelFilter::Debug,
// "trace" => log::LevelFilter::Trace,
// _ => log::LevelFilter::Info,
// };
// tui_logger::set_default_level(default_level);
.with_filter(tracing_subscriber::filter::EnvFilter::from_default_env());
tracing_subscriber::registry().with(file_subscriber).with(ErrorLayer::default()).init();
Ok(())
}
@ -198,16 +160,3 @@ Config directory: {config_dir_path}
Data directory: {data_dir_path}"
)
}
pub fn absolute_path(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
let path = path.as_ref();
let absolute_path = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir()?.join(path)
}
.clean();
Ok(absolute_path)
}