Use comfy-table Cell API instead of raw ANSI in table cells

Amolith created

Embedding raw ANSI escape codes in cell strings causes comfy-table to
count them as visible characters when calculating column widths, shifting
headers out of alignment with their data columns.

Replace format!() with raw escapes with Cell::fg() and
Cell::add_attribute() across list, ready, next, and search. Add
cell_bold() and cell_fg() helpers in color.rs that conditionally apply
styling based on the existing NO_COLOR / TTY checks.

Change summary

src/cmd/list.rs   | 15 ++++++++-------
src/cmd/next.rs   | 13 +++++++------
src/cmd/ready.rs  | 13 +++++++------
src/cmd/search.rs | 10 ++++------
src/color.rs      | 26 ++++++++++++++++++++++++++
5 files changed, 52 insertions(+), 25 deletions(-)

Detailed changes

src/cmd/list.rs 🔗

@@ -1,8 +1,9 @@
 use anyhow::Result;
 use comfy_table::presets::NOTHING;
-use comfy_table::Table;
+use comfy_table::{Cell, Color, Table};
 use std::path::Path;
 
+use crate::color::{cell_bold, cell_fg, stdout_use_color};
 use crate::db;
 
 pub fn run(
@@ -67,17 +68,17 @@ pub fn run(
             .collect::<Result<_>>()?;
         println!("{}", serde_json::to_string(&details)?);
     } else {
-        let c = crate::color::stdout_theme();
+        let use_color = stdout_use_color();
         let mut table = Table::new();
         table.load_preset(NOTHING);
         table.set_header(vec!["ID", "STATUS", "PRIORITY", "EFFORT", "TITLE"]);
         for t in &tasks {
             table.add_row(vec![
-                format!("{}{}{}", c.bold, t.id, c.reset),
-                format!("{}[{}]{}", c.yellow, t.status, c.reset),
-                format!("{}{}{}", c.red, db::priority_label(t.priority), c.reset),
-                format!("{}{}{}", c.blue, db::effort_label(t.effort), c.reset),
-                t.title.clone(),
+                cell_bold(&t.id, use_color),
+                cell_fg(format!("[{}]", t.status), Color::Yellow, use_color),
+                cell_fg(db::priority_label(t.priority), Color::Red, use_color),
+                cell_fg(db::effort_label(t.effort), Color::Blue, use_color),
+                Cell::new(&t.title),
             ]);
         }
         if !tasks.is_empty() {

src/cmd/next.rs 🔗

@@ -1,9 +1,10 @@
 use anyhow::{bail, Result};
 use comfy_table::presets::NOTHING;
-use comfy_table::Table;
+use comfy_table::{Cell, Table};
 use std::collections::HashSet;
 use std::path::Path;
 
+use crate::color::{cell_bold, stdout_use_color};
 use crate::db;
 use crate::score::{self, Mode};
 
@@ -90,17 +91,17 @@ pub fn run(root: &Path, mode_str: &str, verbose: bool, limit: usize, json: bool)
             .collect();
         println!("{}", serde_json::to_string(&items)?);
     } else {
+        let use_color = stdout_use_color();
         let mut table = Table::new();
         table.load_preset(NOTHING);
         table.set_header(vec!["#", "ID", "SCORE", "TITLE"]);
 
         for (i, s) in scored.iter().enumerate() {
-            let c = crate::color::stdout_theme();
             table.add_row(vec![
-                format!("{}", i + 1),
-                format!("{}{}{}", c.bold, s.id, c.reset),
-                format!("{:.2}", s.score),
-                s.title.clone(),
+                Cell::new(i + 1),
+                cell_bold(&s.id, use_color),
+                Cell::new(format!("{:.2}", s.score)),
+                Cell::new(&s.title),
             ]);
         }
         println!("{table}");

src/cmd/ready.rs 🔗

@@ -1,8 +1,9 @@
 use anyhow::Result;
 use comfy_table::presets::NOTHING;
-use comfy_table::Table;
+use comfy_table::{Cell, Color, Table};
 use std::path::Path;
 
+use crate::color::{cell_bold, cell_fg, stdout_use_color};
 use crate::db;
 
 pub fn run(root: &Path, json: bool) -> Result<()> {
@@ -38,16 +39,16 @@ pub fn run(root: &Path, json: bool) -> Result<()> {
             .collect();
         println!("{}", serde_json::to_string(&summary)?);
     } else {
-        let c = crate::color::stdout_theme();
+        let use_color = stdout_use_color();
         let mut table = Table::new();
         table.load_preset(NOTHING);
         table.set_header(vec!["ID", "PRIORITY", "EFFORT", "TITLE"]);
         for t in &tasks {
             table.add_row(vec![
-                format!("{}{}{}", c.green, t.id, c.reset),
-                format!("{}{}{}", c.red, db::priority_label(t.priority), c.reset),
-                format!("{}{}{}", c.blue, db::effort_label(t.effort), c.reset),
-                t.title.clone(),
+                cell_bold(&t.id, use_color),
+                cell_fg(db::priority_label(t.priority), Color::Red, use_color),
+                cell_fg(db::effort_label(t.effort), Color::Blue, use_color),
+                Cell::new(&t.title),
             ]);
         }
         if !tasks.is_empty() {

src/cmd/search.rs 🔗

@@ -1,8 +1,9 @@
 use anyhow::Result;
 use comfy_table::presets::NOTHING;
-use comfy_table::Table;
+use comfy_table::{Cell, Table};
 use std::path::Path;
 
+use crate::color::{cell_bold, stdout_use_color};
 use crate::db;
 
 pub fn run(root: &Path, query: &str, json: bool) -> Result<()> {
@@ -32,14 +33,11 @@ pub fn run(root: &Path, query: &str, json: bool) -> Result<()> {
             .collect();
         println!("{}", serde_json::to_string(&summary)?);
     } else {
-        let c = crate::color::stdout_theme();
+        let use_color = stdout_use_color();
         let mut table = Table::new();
         table.load_preset(NOTHING);
         for t in &tasks {
-            table.add_row(vec![
-                format!("{}{}{}", c.bold, t.id, c.reset),
-                t.title.clone(),
-            ]);
+            table.add_row(vec![cell_bold(&t.id, use_color), Cell::new(&t.title)]);
         }
         if !tasks.is_empty() {
             println!("{table}");

src/color.rs 🔗

@@ -1,3 +1,4 @@
+use comfy_table::{Attribute, Cell, Color};
 use std::io::IsTerminal;
 
 pub struct Theme {
@@ -48,3 +49,28 @@ pub fn stderr_theme() -> &'static Theme {
         &OFF
     }
 }
+
+/// Whether stdout should use colour.
+pub fn stdout_use_color() -> bool {
+    use_color(std::io::stdout().is_terminal())
+}
+
+/// A table cell with bold text.
+pub fn cell_bold(text: impl ToString, use_color: bool) -> Cell {
+    let cell = Cell::new(text);
+    if use_color {
+        cell.add_attribute(Attribute::Bold)
+    } else {
+        cell
+    }
+}
+
+/// A table cell with a coloured foreground.
+pub fn cell_fg(text: impl ToString, color: Color, use_color: bool) -> Cell {
+    let cell = Cell::new(text);
+    if use_color {
+        cell.fg(color)
+    } else {
+        cell
+    }
+}