multiverse/widgets/search/
searching.rs1use 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 let width = results_area.width - 2 - MESSAGE_PADDING_LEFT - MESSAGE_PADDING_RIGHT;
125 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}