concord 2.4.2

A terminal user interface client for Discord
Documentation
//! Poll card and poll-result renderers.

use ratatui::style::Style;
use unicode_width::UnicodeWidthStr;

use crate::discord::PollInfo;
use crate::tui::text::{RenderedText, truncate_display_width, truncate_text};
use crate::tui::theme;

use super::{MessageContentLine, wrap_rendered_text_lines_with_loaded_custom_emoji_urls};

pub(super) fn format_poll_lines(
    poll: &PollInfo,
    content: Option<RenderedText>,
    width: usize,
    loaded_custom_emoji_urls: &[String],
) -> Vec<MessageContentLine> {
    let inner_width = poll_card_inner_width(width);
    let helper = if poll.allow_multiselect {
        "Select one or more answers"
    } else {
        "Select one answer"
    };
    let mut lines = vec![MessageContentLine::styled_text(
        poll_box_border('', '', width),
        theme::current().style(theme::HighlightGroup::Border),
        Vec::new(),
    )];
    lines.push(poll_box_line(
        MessageContentLine::plain(truncate_display_width(&poll.question, inner_width)),
        inner_width,
    ));
    if let Some(content) = content {
        lines.extend(
            wrap_rendered_text_lines_with_loaded_custom_emoji_urls(
                content,
                inner_width,
                Style::default(),
                loaded_custom_emoji_urls,
            )
            .into_iter()
            .map(|line| poll_box_line(line, inner_width)),
        );
    }
    lines.push(poll_box_line(
        MessageContentLine::dim(truncate_display_width(helper, inner_width)),
        inner_width,
    ));
    let counted_votes = poll
        .answers
        .iter()
        .filter_map(|answer| answer.vote_count)
        .sum::<u64>();
    let total_votes = poll.total_votes.unwrap_or(counted_votes);
    lines.extend(poll.answers.iter().enumerate().map(|(index, answer)| {
        let style = if answer.me_voted {
            theme::current().style(theme::HighlightGroup::PollAnswerSelected)
        } else {
            Style::default()
        };
        poll_box_line(
            MessageContentLine::styled_text(
                truncate_display_width(
                    &format_poll_answer(index, answer, total_votes),
                    inner_width,
                ),
                style,
                Vec::new(),
            ),
            inner_width,
        )
    }));
    lines.push(poll_box_line(
        MessageContentLine::dim(truncate_display_width(
            &format_poll_footer(poll, total_votes),
            inner_width,
        )),
        inner_width,
    ));
    lines.push(MessageContentLine::styled_text(
        poll_box_border('', '', width),
        theme::current().style(theme::HighlightGroup::Border),
        Vec::new(),
    ));
    lines
}

pub(crate) fn poll_card_inner_width(width: usize) -> usize {
    poll_box_width(width).saturating_sub(4).max(1)
}

fn poll_box_width(width: usize) -> usize {
    width.clamp(4, 72)
}

pub(in crate::tui) fn poll_box_border(left: char, right: char, width: usize) -> String {
    let width = poll_box_width(width);
    format!("{left}{}{right}", "".repeat(width.saturating_sub(2)))
}

fn poll_box_line(mut line: MessageContentLine, inner_width: usize) -> MessageContentLine {
    let prefix = "";
    let suffix = "";
    let padding = inner_width.saturating_sub(line.text.width());
    let shift = prefix.len();
    for highlight in &mut line.mention_highlights {
        highlight.start = highlight.start.saturating_add(shift);
        highlight.end = highlight.end.saturating_add(shift);
    }
    for styled_prefix in &mut line.styled_prefixes {
        styled_prefix.start = styled_prefix.start.saturating_add(shift);
    }
    for slot in &mut line.image_slots {
        slot.byte_start = slot.byte_start.saturating_add(shift);
        slot.col = slot.col.saturating_add(prefix.width() as u16);
    }
    line.text = format!("{prefix}{}{}{suffix}", line.text, " ".repeat(padding));
    let border_style = theme::current().style(theme::HighlightGroup::Border);
    line.styled_range(0, prefix.len(), border_style);
    line.styled_range(
        line.text.len().saturating_sub(suffix.len()),
        suffix.len(),
        border_style,
    );
    line
}

pub(super) fn format_poll_result_lines(
    poll: Option<&PollInfo>,
    width: usize,
) -> Vec<MessageContentLine> {
    let Some(poll) = poll else {
        return vec![
            MessageContentLine::plain(truncate_text("Poll results", width)),
            MessageContentLine::dim(truncate_text("Result details unavailable", width)),
        ];
    };
    let mut lines = vec![
        MessageContentLine::plain(truncate_text("Poll results", width)),
        MessageContentLine::plain(truncate_text(&poll.question, width)),
    ];
    if let Some(winner) = poll.answers.first() {
        let votes = winner
            .vote_count
            .map(|count| format!(" with {count} votes"))
            .unwrap_or_default();
        lines.push(MessageContentLine::styled_text(
            truncate_text(&format!("Winner: {}{votes}", winner.text), width),
            theme::current().style(theme::HighlightGroup::PollWinner),
            Vec::new(),
        ));
    } else {
        lines.push(MessageContentLine::dim(truncate_text(
            "No winning answer recorded",
            width,
        )));
    }
    let counted_votes = poll
        .answers
        .iter()
        .filter_map(|answer| answer.vote_count)
        .sum::<u64>();
    let total_votes = poll
        .total_votes
        .or_else(|| (counted_votes > 0).then_some(counted_votes));
    if let Some(total_votes) = total_votes {
        let vote_label = if total_votes == 1 { "vote" } else { "votes" };
        lines.push(MessageContentLine::dim(truncate_text(
            &format!("{total_votes} total {vote_label} · Final results"),
            width,
        )));
    }
    lines
}

fn format_poll_answer(
    index: usize,
    answer: &crate::discord::PollAnswerInfo,
    total_votes: u64,
) -> String {
    let marker = if answer.me_voted { "" } else { "" };
    let results = answer.vote_count.map(|count| {
        let percent = count
            .saturating_mul(100)
            .checked_div(total_votes)
            .unwrap_or(0);
        format!("  {count} votes  {percent}%")
    });
    match results {
        Some(results) => format!("  {marker} {}. {}{results}", index + 1, answer.text),
        None => format!("  {marker} {}. {}", index + 1, answer.text),
    }
}

fn format_poll_footer(poll: &PollInfo, total_votes: u64) -> String {
    let vote_label = if total_votes == 1 { "vote" } else { "votes" };
    match poll.results_finalized {
        Some(true) => format!("{total_votes} {vote_label} · Final results"),
        Some(false) => format!("{total_votes} {vote_label} · Results may still change"),
        None => "Results not available yet".to_owned(),
    }
}