use std::collections::HashSet;
use crate::discord::{ChannelState, ChannelUnreadState};
use crate::tui::text_input::TextInputState;
use crate::{
discord::ids::{Id, marker::GuildMarker},
tui::fuzzy::{FuzzyMatchQuality, FuzzyScore, fuzzy_name_match_score},
};
use crate::tui::fuzzy::fuzzy_text_score;
use crate::tui::keybindings::SelectionAction;
use super::super::{
ActiveGuildScope, DashboardState, channel_tree,
model::{
ChannelSwitcherDisplay, ChannelSwitcherItem, ChannelSwitcherMode, ChannelSwitcherView,
FocusPane, GuildPaneEntry,
},
presentation::{is_direct_message_channel, sort_direct_message_channels},
};
use crate::discord::AppCommand;
use crate::tui::state::popups::{
ActiveModalPopupKind, ModalPopup, SelectablePopupState, SelectablePopupTarget,
};
#[derive(Debug)]
pub(in crate::tui::state) struct ChannelSwitcherState {
query: TextInputState,
selection: SelectablePopupState,
mode: ChannelSwitcherMode,
channel_items: Vec<ChannelSwitcherItem>,
guild_items: Vec<ChannelSwitcherItem>,
filtered_items: Option<Vec<ChannelSwitcherItem>>,
}
impl ChannelSwitcherState {
fn new(channel_items: Vec<ChannelSwitcherItem>, guild_items: Vec<ChannelSwitcherItem>) -> Self {
Self {
query: TextInputState::default(),
selection: SelectablePopupState::default(),
mode: ChannelSwitcherMode::Channels,
channel_items,
guild_items,
filtered_items: None,
}
}
fn visible_items(&self) -> &[ChannelSwitcherItem] {
self.filtered_items
.as_deref()
.unwrap_or(&self.channel_items)
}
pub(super) fn visible_len(&self) -> usize {
self.visible_items().len()
}
pub(super) fn selection_mut(&mut self) -> &mut SelectablePopupState {
&mut self.selection
}
pub(super) fn selection(&self) -> &SelectablePopupState {
&self.selection
}
fn view(&self) -> ChannelSwitcherView<'_> {
ChannelSwitcherView {
query: self.query.value(),
query_cursor: self.query.cursor_byte_index(),
mode: self.mode,
items: self.visible_items(),
selected: self.selection.selected_for_len(self.visible_len()),
scroll: self.selection.scroll(),
}
}
fn refresh_filtered_items(&mut self) {
let query = parse_channel_switcher_query(self.query.value());
self.mode = query.mode;
self.filtered_items = match query.mode {
ChannelSwitcherMode::Guilds => {
Some(filter_channel_switcher_items(&self.guild_items, query.text))
}
ChannelSwitcherMode::Channels => (!query.text.is_empty())
.then(|| filter_channel_switcher_items(&self.channel_items, query.text)),
};
}
}
const CHANNEL_SWITCHER_GUILD_QUERY_PREFIX: char = '*';
struct ParsedChannelSwitcherQuery<'a> {
mode: ChannelSwitcherMode,
text: &'a str,
}
fn parse_channel_switcher_query(query: &str) -> ParsedChannelSwitcherQuery<'_> {
let query = query.trim();
match query.strip_prefix(CHANNEL_SWITCHER_GUILD_QUERY_PREFIX) {
Some(guild_query) => ParsedChannelSwitcherQuery {
mode: ChannelSwitcherMode::Guilds,
text: guild_query.trim(),
},
None => ParsedChannelSwitcherQuery {
mode: ChannelSwitcherMode::Channels,
text: query,
},
}
}
impl DashboardState {
pub fn open_channel_switcher(&mut self) {
let channel_items = self.build_channel_switcher_items();
let guild_items = self.build_guild_switcher_items();
self.popups
.set_modal(ModalPopup::ChannelSwitcher(ChannelSwitcherState::new(
channel_items,
guild_items,
)));
}
pub fn close_channel_switcher(&mut self) {
if self.is_active_modal_popup(ActiveModalPopupKind::ChannelSwitcher) {
self.popups.clear_modal();
}
}
pub fn channel_switcher_view(&self) -> Option<ChannelSwitcherView<'_>> {
self.popups
.channel_switcher()
.map(ChannelSwitcherState::view)
}
pub fn move_channel_switcher_down(&mut self) {
self.move_selectable_popup(
SelectablePopupTarget::ChannelSwitcher,
SelectionAction::Next,
);
}
pub fn move_channel_switcher_up(&mut self) {
self.move_selectable_popup(
SelectablePopupTarget::ChannelSwitcher,
SelectionAction::Previous,
);
}
pub fn push_channel_switcher_char(&mut self, value: char) {
if let Some(switcher) = self.popups.channel_switcher_mut() {
switcher.query.insert_char(value);
switcher.selection.select(0);
switcher.refresh_filtered_items();
}
}
pub fn pop_channel_switcher_char(&mut self) {
if let Some(switcher) = self.popups.channel_switcher_mut()
&& switcher.query.delete_previous_grapheme()
{
switcher.selection.select(0);
switcher.refresh_filtered_items();
}
}
pub fn move_channel_switcher_query_cursor_left(&mut self) {
if let Some(switcher) = self.popups.channel_switcher_mut() {
switcher.query.move_left();
}
}
pub fn move_channel_switcher_query_cursor_right(&mut self) {
if let Some(switcher) = self.popups.channel_switcher_mut() {
switcher.query.move_right();
}
}
pub fn activate_selected_channel_switcher_item(&mut self) -> Option<AppCommand> {
let item = {
let switcher = self.popups.channel_switcher()?;
let selected = switcher.selection.selected_for_len(switcher.visible_len());
switcher.visible_items().get(selected)?.clone()
};
let channel_id = match item {
ChannelSwitcherItem::Channel { channel_id, .. } => channel_id,
ChannelSwitcherItem::Guild { guild_id, .. } => {
self.close_channel_switcher();
self.activate_guild(ActiveGuildScope::Guild(guild_id));
self.focus_pane(FocusPane::Channels);
return None;
}
};
let Some(channel) = self.discord.cache.channel(channel_id) else {
self.close_channel_switcher();
return None;
};
let guild_id = channel.guild_id;
let parent_id = channel.parent_id;
self.close_channel_switcher();
match guild_id {
Some(guild_id) => {
self.activate_guild(ActiveGuildScope::Guild(guild_id));
if let Some(parent_id) = parent_id {
self.navigation
.channels
.collapsed_channel_categories
.remove(&parent_id);
}
self.restore_channel_cursor(Some(channel_id));
self.activate_channel(channel_id);
Some(AppCommand::SubscribeGuildChannel {
guild_id,
channel_id,
})
}
None => {
self.activate_guild(ActiveGuildScope::DirectMessages);
self.restore_channel_cursor(Some(channel_id));
self.activate_channel(channel_id);
Some(AppCommand::SubscribeDirectMessage { channel_id })
}
}
}
fn build_guild_switcher_items(&self) -> Vec<ChannelSwitcherItem> {
self.guilds_in_display_order()
.into_iter()
.enumerate()
.map(|(original_index, guild)| {
let unread = self.sidebar_guild_unread(guild.id);
ChannelSwitcherItem::Guild {
guild_id: guild.id,
display: ChannelSwitcherDisplay {
group_label: "Servers".to_owned(),
parent_label: None,
label: guild.name.clone(),
unread,
badge_state: unread,
search_text: guild.name.clone(),
depth: 0,
group_order: 0,
original_index,
},
}
})
.collect()
}
fn build_channel_switcher_items(&self) -> Vec<ChannelSwitcherItem> {
let mut base = Vec::new();
self.push_direct_message_switcher_items(&mut base);
let mut seen = HashSet::new();
for entry in self.guild_pane_entries() {
let GuildPaneEntry::Guild { state: guild, .. } = entry else {
continue;
};
if seen.insert(guild.id) {
self.push_guild_channel_switcher_items(&mut base, guild.id, &guild.name);
}
}
let recent = self.recent_channel_switcher_items(&base);
if !recent.is_empty() {
for item in base.iter_mut() {
let display = item.display_mut();
display.group_order = display.group_order.saturating_add(1);
}
}
let mut items = recent;
items.extend(base);
for (index, item) in items.iter_mut().enumerate() {
item.display_mut().original_index = index;
}
items
}
fn recent_channel_switcher_items(
&self,
base: &[ChannelSwitcherItem],
) -> Vec<ChannelSwitcherItem> {
let mut recent = Vec::new();
let mut seen = HashSet::new();
for channel_id in &self.navigation.channels.recent_channel_ids {
if Some(*channel_id) == self.navigation.channels.active_channel_id {
continue;
}
if !seen.insert(*channel_id) {
continue;
}
let Some(item) = base
.iter()
.find(|item| item.channel_id() == Some(*channel_id))
else {
continue;
};
let mut item = item.clone();
let guild_name = match &item {
ChannelSwitcherItem::Channel { guild_name, .. } => guild_name.clone(),
ChannelSwitcherItem::Guild { .. } => None,
};
let display = item.display_mut();
display.group_label = "Recent Channels".to_owned();
display.parent_label = guild_name;
display.depth = 0;
display.group_order = 0;
recent.push(item);
}
recent
}
fn push_direct_message_switcher_items(&self, items: &mut Vec<ChannelSwitcherItem>) {
let mut channels = self.discord.cache.channels_for_guild(None);
channels.retain(|channel| !channel.is_category() && !channel.is_thread());
sort_direct_message_channels(&mut channels);
let group_order = items.len();
for channel in channels {
push_channel_switcher_item(
items,
ChannelSwitcherItemInput {
guild_id: None,
guild_name: None,
group_label: "Direct Messages",
parent_label: None,
channel,
depth: 0,
group_order,
unread: self.sidebar_channel_unread(channel.id),
unread_message_count: self.channel_unread_message_count(channel.id),
},
);
}
}
fn push_guild_channel_switcher_items(
&self,
items: &mut Vec<ChannelSwitcherItem>,
guild_id: Id<GuildMarker>,
guild_name: &str,
) {
let channels = self
.discord
.cache
.viewable_channels_for_guild(Some(guild_id));
let group_order = items.len();
for root in channel_tree::sorted_channel_tree_roots(&channels) {
if !root.is_category() {
self.push_channel_and_child_threads(
items,
&channels,
guild_id,
guild_name,
root,
None,
0,
group_order,
);
continue;
}
for child in channel_tree::sorted_category_children(&channels, root.id) {
self.push_channel_and_child_threads(
items,
&channels,
guild_id,
guild_name,
child,
Some(root.name.as_str()),
1,
group_order,
);
}
}
}
#[allow(clippy::too_many_arguments)]
fn push_channel_and_child_threads(
&self,
items: &mut Vec<ChannelSwitcherItem>,
channels: &[&ChannelState],
guild_id: Id<GuildMarker>,
guild_name: &str,
channel: &ChannelState,
parent_label: Option<&str>,
depth: usize,
group_order: usize,
) {
push_channel_switcher_item(
items,
ChannelSwitcherItemInput {
guild_id: Some(guild_id),
guild_name: Some(guild_name),
group_label: guild_name,
parent_label,
channel,
depth,
group_order,
unread: self.sidebar_channel_unread(channel.id),
unread_message_count: self.channel_unread_message_count(channel.id),
},
);
if channel.is_forum() {
return;
}
let thread_parent_label = match parent_label {
Some(category) => format!("{category} / {}", channel.name),
None => channel.name.clone(),
};
for thread in channel_tree::sorted_child_threads(channels.iter().copied(), channel.id) {
if !self.discord.cache.thread_is_sidebar_active(thread.id) {
continue;
}
push_channel_switcher_item(
items,
ChannelSwitcherItemInput {
guild_id: Some(guild_id),
guild_name: Some(guild_name),
group_label: guild_name,
parent_label: Some(thread_parent_label.as_str()),
channel: thread,
depth: depth.saturating_add(1),
group_order,
unread: self.sidebar_channel_unread(thread.id),
unread_message_count: self.channel_unread_message_count(thread.id),
},
);
}
}
}
struct ChannelSwitcherItemInput<'a> {
guild_id: Option<Id<GuildMarker>>,
guild_name: Option<&'a str>,
group_label: &'a str,
parent_label: Option<&'a str>,
channel: &'a ChannelState,
depth: usize,
group_order: usize,
unread: ChannelUnreadState,
unread_message_count: usize,
}
fn push_channel_switcher_item(
items: &mut Vec<ChannelSwitcherItem>,
input: ChannelSwitcherItemInput<'_>,
) {
let ChannelSwitcherItemInput {
guild_id,
guild_name,
group_label,
parent_label,
channel,
depth,
group_order,
unread,
unread_message_count,
} = input;
if channel.is_category() {
return;
}
let original_index = items.len();
items.push(ChannelSwitcherItem::Channel {
channel_id: channel.id,
guild_name: guild_name.map(str::to_owned),
display: ChannelSwitcherDisplay {
group_label: group_label.to_owned(),
parent_label: parent_label.map(str::to_owned),
label: channel_switcher_channel_label(channel),
unread,
badge_state: channel_switcher_badge_state(guild_id, unread, unread_message_count),
search_text: format!("{} / {}", group_label, channel.name),
depth,
group_order,
original_index,
},
});
}
fn channel_switcher_badge_state(
guild_id: Option<Id<GuildMarker>>,
unread: ChannelUnreadState,
unread_message_count: usize,
) -> ChannelUnreadState {
if guild_id.is_some() || unread == ChannelUnreadState::Seen {
return unread;
}
if unread_message_count > 0 {
let count = u32::try_from(unread_message_count).unwrap_or(u32::MAX);
return ChannelUnreadState::Notified(count);
}
if unread == ChannelUnreadState::Unread {
return ChannelUnreadState::Notified(1);
}
unread
}
fn channel_switcher_match_score(
item: &ChannelSwitcherItem,
query: &str,
) -> Option<(FuzzyMatchQuality, FuzzyScore)> {
let query = query.trim();
let display = item.display();
if matches!(item, ChannelSwitcherItem::Guild { .. }) {
return fuzzy_name_match_score(&display.search_text, query);
}
if let Some(prefix) = channel_switcher_query_label_prefix(query)
&& !display.label.starts_with(prefix)
{
return None;
}
let channel_query = channel_switcher_search_channel_name(query);
let channel_name = channel_switcher_search_channel_name(&display.label);
if let Some(score) = fuzzy_name_match_score(channel_name, channel_query) {
return Some(score);
}
fuzzy_text_score(&display.search_text, query).map(|score| (FuzzyMatchQuality::Context, score))
}
fn filter_channel_switcher_items(
items: &[ChannelSwitcherItem],
query: &str,
) -> Vec<ChannelSwitcherItem> {
let mut scored: Vec<(FuzzyMatchQuality, FuzzyScore, ChannelSwitcherItem)> = items
.iter()
.filter_map(|item| {
channel_switcher_match_score(item, query)
.map(|(quality, score)| (quality, score, item.clone()))
})
.collect();
scored.sort_by_key(|(quality, score, item)| {
let display = item.display();
(
*quality,
*score,
display.group_order,
display.original_index,
)
});
scored.into_iter().map(|(_, _, item)| item).collect()
}
fn channel_switcher_search_channel_name(channel_label: &str) -> &str {
for prefix in CHANNEL_SWITCHER_ICON_PREFIXES {
if let Some(rest) = channel_label.strip_prefix(prefix) {
return rest;
}
}
channel_label
.strip_prefix('#')
.or_else(|| channel_label.strip_prefix('@'))
.unwrap_or(channel_label)
}
fn channel_switcher_query_label_prefix(query: &str) -> Option<char> {
query
.chars()
.next()
.filter(|prefix| matches!(prefix, '#' | '@'))
}
const CHANNEL_SWITCHER_ICON_PREFIXES: [&str; 4] = ["# ", "@ ", "๐งต ", "๐ "];
fn channel_switcher_channel_label(channel: &ChannelState) -> String {
if is_direct_message_channel(channel) {
match channel.kind.as_str() {
"dm" | "Private" => format!("@ {}", channel.name),
_ => channel.name.clone(),
}
} else if channel.is_thread() {
format!("๐งต {}", channel.name)
} else if channel.is_forum() {
format!("๐ {}", channel.name)
} else {
format!("# {}", channel.name)
}
}