馃 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 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}