Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ pub enum Action {
ToggleLineNum,
ToggleShowDone,
ToggleShowFuture,
ToggleShowBlocked,
CopyLine,
CopyBody,
OpenNote,
Expand Down Expand Up @@ -108,6 +109,7 @@ impl Action {
"toggle_line_num" | "toggle_line_numbers" => Some(Self::ToggleLineNum),
"toggle_show_done" => Some(Self::ToggleShowDone),
"toggle_show_future" => Some(Self::ToggleShowFuture),
"toggle_show_blocked" => Some(Self::ToggleShowBlocked),
"copy_line" => Some(Self::CopyLine),
"copy_body" => Some(Self::CopyBody),
"open_note" | "note" => Some(Self::OpenNote),
Expand Down
6 changes: 6 additions & 0 deletions src/app/palette.rs
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,11 @@ pub const ENTRIES: &[PaletteEntry] = &[
keys: "F",
action: Action::ToggleShowFuture,
},
PaletteEntry {
label: "show blocked in list",
keys: "B",
action: Action::ToggleShowBlocked,
},
PaletteEntry {
label: "toggle filter pane",
keys: "[",
Expand Down Expand Up @@ -553,6 +558,7 @@ mod tests {
Action::ToggleLineNum,
Action::ToggleShowDone,
Action::ToggleShowFuture,
Action::ToggleShowBlocked,
Action::CopyLine,
Action::CopyBody,
Action::EscapeStack,
Expand Down
7 changes: 7 additions & 0 deletions src/app/prefs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ pub struct Prefs {
pub layout: Layout,
pub show_done: bool,
pub show_future: bool,
pub show_blocked: bool,
/// Metadata keys whose `key:value` tokens are hidden from task rows.
/// Config-only (no in-app toggle); see `Config::hidden_keys`.
pub hidden_keys: Vec<String>,
Expand Down Expand Up @@ -64,6 +65,7 @@ impl Prefs {
},
show_done: cfg.show_done.unwrap_or(false),
show_future: cfg.show_future.unwrap_or(false),
show_blocked: cfg.show_blocked.unwrap_or(true),
hidden_keys: cfg.hidden_keys,
week_start: cfg.week_start.unwrap_or(WeekStart::Sunday),
recurrence_builder: cfg.recurrence_builder.unwrap_or(true),
Expand Down Expand Up @@ -133,6 +135,10 @@ impl Prefs {
self.show_future = !self.show_future;
}

pub fn toggle_show_blocked(&mut self) {
self.show_blocked = !self.show_blocked;
}

pub fn cycle_week_start(&mut self) -> String {
self.week_start = match self.week_start {
WeekStart::Sunday => WeekStart::Monday,
Expand Down Expand Up @@ -160,6 +166,7 @@ impl Prefs {
cfg.show_status_bar = Some(self.layout.status_bar);
cfg.show_done = Some(self.show_done);
cfg.show_future = Some(self.show_future);
cfg.show_blocked = Some(self.show_blocked);
cfg.hidden_keys = self.hidden_keys.clone();
cfg.save()
}
Expand Down
15 changes: 15 additions & 0 deletions src/app/visibility.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use super::App;
use super::types::{Sort, View};
use crate::core::filter::{self, ListDueBucket};
use crate::todo::Task;

/// One entry per visible row, parallel to `visible_cache`. Renderers detect
/// group transitions by comparing successive entries; under `Sort::File` every
Expand Down Expand Up @@ -44,8 +45,22 @@ impl App {
let needle = (!self.filter.search.is_empty())
.then(|| filter::resolve_needle(&self.filter.search, today));

let undone_ids: std::collections::HashSet<&str> = tasks
.iter()
.filter(|t| !t.done)
.filter_map(|t| t.id.as_deref())
.collect();
let is_blocked = |t: &Task| {
t.needs
.as_deref()
.is_some_and(|dep| undone_ids.contains(dep))
};

let mut idxs: Vec<usize> = (0..tasks.len())
.filter(|&i| {
if !self.prefs.show_blocked && is_blocked(&tasks[i]) {
return false;
}
filter::list_predicate(
&tasks[i],
self.prefs.show_done,
Expand Down
7 changes: 7 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ pub struct Config {
pub show_status_bar: Option<bool>,
pub show_done: Option<bool>,
pub show_future: Option<bool>,
pub show_blocked: Option<bool>,
/// 64-character lowercase-hex token gating the in-TUI capture server.
/// Persisted across sessions so phone bookmarks survive a relaunch.
/// Stored on disk; only meaningful for LAN access, but flagged here
Expand Down Expand Up @@ -154,6 +155,7 @@ fn parse(s: &str) -> Config {
"show_status_bar" => c.show_status_bar = parse_bool(v),
"show_done" => c.show_done = parse_bool(v),
"show_future" => c.show_future = parse_bool(v),
"show_blocked" => c.show_blocked = parse_bool(v),
// Reject anything that isn't a valid hex token so we don't
// carry forward a corrupt value that the server would later
// refuse to compare against.
Expand Down Expand Up @@ -226,6 +228,9 @@ fn serialize(c: &Config) -> String {
if let Some(v) = c.show_future {
let _ = writeln!(out, "show_future = {v}");
}
if let Some(v) = c.show_blocked {
let _ = writeln!(out, "show_blocked = {v}");
}
if let Some(v) = &c.share_token {
let _ = writeln!(out, "share_token = {v}");
}
Expand Down Expand Up @@ -283,6 +288,7 @@ mod tests {
show_status_bar: Some(true),
show_done: Some(true),
show_future: Some(true),
show_blocked: Some(true),
share_token: Some("a".repeat(64)),
share_port: Some(18080),
filters: vec![
Expand Down Expand Up @@ -443,6 +449,7 @@ mod tests {
show_status_bar: Some(false),
show_done: Some(true),
show_future: Some(false),
show_blocked: Some(false),
share_token: None,
share_port: None,
filters: vec![("errand".into(), "@errand".into())],
Expand Down
9 changes: 9 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,7 @@ fn handle_settings(app: &mut App, key: KeyEvent) {
KeyCode::Char(']') => apply_action(app, Action::ToggleRightPane),
KeyCode::Char('H') => apply_action(app, Action::ToggleShowDone),
KeyCode::Char('F') => apply_action(app, Action::ToggleShowFuture),
KeyCode::Char('B') => apply_action(app, Action::ToggleShowBlocked),
KeyCode::Char('S') => apply_action(app, Action::CycleSort),
_ => {}
}
Expand Down Expand Up @@ -1092,6 +1093,7 @@ fn resolve_normal_key(app: &mut App, key: KeyEvent, keybinds: &KeyBindings) -> O
KeyCode::Char('L') => Action::ToggleLineNum,
KeyCode::Char('H') => Action::ToggleShowDone,
KeyCode::Char('F') => Action::ToggleShowFuture,
KeyCode::Char('B') => Action::ToggleShowBlocked,
KeyCode::Esc => Action::EscapeStack,
KeyCode::Char('W') => Action::ChangeWeekStart,
_ => return None,
Expand Down Expand Up @@ -1136,6 +1138,7 @@ fn apply_action(app: &mut App, action: Action) {
| Action::CycleSort
| Action::ToggleShowDone
| Action::ToggleShowFuture
| Action::ToggleShowBlocked
| Action::Undo => {
app.flash("read-only in archive");
return;
Expand Down Expand Up @@ -1307,6 +1310,12 @@ fn apply_action(app: &mut App, action: Action) {
app.recompute_visible();
app.save_prefs();
}
Action::ToggleShowBlocked => {
app.prefs.toggle_show_blocked();
app.cursor = 0;
app.recompute_visible();
app.save_prefs();
}
Action::CopyLine => copy_current_task(app, false),
Action::CopyBody => copy_current_task(app, true),
Action::OpenNote => app.open_note_for_current(),
Expand Down
15 changes: 15 additions & 0 deletions src/todo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ pub struct Task {
/// filter parses it on demand via `crate::threshold`.
pub threshold: Option<String>,
pub notes: Vec<String>,
/// Raw value of the `id:` tag if present.
pub id: Option<String>,
/// Raw value of the `needs:` tag if present.
pub needs: Option<String>,
}

pub fn parse_line(raw: &str) -> Result<Task, ParseError> {
Expand Down Expand Up @@ -94,6 +98,8 @@ pub fn parse_line(raw: &str) -> Result<Task, ParseError> {
let rec = find_kv(rest, "rec");
let threshold = find_kv(rest, "t");
let notes = find_quoted_kv(rest, "note");
let id = find_kv(rest, "id");
let needs = find_kv(rest, "needs");
let clean_raw = body_after_quoted_kv(line);

Ok(Task {
Expand All @@ -109,6 +115,8 @@ pub fn parse_line(raw: &str) -> Result<Task, ParseError> {
rec,
threshold,
notes,
id,
needs,
})
}

Expand Down Expand Up @@ -673,4 +681,11 @@ mod tests {
assert_eq!(a.raw, b.raw);
}
}

#[test]
fn parses_id_and_needs() {
let t = parse_line("Build wall id:wall needs:foundation").unwrap();
assert_eq!(t.id.as_deref(), Some("wall"));
assert_eq!(t.needs.as_deref(), Some("foundation"));
}
}
1 change: 1 addition & 0 deletions src/ui/archive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
match_term: None,
today: app.today(),
hidden_keys: &app.prefs.hidden_keys,
blocked: false,
};
if i == app.cursor {
cursor_line = Some(lines.len());
Expand Down
1 change: 1 addition & 0 deletions src/ui/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ const VIEW: Section = (
("A", "archive completed"),
("H", "show done in list"),
("F", "show future in list"),
("B", "show blocked in list"),
("[ / ]", "toggle filter / detail"),
("T", "theme picker"),
("D", "cycle density"),
Expand Down
12 changes: 12 additions & 0 deletions src/ui/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,13 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let last = visible.len().saturating_sub(1);
let mut last_group: Option<&GroupKey> = None;

let undone_ids: std::collections::HashSet<&str> = app
.tasks()
.iter()
.filter(|t| !t.done)
.filter_map(|t| t.id.as_deref())
.collect();

for (i, (&abs, gk)) in visible.iter().zip(groups.iter()).enumerate() {
// Emit a section header on group transitions. `GroupKey::None`
// means the active sort is `Sort::File`; we never render a header
Expand All @@ -81,6 +88,10 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
}

let task = &app.tasks()[abs];
let blocked = task
.needs
.as_deref()
.is_some_and(|dep| undone_ids.contains(dep));
let opts = task_row::RowOpts {
idx_label: i,
cursor: i == app.cursor && app.mode != Mode::Help && app.mode != Mode::Settings,
Expand All @@ -91,6 +102,7 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
match_term,
today: app.today(),
hidden_keys: &app.prefs.hidden_keys,
blocked,
};
if i == app.cursor {
cursor_line = Some(lines.len());
Expand Down
4 changes: 4 additions & 0 deletions src/ui/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ pub fn render(frame: &mut Frame, area: Rect, app: &App) {
" show future in list",
Some(format!("{} (F to toggle)", on(app.prefs.show_future))),
),
(
" show blocked in list",
Some(format!("{} (B to toggle)", on(app.prefs.show_blocked))),
),
("", Some("".into())),
("BEHAVIOR", None),
(
Expand Down
36 changes: 35 additions & 1 deletion src/ui/task_row.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ pub struct RowOpts<'a> {
/// rendered body. Empty (the common case) means render everything,
/// byte-for-byte as before.
pub hidden_keys: &'a [String],
/// True when this task has a `needs:` reference pointing to an existing
/// undone task.
pub blocked: bool,
}

pub fn build_line<'a>(task: &'a Task, opts: RowOpts<'a>, theme: &Theme) -> Line<'a> {
Expand Down Expand Up @@ -141,7 +144,11 @@ pub fn build_line<'a>(task: &'a Task, opts: RowOpts<'a>, theme: &Theme) -> Line<
} else {
Style::default()
};
Line::from(spans).style(line_style)
Line::from(spans).style(line_style.add_modifier(if opts.blocked {
Modifier::DIM
} else {
Modifier::empty()
}))
}

fn push_token_spans<'a>(
Expand Down Expand Up @@ -389,6 +396,7 @@ mod tests {
match_term: Some("a"),
today: "2026-05-06",
hidden_keys: &[],
blocked: false,
};
// Build must not panic; we don't assert on the rendered spans.
let _ = build_line(&task, opts, &MUTED);
Expand All @@ -410,6 +418,7 @@ mod tests {
match_term: Some("cade"),
today: "2026-05-06",
hidden_keys: &[],
blocked: false,
};
let line = build_line(&task, opts, &MUTED);
let highlight_bg = MUTED.matched;
Expand Down Expand Up @@ -438,6 +447,7 @@ mod tests {
match_term: None,
today: "2026-05-06",
hidden_keys: hidden,
blocked: false,
};
let line = build_line(&task, opts, &MUTED);
line.spans
Expand Down Expand Up @@ -505,6 +515,7 @@ mod tests {
match_term: None,
today: "2026-05-06",
hidden_keys: &[],
blocked: false,
};
let line = build_line(&task, opts, &MUTED);
let url_span = line
Expand Down Expand Up @@ -536,6 +547,7 @@ mod tests {
match_term: None,
today: "2026-05-06",
hidden_keys: &[],
blocked: false,
};
let line = build_line(&task, opts, &MUTED);
let url_span = line
Expand All @@ -559,4 +571,26 @@ mod tests {
"Pay rent due:2026-05-15",
);
}

#[test]
fn blocked_row_is_dimmed() {
let task = parse_line("Blocked task +project").unwrap();
let opts = RowOpts {
idx_label: 0,
cursor: false,
multi_mode: false,
multi_checked: false,
selected: false,
show_line_num: false,
match_term: None,
today: "2026-05-06",
hidden_keys: &[],
blocked: true,
};
let line = build_line(&task, opts, &MUTED);
assert!(
line.style.add_modifier.contains(Modifier::DIM),
"blocked row line style must carry DIM"
);
}
}
Loading