馃 Vim-like, Command-line Gemini Client
gemini gemini-protocol tui smolweb vim
at main 274 lines 7.0 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 crossterm::event::KeyCode; 19use url::Url; 20 21use crate::command::Command; 22 23#[derive(PartialEq, Eq)] 24pub enum Mode { 25 Normal, 26 Editing, 27} 28 29fn handle_input_response( 30 app: &mut crate::App, 31 key: crossterm::event::KeyEvent, 32) -> bool { 33 match key.code { 34 KeyCode::Enter => { 35 let new_url = match app.url.to_string().split('?').next() { 36 Some(base_url) => { 37 format!("{}?{}", base_url, app.response_input) 38 } 39 None => "".to_string(), 40 }; 41 42 if new_url.is_empty() { 43 app.error = Some("Invalid base URL".to_string()); 44 45 return false; 46 } 47 48 match Url::parse(&new_url) { 49 Ok(url) => { 50 app.set_url(url); 51 app.make_request(); 52 app.response_input.clear(); 53 app.response_input_text.clear(); 54 55 app.accept_response_input = false; 56 } 57 Err(error) => { 58 app.error = Some(error.to_string()); 59 } 60 } 61 } 62 KeyCode::Esc => { 63 app.accept_response_input = false; 64 65 app.response_input.clear(); 66 app.response_input_text.clear(); 67 app.go_back(); 68 } 69 KeyCode::Char(c) => { 70 app.response_input.push(c); 71 } 72 KeyCode::Backspace => { 73 app.response_input.pop(); 74 } 75 _ => {} 76 } 77 78 false 79} 80 81fn handle_normal_input( 82 app: &mut crate::App, 83 key: crossterm::event::KeyEvent, 84) -> bool { 85 match key.code { 86 KeyCode::Char(':') => { 87 app.input.clear(); 88 89 app.input_mode = Mode::Editing; 90 app.error = None; 91 } 92 KeyCode::Char('r') => { 93 app.make_request(); 94 } 95 KeyCode::Esc => app.items.unselect(), 96 KeyCode::Down | KeyCode::Char('j') => { 97 app.items.next(); 98 99 app.error = None; 100 } 101 KeyCode::Up | KeyCode::Char('k') => { 102 app.items.previous(); 103 104 app.error = None; 105 } 106 KeyCode::Char('h') | KeyCode::Left => { 107 app.go_back(); 108 } 109 KeyCode::Char('l') | KeyCode::Right => { 110 if let Some(url) = app.previous_capsule.clone() { 111 app.set_url(url); 112 113 app.previous_capsule = None; 114 115 app.make_request(); 116 } 117 } 118 KeyCode::Char('G') => app.items.last(), 119 KeyCode::Char('g') => { 120 if app.command_stroke_history.contains(&key.code) { 121 app.items.first(); 122 app.command_stroke_history.clear(); 123 } else if app.command_stroke_history.is_empty() { 124 app.command_stroke_history.push(key.code); 125 } 126 } 127 KeyCode::Backspace => app.error = None, 128 KeyCode::Enter => { 129 app.error = None; 130 131 if let Some(link) = &app.items.items[app.items.selected].1 { 132 if !link.starts_with("gemini://") && link.contains("://") { 133 } else { 134 let the_url = &if link.starts_with('/') { 135 if let Some(host) = app.url.host_str() { 136 format!("gemini://{}{}", host, link) 137 } else { 138 app.error = Some("URL has no host".to_string()); 139 140 return false; 141 } 142 } else if link.starts_with("gemini://") { 143 link.to_string() 144 } else if !link.starts_with('/') && !link.starts_with("gemini://") { 145 format!("{}/{}", app.url.to_string().trim_end_matches('/'), link) 146 } else { 147 app.url.to_string() 148 }; 149 150 match Url::parse(the_url) { 151 Ok(the_actual_url) => { 152 app.set_url(the_actual_url); 153 app.make_request(); 154 } 155 Err(error) => app.error = Some(error.to_string()), 156 } 157 } 158 } 159 } 160 _ => {} 161 } 162 163 false 164} 165 166fn handle_editing_input( 167 app: &mut crate::App, 168 key: crossterm::event::KeyEvent, 169) -> bool { 170 match key.code { 171 KeyCode::Enter => { 172 app.command_history.reverse(); 173 app.command_history.push(app.input.to_string()); 174 app.command_history.reverse(); 175 176 match Command::from(app.input.to_string()) { 177 Command::Quit => return true, 178 Command::Open(to) => { 179 if let Some(to) = to { 180 match Url::parse(&crate::url::prefix_gemini(&to)) { 181 Ok(url) => { 182 app.set_url(url); 183 app.make_request(); 184 } 185 Err(error) => app.error = Some(error.to_string()), 186 } 187 } else { 188 app.error = Some("No URL provided for open command".to_string()); 189 } 190 } 191 Command::Unknown => { 192 app.error = Some(format!("\"{}\" is not a valid command", app.input)); 193 } 194 Command::Wrap(at, error) => { 195 if let Some(error) = error { 196 app.error = Some(error); 197 } else { 198 app.error = None; 199 app.wrap_at = at; 200 201 app.make_request(); 202 } 203 } 204 Command::Help => { 205 app.set_url( 206 Url::parse("gemini://fuwn.me/blog/technology/gemini?referrer=sydney").unwrap(), 207 ); 208 app.make_request(); 209 } 210 } 211 212 app.input_mode = Mode::Normal; 213 app.command_history_cursor = 0; 214 } 215 KeyCode::Char(c) => { 216 app.input.push(c); 217 } 218 KeyCode::Up => { 219 if let Some(command) = app.command_history.get(app.command_history_cursor) 220 { 221 app.input = command.to_string(); 222 223 if app.command_history_cursor + 1 < app.command_history.len() { 224 app.command_history_cursor += 1; 225 } 226 } 227 } 228 KeyCode::Down => { 229 let mut dead_set = false; 230 231 if app.command_history_cursor > 0 { 232 app.command_history_cursor -= 1; 233 } else { 234 dead_set = true; 235 } 236 237 if let Some(command) = app.command_history.get(app.command_history_cursor) 238 { 239 app.input = command.to_string(); 240 } 241 242 if dead_set { 243 app.input.clear(); 244 } 245 } 246 KeyCode::Backspace => { 247 app.input.pop(); 248 } 249 KeyCode::Esc => { 250 app.input_mode = Mode::Normal; 251 252 app.input.clear(); 253 } 254 _ => {} 255 } 256 257 false 258} 259 260pub fn handle_key_strokes( 261 app: &mut crate::App, 262 key: crossterm::event::KeyEvent, 263) -> bool { 264 match app.input_mode { 265 Mode::Normal => { 266 if app.accept_response_input { 267 handle_input_response(app, key) 268 } else { 269 handle_normal_input(app, key) 270 } 271 } 272 Mode::Editing => handle_editing_input(app, key), 273 } 274}