finery 0.9.0

Keyboard-first Jira backlog and Composer with optional MCP access
Documentation
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,
}

#[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() {
        append_metadata(&mut metadata, Span::styled(row.status.clone(), 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 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;