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