concord 2.3.2

A terminal user interface client for Discord
Documentation
use super::activity::{ActivityLeading, build_activity_render};
use super::message::list::render_image_preview;
use super::*;
use crate::discord::ActivityKind;
use crate::tui::text::format_byte_size;
use ratatui::layout::Position;

mod action_menu;
mod attachment_viewer;
mod channel_switcher;
mod confirmation;
mod debug_log;
mod downloads;
mod folder_settings;
mod forum_post;
mod keymap;
mod notification_inbox;
mod options;
mod polls;
mod profile;
mod reactions;
mod search;
mod thread_edit;
mod toast;
mod url_picker;

#[cfg(test)]
pub(super) use action_menu::{
    channel_action_menu_lines_for_test, message_action_menu_lines,
    message_action_menu_lines_with_keymap_options,
};
pub(super) use action_menu::{
    leader_popup_area_for_state, message_action_menu_area, render_channel_action_menu,
    render_guild_action_menu, render_leader_popup, render_member_action_menu,
    render_message_action_menu, render_thread_action_menu,
};
#[cfg(test)]
pub(super) use attachment_viewer::centered_viewer_preview_area;
pub(super) use attachment_viewer::render_attachment_viewer;
#[cfg(test)]
pub(super) use channel_switcher::{channel_switcher_cursor_position, channel_switcher_lines};
pub(super) use channel_switcher::{
    channel_switcher_item_index_at, channel_switcher_popup_area, channel_switcher_visible_items,
    render_channel_switcher_popup,
};
pub(super) use confirmation::{
    guild_leave_confirmation_popup_area_for_state, message_confirmation_popup_area_for_state,
    quit_confirmation_popup_area, render_guild_leave_confirmation, render_message_confirmation,
    render_notification_inbox_mark_all_confirmation, render_quit_confirmation,
    render_thread_delete_confirmation, thread_delete_confirmation_popup_area_for_state,
};
#[cfg(test)]
pub(super) use confirmation::{
    message_delete_confirmation_lines, message_pin_confirmation_lines,
    message_remove_embeds_confirmation_lines, quit_confirmation_lines,
};
#[cfg(test)]
pub(super) use debug_log::debug_log_popup_lines;
pub(super) use debug_log::{debug_log_popup_area_for_state, render_debug_log_popup};
#[cfg(test)]
pub(super) use downloads::downloads_popup_lines;
pub(super) use downloads::{
    downloads_popup_area, downloads_popup_line_count, render_downloads_popup,
};
pub(super) use folder_settings::{folder_settings_popup_area, render_folder_settings_popup};
pub(super) use forum_post::{
    forum_post_composer_metrics, forum_post_composer_popup_area,
    forum_post_tag_picker_visible_items, render_forum_post_composer, render_forum_post_tag_picker,
};
#[cfg(test)]
pub(super) use keymap::keymap_help_popup_lines;
pub(super) use keymap::{
    keymap_popup_area, keymap_popup_text_area, keymap_popup_total_lines, render_keymap_help_popup,
};
pub(super) use notification_inbox::{
    notification_inbox_popup_area, render_notification_inbox_popup,
};
#[cfg(test)]
pub(super) use options::options_popup_lines;
pub(super) use options::{options_popup_area, options_popup_visible_items, render_options_popup};
#[cfg(test)]
pub(super) use polls::poll_vote_picker_lines;
pub(super) use polls::{poll_vote_picker_popup_area, render_poll_vote_picker};
pub(in crate::tui) use profile::user_profile_popup_area;
pub(super) use profile::{
    render_user_profile_popup, user_profile_popup_has_avatar, user_profile_popup_text_geometry,
    user_profile_popup_total_lines,
};
#[cfg(test)]
pub(super) use profile::{user_profile_popup_lines, user_profile_popup_lines_with_activities};
#[cfg(test)]
pub(super) use reactions::{
    emoji_reaction_picker_lines, emoji_reaction_picker_lines_for_width,
    emoji_reaction_picker_lines_with_own_reactions, filtered_emoji_reaction_picker_lines,
    reaction_list_lines_with_ready_urls, reaction_users_popup_lines,
};
pub(super) use reactions::{
    emoji_reaction_picker_popup_area_for_state, emoji_reaction_picker_visible_items_for_area,
    reaction_users_popup_area_for_state, render_emoji_reaction_picker, render_reaction_users_popup,
};
pub(super) use search::{
    render_search_popup, search_popup_area_for_state, search_popup_visible_items,
};
pub(super) use thread_edit::{
    render_thread_edit, render_thread_edit_tag_picker, thread_edit_metrics, thread_edit_popup_area,
    thread_edit_tag_picker_visible_items,
};
#[cfg(test)]
pub(super) use toast::toast_line;
pub(super) use toast::{render_toast, toast_area};
#[cfg(test)]
pub(super) use url_picker::message_url_picker_lines_for_width;
pub(super) use url_picker::{message_url_picker_popup_area, render_message_url_picker};

