feat: redesign Todos tab — grouping, collapsible headers, cleaned columns

Implements the approved mockup from the UX debate report:

- new DisplayRow::TodoHeader variant: collapsible group headers, toggled
  by Enter/Space, tracked in HubState.todo_group_collapsed
- [g] cycles grouping: status (default) -> project -> repo -> none
- [s] cycles which statuses are included before grouping: active
  (open+in-progress, default) -> open -> done -> all
- table: dropped SRC/EST (near-always empty), code-scan todos show a dim
  'no status icon' marker instead of a separate source column, project
  name colored by the project's own color (was DarkGray, wasting the most
  useful visual identifier in the tool), age now tracks updated_at not
  created_at ('what's moving' beats 'what's old')
- right panel: added Repo/Difficulty/Estimate, previously only visible in
  the table despite the panel having more room for them
- block title shows live counts + current view/group/code-scan state

Simplified vs. the literal ASCII mockup: [s] filters the underlying set
before grouping, so e.g. the default 'active' view omits Done/Cancelled
groups entirely rather than always showing them collapsed - a simpler,
more internally consistent model. Flagged for foksiu to request the exact
mockup behavior if he wants it.

Verified via pyte: group/status-view cycling, collapse/expand (arrow flips
and child rows appear/disappear), right panel on header vs. todo row.
This commit is contained in:
dualfroz committed 2026-07-07 19:17:28 +02:00
1 parent 3eb922559c
commit 6adc22d665
10 files changed
+288 -61

No files matched your search

