• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

kdheepak / taskwarrior-tui / 18828338815

20 Oct 2025 02:44AM UTC coverage: 10.472%. Remained the same
18828338815

push

github

web-flow
feat: support custom report dataformat like Taskwarrior for `due` datetimes (#636)

To support taskwarrior's`report._.dateformat` option, this PR reads this configuration parameter
and uses it when formatting the `due` task property.

The only slight complexity in implementation is converting from
Taskwarrior's date formatting to `chrono` date format. I want to spend a
little more time cleaning this up, but wanted to post this draft PR to
see if this is an acceptable feature to add.

11 of 51 new or added lines in 2 files covered. (21.57%)

74 existing lines in 1 file now uncovered.

25095 of 239643 relevant lines covered (10.47%)

365.73 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

63.76
/src/table.rs
1
use std::{
2
  collections::{HashMap, HashSet},
3
  fmt::Display,
4
  iter::{self, Iterator},
5
};
6

7
use cassowary::{
8
  strength::{MEDIUM, REQUIRED, WEAK},
9
  Expression, Solver,
10
  WeightedRelation::{EQ, GE, LE},
11
};
12
use ratatui::{
13
  buffer::Buffer,
14
  layout::{Constraint, Rect},
15
  style::Style,
16
  widgets::{Block, StatefulWidget, Widget},
17
};
18
use unicode_segmentation::{Graphemes, UnicodeSegmentation};
19
use unicode_width::UnicodeWidthStr;
20

21
#[derive(Debug, Clone)]
22
pub enum TableMode {
23
  SingleSelection,
24
  MultipleSelection,
25
}
26

27
#[derive(Clone)]
28
pub struct TaskwarriorTuiTableState {
29
  offset: usize,
30
  current_selection: Option<usize>,
31
  marked: HashSet<usize>,
32
  mode: TableMode,
33
}
34

35
impl Default for TaskwarriorTuiTableState {
36
  fn default() -> TaskwarriorTuiTableState {
57✔
37
    TaskwarriorTuiTableState {
57✔
38
      offset: 0,
57✔
39
      current_selection: Some(0),
57✔
40
      marked: HashSet::new(),
57✔
41
      mode: TableMode::SingleSelection,
57✔
42
    }
57✔
43
  }
57✔
44
}
45

46
impl TaskwarriorTuiTableState {
47
  pub fn mode(&self) -> TableMode {
54✔
48
    self.mode.clone()
54✔
49
  }
54✔
50

51
  pub fn multiple_selection(&mut self) {
×
52
    self.mode = TableMode::MultipleSelection;
×
53
  }
×
54

55
  pub fn single_selection(&mut self) {
155✔
56
    self.mode = TableMode::SingleSelection;
155✔
57
  }
155✔
58

59
  pub fn current_selection(&self) -> Option<usize> {
38✔
60
    self.current_selection
38✔
61
  }
38✔
62

63
  pub fn select(&mut self, index: Option<usize>) {
162✔
64
    self.current_selection = index;
162✔
65
    if index.is_none() {
162✔
66
      self.offset = 0;
×
67
    }
162✔
68
  }
162✔
69

70
  pub fn mark(&mut self, index: Option<usize>) {
×
71
    if let Some(i) = index {
×
72
      self.marked.insert(i);
×
73
    }
×
74
  }
×
75

76
  pub fn unmark(&mut self, index: Option<usize>) {
×
77
    if let Some(i) = index {
×
78
      self.marked.remove(&i);
×
79
    }
×
80
  }
×
81

82
  pub fn toggle_mark(&mut self, index: Option<usize>) {
×
83
    if let Some(i) = index {
×
84
      if !self.marked.insert(i) {
×
85
        self.marked.remove(&i);
×
86
      }
×
87
    }
×
88
  }
×
89

NEW
90
  pub fn marked(&'_ self) -> std::collections::hash_set::Iter<'_, usize> {
×
91
    self.marked.iter()
×
92
  }
×
93

94
  pub fn clear(&mut self) {
155✔
95
    self.marked.drain().for_each(drop);
155✔
96
  }
155✔
97
}
98

99
/// Holds data to be displayed in a Table widget
100
#[derive(Debug, Clone)]
101
pub enum Row<D>
102
where
103
  D: Iterator,
104
  D::Item: Display,
105
{
106
  Data(D),
107
  StyledData(D, Style),
108
}
109

110
/// A widget to display data in formatted columns
111
///
112
/// # Examples
113
///
114
/// ```rust
115
/// # use ratatui::widgets::{Block, Borders, Table, Row};
116
/// # use ratatui::layout::Constraint;
117
/// # use ratatui::style::{Style, Color};
118
/// let row_style = Style::default().fg(Color::White);
119
/// Table::new(
120
///         ["Col1", "Col2", "Col3"].into_iter(),
121
///         vec![
122
///             Row::StyledData(["Row11", "Row12", "Row13"].into_iter(), row_style),
123
///             Row::StyledData(["Row21", "Row22", "Row23"].into_iter(), row_style),
124
///             Row::StyledData(["Row31", "Row32", "Row33"].into_iter(), row_style),
125
///             Row::Data(["Row41", "Row42", "Row43"].into_iter())
126
///         ].into_iter()
127
///     )
128
///     .block(Block::default().title("Table"))
129
///     .header_style(Style::default().fg(Color::Yellow))
130
///     .widths(&[Constraint::Length(5), Constraint::Length(5), Constraint::Length(10)])
131
///     .style(Style::default().fg(Color::White))
132
///     .column_spacing(1);
133
/// ```
134
#[derive(Debug, Clone)]
135
pub struct Table<'a, H, R> {
136
  /// A block to wrap the widget in
137
  block: Option<Block<'a>>,
138
  /// Base style for the widget
139
  style: Style,
140
  /// Header row for all columns
141
  header: H,
142
  /// Style for the header
143
  header_style: Style,
144
  /// Width constraints for each column
145
  widths: &'a [Constraint],
146
  /// Space between each column
147
  column_spacing: u16,
148
  /// Space between the header and the rows
149
  header_gap: u16,
150
  /// Style used to render the selected row
151
  highlight_style: Style,
152
  /// Symbol in front of the selected row
153
  highlight_symbol: Option<&'a str>,
154
  /// Symbol in front of the marked row
155
  mark_symbol: Option<&'a str>,
156
  /// Symbol in front of the unmarked row
157
  unmark_symbol: Option<&'a str>,
158
  /// Symbol in front of the marked and selected row
159
  mark_highlight_symbol: Option<&'a str>,
160
  /// Symbol in front of the unmarked and selected row
161
  unmark_highlight_symbol: Option<&'a str>,
162
  /// Data to display in each row
163
  rows: R,
164
}
165

166
impl<'a, H, R> Default for Table<'a, H, R>
167
where
168
  H: Iterator + Default,
169
  R: Iterator + Default,
170
{
171
  fn default() -> Table<'a, H, R> {
×
172
    Table {
×
173
      block: None,
×
174
      style: Style::default(),
×
175
      header: H::default(),
×
176
      header_style: Style::default(),
×
177
      widths: &[],
×
178
      column_spacing: 1,
×
179
      header_gap: 1,
×
180
      highlight_style: Style::default(),
×
181
      highlight_symbol: None,
×
182
      mark_symbol: None,
×
183
      unmark_symbol: None,
×
184
      mark_highlight_symbol: None,
×
185
      unmark_highlight_symbol: None,
×
186
      rows: R::default(),
×
187
    }
×
188
  }
×
189
}
190
impl<'a, H, D, R> Table<'a, H, R>
191
where
192
  H: Iterator,
