馃 Vim-like, Command-line Gemini Client
gemini
gemini-protocol
tui
smolweb
vim
1// This file is part of Sydney <https://github.com/gemrest/sydney>.
2//
3// This program is free software: you can redistribute it and/or modify
4// it under the terms of the GNU General Public License as published by
5// the Free Software Foundation, version 3.
6//
7// This program is distributed in the hope that it will be useful, but
8// WITHOUT ANY WARRANTY; without even the implied warranty of
9// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
10// General Public License for more details.
11//
12// You should have received a copy of the GNU General Public License
13// along with this program. If not, see <http://www.gnu.org/licenses/>.
14//
15// Copyright (C) 2022-2022 Fuwn <contact@fuwn.me>
16// SPDX-License-Identifier: GPL-3.0-only
17
18use germ::ast::Node;
19use ratatui::{
20 layout::{Constraint, Direction, Layout, Rect},
21 style::{Color, Modifier, Style},
22 text::{Line, Span},
23 widgets,
24 widgets::{ListItem, Paragraph},
25};
26
27#[allow(clippy::too_many_lines)]
28pub fn ui(f: &mut ratatui::Frame<'_>, app: &mut crate::App) {
29 let chunks = Layout::default()
30 .direction(Direction::Vertical)
31 .constraints(
32 [
33 Constraint::Percentage(95),
34 Constraint::Percentage(4),
35 Constraint::Percentage(1),
36 ]
37 .as_ref(),
38 )
39 .split(f.size());
40
41 let items: Vec<ListItem<'_>> = app
42 .items
43 .items
44 .iter()
45 .map(|(text_lines, _link, pre)| {
46 let mut lines = vec![];
47
48 for line in text_lines {
49 let mut line = line.clone();
50
51 if *pre {
52 if let Node::Text(text) = line {
53 line = Node::PreformattedText {
54 alt_text: None,
55 text: text.to_string(),
56 }
57 }
58 }
59
60 macro_rules! wrap_split {
61 ($text:ident, $lines:ident) => {
62 let wrappeds = $text
63 .as_bytes()
64 .chunks((app.wrap_at as usize) - 5)
65 .map(|buf| {
66 #[allow(unsafe_code)]
67 unsafe { std::str::from_utf8_unchecked(buf) }.to_string()
68 })
69 .collect::<Vec<_>>();
70
71 for (i, wrapped) in wrappeds.iter().enumerate() {
72 $lines.push(Line::from(format!(" {}{}", wrapped, {
73 if i < wrappeds.len() - 1 && wrappeds.len() != 1 {
74 "-"
75 } else {
76 ""
77 }
78 })));
79 }
80 };
81 }
82
83 match line {
84 germ::ast::Node::Text(text) => {
85 if text != "sydney_abc_123" {
86 wrap_split!(text, lines);
87 }
88 }
89 germ::ast::Node::Blockquote(text) => {
90 let wrappeds = text
91 .as_bytes()
92 .chunks((app.wrap_at as usize) - 5)
93 .map(|buf| {
94 #[allow(unsafe_code)]
95 unsafe { std::str::from_utf8_unchecked(buf) }.to_string()
96 })
97 .collect::<Vec<_>>();
98
99 for (i, wrapped) in wrappeds.iter().enumerate() {
100 lines.push(Line::from(vec![
101 Span::styled(" > ", Style::default().fg(Color::LightBlue)),
102 Span::styled(
103 format!("{}{}", wrapped.clone(), {
104 if i < wrappeds.len() && wrappeds.len() != 1 {
105 "-"
106 } else {
107 ""
108 }
109 }),
110 Style::default().add_modifier(Modifier::ITALIC),
111 ),
112 ]));
113 }
114 }
115 germ::ast::Node::Link { to, text } => {
116 let mut span_list =
117 vec![Span::styled(" => ", Style::default().fg(Color::LightBlue))];
118
119 span_list.push(Span::styled(
120 text.unwrap_or_else(|| to.clone()),
121 Style::default().add_modifier(Modifier::UNDERLINED),
122 ));
123 span_list.push(Span::from(" "));
124 span_list
125 .push(Span::styled(to, Style::default().fg(Color::LightBlue)));
126
127 lines.push(Line::from(span_list));
128 }
129 germ::ast::Node::Heading { text, level } => {
130 lines.push(Line::from(vec![
131 Span::styled(
132 match level {
133 1 => " # ",
134 2 => " ## ",
135 3 => "### ",
136 _ => unreachable!(),
137 },
138 Style::default().fg(Color::LightBlue),
139 ),
140 Span::styled(text, {
141 let mut style = Style::default().add_modifier(Modifier::BOLD);
142
143 match level {
144 1 => {
145 style = style.add_modifier(Modifier::UNDERLINED);
146 }
147 3 => {
148 style = style.add_modifier(Modifier::ITALIC);
149 }
150 _ => {}
151 }
152
153 style
154 }),
155 ]));
156 }
157 germ::ast::Node::List(list_items) => {
158 let mut span_list = vec![];
159
160 for list_item in list_items {
161 span_list.push(Span::styled(
162 " * ",
163 Style::default().fg(Color::LightBlue),
164 ));
165 span_list.push(Span::from(format!("{}\n", list_item)));
166 }
167
168 lines.push(Line::from(span_list));
169 }
170 germ::ast::Node::PreformattedText { text, alt_text } => {
171 let mut span_list = vec![
172 Span::styled("``` ", Style::default().fg(Color::LightBlue)),
173 Span::from(alt_text.unwrap_or_else(|| "".to_string())),
174 ];
175
176 if text != "sydney_abc_123" {
177 span_list.push(Span::from(text));
178 }
179
180 lines.push(Line::from(span_list));
181 }
182 germ::ast::Node::Whitespace => {
183 lines.push(Line::from("".to_string()));
184 }
185 };
186 }
187
188 ListItem::new(lines)
189 })
190 .collect();
191
192 let items = widgets::List::new(items)
193 .highlight_style(
194 Style::default()
195 .bg(Color::White)
196 .fg(Color::Black)
197 .remove_modifier(Modifier::BOLD),
198 )
199 .style(Style::default().bg(Color::Black).fg(Color::White));
200
201 f.render_stateful_widget(items, chunks[0], &mut app.items.state);
202 f.render_widget(
203 Paragraph::new(app.url.to_string())
204 .style(Style::default().bg(Color::White).fg(Color::Black)),
205 chunks[1],
206 );
207
208 if let Some(error) = app.error.as_ref() {
209 f.render_widget(
210 Paragraph::new(&**error).style(Style::default().bg(Color::Red)),
211 chunks[2],
212 );
213 } else if !app.input.is_empty()
214 || app.input_mode == crate::input::Mode::Editing
215 {
216 f.render_widget(Paragraph::new(format!(":{}", app.input)), chunks[2]);
217 }
218
219 if app.accept_response_input {
220 let block = widgets::Block::default()
221 .title(app.url.to_string())
222 .borders(widgets::Borders::ALL);
223 let area = centered_rect(60, 20, f.size());
224
225 f.render_widget(widgets::Clear, area);
226 f.render_widget(block.clone(), area);
227 f.render_widget(
228 Paragraph::new(format!(
229 "{} {}",
230 app.response_input_text.trim(),
231 app.response_input
232 ))
233 .wrap(widgets::Wrap { trim: false }),
234 block.inner(area),
235 );
236 }
237
238 if let Some(error) = &app.error {
239 let block = widgets::Block::default()
240 .title("Sydney")
241 .borders(widgets::Borders::ALL)
242 .style(Style::default().bg(Color::Cyan));
243 let area = centered_rect(60, 20, f.size());
244
245 f.render_widget(widgets::Clear, area);
246 f.render_widget(block.clone(), area);
247 f.render_widget(
248 Paragraph::new(error.to_string()).wrap(widgets::Wrap { trim: false }),
249 block.inner(area),
250 );
251 }
252}
253
254fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
255 let popup_layout = Layout::default()
256 .direction(Direction::Vertical)
257 .constraints(
258 [
259 Constraint::Percentage((100 - percent_y) / 2),
260 Constraint::Percentage(percent_y),
261 Constraint::Percentage((100 - percent_y) / 2),
262 ]
263 .as_ref(),
264 )
265 .split(r);
266
267 Layout::default()
268 .direction(Direction::Horizontal)
269 .constraints(
270 [
271 Constraint::Percentage((100 - percent_x) / 2),
272 Constraint::Percentage(percent_x),
273 Constraint::Percentage((100 - percent_x) / 2),
274 ]
275 .as_ref(),
276 )
277 .split(popup_layout[1])[1]
278}