use super::super::message::list::format_message_sent_time;
use super::*;
use crate::tui::state::{MemberSearchResultItem, SearchSuggestionItem};
use crate::tui::ui::loading_indicator::AsciiLoadingIndicator;
const SEARCH_POPUP_WIDTH: u16 = 86;
pub(in crate::tui::ui) fn render_search_popup(
frame: &mut Frame,
area: Rect,
state: &DashboardState,
) {
if !state.is_active_modal_popup(ActiveModalPopupKind::Search) {
return;
}
let Some(view) = state.search_popup_view() else {
return;
};
let layout = search_popup_render_layout(area, &view, state.animation_frame());
let inner = render_modal_frame(frame, layout.popup, view.mode.title());
frame.render_widget(Paragraph::new(layout.header_lines), layout.header);
frame.render_widget(
Paragraph::new(search_popup_result_lines(
&view,
usize::from(layout.results.height),
usize::from(layout.results.width),
)),
layout.results,
);
frame.render_widget(Paragraph::new(layout.footer_lines), layout.footer);
let (scroll, content_len) = if view.suggestions.is_empty() {
(view.scroll, view.results.len())
} else {
(view.suggestion_scroll, view.suggestions.len())
};
render_vertical_scrollbar(
frame,
Rect {
y: layout.results.y,
height: layout.results.height,
..inner
},
scroll,
usize::from(layout.results.height),
content_len,
);
if let Some(position) = search_popup_cursor_position(layout.popup, &view) {
frame.set_cursor_position(position);
}
}
pub(in crate::tui::ui) fn search_popup_area(area: Rect, view: &SearchPopupView) -> Rect {
let field_rows = view.fields.len() as u16;
let height = area
.height
.saturating_sub(2)
.clamp(field_rows.saturating_add(8), 24);
centered_rect(area, SEARCH_POPUP_WIDTH, height)
}
pub(in crate::tui::ui) fn search_popup_area_for_state(
area: Rect,
state: &DashboardState,
) -> Option<Rect> {
let view = state.search_popup_view()?;
Some(search_popup_area(area, &view))
}
struct SearchPopupRenderLayout {
popup: Rect,
header: Rect,
results: Rect,
footer: Rect,
header_lines: Vec<Line<'static>>,
footer_lines: Vec<Line<'static>>,
}
fn search_popup_render_layout(
area: Rect,
view: &SearchPopupView,
animation_frame: usize,
) -> SearchPopupRenderLayout {
let popup = search_popup_area(area, view);
let inner = panel_block(view.mode.title(), true).inner(popup);
let content = Rect {
width: inner.width.saturating_sub(1).max(1),
..inner
};
let width = usize::from(content.width).max(1);
let header_lines = search_popup_header_lines(view, width, animation_frame);
let footer_lines = search_popup_footer_lines(view, width);
let header_height = u16::try_from(header_lines.len())
.unwrap_or(u16::MAX)
.min(content.height);
let footer_height = u16::try_from(footer_lines.len())
.unwrap_or(u16::MAX)
.min(content.height.saturating_sub(header_height));
let results_height = content
.height
.saturating_sub(header_height)
.saturating_sub(footer_height);
let header = Rect {
height: header_height,
..content
};
let results = Rect {
y: content.y.saturating_add(header_height),
height: results_height,
..content
};
let footer = Rect {
y: results.y.saturating_add(results.height),
height: footer_height,
..content
};
SearchPopupRenderLayout {
popup,
header,
results,
footer,
header_lines,
footer_lines,
}
}
pub(in crate::tui::ui) fn search_popup_list_layout(
area: Rect,
state: &DashboardState,
snapshot: SelectablePopupSnapshot,
) -> SelectablePopupLayout {
let view = state
.search_popup_view()
.expect("selectable search popup has a view");
let layout = search_popup_render_layout(area, &view, state.animation_frame());
SelectablePopupLayout::new(
snapshot.target,
layout.popup,
layout.results,
snapshot,
|start, max_rows| {
(start..snapshot.item_count.min(start.saturating_add(max_rows)))
.map(Some)
.collect()
},
)
}
fn search_popup_header_lines(
view: &SearchPopupView,
width: usize,
animation_frame: usize,
) -> Vec<Line<'static>> {
let mut lines = view
.fields
.iter()
.map(|field| search_field_line(field, width))
.collect::<Vec<_>>();
lines.push(Line::from(Span::styled(
"─".repeat(width.max(1)),
theme::current().style(theme::HighlightGroup::Decoration),
)));
if view.loading {
lines.extend(
AsciiLoadingIndicator::new(
"Searching...",
theme::current().style(theme::HighlightGroup::Hint),
)
.lines(animation_frame),
);
} else {
let status = if let Some(error) = &view.error {
error.clone()
} else if let Some(total) = view.total_results {
format!("{} result(s)", total)
} else {
match view.mode {
SearchPopupMode::Message => "Enter filters, then [Enter] search".to_owned(),
SearchPopupMode::Member => "Type to filter members".to_owned(),
}
};
push_wrapped_styled_popup_text(
&mut lines,
&status,
width,
theme::current().style(theme::HighlightGroup::Hint),
);
}
lines
}
fn search_popup_result_lines(
view: &SearchPopupView,
max_result_lines: usize,
width: usize,
) -> Vec<Line<'static>> {
if max_result_lines == 0 {
return Vec::new();
}
if !view.suggestions.is_empty() {
let start = view
.suggestion_scroll
.min(view.suggestions.len().saturating_sub(max_result_lines));
return view
.suggestions
.iter()
.enumerate()
.skip(start)
.take(max_result_lines)
.map(|(index, suggestion)| {
search_suggestion_line(suggestion, index == view.selected_suggestion, width)
})
.collect();
}
if view.results.is_empty() && !view.loading {
return vec![Line::from(Span::styled(
"No results",
theme::current().style(theme::HighlightGroup::Placeholder),
))];
}
let start = view
.scroll
.min(view.results.len().saturating_sub(max_result_lines));
view.results
.iter()
.enumerate()
.skip(start)
.take(max_result_lines)
.map(|(index, result)| search_result_line(result, index == view.selected, width))
.collect()
}
fn search_popup_footer_lines(view: &SearchPopupView, width: usize) -> Vec<Line<'static>> {
let mut lines = Vec::new();
if view.suggestions.is_empty() && view.has_more {
push_wrapped_styled_popup_text(
&mut lines,
"More results: [Down/PageDown] load more at the end",
width,
theme::current().style(theme::HighlightGroup::Hint),
);
}
lines
}
fn search_field_line(field: &SearchFieldView, width: usize) -> Line<'static> {
let label = format!("{:>11}: ", field.label);
let available = width.saturating_sub(label.width()).max(1);
let value = if field.value.is_empty() {
Span::styled(
truncate_display_width(&field.placeholder, available),
theme::current().style(theme::HighlightGroup::Placeholder),
)
} else if field.active {
Span::styled(
truncate_display_width(&field.value, available),
editable_field_value_style(true, false),
)
} else {
Span::styled(
truncate_display_width(&field.value, available),
editable_field_value_style(false, false),
)
};
let label_style = editable_field_label_style(field.active, false);
Line::from(vec![Span::styled(label, label_style), value])
}
fn search_result_line(result: &SearchResultItem, selected: bool, width: usize) -> Line<'static> {
let style = if selected {
highlight_style()
} else {
Style::default()
};
let mut spans = vec![selectable_popup_marker(selected)];
match result {
SearchResultItem::Message(item) => {
spans.push(Span::styled(
format!("#{} ", item.channel_label),
Style::default(),
));
spans.push(Span::styled(
format!("{} ", item.author),
theme::current().style(theme::HighlightGroup::MessageAuthor),
));
spans.push(Span::styled(
format!("{}: ", format_message_sent_time(item.message_id)),
theme::current().style(theme::HighlightGroup::MessageTimestamp),
));
spans.push(Span::raw(item.content.clone()));
}
SearchResultItem::Member(item) => {
push_member_search_spans(&mut spans, item, selected, true);
}
}
let mut line = truncate_line_to_display_width(Line::from(spans), width);
line.style = style;
selected_row_line(line, selected)
}
fn search_suggestion_line(
suggestion: &SearchSuggestionItem,
selected: bool,
width: usize,
) -> Line<'static> {
let style = if selected {
highlight_style()
} else {
Style::default()
};
let mut spans = vec![selectable_popup_marker(selected)];
match suggestion {
SearchSuggestionItem::Member(item) => {
push_member_search_spans(&mut spans, item, selected, false);
}
SearchSuggestionItem::Channel(item) => {
spans.push(Span::styled(
format!("#{}", item.channel_label),
theme::current().style(theme::HighlightGroup::Heading),
));
if let Some(guild_label) = &item.guild_label {
spans.push(Span::styled(
format!(" in {guild_label}"),
theme::current().style(theme::HighlightGroup::SearchContext),
));
}
}
}
let mut line = truncate_line_to_display_width(Line::from(spans), width);
line.style = style;
selected_row_line(line, selected)
}
fn push_member_search_spans(
spans: &mut Vec<Span<'static>>,
item: &MemberSearchResultItem,
selected: bool,
show_bot_marker: bool,
) {
spans.push(Span::styled(
format!("{} ", presence_marker(item.status)),
selected_presence_style(selected, item.status),
));
spans.push(Span::styled(
item.display_name.clone(),
theme::current().style(theme::HighlightGroup::Strong),
));
if let Some(username) = &item.username {
spans.push(Span::styled(
format!(" @{username}"),
theme::current().style(theme::HighlightGroup::SearchContext),
));
}
if show_bot_marker && item.is_bot {
spans.push(Span::raw(" [bot]"));
}
}
fn search_popup_cursor_position(popup: Rect, view: &SearchPopupView) -> Option<Position> {
let row = view.fields.iter().position(|field| field.active)?;
let field = view.fields.get(row)?;
let label_width = format!("{:>11}: ", field.label).width();
let value_width = field.value[..field.cursor.min(field.value.len())].width();
let x = popup
.x
.saturating_add(1)
.saturating_add((label_width + value_width) as u16)
.min(popup.x.saturating_add(popup.width.saturating_sub(3)));
Some(Position::new(x, popup.y.saturating_add(1 + row as u16)))
}