pub mod ci_log;
pub mod diff;
pub mod diff_render;
pub mod file;
pub mod graph;
pub mod log;
pub mod popup;
mod prs;
mod runs;
pub mod status;
use diffler_core::model::FileStatus;
use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use unicode_width::UnicodeWidthChar;
use crate::app::{App, BranchAction, Modal, Screen, Severity, fuzzy};
use crate::keymap::{Action, render_chord};
use crate::theme::Theme;
use crate::transient::TransientKind;
pub(super) fn highlight_spans(
text: &str,
base: Style,
ranges: &[(std::ops::Range<usize>, bool)],
theme: &Theme,
) -> Vec<Span<'static>> {
highlight_spans_split(text, 0, base, base, ranges, theme)
}
pub(super) fn highlight_spans_split(
text: &str,
split: usize,
lead: Style,
base: Style,
ranges: &[(std::ops::Range<usize>, bool)],
theme: &Theme,
) -> Vec<Span<'static>> {
if ranges.is_empty() && split == 0 {
return vec![Span::styled(text.to_owned(), base)];
}
let snap = |i: usize| {
let mut i = i.min(text.len());
while !text.is_char_boundary(i) {
i -= 1;
}
i
};
let split = snap(split);
let mut bounds = vec![0, split, text.len()];
for (range, _) in ranges {
bounds.push(snap(range.start));
bounds.push(snap(range.end));
}
bounds.sort_unstable();
bounds.dedup();
let bg_at = |at: usize| {
ranges
.iter()
.find(|(range, _)| snap(range.start) <= at && at < snap(range.end))
.map(|(_, current)| {
if *current {
theme.search_current
} else {
theme.search
}
})
};
let mut spans = Vec::new();
for pair in bounds.windows(2) {
let &[start, end] = pair else { continue };
let Some(segment) = text.get(start..end) else {
continue;
};
if segment.is_empty() {
continue;
}
let base = if start < split { lead } else { base };
let style = bg_at(start).map_or(base, |bg| base.bg(bg));
spans.push(Span::styled(segment.to_owned(), style));
}
spans
}
pub(super) fn screen_chrome(frame: &mut Frame<'_>, app: &App, hints: &[Hint]) -> (Rect, Rect) {
let area = frame.area();
frame.render_widget(Block::new().style(app.theme.base()), area);
let [hint, body, bar] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(area);
frame.render_widget(Paragraph::new(hint_line(app, hints)), hint);
(body, bar)
}
pub(super) fn screen_chrome_with_header(
frame: &mut Frame<'_>,
app: &App,
hints: &[Hint],
) -> (Rect, Rect, Rect) {
let area = frame.area();
frame.render_widget(Block::new().style(app.theme.base()), area);
let [hint, header, body, bar] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
Constraint::Length(1),
])
.areas(area);
frame.render_widget(Paragraph::new(hint_line(app, hints)), hint);
(header, body, bar)
}
pub fn draw(frame: &mut Frame<'_>, app: &mut App) {
match app.screen() {
Screen::Status => {
app.queue_enrich_status_expanded();
status::draw(frame, app);
}
Screen::Log => log::draw(frame, app),
Screen::Diff => diff::draw(frame, app),
Screen::Graph => graph::draw(frame, app),
Screen::Runs => runs::draw(frame, app),
Screen::Prs => prs::draw(frame, app),
Screen::CiLog => ci_log::draw(frame, app),
Screen::File => file::draw(frame, app),
}
app.modal_hits = draw_modal(frame, app);
if app.modal.is_none()
&& let Some(transient) = app.which_key_panel()
{
popup::WhichKeyPanel { transient }.render(frame, &app.theme);
}
}
fn draw_modal(frame: &mut Frame<'_>, app: &App) -> Option<popup::ListHits> {
match &app.modal {
Some(Modal::Confirm { message, .. }) => {
popup::ConfirmDialog {
message: message.clone(),
}
.render(frame, &app.theme);
None
}
Some(Modal::Input {
title,
buffer,
cursor,
..
}) => {
popup::InputModal {
title: title.clone(),
buffer: buffer.clone(),
cursor: *cursor,
}
.render(frame, &app.theme);
None
}
Some(Modal::Help) => {
let screen = match app.screen() {
Screen::Status => "status",
Screen::Diff => "diff",
Screen::Log => "log",
Screen::Graph => "graph",
Screen::Runs => "runs",
Screen::Prs => "prs",
Screen::CiLog => "logs",
Screen::File => "file",
};
popup::Popup {
title: format!("Help: {screen} keys"),
entries: help_entries(app),
summary: Vec::new(),
}
.render(frame, &app.theme);
None
}
Some(
Modal::BranchList { .. }
| Modal::PrBase { .. }
| Modal::RevList { .. }
| Modal::Palette { .. }
| Modal::Themes { .. }
| Modal::FilePicker { .. }
| Modal::RemoteList { .. },
) => fuzzy_modal(app).map(|modal| modal.render(frame, &app.theme)),
Some(Modal::PullDiverged { upstream }) => {
popup::Popup {
title: format!("Diverged from {upstream}"),
entries: vec![
("r".to_owned(), "rebase your commits on top".to_owned()),
("m".to_owned(), "merge".to_owned()),
("f".to_owned(), "force (discard local commits)".to_owned()),
("esc".to_owned(), "cancel".to_owned()),
],
summary: Vec::new(),
}
.render(frame, &app.theme);
None
}
Some(Modal::CreatePr { draft }) => {
Some(popup::CreatePrForm { draft }.render(frame, &app.theme))
}
Some(Modal::ReviewVerdict { number, summary }) => {
popup::Popup {
title: format!("Submit review: PR #{number}"),
entries: vec![
("a".to_owned(), "approve".to_owned()),
("x".to_owned(), "request changes".to_owned()),
("c".to_owned(), "comment only".to_owned()),
("esc".to_owned(), "cancel".to_owned()),
],
summary: summary.clone(),
}
.render(frame, &app.theme);
None
}
None => None,
}
}
fn footer_for(list: &fuzzy::FuzzyList, list_keys: &str, verb: &str) -> String {
match list.focus {
fuzzy::FuzzyFocus::List => {
format!(" enter{verb} · j/k move{list_keys} · tab filter · q close ")
}
fuzzy::FuzzyFocus::Input => {
format!(" type to filter · enter{verb} · tab list · esc close ")
}
}
}
fn plain_list(
title: String,
list: &fuzzy::FuzzyList,
labels: &[String],
verb: &str,
) -> popup::FuzzyModal {
popup::FuzzyModal {
title,
query: list.query.clone(),
cursor: list.cursor,
typing: matches!(list.focus, fuzzy::FuzzyFocus::Input),
items: list
.matches
.iter()
.filter_map(|index| labels.get(*index))
.map(|label| (label.clone(), String::new()))
.collect(),
selected: list.selected,
footer: footer_for(list, "", verb),
}
}
fn fuzzy_modal(app: &App) -> Option<popup::FuzzyModal> {
match &app.modal {
Some(Modal::BranchList {
branches,
list,
action,
}) => {
let title = match action {
BranchAction::Checkout => "Checkout branch",
BranchAction::Delete => "Delete branch",
};
Some(popup::FuzzyModal {
title: title.to_owned(),
query: list.query.clone(),
cursor: list.cursor,
typing: matches!(list.focus, fuzzy::FuzzyFocus::Input),
items: list
.matches
.iter()
.filter_map(|index| branches.get(*index))
.map(|b| {
(
format!("{} {}", if b.is_head { "*" } else { " " }, b.name),
String::new(),
)
})
.collect(),
selected: list.selected,
footer: footer_for(list, "", " select"),
})
}
Some(Modal::RevList {
title,
entries,
list,
}) => {
let labels: Vec<String> = entries.iter().map(|c| c.label.clone()).collect();
Some(plain_list((*title).to_owned(), list, &labels, " review"))
}
Some(Modal::PrBase { names, list, .. }) => Some(plain_list(
"Base branch".to_owned(),
list,
names,
" set base",
)),
Some(Modal::Palette { list }) => {
let commands = app.command_index();
Some(popup::FuzzyModal {
title: "Commands".to_owned(),
query: list.query.clone(),
cursor: list.cursor,
typing: matches!(list.focus, fuzzy::FuzzyFocus::Input),
items: list
.matches
.iter()
.filter_map(|index| commands.get(*index))
.map(|c| (c.label.to_owned(), c.chord.clone()))
.collect(),
selected: list.selected,
footer: footer_for(list, "", " run"),
})
}
Some(Modal::Themes { list }) => Some(plain_list(
"Theme".to_owned(),
list,
&crate::theme::names(),
" apply",
)),
Some(Modal::RemoteList { remotes, list, .. }) => {
Some(plain_list("Remote".to_owned(), list, remotes, " select"))
}
Some(Modal::FilePicker { paths, list }) => {
let mut modal = plain_list(
format!("File · {} tracked", paths.len()),
list,
paths,
" open",
);
modal.footer = footer_for(list, " · b blame · e editor", " open");
Some(modal)
}
_ => None,
}
}
fn help_entries(app: &App) -> Vec<(String, String)> {
let keymap = app.active_keymap();
let mut entries: Vec<(String, String)> = keymap
.bindings()
.iter()
.map(|(chord, action)| (render_chord(chord), action.label().to_owned()))
.collect();
if app.screen() == Screen::Status {
for kind in TransientKind::ALL {
let Some(prefix) = keymap.prefix_chord(kind) else {
continue;
};
entries.push((prefix, format!("{} …", kind.title())));
for (key, entry) in app.transient(kind).flat_entries() {
entries.push((format!(" {key}"), entry.label.to_owned()));
}
}
}
entries
}
pub(super) fn status_color(theme: &Theme, status: FileStatus) -> Color {
match status {
FileStatus::Added | FileStatus::Untracked => theme.added,
FileStatus::Deleted => theme.error_fg,
FileStatus::Modified | FileStatus::Renamed => theme.warn_fg,
}
}
pub(super) fn ci_status_color(theme: &Theme, status: crate::ci::JobStatus) -> Color {
use crate::ci::JobStatus;
match status {
JobStatus::Ok => theme.added,
JobStatus::Failed => theme.error_fg,
JobStatus::Running => theme.warn_fg,
JobStatus::Queued | JobStatus::Skipped | JobStatus::Neutral => theme.dim,
}
}
pub(super) fn diffstat_spans(
theme: &Theme,
added: usize,
deleted: usize,
bg: Color,
) -> Vec<Span<'static>> {
if added == 0 && deleted == 0 {
return Vec::new();
}
let side = |count: usize, color: Color| {
let fg = if count == 0 { theme.dim } else { color };
Style::new().fg(fg).bg(bg)
};
vec![
Span::styled(format!(" +{added}"), side(added, theme.added)),
Span::styled(format!(" -{deleted}"), side(deleted, theme.error_fg)),
]
}
pub(super) fn proportion_bar(
theme: &Theme,
added: usize,
deleted: usize,
bg: Color,
) -> Vec<Span<'static>> {
const CELLS: usize = 5;
let total = added + deleted;
if total == 0 {
return Vec::new();
}
let mut add_cells = (added * CELLS).div_ceil(total).min(CELLS);
if added > 0 && add_cells == 0 {
add_cells = 1;
}
if deleted > 0 && add_cells == CELLS {
add_cells = CELLS - 1;
}
let del_cells = CELLS - add_cells;
let mut spans = Vec::new();
if add_cells > 0 {
spans.push(Span::styled(
"█".repeat(add_cells),
Style::new().fg(theme.added).bg(bg),
));
}
if del_cells > 0 {
spans.push(Span::styled(
"█".repeat(del_cells),
Style::new().fg(theme.error_fg).bg(bg),
));
}
spans
}
pub(super) enum Hint {
Leaf(&'static [Action], &'static str),
Prefix(TransientKind, &'static str),
}
pub(super) fn hint_line(app: &App, items: &[Hint]) -> Line<'static> {
let keymap = app.active_keymap();
let mut parts: Vec<(String, &str)> = Vec::new();
for item in items {
match item {
Hint::Leaf(actions, label) => {
let chords: Vec<String> = actions
.iter()
.filter_map(|action| keymap.chord_for(*action))
.collect();
if chords.len() == actions.len() {
parts.push((chords.join("/"), label));
}
}
Hint::Prefix(kind, label) => {
if let Some(chord) = keymap.prefix_chord(*kind) {
parts.push((chord, label));
}
}
}
}
let dim = app.theme.dim_style();
let key_style = Style::new().fg(app.theme.fg).bg(app.theme.bg);
let mut spans = Vec::new();
for (index, (chord, label)) in parts.into_iter().enumerate() {
spans.push(Span::styled(if index == 0 { " " } else { " · " }, dim));
spans.push(Span::styled(chord, key_style));
spans.push(Span::styled(format!(" {label}"), dim));
}
Line::from(spans)
}
pub(super) fn relative_time(now: i64, then: i64) -> String {
let secs = (now - then).max(0);
let (n, unit) = match secs {
s if s < 60 => (s, "s"),
s if s < 3600 => (s / 60, "m"),
s if s < 86_400 => (s / 3600, "h"),
s if s < 86_400 * 7 => (s / 86_400, "d"),
s if s < 86_400 * 30 => (s / (86_400 * 7), "w"),
s if s < 86_400 * 365 => (s / (86_400 * 30), "mo"),
s => (s / (86_400 * 365), "y"),
};
format!("{n}{unit}")
}
fn right_align_pad(used: usize, content_width: usize, width: usize) -> Option<usize> {
(used + content_width < width).then(|| width - used - content_width)
}
pub(super) fn commit_meta_spans(
theme: &Theme,
author: &str,
time_unix: i64,
now: i64,
used: usize,
width: usize,
) -> Vec<Span<'static>> {
let age = relative_time(now, time_unix);
let content_width = author.chars().count() + 2 + age.chars().count() + 1;
let Some(pad) = right_align_pad(used, content_width, width) else {
return Vec::new();
};
vec![
Span::styled(" ".repeat(pad), Style::new().bg(theme.bg)),
Span::styled(
author.to_owned(),
Style::new().fg(theme.accent).bg(theme.bg),
),
Span::styled(" ", Style::new().bg(theme.bg)),
Span::styled(age, theme.dim_style()),
Span::styled(" ", Style::new().bg(theme.bg)),
]
}
pub(super) fn age_spans(
theme: &Theme,
time_unix: i64,
now: i64,
used: usize,
width: usize,
) -> Vec<Span<'static>> {
let age = relative_time(now, time_unix);
let content_width = age.chars().count() + 1;
let Some(pad) = right_align_pad(used, content_width, width) else {
return Vec::new();
};
vec![
Span::styled(" ".repeat(pad), Style::new().bg(theme.bg)),
Span::styled(age, theme.dim_style()),
Span::styled(" ", Style::new().bg(theme.bg)),
]
}
pub(super) const SCROLLOFF: usize = 3;
pub(super) fn scroll_to_cursor(cursor: usize, scroll: usize, height: usize, total: usize) -> usize {
scroll_to_span(cursor, 1, scroll, height, total)
}
pub(super) fn scroll_to_span(
start: usize,
span: usize,
scroll: usize,
height: usize,
total: usize,
) -> usize {
if height == 0 {
return 0;
}
let gap = SCROLLOFF.min(height.saturating_sub(1) / 2);
let last = total.saturating_sub(height);
let highest = start.saturating_sub(gap);
let lowest = (start + span.max(1) + gap).saturating_sub(height);
scroll.min(highest).max(lowest).min(last)
}
pub(super) fn elide(s: &str, max: usize) -> String {
if s.chars().count() <= max {
s.to_owned()
} else {
let kept: String = s.chars().take(max.saturating_sub(1)).collect();
format!("{kept}…")
}
}
pub(super) fn cursor_line(line: Line<'static>, theme: &Theme, width: u16) -> Line<'static> {
let mut spans: Vec<Span<'static>> = line
.spans
.into_iter()
.map(|span| {
let style = span.style.bg(theme.cursor_line);
Span::styled(span.content, style)
})
.collect();
claim_lead_cell(&mut spans, theme);
let used: usize = spans.iter().map(Span::width).sum();
let pad = (width as usize).saturating_sub(used);
if pad > 0 {
spans.push(Span::styled(
" ".repeat(pad),
Style::new().bg(theme.cursor_line),
));
}
Line::from(spans)
}
fn claim_lead_cell(spans: &mut Vec<Span<'static>>, theme: &Theme) {
let bar = |bg| Span::styled("▌", Style::new().fg(theme.accent).bg(bg));
let Some(first) = spans.first_mut() else {
spans.push(bar(theme.cursor_line));
return;
};
let mut rest = first.content.chars();
match rest.next() {
Some(lead) if lead.width().unwrap_or(0) == 1 => {
let bg = first.style.bg.unwrap_or(theme.cursor_line);
first.content = rest.collect::<String>().into();
spans.insert(0, bar(bg));
}
_ => spans.insert(0, bar(theme.cursor_line)),
}
}
pub(super) fn status_bar(app: &App, width: u16) -> Line<'static> {
let theme = &app.theme;
let on_panel = |fg| Style::new().fg(fg).bg(theme.panel);
let chip = match app.screen() {
Screen::Status => " STATUS ".to_owned(),
Screen::Diff => match app.diff.as_ref().map(|d| &d.source) {
Some(source @ diffler_core::source::ReviewSource::Pr { number }) => {
let pending = app
.review
.session_for(source)
.comments
.iter()
.filter(|c| c.remote_id.is_none())
.count();
if pending == 0 {
format!(" PR #{number} ")
} else {
format!(" PR #{number} · {pending} pending ")
}
}
Some(source @ diffler_core::source::ReviewSource::Against { .. }) => {
format!(" DIFF {} ", source.label())
}
_ => " DIFF ".to_owned(),
},
Screen::Log => " LOG ".to_owned(),
Screen::Graph => " GRAPH ".to_owned(),
Screen::Runs => " RUNS ".to_owned(),
Screen::Prs => " PRS ".to_owned(),
Screen::CiLog => " LOGS ".to_owned(),
Screen::File => " FILE ".to_owned(),
};
let repo = app
.review
.repo_root
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_default();
let branch = app.head.branch.clone().unwrap_or_else(|| "?".to_owned());
let mut spans = vec![
Span::styled(chip, theme.chip),
Span::styled(format!(" {repo}@{branch}"), on_panel(theme.fg)),
];
if let Some(port) = app.mcp_port {
spans.push(Span::styled(format!(" · mcp :{port}"), on_panel(theme.dim)));
} else if app.config.mcp.enabled {
spans.push(Span::styled(" · mcp off", on_panel(theme.dim)));
}
if app.refresh_flash > 0 {
spans.push(Span::styled(" · ↻", on_panel(theme.dim)));
}
let (files, viewed) = app.viewed_counts();
if files > 0 {
let text = if app.screen() == Screen::Diff {
format!(" · viewed {viewed}/{files} files")
} else {
let noun = if files == 1 { "file" } else { "files" };
format!(" · {files} {noun}, {viewed} viewed")
};
spans.push(Span::styled(text, on_panel(theme.dim)));
}
if let Some(search) = &app.search {
let (i, n) = search.count();
let count = if n == 0 {
" [no match]".to_owned()
} else {
format!(" [{i}/{n}]")
};
spans.push(Span::styled(
format!(" · /{}", search.query()),
on_panel(theme.accent),
));
spans.push(Span::styled(count, on_panel(theme.dim)));
} else if let Some(message) = &app.message {
let fg = match message.severity {
Severity::Info => theme.dim,
Severity::Warning => theme.warn_fg,
Severity::Error => theme.error_fg,
};
let used: usize = spans.iter().map(Span::width).sum();
let text = format!("{} ", message.text);
let pad = (width as usize).saturating_sub(used + text.len());
if pad > 0 {
spans.push(Span::styled(" ".repeat(pad), on_panel(theme.fg)));
} else {
spans.push(Span::styled(" ", on_panel(theme.fg)));
}
spans.push(Span::styled(text, on_panel(fg)));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::{
Line, SCROLLOFF, Span, Theme, cursor_line, highlight_spans_split, relative_time,
scroll_to_cursor,
};
use ratatui::style::{Color, Style};
#[test]
fn a_paths_parents_recede_behind_its_basename() {
let theme = Theme::github_dark();
let (dim, bright) = (
Style::new().fg(theme.dim),
Style::new().fg(Color::Rgb(1, 2, 3)),
);
let name = "app/diff/mod.rs";
let split = name.rfind('/').map_or(0, |at| at + 1);
let spans = highlight_spans_split(name, split, dim, bright, &[], &theme);
let painted: Vec<(&str, Style)> = spans
.iter()
.map(|span| (span.content.as_ref(), span.style))
.collect();
assert_eq!(painted, vec![("app/diff/", dim), ("mod.rs", bright)]);
}
#[test]
fn a_search_hit_stays_lit_across_the_parent_boundary() {
let theme = Theme::github_dark();
let (dim, bright) = (
Style::new().fg(theme.dim),
Style::new().fg(Color::Rgb(1, 2, 3)),
);
let spans = highlight_spans_split("diff/mod.rs", 5, dim, bright, &[(3..6, true)], &theme);
let lit: Vec<&str> = spans
.iter()
.filter(|span| span.style.bg == Some(theme.search_current))
.map(|span| span.content.as_ref())
.collect();
assert_eq!(lit, vec!["f/", "m"], "both halves of the match are lit");
}
#[test]
fn the_view_holds_still_until_the_cursor_reaches_its_margin() {
let held = scroll_to_cursor(10, 0, 20, 100);
assert_eq!(held, 0, "a cursor in the middle moves nothing");
assert_eq!(scroll_to_cursor(16, 0, 20, 100), 0);
assert_eq!(scroll_to_cursor(17, 0, 20, 100), 1, "the margin is reached");
assert_eq!(
scroll_to_cursor(18, 1, 20, 100),
2,
"and keeps up from there"
);
}
#[test]
fn the_last_rows_are_reachable_without_a_margin() {
let scroll = scroll_to_cursor(99, 80, 20, 100);
assert_eq!(scroll, 80, "the last screenful is the end of the scroll");
assert_eq!(99 - scroll, 19, "the cursor still reaches the bottom row");
}
#[test]
fn the_first_rows_are_reachable_without_a_margin() {
assert_eq!(scroll_to_cursor(1, 5, 20, 100), 0, "the top pulls it home");
assert_eq!(scroll_to_cursor(0, 0, 20, 100), 0);
}
#[test]
fn a_short_viewport_keeps_the_cursor_centred() {
for height in 1..=(SCROLLOFF * 2) {
let scroll = scroll_to_cursor(50, 0, height, 100);
assert!(
(scroll..scroll + height).contains(&50),
"the cursor stays on screen at height {height}"
);
}
}
#[test]
fn the_chip_names_the_pr_when_reviewing_one() {
use crate::app::App;
use crate::config::LoadedConfig;
use crate::test_support::standard_fixture;
let fixture = standard_fixture();
let mut app = App::new(fixture.review(), LoadedConfig::default());
let head = app.review.vcs.resolve("HEAD").expect("head");
app.open_pr_diff(7, &head, &head);
let bar = super::status_bar(&app, 80);
let text: String = bar.spans.iter().map(|s| s.content.clone()).collect();
assert!(text.contains(" PR #7 "), "{text}");
}
#[test]
fn the_status_bar_names_the_revision_an_against_review_diffs_from() {
use crate::app::App;
let fixture = crate::test_support::branch_fixture();
let mut app = App::new(fixture.review(), crate::config::LoadedConfig::default());
app.open_against_diff("main");
let bar = super::status_bar(&app, 80);
let text: String = bar.spans.iter().map(|s| s.content.clone()).collect();
assert!(text.contains(" DIFF vs main "), "{text}");
}
#[test]
fn relative_time_picks_a_compact_unit() {
let now = 1_000_000;
assert_eq!(relative_time(now, now), "0s");
assert_eq!(relative_time(now, now - 49), "49s");
assert_eq!(relative_time(now, now - 6 * 60), "6m");
assert_eq!(relative_time(now, now - 21 * 3600), "21h");
assert_eq!(relative_time(now, now - 3 * 86_400), "3d");
assert_eq!(relative_time(now, now - 2 * 7 * 86_400), "2w");
assert_eq!(relative_time(now, now - 90 * 86_400), "3mo");
assert_eq!(relative_time(now, now - 800 * 86_400), "2y");
assert_eq!(relative_time(now, now + 500), "0s");
}
#[test]
fn the_cursor_bar_takes_the_lead_cell_without_moving_the_row() {
let theme = Theme::github_dark();
let plain = Line::from(vec![
Span::raw(" "),
Span::styled("● src/lib.rs", theme.base()),
]);
let under_cursor = cursor_line(plain.clone(), &theme, 40);
let text: String = under_cursor
.spans
.iter()
.map(|s| s.content.clone())
.collect();
assert!(text.starts_with("▌● src/lib.rs"), "{text}");
assert_eq!(under_cursor.width(), 40, "the band spans the full width");
let column = |line: &Line<'_>| {
line.spans
.iter()
.flat_map(|s| s.content.chars().collect::<Vec<_>>())
.position(|c| c == '●')
};
assert_eq!(column(&under_cursor), column(&plain));
}
#[test]
fn a_row_with_no_lead_cell_to_spare_still_bands_exactly_its_width() {
let theme = Theme::github_dark();
for row in [Span::raw(""), Span::raw("世界"), Span::raw("x")] {
let banded = cursor_line(Line::from(row.clone()), &theme, 6);
let text: String = banded.spans.iter().map(|s| s.content.clone()).collect();
assert!(text.starts_with('▌'), "{text}");
assert_eq!(banded.width(), 6, "{:?} overflows its row", row.content);
}
}
}