use ratatui::Frame;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::Paragraph;
use crate::tui::app::{App, StateFilter, View};
use crate::util::unicode;
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct TabLayout {
pub labels: Vec<String>,
pub show_project_name: bool,
pub scroll_mode: bool,
}
fn track_tab_width(label: &str, is_cc: bool) -> usize {
let base = unicode::display_width(label) + 2 + 1;
if is_cc { base + 2 } else { base }
}
fn fixed_width(inbox_count: usize) -> usize {
let leading = 3; let tracks_tab = 4; let board_tab = 4; let inbox_tab = if inbox_count > 0 {
3 + digit_count(inbox_count) + 1
} else {
4
};
let recent_tab = 4; leading + tracks_tab + board_tab + inbox_tab + recent_tab
}
fn digit_count(n: usize) -> usize {
if n == 0 {
return 1;
}
let mut count = 0;
let mut val = n;
while val > 0 {
count += 1;
val /= 10;
}
count
}
fn fits(labels: &[String], cc_focus_idx: Option<usize>, fixed: usize, available: usize) -> bool {
let track_total: usize = labels
.iter()
.enumerate()
.map(|(i, l)| track_tab_width(l, Some(i) == cc_focus_idx))
.sum();
track_total + fixed <= available
}
fn truncate_display(s: &str, n: usize) -> String {
if unicode::display_width(s) <= n {
return s.to_string();
}
let mut width = 0;
let mut result = String::new();
for c in s.chars() {
let cw = unicode::char_display_width(c);
if width + cw > n {
break;
}
width += cw;
result.push(c);
}
result
}
pub(crate) fn compute_tab_layout(
names: &[String],
prefixes: &[Option<String>],
cc_focus_idx: Option<usize>,
available_width: usize,
project_name_len: usize,
inbox_count: usize,
) -> TabLayout {
let fixed = fixed_width(inbox_count);
let project_name_width = if project_name_len > 0 {
project_name_len + 2 } else {
0
};
if names.is_empty() {
return TabLayout {
labels: Vec::new(),
show_project_name: fixed + project_name_width <= available_width,
scroll_mode: false,
};
}
let full_names: Vec<String> = names.to_vec();
if fits(
&full_names,
cc_focus_idx,
fixed + project_name_width,
available_width,
) {
return TabLayout {
labels: full_names,
show_project_name: true,
scroll_mode: false,
};
}
if fits(&full_names, cc_focus_idx, fixed, available_width) {
return TabLayout {
labels: full_names,
show_project_name: false,
scroll_mode: false,
};
}
let mut labels = full_names;
let mut using_prefix = vec![false; labels.len()];
let default_floor = 3usize;
let label_floors: Vec<usize> = prefixes
.iter()
.map(|p| {
p.as_ref().map_or(default_floor, |s| {
unicode::display_width(s).min(default_floor)
})
})
.collect();
loop {
let mut best_idx: Option<usize> = None;
let mut best_len: usize = 0;
for (i, label) in labels.iter().enumerate() {
let len = unicode::display_width(label);
if len > label_floors[i] && len >= best_len {
best_len = len;
best_idx = Some(i);
}
}
let idx = match best_idx {
Some(i) => i,
None => break, };
let current_width = unicode::display_width(&labels[idx]);
labels[idx] = truncate_display(&labels[idx], current_width - 1);
let new_len = unicode::display_width(&labels[idx]);
if !using_prefix[idx]
&& let Some(ref prefix) = prefixes[idx]
&& unicode::display_width(prefix) == new_len
{
labels[idx] = prefix.clone();
using_prefix[idx] = true;
}
if fits(&labels, cc_focus_idx, fixed, available_width) {
return TabLayout {
labels,
show_project_name: false,
scroll_mode: false,
};
}
}
TabLayout {
labels,
show_project_name: false,
scroll_mode: true,
}
}
pub fn render_tab_bar(frame: &mut Frame, app: &mut App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), ])
.split(area);
if app.view == View::Search {
render_search_tab_bar(frame, app, chunks[0]);
render_separator(frame, app, chunks[1], &[]);
} else {
let sep_cols = render_tabs(frame, app, chunks[0]);
render_separator(frame, app, chunks[1], &sep_cols);
}
}
fn render_search_tab_bar(frame: &mut Frame, app: &App, area: Rect) {
let bg = app.theme.background;
let total_width = area.width as usize;
let mut spans: Vec<Span> = vec![
Span::styled(" ", Style::default().bg(bg)),
Span::styled("\u{25B6}", Style::default().fg(app.theme.purple).bg(bg)),
Span::styled(" ", Style::default().bg(bg)),
Span::styled(
"Search: ",
Style::default()
.fg(app.theme.text_bright)
.bg(app.theme.selection_bg)
.add_modifier(Modifier::BOLD),
),
];
if let Some(ref sr) = app.project_search_results {
spans.push(Span::styled(
sr.query.clone(),
Style::default()
.fg(app.theme.highlight)
.bg(app.theme.selection_bg),
));
let total = sr.items.len();
let group_count = sr.groups.len();
let source_summary = if group_count == 1 {
"1 source".to_string()
} else {
format!("{} sources", group_count)
};
spans.push(Span::styled(
format!(
" {} match{} \u{2500} {}",
total,
if total == 1 { "" } else { "es" },
source_summary
),
Style::default()
.fg(app.theme.text)
.bg(app.theme.selection_bg),
));
}
let used: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
if used < total_width {
spans.push(Span::styled(
" ".repeat(total_width - used),
Style::default().bg(app.theme.selection_bg),
));
}
let line = Line::from(spans);
let widget = Paragraph::new(line).style(Style::default().bg(bg));
frame.render_widget(widget, area);
}
fn render_tabs(frame: &mut Frame, app: &mut App, area: Rect) -> Vec<usize> {
let total_width = area.width as usize;
let cc_focus = app.project.config.agent.cc_focus.as_deref();
let names: Vec<String> = app
.active_track_ids
.iter()
.map(|id| app.track_name(id).to_string())
.collect();
let prefixes: Vec<Option<String>> = app
.active_track_ids
.iter()
.map(|id| app.project.config.ids.prefixes.get(id.as_str()).cloned())
.collect();
let cc_focus_idx = cc_focus.and_then(|cf| app.active_track_ids.iter().position(|id| id == cf));
let inbox_count = app.inbox_count();
let project_name = app.project.config.project.name.clone();
let project_name_len = unicode::display_width(&project_name);
let layout = compute_tab_layout(
&names,
&prefixes,
cc_focus_idx,
total_width,
project_name_len,
inbox_count,
);
let mut spans: Vec<Span> = Vec::new();
let mut sep_cols: Vec<usize> = Vec::new();
let sep = Span::styled(
"\u{2502}",
Style::default().fg(app.theme.dim).bg(app.theme.background),
);
let bg_style = Style::default().bg(app.theme.background);
spans.push(Span::styled(" ", bg_style));
spans.push(Span::styled(
"\u{25B6}",
Style::default()
.fg(app.theme.purple)
.bg(app.theme.background),
));
spans.push(Span::styled(" ", bg_style));
if layout.scroll_mode {
let fixed = fixed_width(inbox_count);
let budget = total_width.saturating_sub(fixed);
let active_idx = match &app.view {
View::Track(i) => Some(*i),
View::Detail { track_id, .. } => {
app.active_track_ids.iter().position(|id| id == track_id)
}
_ => None,
};
let n = layout.labels.len();
if app.tab_scroll >= n {
app.tab_scroll = n.saturating_sub(1);
}
if let Some(aidx) = active_idx {
if aidx < app.tab_scroll {
app.tab_scroll = aidx;
}
loop {
let (vis_end, _) =
visible_range(&layout.labels, cc_focus_idx, app.tab_scroll, budget);
if aidx < vis_end {
break;
}
if app.tab_scroll >= n.saturating_sub(1) {
break;
}
app.tab_scroll += 1;
}
}
let has_left_initial = app.tab_scroll > 0;
let left_cost = usize::from(has_left_initial);
let track_budget = budget.saturating_sub(left_cost);
let (full_end, full_used) =
visible_range(&layout.labels, cc_focus_idx, app.tab_scroll, track_budget);
let has_right = full_end < n;
let mut first_partial: Option<String> = None;
let mut first_partial_show_cc = false;
if app.tab_scroll > 0 {
let prev = app.tab_scroll - 1;
let prev_has_left = prev > 0;
let prev_left_cost = usize::from(prev_has_left);
let prev_track_budget = budget.saturating_sub(prev_left_cost);
let partial_space = prev_track_budget.saturating_sub(full_used);
if partial_space >= 4 {
let is_cc = Some(prev) == cc_focus_idx;
let (overhead, show_cc) = if is_cc {
if partial_space >= 6 {
(5, true)
} else {
(3, false)
}
} else {
(3, false)
};
let max_chars = partial_space.saturating_sub(overhead);
if max_chars > 0 {
app.tab_scroll = prev;
first_partial = Some(truncate_display(&layout.labels[prev], max_chars));
first_partial_show_cc = show_cc;
}
}
}
let has_left = app.tab_scroll > 0;
if has_left {
spans.push(Span::styled(
"\u{25C2}",
Style::default().fg(app.theme.dim).bg(app.theme.background),
));
}
let mut tabs_used = full_used;
let full_start = if let Some(ref label) = first_partial {
let show_cc = first_partial_show_cc;
tabs_used += track_tab_width(label, show_cc);
render_track_tab(
&mut spans,
app,
app.tab_scroll,
label,
show_cc,
&sep,
&mut sep_cols,
);
app.tab_scroll + 1
} else {
app.tab_scroll
};
for i in full_start..full_end {
let label = &layout.labels[i];
let is_cc = Some(i) == cc_focus_idx;
render_track_tab(&mut spans, app, i, label, is_cc, &sep, &mut sep_cols);
}
let actual_left_cost = usize::from(has_left);
let right_avail = budget
.saturating_sub(actual_left_cost)
.saturating_sub(tabs_used);
if has_right && full_end < n && right_avail >= 5 {
let is_cc = Some(full_end) == cc_focus_idx;
let partial_budget = right_avail - 1; let (overhead, show_cc) = if is_cc {
if partial_budget >= 6 {
(5, true)
} else {
(3, false)
}
} else {
(3, false)
};
let max_chars = partial_budget.saturating_sub(overhead);
if max_chars > 0 {
let trunc_label = truncate_display(&layout.labels[full_end], max_chars);
let partial_w = track_tab_width(&trunc_label, show_cc);
tabs_used += partial_w;
render_track_tab(
&mut spans,
app,
full_end,
&trunc_label,
show_cc,
&sep,
&mut sep_cols,
);
}
}
if has_right {
let final_right = budget
.saturating_sub(actual_left_cost)
.saturating_sub(tabs_used);
let pad = final_right.saturating_sub(1);
if pad > 0 {
spans.push(Span::styled(
" ".repeat(pad),
Style::default().bg(app.theme.background),
));
}
spans.push(Span::styled(
"\u{25B8}",
Style::default().fg(app.theme.dim).bg(app.theme.background),
));
}
} else {
app.tab_scroll = 0;
for (i, label) in layout.labels.iter().enumerate() {
let is_cc = Some(i) == cc_focus_idx;
render_track_tab(&mut spans, app, i, label, is_cc, &sep, &mut sep_cols);
}
}
let is_tracks = app.view == View::Tracks;
spans.push(Span::styled(" \u{25B6} ", tab_style(app, is_tracks)));
sep_cols.push(
spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum(),
);
spans.push(sep.clone());
let is_board = app.view == View::Board;
spans.push(Span::styled(" \u{2261} ", tab_style(app, is_board)));
sep_cols.push(
spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum(),
);
spans.push(sep.clone());
let is_inbox = app.view == View::Inbox;
let tab_bg = if is_inbox {
app.theme.selection_bg
} else {
app.theme.background
};
let style = tab_style(app, is_inbox);
spans.push(Span::styled(" ", style));
spans.push(Span::styled(
"*",
Style::default().fg(app.theme.purple).bg(tab_bg),
));
if inbox_count > 0 {
spans.push(Span::styled(format!("{} ", inbox_count), style));
} else {
spans.push(Span::styled(" ", style));
}
sep_cols.push(
spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum(),
);
spans.push(sep.clone());
let is_recent = app.view == View::Recent;
spans.push(Span::styled(" \u{2713} ", tab_style(app, is_recent)));
sep_cols.push(
spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum(),
);
spans.push(sep.clone());
if layout.show_project_name {
let tabs_width: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
let available = total_width.saturating_sub(tabs_width);
let name_style = Style::default().fg(app.theme.text).bg(app.theme.background);
if available >= project_name_len + 2 {
let pad = available - project_name_len - 2;
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), bg_style));
}
spans.push(Span::styled(format!(" {} ", project_name), name_style));
}
}
let line = Line::from(spans);
let tabs = Paragraph::new(line).style(Style::default().bg(app.theme.background));
frame.render_widget(tabs, area);
sep_cols
}
fn visible_range(
labels: &[String],
cc_focus_idx: Option<usize>,
start: usize,
budget: usize,
) -> (usize, usize) {
let mut used = 0;
for (i, label) in labels.iter().enumerate().skip(start) {
let is_cc = Some(i) == cc_focus_idx;
let w = track_tab_width(label, is_cc);
if used + w > budget {
return (i, used);
}
used += w;
}
(labels.len(), used)
}
fn render_track_tab(
spans: &mut Vec<Span<'static>>,
app: &App,
track_idx: usize,
label: &str,
is_cc: bool,
sep: &Span<'static>,
sep_cols: &mut Vec<usize>,
) {
let track_id = &app.active_track_ids[track_idx];
let is_current = app.view == View::Track(track_idx)
|| matches!(&app.view, View::Detail { track_id: tid, .. } if tid == track_id.as_str());
let style = tab_style(app, is_current);
if is_cc {
spans.push(Span::styled(format!(" {} ", label), style));
spans.push(Span::styled(
"\u{2605}",
Style::default().fg(app.theme.purple).bg(if is_current {
app.theme.selection_bg
} else {
app.theme.background
}),
));
spans.push(Span::styled(
" ",
Style::default().bg(if is_current {
app.theme.selection_bg
} else {
app.theme.background
}),
));
} else {
spans.push(Span::styled(format!(" {} ", label), style));
}
sep_cols.push(
spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum(),
);
spans.push(sep.clone());
}
fn render_separator(frame: &mut Frame, app: &App, area: Rect, sep_cols: &[usize]) {
let width = area.width as usize;
let bg = app.theme.background;
let dim = app.theme.dim;
let is_track_view = matches!(app.view, View::Track(_));
let is_board_view = app.view == View::Board;
let filter = &app.filter_state;
if (is_track_view || is_board_view) && filter.is_active() {
let mut indicator_spans: Vec<Span> = Vec::new();
indicator_spans.push(Span::styled(
"filter: ",
Style::default().fg(app.theme.purple).bg(bg),
));
if let Some(sf) = &filter.state_filter {
let state_color = match sf {
StateFilter::Active => app.theme.state_color(crate::model::TaskState::Active),
StateFilter::Todo => app.theme.state_color(crate::model::TaskState::Todo),
StateFilter::Blocked => app.theme.state_color(crate::model::TaskState::Blocked),
StateFilter::Parked => app.theme.state_color(crate::model::TaskState::Parked),
StateFilter::Ready => app.theme.state_color(crate::model::TaskState::Active),
};
indicator_spans.push(Span::styled(
sf.label(),
Style::default().fg(state_color).bg(bg),
));
}
if let Some(ref tag) = filter.tag_filter {
if filter.state_filter.is_some() {
indicator_spans.push(Span::styled(" ", Style::default().bg(bg)));
}
let tag_color = app.theme.tag_color(tag);
indicator_spans.push(Span::styled(
format!("#{}", tag),
Style::default().fg(tag_color).bg(bg),
));
}
let indicator_width: usize = indicator_spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
let separator_end = width.saturating_sub(indicator_width + 2);
let mut spans: Vec<Span> = Vec::new();
let mut sep_text = String::with_capacity(separator_end * 3);
for col in 0..separator_end {
if sep_cols.contains(&col) {
sep_text.push('\u{2534}');
} else {
sep_text.push('\u{2500}');
}
}
spans.push(Span::styled(sep_text, Style::default().fg(dim).bg(bg)));
spans.push(Span::styled(" ", Style::default().bg(bg)));
spans.extend(indicator_spans);
let current_width: usize = spans
.iter()
.map(|s| unicode::display_width(&s.content))
.sum();
if current_width < width {
spans.push(Span::styled(
" ".repeat(width - current_width),
Style::default().bg(bg),
));
}
let line = Line::from(spans);
let sep_widget = Paragraph::new(line).style(Style::default().bg(bg));
frame.render_widget(sep_widget, area);
} else {
let mut line: String = String::with_capacity(width * 3);
for col in 0..width {
if sep_cols.contains(&col) {
line.push('\u{2534}');
} else {
line.push('\u{2500}');
}
}
let sep_widget = Paragraph::new(line).style(Style::default().fg(dim).bg(bg));
frame.render_widget(sep_widget, area);
}
}
fn tab_style(app: &App, is_current: bool) -> Style {
if is_current {
Style::default()
.fg(app.theme.text_bright)
.bg(app.theme.selection_bg)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(app.theme.text).bg(app.theme.background)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_all_fit_with_project_name() {
let names = vec!["Alpha".to_string(), "Beta".to_string()];
let prefixes = vec![None, None];
let layout = compute_tab_layout(&names, &prefixes, None, 50, 10, 0);
assert!(layout.show_project_name);
assert!(!layout.scroll_mode);
assert_eq!(layout.labels, vec!["Alpha", "Beta"]);
}
#[test]
fn test_project_name_removed() {
let names = vec!["Alpha".to_string(), "Beta".to_string()];
let prefixes = vec![None, None];
let layout = compute_tab_layout(&names, &prefixes, None, 35, 10, 0);
assert!(!layout.show_project_name);
assert!(!layout.scroll_mode);
assert_eq!(layout.labels, vec!["Alpha", "Beta"]);
}
#[test]
fn test_shrink_longest_first() {
let names = vec![
"Infrastructure".to_string(), "Backend".to_string(), "Frontend".to_string(), ];
let prefixes = vec![None, None, None];
let layout = compute_tab_layout(&names, &prefixes, None, 49, 0, 0);
assert!(!layout.scroll_mode);
let lens: Vec<usize> = layout
.labels
.iter()
.map(|l| unicode::display_width(l))
.collect();
assert!(
lens[0] >= lens[1],
"longest name should still be >= shorter: {:?}",
lens
);
assert!(
lens[0] >= lens[2],
"longest name should still be >= shorter: {:?}",
lens
);
assert_eq!(layout.labels[1], "Backend"); let total: usize = layout
.labels
.iter()
.map(|l| unicode::display_width(l) + 2 + 1)
.sum::<usize>()
+ 19;
assert!(total <= 49);
}
#[test]
fn test_prefix_swap_at_exact_length() {
let names = vec![
"Infrastructure".to_string(),
"Backend".to_string(),
"Frontend".to_string(),
];
let prefixes = vec![
Some("INF".to_string()), Some("BE".to_string()), Some("FE".to_string()), ];
let layout = compute_tab_layout(&names, &prefixes, None, 36, 0, 0);
assert!(!layout.scroll_mode);
assert_eq!(layout.labels[0], "INF");
assert_eq!(layout.labels[2], "FE");
let layout2 = compute_tab_layout(&names, &prefixes, None, 35, 0, 0);
assert!(!layout2.scroll_mode);
assert_eq!(layout2.labels, vec!["INF", "BE", "FE"]);
}
#[test]
fn test_prefix_swap_is_zero_width() {
let names = vec!["Alpha".to_string(), "Bravo".to_string()];
let prefixes = vec![Some("ALP".to_string()), Some("BRV".to_string())];
let layout = compute_tab_layout(&names, &prefixes, None, 32, 0, 0);
assert!(!layout.scroll_mode);
assert_eq!(layout.labels[0], "Alph"); assert_eq!(layout.labels[1], "BRV"); }
#[test]
fn test_shrink_past_prefix() {
let names = vec![
"AAAA".to_string(),
"BBBB".to_string(),
"CCCC".to_string(),
"DDDD".to_string(),
];
let prefixes = vec![
Some("AAAA".to_string()),
Some("BBBB".to_string()),
Some("CCCC".to_string()),
Some("DDDD".to_string()),
];
let layout = compute_tab_layout(&names, &prefixes, None, 43, 0, 0);
assert!(!layout.scroll_mode);
for label in &layout.labels {
assert_eq!(unicode::display_width(label), 3);
}
}
#[test]
fn test_scrolling_when_nothing_fits() {
let names: Vec<String> = (0..20).map(|i| format!("Track{}", i)).collect();
let prefixes: Vec<Option<String>> = (0..20).map(|i| Some(format!("T{}", i))).collect();
let layout = compute_tab_layout(&names, &prefixes, None, 60, 0, 0);
assert!(layout.scroll_mode);
}
#[test]
fn test_no_over_shrink() {
let names = vec![
"TUI Dev".to_string(), "CLI".to_string(), "Another".to_string(), "One More".to_string(), "Booyah".to_string(), "Delete".to_string(), "Echo".to_string(), "Further".to_string(), ];
let prefixes: Vec<Option<String>> = vec![None; 8];
let layout = compute_tab_layout(&names, &prefixes, None, 79, 0, 0);
assert!(!layout.scroll_mode);
let total: usize = layout
.labels
.iter()
.map(|l| unicode::display_width(l) + 2 + 1)
.sum::<usize>()
+ 19;
assert!(total <= 79, "total {} should be <= 79", total);
assert!(total >= 78, "total {} shouldn't leave much slack", total);
assert_eq!(layout.labels[1], "CLI");
}
#[test]
fn test_balanced_shrinking_equal_names() {
let names = vec![
"Alpha".to_string(),
"Bravo".to_string(),
"Delta".to_string(),
];
let prefixes = vec![None, None, None];
let layout = compute_tab_layout(&names, &prefixes, None, 41, 0, 0);
assert!(!layout.scroll_mode);
let lens: Vec<usize> = layout
.labels
.iter()
.map(|l| unicode::display_width(l))
.collect();
let max = *lens.iter().max().unwrap();
let min = *lens.iter().min().unwrap();
assert!(max - min <= 1, "labels should be balanced: {:?}", lens);
}
#[test]
fn test_cc_focus_width_accounting() {
let names = vec!["Alpha".to_string(), "Beta".to_string()];
let prefixes = vec![None, None];
let layout_no_cc = compute_tab_layout(&names, &prefixes, None, 35, 0, 0);
assert!(!layout_no_cc.scroll_mode);
let layout_cc = compute_tab_layout(&names, &prefixes, Some(0), 35, 0, 0);
assert!(!layout_cc.show_project_name);
}
#[test]
fn test_zero_tracks() {
let layout = compute_tab_layout(&[], &[], None, 80, 10, 0);
assert!(layout.labels.is_empty());
assert!(!layout.scroll_mode);
assert!(layout.show_project_name); }
#[test]
fn test_one_track() {
let names = vec!["Solo".to_string()];
let prefixes = vec![None];
let layout = compute_tab_layout(&names, &prefixes, None, 30, 0, 0);
assert!(!layout.scroll_mode);
assert_eq!(layout.labels, vec!["Solo"]);
}
#[test]
fn test_inbox_count_affects_fixed_width() {
assert!(fixed_width(99) > fixed_width(0));
assert_eq!(fixed_width(0), 19); assert_eq!(fixed_width(99), 21);
let names = vec!["A".to_string()];
let prefixes = vec![None];
let layout_0 = compute_tab_layout(&names, &prefixes, None, 24, 0, 0);
assert!(!layout_0.scroll_mode);
let layout_99 = compute_tab_layout(&names, &prefixes, None, 25, 0, 99);
assert!(!layout_99.scroll_mode);
}
#[test]
fn test_digit_count() {
assert_eq!(digit_count(0), 1);
assert_eq!(digit_count(1), 1);
assert_eq!(digit_count(9), 1);
assert_eq!(digit_count(10), 2);
assert_eq!(digit_count(99), 2);
assert_eq!(digit_count(100), 3);
}
mod snapshots {
use super::super::*;
use crate::tui::render::test_helpers::*;
use insta::assert_snapshot;
#[test]
fn single_track_tab() {
let mut app = app_with_track(SIMPLE_TRACK_MD);
let output = render_to_string(TERM_W, 2, |frame, area| {
render_tab_bar(frame, &mut app, area);
});
assert_snapshot!(output);
}
#[test]
fn multiple_tracks() {
let mut project =
project_with_track("alpha", "Alpha", "# Alpha\n\n## Backlog\n\n## Done\n");
let track2 = crate::parse::parse_track("# Beta\n\n## Backlog\n\n## Done\n");
project.config.tracks.push(crate::model::TrackConfig {
id: "beta".into(),
name: "Beta".into(),
state: "active".into(),
file: "tracks/beta.md".into(),
});
project.tracks.push(("beta".into(), track2));
let mut app = App::new(project);
let output = render_to_string(TERM_W, 2, |frame, area| {
render_tab_bar(frame, &mut app, area);
});
assert_snapshot!(output);
}
#[test]
fn inbox_tab_selected() {
let mut app = app_with_inbox(INBOX_MD);
app.view = crate::tui::app::View::Inbox;
let output = render_to_string(TERM_W, 2, |frame, area| {
render_tab_bar(frame, &mut app, area);
});
assert_snapshot!(output);
}
}
}