193
  D: Iterator,
194
  D::Item: Display,
195
  R: Iterator<Item = Row<D>>,
196
{
197
  pub fn new(header: H, rows: R) -> Table<'a, H, R> {
4✔
198
    Table {
4✔
199
      block: None,
4✔
200
      style: Style::default(),
4✔
201
      header,
4✔
202
      header_style: Style::default(),
4✔
203
      widths: &[],
4✔
204
      column_spacing: 1,
4✔
205
      header_gap: 1,
4✔
206
      highlight_style: Style::default(),
4✔
207
      highlight_symbol: None,
4✔
208
      mark_symbol: None,
4✔
209
      unmark_symbol: None,
4✔
210
      mark_highlight_symbol: None,
4✔
211
      unmark_highlight_symbol: None,
4✔
212
      rows,
4✔
213
    }
4✔
214
  }
4✔
215
  pub fn block(mut self, block: Block<'a>) -> Table<'a, H, R> {
×
216
    self.block = Some(block);
×
217
    self
×
218
  }
×
219

220
  pub fn header<II>(mut self, header: II) -> Table<'a, H, R>
×
221
  where
×
222
    II: IntoIterator<Item = H::Item, IntoIter = H>,
×
223
  {
224
    self.header = header.into_iter();
×
225
    self
×
226
  }
×
227

228
  pub fn header_style(mut self, style: Style) -> Table<'a, H, R> {
4✔
229
    self.header_style = style;
4✔
230
    self
4✔
231
  }
