馃 Vim-like, Command-line Gemini Client
gemini gemini-protocol tui smolweb vim
at main 278 lines 8.4 kB view raw
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}