use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyEventKind};
use ratatui::layout::{Constraint, Flex, Layout, Rect};
use ratatui::style::Style;
use ratatui::text::{Line, Span};
use ratatui::widgets::{Clear, List, ListItem};
use uuid::Uuid;
use crate::entities::chat::ChatSummary;
use crate::shared::i18n::Locale;
use crate::shared::theme::Palette;
use crate::shared::ui::ListScroll;
use crate::shared::wrap;
#[derive(Debug, Clone, PartialEq)]
pub enum ChatLinkAction {
None,
Cancel,
Open(Uuid),
}
pub struct ChatLinkPickerState {
chats: Vec<ChatSummary>,
current: Option<Uuid>,
selected: usize,
scroll: ListScroll,
}
impl ChatLinkPickerState {
pub fn new(chats: Vec<ChatSummary>, current: Option<Uuid>) -> Self {
Self {
chats,
current,
selected: 0,
scroll: ListScroll::default(),
}
}
pub fn selected_id(&self) -> Option<Uuid> {
self.chats.get(self.selected).map(|c| c.id)
}
pub fn on_key(&mut self, key: KeyEvent) -> ChatLinkAction {
if key.kind != KeyEventKind::Press {
return ChatLinkAction::None;
}
match key.code {
KeyCode::Esc => ChatLinkAction::Cancel,
KeyCode::Enter => match self.selected_id() {
Some(id) => ChatLinkAction::Open(id),
None => ChatLinkAction::Cancel,
},
KeyCode::Up => {
self.selected = self.selected.saturating_sub(1);
ChatLinkAction::None
}
KeyCode::Down => {
if !self.chats.is_empty() {
self.selected = (self.selected + 1).min(self.chats.len() - 1);
}
ChatLinkAction::None
}
_ => ChatLinkAction::None,
}
}
pub fn render(
&mut self,
frame: &mut Frame,
area: Rect,
palette: &Palette,
loc: &'static Locale,
) {
let rows = (self.chats.len() as u16 + 2).clamp(5, area.height);
let popup = centered_rect(60, 40, rows, area);
frame.render_widget(Clear, popup);
let block = palette
.panel(
format!(
"{} {}",
palette.glyphs().chats_icon,
loc.t("ui.chat_links.title")
),
true,
)
.title_bottom(Line::from(Span::styled(
loc.t("ui.chat_links.footer"),
palette.muted_style(),
)));
let items: Vec<ListItem> = self
.chats
.iter()
.map(|c| {
let date = format!(" {}", c.modified_at.format("%Y-%m-%d"));
let current = (self.current == Some(c.id))
.then(|| format!(" {}", loc.t("ui.chat_links.current")));
let budget = (popup.width as usize).saturating_sub(
2 + wrap::str_width(&date) + current.as_deref().map_or(0, wrap::str_width),
);
let (title, _) = wrap::truncate_to_width(&c.title, budget);
let mut spans = vec![Span::styled(title, Style::new().fg(palette.text))];
spans.push(Span::styled(date, palette.muted_style()));
if let Some(current) = current {
spans.push(Span::styled(current, palette.muted_style()));
}
ListItem::new(Line::from(spans))
})
.collect();
let list = List::new(items)
.block(block)
.highlight_style(Style::new().reversed());
self.scroll.render(
frame,
list,
popup,
self.chats.len(),
popup.height.saturating_sub(2) as usize, (!self.chats.is_empty()).then_some(self.selected),
);
}
}
fn centered_rect(pct_x: u16, min_w: u16, height: u16, area: Rect) -> Rect {
let w = area.width.saturating_mul(pct_x) / 100;
let [h_area] = Layout::horizontal([Constraint::Length(w.max(min_w).min(area.width))])
.flex(Flex::Center)
.areas(area);
let [v_area] = Layout::vertical([Constraint::Length(height.min(area.height))])
.flex(Flex::Center)
.areas(h_area);
v_area
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::crossterm::event::KeyModifiers;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn ru() -> &'static Locale {
crate::shared::i18n::locale(crate::shared::i18n::Lang::Ru)
}
fn chat(title: &str) -> ChatSummary {
let mut c = ChatSummary::fixture(title);
c.message_count = 1;
c
}
#[test]
fn arrows_move_and_stay_inside_the_list() {
let chats = vec![chat("a"), chat("b")];
let (first, last) = (chats[0].id, chats[1].id);
let mut s = ChatLinkPickerState::new(chats, None);
assert_eq!(s.selected_id(), Some(first));
s.on_key(key(KeyCode::Up));
assert_eq!(s.selected_id(), Some(first), "no wrap past the top");
s.on_key(key(KeyCode::Down));
s.on_key(key(KeyCode::Down));
assert_eq!(s.selected_id(), Some(last), "no wrap past the bottom");
}
#[test]
fn a_long_title_is_cut_and_the_date_survives() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let long = "заголовок который заведомо шире этого всплывающего окна";
let mut s = ChatLinkPickerState::new(vec![chat(long)], None);
let mut term = Terminal::new(TestBackend::new(60, 12)).unwrap();
term.draw(|f| s.render(f, f.area(), &Palette::default(), ru()))
.unwrap();
let buf = term.backend().buffer().clone();
let row = (buf.area.top()..buf.area.bottom())
.map(|y| {
(buf.area.left()..buf.area.right())
.map(|x| buf[(x, y)].symbol().to_string())
.collect::<String>()
})
.find(|row| row.contains("заголовок"))
.expect("the row is drawn");
assert!(row.contains('…'), "a cut title says so: {row}");
assert!(
row.contains(&chrono::Local::now().format("%Y").to_string()),
"the date keeps its place: {row}"
);
}
#[test]
fn a_long_reference_list_scrolls_symmetrically() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let chats: Vec<ChatSummary> = (0..30).map(|i| chat(&format!("чат {i}"))).collect();
let mut s = ChatLinkPickerState::new(chats, None);
let mut term = Terminal::new(TestBackend::new(60, 12)).unwrap();
let mut draw = |s: &mut ChatLinkPickerState| {
term.draw(|f| s.render(f, f.area(), &Palette::default(), ru()))
.unwrap();
};
draw(&mut s);
for _ in 0..29 {
s.on_key(key(KeyCode::Down));
draw(&mut s);
}
let bottom = s.scroll.offset();
assert!(bottom > 0, "the popup is capped at the screen's height");
s.on_key(key(KeyCode::Up));
draw(&mut s);
assert_eq!(
s.scroll.offset(),
bottom,
"the rows must stay put while the selection can still move inside them"
);
for _ in 0..29 {
s.on_key(key(KeyCode::Up));
draw(&mut s);
}
assert_eq!(s.scroll.offset(), 0);
}
#[test]
fn enter_opens_and_esc_cancels() {
let chats = vec![chat("a")];
let id = chats[0].id;
let mut s = ChatLinkPickerState::new(chats, None);
assert_eq!(s.on_key(key(KeyCode::Enter)), ChatLinkAction::Open(id));
assert_eq!(s.on_key(key(KeyCode::Esc)), ChatLinkAction::Cancel);
}
#[test]
fn only_presses_are_handled() {
let mut s = ChatLinkPickerState::new(vec![chat("a")], None);
let mut ev = key(KeyCode::Enter);
ev.kind = KeyEventKind::Release;
assert_eq!(s.on_key(ev), ChatLinkAction::None);
}
#[test]
fn an_empty_list_cancels_on_enter() {
let mut s = ChatLinkPickerState::new(Vec::new(), None);
assert_eq!(s.on_key(key(KeyCode::Enter)), ChatLinkAction::Cancel);
assert_eq!(s.selected_id(), None);
}
}