4✔
232

233
  pub fn widths(mut self, widths: &'a [Constraint]) -> Table<'a, H, R> {
4✔
234
    let between_0_and_100 = |&w| match w {
36✔
235
      Constraint::Percentage(p) => p <= 100,
×
236
      _ => true,
36✔
237
    };
36✔
238
    assert!(
4✔
239
      widths.iter().all(between_0_and_100),
4✔
240
      "Percentages should be between 0 and 100 inclusively."
241
    );
242
    self.widths = widths;
4✔
243
    self
4✔
244
  }
4✔
245

246
  pub fn rows<II>(mut self, rows: II) -> Table<'a, H, R>
×
247
  where
×
248
    II: IntoIterator<Item = Row<D>, IntoIter = R>,
×
249
  {
250
    self.rows = rows.into_iter();
×
251
    self
×
252
  }
×
253

254
  pub fn style(mut self, style: Style) -> Table<'a, H, R> {
×
255
    self.style = style;
×
256
    self
×
257
  }
×
258

259
  pub fn mark_symbol(mut self, mark_symbol: &'a str) -> Table<'a, H, R> {
4✔
260
    self.mark_symbol = Some(mark_symbol);
4✔
261
    self
4✔
262
  }
4✔
263

264
  pub fn unmark_symbol(mut self, unmark_symbol: &'a str) -> Table<'a, H, R> {
4✔
265
    self.unmark_symbol = Some(unmark_symbol);
4✔
266
    self
4✔
267
  }
4✔
268

269
  pub fn mark_highlight_symbol(mut self, mark_highlight_symbol: &'a str) -> Table<'a, H, R> {
4✔
270
    self.mark_highlight_symbol = Some(mark_highlight_symbol);
4✔
271
    self
4✔
272
  }
4✔
273

274
  pub fn unmark_highlight_symbol(mut self, unmark_highlight_symbol: &'a str) -> Table<'a, H, R> {
4✔
275
    self.unmark_highlight_symbol = Some(unmark_highlight_symbol);
4✔
276
    self
4✔
277
  }
4✔
278

279
  pub fn highlight_symbol(mut self, highlight_symbol: &'a str) -> Table<'a, H, R> {
4✔
280
    self.highlight_symbol = Some(highlight_symbol);
4✔
281
    self
4✔
282
  }
4✔
283

284
  pub fn highlight_style(mut self, highlight_style: Style) -> Table<'a, H, R> {
4✔
285
    self.highlight_style = highlight_style;
4✔
286
    self
4✔
287
  }
4✔
288

289
  pub fn column_spacing(mut self, spacing: u16) -> Table<'a, H, R> {
×
290
    self.column_spacing = spacing;
×
291
    self
×
292
  }
×
293

294
  pub fn header_gap(mut self, gap: u16) -> Table<'a, H, R> {
×
295
    self.header_gap = gap;
×
296
    self
×
297
  }
×
298
}
299

300
impl<H, D, R> StatefulWidget for Table<'_, H, R>
301
where
302
  H: Iterator,
303
  H::Item: Display,
304
  D: Iterator,
305
  D::Item: Display,
306
  R: Iterator<Item = Row<D>>,
307
{
308
  type State = TaskwarriorTuiTableState;
309

310
  fn render(mut self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
4✔
311
    buf.set_style(area, self.style);
4✔
312

313
    // Render block if necessary and get the drawing area
314
    let table_area = match self.block.take() {
4✔
315
      Some(b) => {
×
316
        let inner_area = b.inner(area);
×
317
        b.render(area, buf);
×
318
        inner_area
×
319
      }
320
      None => area,
4✔
321
    };
322

323
    let mut solver = Solver::new();
4✔
324
    let mut var_indices = HashMap::new();
4✔
325
    let mut ccs = Vec::new();
4✔
326
    let mut variables = Vec::new();
4✔
327
    for i in 0..self.widths.len() {
36✔
328
      let var = cassowary::Variable::new();
36✔
329
      variables.push(var);
36✔
330
      var_indices.insert(var, i);
36✔
331
    }
36✔
332
    for (i, constraint) in self.widths.iter().enumerate() {
36✔
333
      ccs.push(variables[i] | GE(WEAK) | 0.);
36✔
334
      ccs.push(match *constraint {
36✔
335
        Constraint::Length(v) => variables[i] | EQ(MEDIUM) | f64::from(v),
36✔
336
        Constraint::Percentage(v) => variables[i] | EQ(WEAK) | (f64::from(v * area.width) / 100.0),
×
337
        Constraint::Ratio(n, d) => variables[i] | EQ(WEAK) | (f64::from(area.width) * f64::from(n) / f64::from(d)),
×
338
        Constraint::Min(v) => variables[i] | GE(WEAK) | f64::from(v),
×
339
        Constraint::Max(v) => variables[i] | LE(WEAK) | f64::from(v),
×
340
        Constraint::Fill(v) => variables[i] | EQ(WEAK) | f64::from(v),
×
341
      });
342
    }
343
    solver
4✔
344
      .add_constraint(
4✔
345
        variables.iter().fold(Expression::from_constant(0.), |acc, v| acc + *v)
36✔
346
          | LE(REQUIRED)
4✔
347
          | f64::from(area.width - 2 - (self.column_spacing * (variables.len() as u16 - 1))),
4✔
348
      )
349
      .unwrap();
4✔
350
    solver.add_constraints(&ccs).unwrap();
4✔
351
    let mut solved_widths = vec![0; variables.len()];
4✔
352
    for &(var, value) in solver.fetch_changes() {
36✔
353
      let index = var_indices[&var];
36✔
354
      let value = if value.is_sign_negative() { 0 } else { value as u16 };
36✔
355
      solved_widths[index] = value;
36✔
356
    }
357

358
    let mut y = table_area.top();
4✔
359
    let mut x = table_area.left();
4✔
360

361
    // Draw header
362
    let mut header_index = usize::MAX;
4✔
363
    let mut index = 0;
4✔
364
    if y < table_area.bottom() {
4✔
365
      for (w, t) in solved_widths.iter().zip(self.header.by_ref()) {
36✔
366
        buf.set_stringn(
36✔
367
          x,
36✔
368
          y,
36✔
369
          format!("{symbol:>width$}", symbol = " ", width = *w as usize),
36✔
370
          *w as usize,
36✔
371
          self.header_style,
36✔
372
        );
373
        if t.to_string() == "ID" {
36✔
374
          buf.set_stringn(
4✔
375
            x,
4✔
376
            y,
4✔
377
            format!("{symbol:>width$}", symbol = t, width = *w as usize),
4✔
378
            *w as usize,
4✔
379
            self.header_style,
4✔
380
          );
4✔
381
          header_index = index;
4✔
382
        } else {
32✔
383
          buf.set_stringn(x, y, format!("{}", t), *w as usize, self.header_style);
32✔
384
        }
32✔
385
        x += *w + self.column_spacing;
36✔
386
        index += 1;
36✔
387
      }
388
    }
×
389
    y += 1 + self.header_gap;
4✔
390

391
    // Use highlight_style only if something is selected
392
    let (selected, highlight_style) = if state.current_selection().is_some() {
4✔
393
      (state.current_selection(), self.highlight_style)
4✔
394
    } else {
395
      (None, self.style)
×
396
    };
397

398
    let highlight_symbol = match state.mode {
4✔
399
      TableMode::MultipleSelection => {
400
        let s = self.highlight_symbol.unwrap_or("\u{2022}").trim_end();
×
401
        format!("{} ", s)
×
402
      }
403
      TableMode::SingleSelection => self.highlight_symbol.unwrap_or("").to_string(),
4✔
404
    };
405

406
    let mark_symbol = match state.mode {
4✔
407
      TableMode::MultipleSelection => {
408
        let s = self.mark_symbol.unwrap_or("\u{2714}").trim_end();
×
409
        format!("{} ", s)
×
410
      }
411
      TableMode::SingleSelection => self.highlight_symbol.unwrap_or("").to_string(),
4✔
412
    };
413

414
    let blank_symbol = match state.mode {
4✔
415
      TableMode::MultipleSelection => {
416
        let s = self.unmark_symbol.unwrap_or(" ").trim_end();
×
417
        format!("{} ", s)
×
418
      }
419
      TableMode::SingleSelection => " ".repeat(highlight_symbol.width()),
4✔
420
    };
421

422
    let mark_highlight_symbol = {
4✔
423
      let s = self.mark_highlight_symbol.unwrap_or("\u{29bf}").trim_end();
4✔
424
      format!("{} ", s)
4✔
425
    };
426

427
    let unmark_highlight_symbol = {
4✔
428
      let s = self.unmark_highlight_symbol.unwrap_or("\u{29be}").trim_end();
4✔
429
      format!("{} ", s)
4✔
430
    };
431

432
    // Draw rows
433
    let default_style = Style::default();
4✔
434
    if y < table_area.bottom() {
4✔
435
      let remaining = (table_area.bottom() - y) as usize;
4✔
436

437
      // Make sure the table shows the selected item
438
      state.offset = selected.map_or(0, |s| {
4✔
439
        if s >= remaining + state.offset - 1 {
4✔
440
          s + 1 - remaining
4✔
441
        } else if s < state.offset {
×
442
          s
×
443
        } else {
444
          state.offset
×
445
        }
446
      });
4✔
447
      for (i, row) in self.rows.skip(state.offset).take(remaining).enumerate() {
16✔
448
        let (data, style, symbol) = match row {
16✔
449
          Row::Data(d) | Row::StyledData(d, _) if Some(i) == state.current_selection().map(|s| s - state.offset) => match state.mode {
16✔
450
            TableMode::MultipleSelection => {
451
              if state.marked.contains(&(i + state.offset)) {
×
452
                (d, highlight_style, mark_highlight_symbol.to_string())
×
453
              } else {
454
                (d, highlight_style, unmark_highlight_symbol.to_string())
×
455
              }
456
            }
457
            TableMode::SingleSelection => (d, highlight_style, highlight_symbol.to_string()),
4✔
458
          },
459
          Row::Data(d) => {
×
460
            if state.marked.contains(&(i + state.offset)) {
×
461
              (d, default_style, mark_symbol.to_string())
×
462
            } else {
463
              (d, default_style, blank_symbol.to_string())
×
464
            }
465
          }
466
          Row::StyledData(d, s) => {
12✔
467
            if state.marked.contains(&(i + state.offset)) {
12✔
468
              (d, s, mark_symbol.to_string())
×
469
            } else {
470
              (d, s, blank_symbol.to_string())
12✔
471
            }
472
          }
473
        };
474
        x = table_area.left();
16✔
475
        for (c, (w, elt)) in solved_widths.iter().zip(data).enumerate() {
144✔
476
          let s = if c == 0 {
144✔
477
            buf.set_stringn(
16✔
478
              x,
16✔
479
              y + i as u16,
16✔
480
              format!("{symbol:^width$}", symbol = "", width = area.width as usize),
16✔
481
              *w as usize,
16✔
482
              style,
16✔
483
            );
484
            if c == header_index {
16✔
485
              let symbol = match state.mode {
16✔
486
                TableMode::SingleSelection | TableMode::MultipleSelection => &symbol,
16✔
487
              };
488
              format!(
16✔
489
                "{symbol}{elt:>width$}",
16✔
490
                symbol = symbol,
491
                elt = elt,
492
                width = (*w as usize).saturating_sub(symbol.to_string().width())
16✔
493
              )
494
            } else {
495
              format!(
×
496
                "{symbol}{elt:<width$}",
×
497
                symbol = symbol,
498
                elt = elt,
499
                width = (*w as usize).saturating_sub(symbol.to_string().width())
×
500
              )
501
            }
502
          } else {
503
            buf.set_stringn(
128✔
504
              x - 1,
128✔
505
              y + i as u16,
128✔
506
              format!("{symbol:^width$}", symbol = "", width = area.width as usize),
128✔
507
              *w as usize + 1,
128✔
508
              style,
128✔
509
            );
510
            if c == header_index {
128✔
511
              format!("{elt:>width$}", elt = elt, width = *w as usize)
×
512
            } else {
513
              format!("{elt:<width$}", elt = elt, width = *w as usize)
128✔
514
            }
515
          };
516
          buf.set_stringn(x, y + i as u16, s, *w as usize, style);
144✔
517
          x += *w + self.column_spacing;
144✔
518
        }
519
      }
520
    }
×
521
  }
4✔
522
}
523

524
impl<H, D, R> Widget for Table<'_, H, R>
525
where
526
  H: Iterator,
527
  H::Item: Display,
528
  D: Iterator,
529
  D::Item: Display,
530
  R: Iterator<Item = Row<D>>,
531
{
532
  fn render(self, area: Rect, buf: &mut Buffer) {
×
533
    let mut state = TaskwarriorTuiTableState::default();
×
534
    StatefulWidget::render(self, area, buf, &mut state);
×
535
  }
×
536
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc