Skip to main content

multiverse/widgets/search/
searching.rs

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