multiverse/widgets/search/
searching.rs

1use crossterm::event::KeyEvent;
2use matrix_sdk::{
3    deserialized_responses::TimelineEvent,
4    ruma::{
5        OwnedUserId,
6        events::{
7            AnySyncMessageLikeEvent, AnySyncTimelineEvent,
8            room::message::{MessageType, SyncRoomMessageEvent},
9        },
10    },
11};
12use ratatui::{
13    layout::Flex,
14    prelude::*,
15    symbols::border::Set,
16    widgets::{Block, BorderType, Borders, Clear, Padding, Paragraph, Wrap},
17};
18use tui_widget_list::{ListBuilder, ListState, ListView};
19
20use crate::{
21    ALT_ROW_COLOR, HEADER_BG, NORMAL_ROW_COLOR, SELECTED_STYLE_FG, TEXT_COLOR,
22    widgets::popup_input::{PopupInput, PopupInputBuilder},
23};
24
25const MESSAGE_PADDING_LEFT: u16 = 2;
26const MESSAGE_PADDING_RIGHT: u16 = 1;
27const MESSAGE_PADDING_TOP: u16 = 0;
28const MESSAGE_PADDING_BOTTOM: u16 = 0;
29
30#[derive(Default)]
31pub struct SearchingView {
32    input: PopupInput,
33    results: Option<Vec<(OwnedUserId, String, String)>>,
34    pub(crate) list_state: ListState,
35}
36
37impl SearchingView {
38    pub fn new() -> Self {
39        let border_set = Set { bottom_left: "╟", bottom_right: "╢", ..symbols::border::PLAIN };
40
41        Self {
42            input: PopupInputBuilder::new("", "(Enter search query)")
43                .height_constraint(Constraint::Percentage(100))
44                .width_constraint(Constraint::Percentage(100))
45                .border_set(border_set)
46                .borders(Borders::BOTTOM)
47                .bg(HEADER_BG)
48                .build(),
49            results: None,
50            list_state: ListState::default(),
51        }
52    }
53
54    pub fn results(&mut self, values: Vec<TimelineEvent>) {
55        let values: Vec<(OwnedUserId, String, String)> =
56            values.iter().filter_map(get_message_from_timeline_event).collect();
57
58        self.results = Some(values);
59    }
60
61    pub fn get_text(&self) -> Option<String> {
62        let name = self.input.get_input();
63        if !name.is_empty() { Some(name) } else { None }
64    }
65
66    pub fn handle_key_press(&mut self, key: KeyEvent) {
67        self.input.handle_key_press(key);
68    }
69}
70
71impl Widget for &mut SearchingView {
72    fn render(self, area: Rect, buf: &mut Buffer) {
73        let [area] = Layout::vertical([Constraint::Percentage(70)]).flex(Flex::Center).areas(area);
74        let [area] =
75            Layout::horizontal([Constraint::Percentage(70)]).flex(Flex::Center).areas(area);
76
77        let block = Block::bordered()
78            .title("Search")
79            .title_alignment(Alignment::Center)
80            .border_style(Style::new().bg(HEADER_BG))
81            .border_type(BorderType::Double);
82        let inner_area = block.inner(area);
83
84        let [search_area, results_area] =
85            Layout::vertical([Constraint::Length(3), Constraint::Fill(1)]).areas(inner_area);
86
87        let messages = if let Some(results) = &self.results
88            && !results.is_empty()
89        {
90            results
91                .iter()
92                .map(|(sender, time, message)| {
93                    MessageWidget::new(sender.to_string(), time.clone(), message.clone())
94                })
95                .collect()
96        } else {
97            vec![MessageWidget::new("", "", "No results found!")]
98        };
99
100        let count = messages.len();
101
102        let builder = ListBuilder::new(move |context| {
103            let mut message_widget = messages[context.index].clone();
104            // -2 for the border width.
105            let width = results_area.width - 2 - MESSAGE_PADDING_LEFT - MESSAGE_PADDING_RIGHT;
106            // +2 for the border with, +1 for safety.
107            let main_axis_size =
108                textwrap::wrap(&message_widget.content, textwrap::Options::new(width as usize))
109                    .len()
110                    + 3;
111
112            if context.index % 2 == 0 {
113                message_widget.style = Style::default().fg(TEXT_COLOR).bg(ALT_ROW_COLOR);
114            } else {
115                message_widget.style = Style::default().fg(TEXT_COLOR).bg(NORMAL_ROW_COLOR);
116            }
117
118            if context.is_selected {
119                message_widget.style = Style::default().bg(NORMAL_ROW_COLOR).fg(SELECTED_STYLE_FG);
120            }
121
122            (message_widget, main_axis_size as u16)
123        });
124
125        let list = ListView::new(builder, count);
126
127        block.render(area, buf);
128
129        Clear.render(results_area, buf);
130        Block::new().borders(Borders::NONE).bg(HEADER_BG).render(results_area, buf);
131
132        list.render(results_area, buf, &mut self.list_state);
133
134        self.input.render(search_area, buf);
135    }
136}
137
138fn get_message_from_timeline_event(ev: &TimelineEvent) -> Option<(OwnedUserId, String, String)> {
139    if let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomMessage(
140        SyncRoomMessageEvent::Original(ev),
141    ))) = ev.raw().deserialize()
142        && let MessageType::Text(content) = &ev.content.msgtype
143    {
144        let time = format!("{:?}", ev.origin_server_ts);
145
146        return Some((ev.sender.to_owned(), time, content.body.clone()));
147    }
148    None
149}
150
151#[derive(Debug, Clone)]
152pub struct MessageWidget {
153    title: String,
154    time: String,
155    content: String,
156    style: Style,
157}
158
159impl MessageWidget {
160    pub fn new<T: Into<String>>(title: T, time: T, content: T) -> Self {
161        Self {
162            title: title.into(),
163            time: time.into(),
164            content: content.into(),
165            style: Style::default().fg(TEXT_COLOR).bg(NORMAL_ROW_COLOR),
166        }
167    }
168}
169
170impl Widget for MessageWidget {
171    fn render(self, area: Rect, buf: &mut Buffer) {
172        let block = Block::bordered()
173            .title(self.title)
174            .title_top(Line::from(self.time).right_aligned())
175            .padding(Padding::new(
176                MESSAGE_PADDING_LEFT,
177                MESSAGE_PADDING_RIGHT,
178                MESSAGE_PADDING_TOP,
179                MESSAGE_PADDING_BOTTOM,
180            ));
181        Paragraph::new(Text::from(self.content))
182            .wrap(Wrap { trim: true })
183            .block(block)
184            .style(self.style)
185            .render(area, buf);
186    }
187}