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(),
}
}