+29 -1
View File
@@ -80,6 +80,9 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
state.todos[ti].status = state.todos[ti].status.cycle(); state.todos[ti].status = state.todos[ti].status.cycle();
state.todos[ti].touch(); state.todos[ti].touch();
let _ = todos::save(&state.todos); let _ = todos::save(&state.todos);
state.rebuild_display();
} else if let Some(key) = state.cur_todo_header_key() {
toggle_todo_group(state, key);
} }
} }
_ => state.toggle_select(), _ => state.toggle_select(),
@@ -151,7 +154,12 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
} }
} }
} }
Tab::Todos | Tab::Overview | Tab::Stats | Tab::Activity => {} Tab::Todos => {
if let Some(key) = state.cur_todo_header_key() {
toggle_todo_group(state, key);
}
}
Tab::Overview | Tab::Stats | Tab::Activity => {}
Tab::HubSettings => { Tab::HubSettings => {
match state.hub_settings_cur { match state.hub_settings_cur {
0 => { 0 => {
@@ -249,6 +257,18 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
} }
} }
} }
KeyCode::Char('s') if state.tab == Tab::Todos => {
state.todo_status_view = state.todo_status_view.next();
state.rebuild_display();
}
KeyCode::Char('g') if state.tab == Tab::Todos => {
state.todo_group_by = state.todo_group_by.next();
state.rebuild_display();
}
KeyCode::Char('c') if state.tab == Tab::Todos => {
state.show_code_todos = !state.show_code_todos;
state.rebuild_display();
}
KeyCode::Char('s') => { KeyCode::Char('s') => {
if !state.action_targets().is_empty() { if !state.action_targets().is_empty() {
state.modal = Modal::Select { state.modal = Modal::Select {
@@ -565,3 +585,11 @@ pub fn handle_global_key(state: &mut HubState, key: KeyEvent) -> Option<PhaseRes
} }
None None
} }
/// Collapses/expands one Todos-tab group header.
fn toggle_todo_group(state: &mut HubState, key: String) {
if !state.todo_group_collapsed.remove(&key) {
state.todo_group_collapsed.insert(key);
}
state.rebuild_display();
}
+4
View File
@@ -122,6 +122,10 @@ pub async fn run_overview_tui(
editor: cfg.editor.clone(), editor: cfg.editor.clone(),
clipboard_cmd: cfg.clipboard_cmd.clone(), clipboard_cmd: cfg.clipboard_cmd.clone(),
show_code_todos: false, show_code_todos: false,
todo_status_view: Default::default(),
todo_group_by: Default::default(),
// Done/Cancelled start collapsed — active work stays front and center.
todo_group_collapsed: ["Done", "Cancelled"].into_iter().map(String::from).collect(),
debug_mode, debug_mode,
debug_log, debug_log,
scan_rx, scan_rx,
+4 -1
View File
@@ -618,10 +618,13 @@ pub fn open_action_menu(state: &mut HubState) {
match state.tab { match state.tab {
Tab::Todos => { Tab::Todos => {
entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' ')))); entries.push(entry("Cycle todo status", "Space", kc(KeyCode::Char(' '))));
entries.push(entry("Expand/collapse group","↵", kc(KeyCode::Enter)));
entries.push(entry("Edit todo", "e", k('e'))); entries.push(entry("Edit todo", "e", k('e')));
entries.push(entry("Delete todo", "Del", kc(KeyCode::Delete))); entries.push(entry("Delete todo", "Del", kc(KeyCode::Delete)));
entries.push(entry("New todo", "+", k('+'))); entries.push(entry("New todo", "+", k('+')));
entries.push(entry("Toggle code todos", "X", k('X'))); entries.push(entry("Cycle status view", "s", k('s')));
entries.push(entry("Cycle grouping", "g", k('g')));
entries.push(entry("Toggle code todos", "c", k('c')));
entries.push(entry("Scan repos for TODOs","T", k('T'))); entries.push(entry("Scan repos for TODOs","T", k('T')));
} }
Tab::PRs => { Tab::PRs => {
+15 -3
View File
@@ -60,10 +60,13 @@ pub static HELP_LINES: &[&str] = &[
" p Project menu (create / assign / rename / delete)", " p Project menu (create / assign / rename / delete)",
" + New todo", " + New todo",
" e Edit todo (Todos tab)", " e Edit todo (Todos tab)",
" Space (Todos tab) Cycle todo status", " Space (Todos tab) Cycle todo status, or expand/collapse a group",
" Enter (Todos tab) Expand/collapse a group header",
" s (Todos tab) Cycle status view: active → open → done → all",
" g (Todos tab) Cycle grouping: status → project → repo → none",
" c / X (Todos tab) Toggle code-scan todos",
" D / Del Delete todo or project", " D / Del Delete todo or project",
" T Scan repos for TODO/FIXME/HACK → import to Todos", " T Scan repos for TODO/FIXME/HACK → import to Todos",
" X (Todos tab) Toggle code-scan todos",
"", "",
"── Help ──────────────────────────────────────", "── Help ──────────────────────────────────────",
" ? Show this help", " ? Show this help",
@@ -165,7 +168,16 @@ pub fn render_hub(f: &mut Frame, state: &mut HubState) {
.constraints([Constraint::Percentage(73), Constraint::Percentage(27)]) .constraints([Constraint::Percentage(73), Constraint::Percentage(27)])
.split(outer[1]); .split(outer[1]);
let left_blk = Block::default().borders(Borders::ALL); let mut left_blk = Block::default().borders(Borders::ALL);
if state.tab == Tab::Todos {
let in_progress = state.todos.iter().filter(|t| t.status == crate::todos::TodoStatus::InProgress).count();
let open = state.todos.iter().filter(|t| t.status == crate::todos::TodoStatus::Open).count();
left_blk = left_blk.title(format!(
" todos · {} in progress · {} open · [s]tatus:{} · [g]roup:{} · [c]ode-scan:{} ",
in_progress, open, state.todo_status_view.label(), state.todo_group_by.label(),
if state.show_code_todos { "on" } else { "off" },
));
}
let left_inner = left_blk.inner(cols[0]); let left_inner = left_blk.inner(cols[0]);
f.render_widget(left_blk, cols[0]); f.render_widget(left_blk, cols[0]);
state.visible_rows = left_inner.height.saturating_sub(1) as usize; state.visible_rows = left_inner.height.saturating_sub(1) as usize;
+9
View File
@@ -367,6 +367,13 @@ fn render_right_todo(f: &mut Frame, state: &HubState, area: Rect) {
lines.push(r_kv_color("Status", &format!("{} {}", t.status.icon(), t.status.label()), t.status.color())); lines.push(r_kv_color("Status", &format!("{} {}", t.status.icon(), t.status.label()), t.status.color()));
lines.push(r_kv("Project", t.project.as_deref().unwrap_or("— not assigned"), Color::DarkGray)); lines.push(r_kv("Project", t.project.as_deref().unwrap_or("— not assigned"), Color::DarkGray));
lines.push(r_kv("Repo", t.repo.as_deref().unwrap_or("— not assigned"), Color::DarkGray));
let diff_str = match t.difficulty {
Some(d) => "★".repeat(d.min(5) as usize) + &"☆".repeat(5usize.saturating_sub(d as usize)),
None => "—".into(),
};
lines.push(r_kv("Difficulty", &diff_str, Color::Yellow));
lines.push(r_kv("Estimate", t.estimate.as_deref().unwrap_or("—"), Color::DarkGray));
lines.push(Line::from("")); lines.push(Line::from(""));
if !t.description.is_empty() { if !t.description.is_empty() {
@@ -383,6 +390,8 @@ fn render_right_todo(f: &mut Frame, state: &HubState, area: Rect) {
lines.push(r_section("Timeline")); lines.push(r_section("Timeline"));
lines.push(r_kv("Created", &format_age_pub(t.created_at), Color::DarkGray)); lines.push(r_kv("Created", &format_age_pub(t.created_at), Color::DarkGray));
lines.push(r_kv("Updated", &format_age_pub(t.updated_at), Color::DarkGray)); lines.push(r_kv("Updated", &format_age_pub(t.updated_at), Color::DarkGray));
} else if state.cur_todo_header_key().is_some() {
lines.push(r_dim(" Group header — [↵/Space] expand/collapse".into()));
} else { } else {
lines.push(r_dim(" ← select a todo".into())); lines.push(r_dim(" ← select a todo".into()));
} }
+95 -7
View File
@@ -13,7 +13,8 @@ pub use super::state_rows::*;
use crate::projects::{self, Project}; use crate::projects::{self, Project};
use crate::providers::{IssueDetail, PrDetail}; use crate::providers::{IssueDetail, PrDetail};
use crate::status::{count_tracked_files, repo_recent_commits, RepoSummary}; use crate::status::{count_tracked_files, repo_recent_commits, RepoSummary};
use crate::todos::{Todo, TodoSource}; use crate::status::format_age_pub;
use crate::todos::{Todo, TodoSource, TodoStatus};
use ratatui::style::Color; use ratatui::style::Color;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf; use std::path::PathBuf;
@@ -70,6 +71,13 @@ pub struct HubState {
pub editor: String, pub editor: String,
pub clipboard_cmd: String, pub clipboard_cmd: String,
pub show_code_todos:bool, pub show_code_todos:bool,
pub todo_status_view: TodoStatusView,
pub todo_group_by: TodoGroupBy,
/// Group keys currently collapsed on the Todos tab (e.g. "Done",
/// "Cancelled", a project name, a repo name — whatever the current
/// `todo_group_by` mode keys groups by). Toggled by Enter/Space on a
/// header row; not persisted across restarts.
pub todo_group_collapsed: HashSet<String>,
pub debug_mode: bool, pub debug_mode: bool,
pub debug_log: Vec<String>, pub debug_log: Vec<String>,
/// Live receiver from the background scan thread; None when scan is complete. /// Live receiver from the background scan thread; None when scan is complete.
@@ -178,12 +186,7 @@ impl HubState {
d.push(DisplayRow::Project { proj_idx: pi }); d.push(DisplayRow::Project { proj_idx: pi });
} }
} }
Tab::Todos => { Tab::Todos => self.build_todos_display(&mut d),
for (ti, t) in self.todos.iter().enumerate() {
if !self.show_code_todos && t.source == TodoSource::CodeScan { continue; }
d.push(DisplayRow::Todo { todo_idx: ti });
}
}
Tab::PRs => { Tab::PRs => {
for i in 0..self.prs.len() { for i in 0..self.prs.len() {
d.push(DisplayRow::Pr { pr_idx: i }); d.push(DisplayRow::Pr { pr_idx: i });
@@ -212,6 +215,83 @@ impl HubState {
self.display = d; self.display = d;
} }
/// Builds the Todos tab's display rows: status-view-filters the todo
/// list, then groups what survives per `todo_group_by` — `None` just
/// sorts by recency, the other modes interleave a collapsible
/// `TodoHeader` before each group's rows (omitted entirely when that
/// group's key is in `todo_group_collapsed`).
fn build_todos_display(&self, d: &mut Vec<DisplayRow>) {
let filtered: Vec<usize> = (0..self.todos.len())
.filter(|&ti| {
let t = &self.todos[ti];
if !self.show_code_todos && t.source == TodoSource::CodeScan { return false; }
self.todo_status_view.allows(&t.status)
})
.collect();
let by_recency = |a: &usize, b: &usize| self.todos[*b].updated_at.cmp(&self.todos[*a].updated_at);
match self.todo_group_by {
TodoGroupBy::None => {
let mut sorted = filtered;
sorted.sort_by(by_recency);
for ti in sorted { d.push(DisplayRow::Todo { todo_idx: ti }); }
}
TodoGroupBy::Status => {
for status in [TodoStatus::InProgress, TodoStatus::Open, TodoStatus::Done, TodoStatus::Cancelled] {
let mut group: Vec<usize> = filtered.iter().copied()
.filter(|&ti| self.todos[ti].status == status).collect();
if group.is_empty() { continue; }
group.sort_by(by_recency);
let key = status.label().to_string();
let extra = if status == TodoStatus::Done {
format!(" · ostatni {}", format_age_pub(self.todos[group[0]].updated_at))
} else {
String::new()
};
self.push_todo_group(d, key.clone(), status.label().to_string(), group, extra);
}
}
TodoGroupBy::Project => {
let mut keys: Vec<String> = filtered.iter()
.map(|&ti| self.todos[ti].project.clone().unwrap_or_else(|| "— brak projektu —".into()))
.collect();
keys.sort(); keys.dedup();
for key in keys {
let mut group: Vec<usize> = filtered.iter().copied()
.filter(|&ti| self.todos[ti].project.as_deref().unwrap_or("— brak projektu —") == key)
.collect();
group.sort_by(by_recency);
self.push_todo_group(d, key.clone(), key, group, String::new());
}
}
TodoGroupBy::Repo => {
let mut keys: Vec<String> = filtered.iter()
.map(|&ti| self.todos[ti].repo.clone().unwrap_or_else(|| "— brak repo —".into()))
.collect();
keys.sort(); keys.dedup();
for key in keys {
let mut group: Vec<usize> = filtered.iter().copied()
.filter(|&ti| self.todos[ti].repo.as_deref().unwrap_or("— brak repo —") == key)
.collect();
group.sort_by(by_recency);
self.push_todo_group(d, key.clone(), key, group, String::new());
}
}
}
}
/// Pushes one group's header row, then its todo rows unless the group
/// is collapsed.
fn push_todo_group(&self, d: &mut Vec<DisplayRow>, key: String, label: String, group: Vec<usize>, extra: String) {
let count = group.len();
let collapsed = self.todo_group_collapsed.contains(&key);
d.push(DisplayRow::TodoHeader { key, label, count, extra });
if !collapsed {
for ti in group { d.push(DisplayRow::Todo { todo_idx: ti }); }
}
}
pub fn rebuild_project_map(&mut self) { pub fn rebuild_project_map(&mut self) {
self.project_map = projects::index_by_repo(&self.projects); self.project_map = projects::index_by_repo(&self.projects);
} }
@@ -237,6 +317,14 @@ impl HubState {
_ => None, _ => None,
} }
} }
/// The group key of the `TodoHeader` row under the cursor, if any —
/// used to toggle that group's collapsed state on Enter/Space.
pub fn cur_todo_header_key(&self) -> Option<String> {
match self.display.get(self.cur_di()?)? {
DisplayRow::TodoHeader { key, .. } => Some(key.clone()),
_ => None,
}
}
pub fn project_repos(&self, pi: usize) -> Vec<usize> { pub fn project_repos(&self, pi: usize) -> Vec<usize> {
self.projects[pi].repos.iter() self.projects[pi].repos.iter()
+73 -2
View File
@@ -1,6 +1,77 @@
//! Small standalone types that don't fit the other state groupings: the //! Small standalone types that don't fit the other state groupings: the
//! repo-list sort order, and the user-defined repo template format //! repo-list sort order, the Todos tab's status-view/grouping modes, and
//! (persisted to `templates.toml`). //! the user-defined repo template format (persisted to `templates.toml`).
/// Which todo statuses the Todos tab currently shows, cycled with `[s]`.
/// Filters the underlying set *before* grouping — e.g. in `Active`, a
/// `Status` grouping only ever produces "In Progress"/"Open" headers.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum TodoStatusView {
#[default]
Active,
OpenOnly,
DoneOnly,
All,
}
impl TodoStatusView {
pub fn label(self) -> &'static str {
match self {
Self::Active => "active",
Self::OpenOnly => "open",
Self::DoneOnly => "done",
Self::All => "all",
}
}
pub fn next(self) -> Self {
match self {
Self::Active => Self::OpenOnly,
Self::OpenOnly => Self::DoneOnly,
Self::DoneOnly => Self::All,
Self::All => Self::Active,
}
}
/// Whether `status` survives this view's filter.
pub fn allows(self, status: &crate::todos::TodoStatus) -> bool {
use crate::todos::TodoStatus as S;
match self {
Self::Active => matches!(status, S::Open | S::InProgress),
Self::OpenOnly => matches!(status, S::Open),
Self::DoneOnly => matches!(status, S::Done),
Self::All => true,
}
}
}
/// How the Todos tab groups its (already status-view-filtered) list,
/// cycled with `[g]`.
#[derive(Clone, Copy, PartialEq, Eq, Default)]
pub enum TodoGroupBy {
#[default]
Status,
Project,
Repo,
None,
}
impl TodoGroupBy {
pub fn label(self) -> &'static str {
match self {
Self::Status => "status",
Self::Project => "project",
Self::Repo => "repo",
Self::None => "none",
}
}
pub fn next(self) -> Self {
match self {
Self::Status => Self::Project,
Self::Project => Self::Repo,
Self::Repo => Self::None,
Self::None => Self::Status,
}
}
}
/// Repo-list ordering for the Repos tab, cycled with a single key. Pinned /// Repo-list ordering for the Repos tab, cycled with a single key. Pinned
/// repos always sort to the top regardless of this setting (see /// repos always sort to the top regardless of this setting (see
+5
View File
@@ -11,6 +11,11 @@ pub enum DisplayRow {
Repo { repo_idx: usize }, Repo { repo_idx: usize },
Project { proj_idx: usize }, Project { proj_idx: usize },
Todo { todo_idx: usize }, Todo { todo_idx: usize },
/// A collapsible group header on the Todos tab. `key` identifies the
/// group for collapse-state lookups (`HubState::todo_group_collapsed`);
/// `label`/`count`/`extra` are purely for display (`extra` carries e.g.
/// "· last 3h ago" on the Done group).
TodoHeader { key: String, label: String, count: usize, extra: String },
Pr { pr_idx: usize }, Pr { pr_idx: usize },
Issue { issue_idx: usize }, Issue { issue_idx: usize },
} }
+54 -40
View File
@@ -163,58 +163,72 @@ pub fn render_todos_table(f: &mut Frame, state: &HubState, area: Rect) {
let avail = area.height.saturating_sub(1) as usize; let avail = area.height.saturating_sub(1) as usize;
let cur_di = state.cur_di().unwrap_or(usize::MAX); let cur_di = state.cur_di().unwrap_or(usize::MAX);
let header = Row::new(vec![" SRC", " STATUS", "TITLE", "REPO", "PROJECT", "EST", "DIFF", "CREATED"]) let header = Row::new(vec![" ", "TITLE", "REPO", "PROJECT", "DIFF", "AGE"])
.style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)); .style(Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let rows: Vec<Row> = state.display.iter().enumerate() let rows: Vec<Row> = state.display.iter().enumerate()
.skip(state.offset).take(avail) .skip(state.offset).take(avail)
.filter_map(|(di, row)| { .filter_map(|(di, row)| {
let DisplayRow::Todo { todo_idx: ti } = row else { return None; };
let t = &state.todos[*ti];
let is_cur = di == cur_di; let is_cur = di == cur_di;
let status_color = t.status.color(); let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() };
let proj_name = t.project.as_deref().unwrap_or("—"); match row {
let repo_name = t.repo.as_deref().unwrap_or("—"); DisplayRow::TodoHeader { label, count, extra, key } => {
let created = format_age_pub(t.created_at); let arrow = if state.todo_group_collapsed.contains(key) { "▸" } else { "▾" };
let est_str = t.estimate.as_deref().unwrap_or("—").chars().take(10).collect::<String>(); let text = format!("{arrow} {} ── {}{}", label.to_uppercase(), count, extra);
let diff_str = match t.difficulty { Some(Row::new(vec![
Some(d) => format!("{}{}", "★".repeat(d.min(5) as usize), "☆".repeat(5usize.saturating_sub(d as usize))), Cell::from(""),
None => "—".into(), Cell::from(Span::styled(text, Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD))),
}; Cell::from(""), Cell::from(""), Cell::from(""), Cell::from(""),
let title_short: String = if t.source == TodoSource::CodeScan { ]).style(row_sty))
let raw = &t.title; }
let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() }; DisplayRow::Todo { todo_idx: ti } => {
trimmed.chars().take(55).collect() let t = &state.todos[*ti];
} else { let is_code_scan = t.source == TodoSource::CodeScan;
t.title.chars().take(55).collect() let (icon, icon_col) = if is_code_scan {
}; ("⌁", Color::DarkGray)
let src_icon = t.source.icon(); } else {
let src_col = match t.source { TodoSource::Manual => Color::Cyan, TodoSource::CodeScan => Color::DarkGray }; (t.status.icon(), t.status.color())
let row_sty = if is_cur { Style::default().bg(Color::DarkGray) } else { Style::default() }; };
Some(Row::new(vec![ let repo_name = t.repo.as_deref().unwrap_or("—");
Cell::from(format!(" {}", src_icon)).style(Style::default().fg(src_col)), let proj_name = t.project.as_deref().unwrap_or("—");
Cell::from(format!(" {} {}", t.status.icon(), t.status.label())) let proj_col = t.project.as_deref()
.style(Style::default().fg(status_color)), .and_then(|name| state.projects.iter().find(|p| p.name == name))
Cell::from(title_short), .map(|p| { let (r, g, b) = crate::projects::hex_to_rgb(&p.color); Color::Rgb(r, g, b) })
Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)), .unwrap_or(Color::DarkGray);
Cell::from(proj_name).style(Style::default().fg(Color::DarkGray)), let diff_str = match t.difficulty {
Cell::from(est_str).style(Style::default().fg(Color::DarkGray)), Some(d) => "★".repeat(d.min(5) as usize),
Cell::from(diff_str).style(Style::default().fg(Color::Yellow)), None => "—".into(),
Cell::from(created).style(Style::default().fg(Color::DarkGray)), };
]).style(row_sty)) let age = format_age_pub(t.updated_at);
let title_short: String = if is_code_scan {
let raw = &t.title;
let trimmed = if let Some(end) = raw.find(']') { raw[end+1..].trim() } else { raw.as_str() };
trimmed.chars().take(55).collect()
} else {
t.title.chars().take(55).collect()
};
Some(Row::new(vec![
Cell::from(icon).style(Style::default().fg(icon_col)),
Cell::from(title_short),
Cell::from(repo_name).style(Style::default().fg(Color::DarkGray)),
Cell::from(proj_name).style(Style::default().fg(proj_col)),
Cell::from(diff_str).style(Style::default().fg(Color::Yellow)),
Cell::from(age).style(Style::default().fg(age_color(t.updated_at))),
]).style(row_sty))
}
_ => None,
}
}) })
.collect(); .collect();
f.render_widget( f.render_widget(
Table::new(rows, [ Table::new(rows, [
Constraint::Length(4), Constraint::Length(2),
Constraint::Length(14),
Constraint::Fill(1), Constraint::Fill(1),
Constraint::Length(12), Constraint::Length(14),
Constraint::Length(12), Constraint::Length(14),
Constraint::Length(10), Constraint::Length(6),
Constraint::Length(7), Constraint::Length(9),
Constraint::Length(10),
]).header(header), ]).header(header),
area, area,
); );
-7
View File
@@ -70,13 +70,6 @@ pub enum TodoSource {
CodeScan, CodeScan,
} }
impl TodoSource {
/// One-character glyph shown next to a todo's title to mark its origin.
pub fn icon(&self) -> &'static str {
match self { Self::Manual => "✎", Self::CodeScan => "⌂" }
}
}
/// One todo item. /// One todo item.
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Todo { pub struct Todo {