pub(super) fn background_media_occlusion_areas(
    frame_area: Rect,
    state: &DashboardState,
) -> Vec<Rect> {
    let mut areas = Vec::new();

    if state.is_folder_settings_open() {
        areas.push(folder_settings_popup_area(frame_area));
    }
    if let Some(area) = active_modal_popup_area(frame_area, state) {
        areas.push(area);
    }

    let downloads = state.attachment_downloads();
    if !downloads.is_empty() {
        areas.push(downloads_popup_area(
            frame_area,
            downloads_popup_line_count(downloads.len()),
        ));
    }
    if let Some(toast) = state.toast_message() {
        areas.push(toast_area(frame_area, toast.text));
    }

    areas.into_iter().filter(|area| !area.is_empty()).collect()
}

fn active_modal_popup_area(frame_area: Rect, state: &DashboardState) -> Option<Rect> {
    let kind = state.active_modal_popup_kind()?;
    match kind {
        ActiveModalPopupKind::MessageActionMenu => {
            let actions = state.selected_message_action_items();
            (!actions.is_empty()).then(|| message_action_menu_area(frame_area, actions.len()))
        }
        ActiveModalPopupKind::GuildActionMenu => {
            let count = if state.is_guild_action_mute_duration_phase() {
                state.selected_guild_mute_duration_items().len()
            } else {
                state.selected_guild_action_items().len()
            };
            (count > 0).then(|| message_action_menu_area(frame_area, count))
        }
        ActiveModalPopupKind::ChannelActionMenu => {
            let count = if state.is_channel_action_mute_duration_phase() {
                state.selected_channel_mute_duration_items().len()
            } else {
                state.selected_channel_action_items().len()
            };
            (count > 0).then(|| message_action_menu_area(frame_area, count))
        }
        ActiveModalPopupKind::MemberActionMenu => {
            let count = state.selected_member_action_items().len();
            (count > 0).then(|| message_action_menu_area(frame_area, count))
        }
        ActiveModalPopupKind::MessageUrlPicker => {
            let urls = state.selected_message_url_items();
            (!urls.is_empty()).then(|| message_url_picker_popup_area(frame_area, urls.len()))
        }
        ActiveModalPopupKind::MessageConfirmation => {
            message_confirmation_popup_area_for_state(frame_area, state)
        }
        ActiveModalPopupKind::QuitConfirmation => Some(quit_confirmation_popup_area(frame_area)),
        ActiveModalPopupKind::GuildLeaveConfirmation => {
            guild_leave_confirmation_popup_area_for_state(frame_area, state)
        }
        ActiveModalPopupKind::ThreadDeleteConfirmation => {
            thread_delete_confirmation_popup_area_for_state(frame_area, state)
        }
        ActiveModalPopupKind::Options => Some(options_popup_area(frame_area, state)),
        ActiveModalPopupKind::AttachmentViewer => Some(attachment_viewer_popup(
            frame_area,
            state.attachment_viewer_zoom(),
        )),
        ActiveModalPopupKind::Leader => Some(leader_popup_area_for_state(frame_area, state)),
        ActiveModalPopupKind::UserProfile => Some(user_profile_popup_area(frame_area)),
        ActiveModalPopupKind::EmojiReactionPicker => {
            emoji_reaction_picker_popup_area_for_state(frame_area, state)
        }
        ActiveModalPopupKind::PollVotePicker => state
            .poll_vote_picker_items()
            .filter(|answers| !answers.is_empty())
            .map(|answers| poll_vote_picker_popup_area(frame_area, answers.len())),
        ActiveModalPopupKind::ReactionUsers => {
            reaction_users_popup_area_for_state(frame_area, state)
        }
        ActiveModalPopupKind::DebugLog => Some(debug_log_popup_area_for_state(frame_area, state)),
        ActiveModalPopupKind::KeymapHelp => Some(keymap_popup_area(frame_area)),
        ActiveModalPopupKind::ChannelSwitcher => Some(channel_switcher_popup_area(frame_area)),
        ActiveModalPopupKind::NotificationInbox => Some(notification_inbox_popup_area(frame_area)),
        ActiveModalPopupKind::Search => search_popup_area_for_state(frame_area, state),
        ActiveModalPopupKind::ForumPostComposer => Some(forum_post_composer_popup_area(frame_area)),
        ActiveModalPopupKind::ThreadEdit => Some(thread_edit_popup_area(frame_area)),
        ActiveModalPopupKind::ThreadActionMenu => {
            let count = if state.is_thread_action_mute_duration_phase() {
                state.selected_thread_mute_duration_items().len()
            } else if state.is_thread_action_notification_phase() {
                state.selected_thread_notification_items().len()
            } else {
                state.selected_thread_action_items().len()
            };
            (count > 0).then(|| message_action_menu_area(frame_area, count))
        }
    }
}

