use ratatui::{
style::{Modifier, Style},
text::{Line, Span, Text},
};
use tuicore::{Chip, ChipColorRole, MatchSpan, SearchMode, search_match};
pub(crate) const TICKET_MENU_WIDTH: u16 = 84;
pub(crate) const TICKET_MENU_MAX_HEIGHT_PERCENT: u16 = 60;
const LABEL_CHIP_TEXT_MAX_WIDTH: usize = 16;
const LABEL_TEXT_MAX_WIDTH: usize = 10;
pub(crate) fn ticket_menu_max_height(viewport_height: u16) -> u16 {
viewport_height.saturating_mul(TICKET_MENU_MAX_HEIGHT_PERCENT) / 100
}
#[derive(Clone)]
pub(crate) struct WorkItemRow {
pub id: String,
pub key: String,
pub title: String,
pub kind: WorkItemKind,
pub priority: String,
pub status: String,
pub done: bool,
pub assignee: String,
pub labels: Vec<String>,
pub story_points: Option<f64>,
pub show_story_points: bool,
pub story_points_estimated: bool,
pub story_points_from_average: bool,
pub change_badge: Option<ChangeBadge>,
pub submitted: bool,
pub status_changed_at: Option<chrono::DateTime<chrono::Utc>>,
pub show_time_in_status: bool,
}
impl WorkItemRow {
pub(crate) fn prepare_reference(&self) -> Option<String> {
prepare_references(std::iter::once(self))
}
}
pub(crate) fn prepare_references<'a>(
rows: impl IntoIterator<Item = &'a WorkItemRow>,
) -> Option<String> {
let references = rows
.into_iter()
.filter(|row| !row.key.starts_with("NEW-"))
.map(|row| {
let title = row.title.replace('\\', "\\\\").replace('"', "\\\"");
format!("{} \"{title}\"", row.key)
})
.collect::<Vec<_>>();
(!references.is_empty()).then(|| format!("finery prepare {}", references.join(" ")))
}
#[derive(Clone, Copy)]
pub(crate) enum WorkItemKind {
Epic,
Story,
Task,
Bug,
Subtask,
Other,
}
#[derive(Clone, Copy)]
pub(crate) enum ChangeBadge {
Added,
Modified,
Deleted,
Synced,
}
pub(crate) struct TicketRowDetails<'a> {
pub subtask_progress: Option<(usize, usize)>,
pub fix_versions: &'a [String],
pub epic_name: Option<&'a str>,
pub annotation: Option<&'a str>,
}
pub(crate) fn ticket_summary_text(
row: &WorkItemRow,
number_query: Option<&str>,
text_query: Option<&str>,
details: TicketRowDetails<'_>,
) -> Text<'static> {
let theme = tuicore::theme();
let text_style = Style::default().fg(if row.submitted {
theme.muted_fg()
} else {
theme.text_fg()
});
let muted_style = Style::default().fg(theme.muted_fg());
let mut metadata = Vec::new();
if let Some(change) = row.change_badge {
let (badge, color) = change_badge(change);
metadata.push(Span::styled(
badge,
Style::default()
.fg(if row.submitted {
theme.muted_fg()
} else {
color
})
.add_modifier(Modifier::BOLD),
));
}
if row.show_story_points {
let style = (row.story_points.is_some() && !row.story_points_estimated)
.then_some(text_style)
.unwrap_or(muted_style);
let style = (row.story_points.is_some() && row.story_points_estimated)
.then(|| style.add_modifier(Modifier::UNDERLINED))
.unwrap_or(style);
append_metadata(&mut metadata, Span::styled(story_points_label(row), style));
}
append_metadata(
&mut metadata,
crate::components::avatar::bubble_span(&row.assignee),
);
if let Some((completed, total)) = details.subtask_progress {
append_metadata(
&mut metadata,
Span::styled(format!("{completed}/{total} "), text_style),
);
}
if !row.status.is_empty() {
let mut status_text = row.status.clone();
if !row.done && row.show_time_in_status {
if let Some(changed_at) = row.status_changed_at {
let time =
crate::store::work_items::format_time_in_status(chrono::Utc::now(), changed_at);
status_text.push_str(&format!(" ({time})"));
}
}
append_metadata(&mut metadata, Span::styled(status_text, text_style));
}
append_labels_chip(&mut metadata, &row.labels);
if let Some(epic_name) = details.epic_name {
append_metadata(
&mut metadata,
Span::styled(
epic_name.to_owned(),
Style::default()
.fg(theme.warning_fg())
.add_modifier(Modifier::BOLD),
),
);
}
append_release_text(&mut metadata, details.fix_versions);
if row.submitted {
metadata.push(Span::styled(
" · submitted",
Style::default().fg(theme.muted_fg()),
));
}
if let Some(annotation) = details.annotation {
append_metadata(
&mut metadata,
Span::styled(annotation.to_owned(), Style::default().fg(theme.muted_fg())),
);
}
Text::from(vec![
work_item_title_with_key_line_with_match(row, number_query, text_query),
Line::from(metadata),
])
}
pub(crate) fn attachment_summary_text(
change: crate::store::composer::AttachmentChangeKind,
filename: &str,
created: &str,
size: u64,
highlighted: bool,
submitted: bool,
) -> Text<'static> {
let theme = tuicore::theme();
let text_style = Style::default().fg(if submitted {
theme.muted_fg()
} else if highlighted {
theme.selected_fg()
} else {
theme.text_fg()
});
let muted_style = Style::default().fg(if submitted {
theme.muted_fg()
} else if highlighted {
theme.selected_fg()
} else {
theme.muted_fg()
});
Text::from(Line::from(vec![
Span::styled(attachment_change_badge(change), text_style),
Span::styled(" ", text_style),
Span::styled(" ", text_style),
Span::styled(filename.to_owned(), text_style.add_modifier(Modifier::BOLD)),
Span::styled(" • ", muted_style),
Span::styled(attachment_date_label(created), muted_style),
Span::styled(" • ", muted_style),
Span::styled(attachment_size_label(size), muted_style),
]))
}
pub(crate) fn mermaid_diagram_summary_text(
title: &str,
diagram_type: &str,
highlighted: bool,
submitted: bool,
published: bool,
) -> Text<'static> {
let theme = tuicore::theme();
let text_style = Style::default().fg(if submitted {
theme.muted_fg()
} else if highlighted {
theme.selected_fg()
} else {
theme.text_fg()
});
let muted_style = Style::default().fg(if submitted {
theme.muted_fg()
} else if highlighted {
theme.selected_fg()
} else {
theme.muted_fg()
});
let mut spans = vec![
Span::styled("A", text_style),
Span::styled(" ", text_style),
Span::styled(" ", text_style),
Span::styled(title.to_owned(), text_style.add_modifier(Modifier::BOLD)),
Span::styled(" • ", muted_style),
Span::styled(diagram_type_label(diagram_type), muted_style),
];
if published {
spans.push(Span::styled(" • published", muted_style));
}
Text::from(Line::from(spans))
}
fn diagram_type_label(diagram_type: &str) -> String {
diagram_type
.split_whitespace()
.map(|word| match word.to_ascii_lowercase().as_str() {
"er" => "ER".into(),
"xy" => "XY".into(),
"c4" => "C4".into(),
_ => {
let mut characters = word.chars();
let Some(first) = characters.next() else {
return String::new();
};
format!("{}{}", first.to_uppercase(), characters.as_str())
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn attachment_change_badge(change: crate::store::composer::AttachmentChangeKind) -> &'static str {
match change {
crate::store::composer::AttachmentChangeKind::Synced => "S",
crate::store::composer::AttachmentChangeKind::Added => "A",
crate::store::composer::AttachmentChangeKind::Modified => "M",
crate::store::composer::AttachmentChangeKind::Deleted => "D",
}
}
fn attachment_date_label(created: &str) -> String {
if created.is_empty() {
return "Today".into();
}
let Some((year, month, day)) = created.split('T').next().and_then(|date| {
let mut parts = date.split('-');
Some((parts.next()?, parts.next()?, parts.next()?))
}) else {
return created.to_owned();
};
let month = match month {
"01" => "Jan",
"02" => "Feb",
"03" => "Mar",
"04" => "Apr",
"05" => "May",
"06" => "Jun",
"07" => "Jul",
"08" => "Aug",
"09" => "Sep",
"10" => "Oct",
"11" => "Nov",
"12" => "Dec",
_ => return created.to_owned(),
};
format!("{month} {day}, {year}")
}
fn attachment_size_label(size: u64) -> String {
const KIB: u64 = 1024;
const MIB: u64 = KIB * 1024;
const GIB: u64 = MIB * 1024;
match size {
size if size >= GIB => format_size(size, GIB, "GB"),
size if size >= MIB => format_size(size, MIB, "MB"),
size if size >= KIB => format_size(size, KIB, "KB"),
size => format!("{size} B"),
}
}
fn format_size(size: u64, unit: u64, label: &str) -> String {
let value = size as f64 / unit as f64;
if value >= 10.0 || value.fract() == 0.0 {
format!("{value:.0} {label}")
} else {
format!("{value:.1} {label}")
}
}
pub(crate) fn work_item_title_with_key_line_with_match(
row: &WorkItemRow,
number_query: Option<&str>,
text_query: Option<&str>,
) -> Line<'static> {
let theme = tuicore::theme();
let (kind_icon, mut kind_color) = ticket_icon(row.kind);
let (priority_icon, mut priority_color) = priority_icon(&row.priority);
let text_color = if row.submitted {
kind_color = theme.muted_fg();
priority_color = theme.muted_fg();
theme.muted_fg()
} else {
theme.text_fg()
};
let mut spans = vec![
Span::styled(format!("{kind_icon} "), Style::default().fg(kind_color)),
Span::styled(
format!("{priority_icon} "),
Style::default().fg(priority_color),
),
];
let key_style = ticket_key_style(
row,
Style::default()
.fg(theme.muted_fg())
.add_modifier(Modifier::BOLD),
);
if text_query.is_some() {
spans.extend(search_match_spans(&row.key, text_query, key_style));
} else {
spans.extend(ticket_key_spans(&row.key, number_query, key_style));
}
spans.push(Span::raw(" "));
spans.extend(search_match_spans(
&row.title,
text_query,
Style::default().fg(text_color).add_modifier(Modifier::BOLD),
));
Line::from(spans)
}
fn ticket_key_style(row: &WorkItemRow, style: Style) -> Style {
row.done
.then(|| style.add_modifier(Modifier::CROSSED_OUT))
.unwrap_or(style)
}
fn search_match_spans(text: &str, query: Option<&str>, style: Style) -> Vec<Span<'static>> {
let mut matches = query
.unwrap_or_default()
.split_whitespace()
.filter_map(|term| search_match(term, text, SearchMode::Contains))
.flat_map(|matched| matched.spans)
.collect::<Vec<_>>();
if matches.is_empty() {
return vec![Span::styled(text.to_owned(), style)];
}
matches.sort_by_key(|matched| matched.start);
let matches = merge_match_spans(matches);
let mut spans = Vec::with_capacity(matches.len().saturating_mul(2).saturating_add(1));
let mut cursor = 0;
for matched in matches {
if cursor < matched.start {
spans.push(Span::styled(text[cursor..matched.start].to_owned(), style));
}
spans.push(Span::styled(
text[matched.start..matched.end].to_owned(),
style.add_modifier(Modifier::UNDERLINED),
));
cursor = matched.end;
}
if cursor < text.len() {
spans.push(Span::styled(text[cursor..].to_owned(), style));
}
spans
}
fn merge_match_spans(matches: Vec<MatchSpan>) -> Vec<MatchSpan> {
let mut merged = Vec::<MatchSpan>::new();
for matched in matches {
if let Some(previous) = merged.last_mut()
&& matched.start <= previous.end
{
previous.end = previous.end.max(matched.end);
} else {
merged.push(matched);
}
}
merged
}
fn ticket_key_spans(key: &str, number_query: Option<&str>, style: Style) -> Vec<Span<'static>> {
let Some(number) = number_query
.filter(|number| crate::components::ticket_number_jump::ticket_number_matches(key, number))
else {
return vec![Span::styled(key.to_owned(), style)];
};
let (_, suffix) = key
.rsplit_once('-')
.expect("matching ticket key has a number suffix");
let prefix_len = key.len().saturating_sub(suffix.len());
let underline_end = prefix_len.saturating_add(number.len());
vec![
Span::styled(key[..prefix_len].to_owned(), style),
Span::styled(
key[prefix_len..underline_end].to_owned(),
style.add_modifier(Modifier::UNDERLINED),
),
Span::styled(key[underline_end..].to_owned(), style),
]
}
pub(crate) fn work_item_title_prefix_width(row: &WorkItemRow) -> usize {
work_item_title_prefix_width_for(row.kind, &row.priority, &row.key)
}
pub(crate) fn work_item_title_prefix_width_for(
kind: WorkItemKind,
priority: &str,
key: &str,
) -> usize {
let (kind_icon, _) = ticket_icon(kind);
let (priority_icon, _) = priority_icon(priority);
Line::from(format!("{kind_icon} {priority_icon} {key} ")).width()
}
pub(crate) fn story_points_label(row: &WorkItemRow) -> String {
let Some(points) = row.story_points else {
return "-".into();
};
let prefix = (row.story_points_estimated && row.story_points_from_average)
.then_some("~")
.unwrap_or("");
if row.story_points_estimated && row.story_points_from_average {
return format!("{prefix}{points:.1}");
}
format!("{prefix}{points}")
}
pub(crate) fn append_release_text(metadata: &mut Vec<Span<'static>>, fix_versions: &[String]) {
if fix_versions.is_empty() {
return;
}
if !metadata.is_empty() {
metadata.push(Span::raw(" • "));
}
metadata.push(Span::styled(
fix_versions.join(", "),
Style::default()
.fg(tuicore::theme().accent_fg())
.add_modifier(Modifier::BOLD),
));
}
fn append_labels_chip(metadata: &mut Vec<Span<'static>>, labels: &[String]) {
if labels.is_empty() {
return;
}
if !metadata.is_empty() {
metadata.push(Span::raw(" • "));
}
metadata.extend(
Chip::new(compact_labels_text(labels))
.color_role(ChipColorRole::Highlight)
.line()
.spans,
);
}
fn compact_labels_text(labels: &[String]) -> String {
let mut displayed = Vec::new();
for (index, label) in labels.iter().enumerate() {
let label = truncate_label(label);
let candidate = displayed
.iter()
.chain(std::iter::once(&label))
.map(String::as_str)
.collect::<Vec<_>>()
.join("|");
let hidden = labels.len().saturating_sub(index + 1);
let overflow = (hidden > 0)
.then(|| format!("|+{hidden}"))
.unwrap_or_default();
if Line::from(format!("{candidate}{overflow}")).width() > LABEL_CHIP_TEXT_MAX_WIDTH {
break;
}
displayed.push(label);
}
let hidden = labels.len().saturating_sub(displayed.len());
format!(
"{}{}",
displayed.join("|"),
(hidden > 0)
.then(|| format!("|+{hidden}"))
.unwrap_or_default()
)
}
fn truncate_label(label: &str) -> String {
if Line::from(label).width() <= LABEL_TEXT_MAX_WIDTH {
return label.into();
}
let mut truncated = String::new();
for character in label.chars() {
let candidate = format!("{truncated}{character}…");
if Line::from(candidate).width() > LABEL_TEXT_MAX_WIDTH {
break;
}
truncated.push(character);
}
format!("{truncated}…")
}
fn append_metadata(metadata: &mut Vec<Span<'static>>, value: Span<'static>) {
if !metadata.is_empty() {
metadata.push(Span::raw(" • "));
}
metadata.push(value);
}
fn ticket_icon(kind: WorkItemKind) -> (&'static str, ratatui::style::Color) {
let theme = tuicore::theme();
match kind {
WorkItemKind::Epic => ("", theme.warning_fg()),
WorkItemKind::Story => ("", theme.accent_fg()),
WorkItemKind::Task => ("", theme.success_fg()),
WorkItemKind::Subtask => ("", theme.accent_fg()),
WorkItemKind::Bug => ("", theme.error_fg()),
WorkItemKind::Other => ("?", theme.muted_fg()),
}
}
fn priority_icon(priority: &str) -> (&'static str, ratatui::style::Color) {
let theme = tuicore::theme();
match priority {
"Highest" => ("", theme.error_fg()),
"High" => ("", theme.warning_fg()),
"Low" => ("", theme.success_fg()),
"Lowest" => ("", theme.muted_fg()),
_ => ("", theme.accent_fg()),
}
}
fn change_badge(change: ChangeBadge) -> (&'static str, ratatui::style::Color) {
let theme = tuicore::theme();
match change {
ChangeBadge::Added => ("A", theme.success_fg()),
ChangeBadge::Modified => ("M", theme.warning_fg()),
ChangeBadge::Deleted => ("D", theme.error_fg()),
ChangeBadge::Synced => ("S", theme.text_fg()),
}
}
#[cfg(test)]
mod tests;