/// Clears the popup area, draws the standard focused panel border, and
/// returns the inner content rect. Every modal popup opens with this
/// sequence and then renders its content into the returned rect.
fn render_modal_frame(frame: &mut Frame, popup: Rect, title: impl Into<String>) -> Rect {
    frame.render_widget(Clear, popup);
    let block = panel_block_owned(title.into(), true);
    let inner = block.inner(popup);
    frame.render_widget(block, popup);
    inner
}

fn render_modal_paragraph(
    frame: &mut Frame,
    popup: Rect,
    title: impl Into<String>,
    lines: Vec<Line<'static>>,
) {
    let inner = render_modal_frame(frame, popup, title);
    frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
}

fn popup_shortcut_help_text(items: &[(&str, &str)]) -> String {
    items
        .iter()
        .map(|(shortcut, description)| format!("[{shortcut}] {description}"))
        .collect::<Vec<_>>()
        .join(" · ")
}

fn popup_button_line(shortcut: &'static str, label: &'static str, active: bool) -> Line<'static> {
    popup_button_line_with_style(shortcut, label, active, Style::default())
}

fn popup_danger_button_line(
    shortcut: &'static str,
    label: &'static str,
    active: bool,
) -> Line<'static> {
    popup_button_line_with_style(shortcut, label, active, Style::default().fg(Color::Red))
}

fn popup_button_line_with_style(
    shortcut: &'static str,
    label: &'static str,
    active: bool,
    label_style: Style,
) -> Line<'static> {
    Line::from(vec![
        selectable_popup_marker(active),
        Span::styled(
            format!("[{shortcut}] "),
            Style::default().fg(ACCENT).add_modifier(Modifier::BOLD),
        ),
        Span::styled(label, label_style),
    ])
}

fn truncate_line_to_display_width(line: Line<'static>, max_width: usize) -> Line<'static> {
    if max_width == 0 {
        return Line::default();
    }
    let mut remaining = max_width;
    let mut new_spans: Vec<Span<'static>> = Vec::with_capacity(line.spans.len() + 1);
    for span in line.spans {
        if remaining == 0 {
            break;
        }
        if span.content.width() <= remaining {
            remaining = remaining.saturating_sub(span.content.width());
            new_spans.push(span);
            continue;
        }
        let truncated = truncate_display_width(&span.content, remaining);
        remaining = remaining.saturating_sub(truncated.width());
        new_spans.push(Span::styled(truncated, span.style));
    }
    if remaining > 0 {
        new_spans.push(Span::styled(" ".repeat(remaining), line.style));
    }
    let mut truncated = Line::from(new_spans);
    truncated.style = line.style;
    truncated.alignment = line.alignment;
    truncated
}

fn truncate_popup_lines(lines: Vec<Line<'static>>, width: usize) -> Vec<Line<'static>> {
    lines
        .into_iter()
        .map(|line| truncate_line_to_display_width(line, width))
        .collect()
}

fn wrapped_styled_popup_lines(text: &str, width: usize, style: Style) -> Vec<Line<'static>> {
    if width == 0 {
        return vec![Line::from(Span::styled(String::new(), style))];
    }

    let mut lines = Vec::new();
    let mut current = String::new();
    for word in text.split_whitespace() {
        let candidate = if current.is_empty() {
            word.to_owned()
        } else {
            format!("{current} {word}")
        };

        if candidate.width() <= width {
            current = candidate;
            continue;
        }

        if !current.is_empty() {
            lines.push(Line::from(Span::styled(current, style)));
        }
        current = truncate_display_width(word, width);
    }

    if !current.is_empty() {
        lines.push(Line::from(Span::styled(current, style)));
    }
    if lines.is_empty() {
        lines.push(Line::from(Span::styled(String::new(), style)));
    }
    lines
}

fn push_wrapped_styled_popup_text(
    lines: &mut Vec<Line<'static>>,
    text: &str,
    width: usize,
    style: Style,
) {
    lines.extend(wrapped_styled_popup_lines(text, width, style));
}

fn selectable_popup_marker(selected: bool) -> Span<'static> {
    let marker = if selected { "" } else { "  " };
    Span::styled(marker, Style::default().fg(ACCENT))
}

fn selectable_popup_shortcut_span(shortcut: impl Into<String>) -> Span<'static> {
    Span::styled(shortcut.into(), Style::default().fg(DIM))
}

fn selectable_popup_label_style(selected: bool, enabled: bool) -> Style {
    let mut style = if enabled {
        Style::default()
    } else {
        Style::default().fg(DIM)
    };
    if selected {
        style = style
            .bg(Color::Rgb(40, 45, 90))
            .add_modifier(Modifier::BOLD);
    }
    style
}

fn shortcut_prefix(shortcut: Option<char>) -> String {
    shortcut
        .map(|shortcut| format!("[{shortcut}] "))
        .unwrap_or_else(|| "    ".to_owned())
}