diff options
| author | Fuwn <[email protected]> | 2022-07-16 10:47:07 +0000 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2022-07-16 10:47:07 +0000 |
| commit | f63f2b749aa0ff4efdc367b514aba08c23dd7c89 (patch) | |
| tree | e7d692aeff08cabc8d6d321aa05c4f0c25d7ca45 /src | |
| download | sydney-f63f2b749aa0ff4efdc367b514aba08c23dd7c89.tar.xz sydney-f63f2b749aa0ff4efdc367b514aba08c23dd7c89.zip | |
feat: initial release
Diffstat (limited to 'src')
| -rw-r--r-- | src/app.rs | 203 | ||||
| -rw-r--r-- | src/command.rs | 49 | ||||
| -rw-r--r-- | src/input.rs | 267 | ||||
| -rw-r--r-- | src/main.rs | 106 | ||||
| -rw-r--r-- | src/stateful_list.rs | 82 | ||||
| -rw-r--r-- | src/ui.rs | 142 |
6 files changed, 849 insertions, 0 deletions
diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..278a96c --- /dev/null +++ b/src/app.rs @@ -0,0 +1,203 @@ +// This file is part of Germ <https://github.com/gemrest/sydney>. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only + +use std::time::{Duration, Instant}; + +use crossterm::event; +use germ::request::Status; +use url::Url; + +use crate::{input::Mode as InputMode, stateful_list::StatefulList}; + +pub struct App { + pub items: StatefulList<(Vec<String>, Option<String>)>, + pub input: String, + pub input_mode: InputMode, + pub command_stroke_history: Vec<event::KeyCode>, + pub command_history: Vec<String>, + pub command_history_cursor: usize, + pub error: Option<String>, + pub url: Url, + pub capsule_history: Vec<Url>, + pub previous_capsule: Option<Url>, + pub response_input: String, + pub accept_response_input: bool, + pub response_input_text: String, + pub wrap_at: usize, +} +impl App { + pub fn new() -> Self { + let url = Url::parse("gemini://gemini.circumlunar.space/").unwrap(); + + let mut app = Self { + response_input: String::new(), + error: None, + command_stroke_history: Vec::new(), + input: String::new(), + input_mode: InputMode::Normal, + items: StatefulList::with_items(Vec::new()), + command_history: vec![], + command_history_cursor: 0, + url, + capsule_history: vec![], + previous_capsule: None, + accept_response_input: false, + response_input_text: "".to_string(), + wrap_at: 80, + }; + + app.make_request(); + + app + } + + pub fn set_url(&mut self, url: Url) { + self.previous_capsule = Some(self.url.clone()); + self.url = url; + } + + pub fn make_request(&mut self) { + self.items = StatefulList::with_items({ + let mut items = vec![]; + + match germ::request::request(&self.url) { + Ok(mut response) => { + if response.status() == &Status::TemporaryRedirect + || response.status() == &Status::PermanentRedirect + { + self.url = Url::parse(&if response.meta().starts_with('/') { + format!( + "gemini://{}{}", + self.url.host_str().unwrap(), + response.meta() + ) + } else if response.meta().starts_with("gemini://") { + response.meta().to_string() + } else if !response.meta().starts_with('/') + && !response.meta().starts_with("gemini://") + { + format!( + "{}/{}", + self.url.to_string().trim_end_matches('/'), + response.meta() + ) + } else { + self.url.to_string() + }) + .unwrap(); + + response = germ::request::request(&self.url).unwrap(); + } + + if response.status() == &Status::Input + || response.status() == &Status::SensitiveInput + { + self.accept_response_input = true; + self.response_input_text = response.meta().to_string(); + } + + if let Some(content) = response.content().clone() { + for line in content.lines().clone() { + let line = line.replace('\t', " "); + let mut parts = line.split_whitespace(); + let lines = if line.is_empty() { + vec![line.to_string()] + } else { + line + .as_bytes() + .chunks(self.wrap_at) + .map(|buf| { + #[allow(unsafe_code)] + unsafe { std::str::from_utf8_unchecked(buf) }.to_string() + }) + .collect::<Vec<_>>() + }; + + if let (Some("=>"), Some(to)) = (parts.next(), parts.next()) { + items.push((lines, Some(to.to_string()))); + } else { + items.push((lines, None)); + } + } + } else if response.status() != &Status::Input + && response.status() != &Status::SensitiveInput + { + items.push((vec![response.meta().to_string()], None)); + + self.error = Some(response.meta().to_string()); + } + + if let Some(last_url) = self.capsule_history.last() { + if last_url.to_string() != self.url.to_string() { + self.capsule_history.push(self.url.clone()); + } + } else { + self.capsule_history.push(self.url.clone()); + } + } + Err(error) => { + self.error = Some(error.to_string()); + + return; + } + } + + items + }); + } + + pub fn run<B: tui::backend::Backend>( + terminal: &mut tui::Terminal<B>, + mut app: Self, + tick_rate: Duration, + ) -> std::io::Result<()> { + let mut last_tick = Instant::now(); + loop { + terminal.draw(|f| crate::ui::ui(f, &mut app))?; + + let timeout = tick_rate + .checked_sub(last_tick.elapsed()) + .unwrap_or_else(|| Duration::from_secs(0)); + + if event::poll(timeout)? { + if let event::Event::Key(key) = event::read()? { + if crate::input::handle_key_strokes(&mut app, key) { + return Ok(()); + } + } + } + + if last_tick.elapsed() >= tick_rate { + last_tick = Instant::now(); + } + } + } + + pub fn go_back(&mut self) { + if let Some(url) = self.capsule_history.pop() { + if url == self.url { + if let Some(url) = self.capsule_history.pop() { + self.set_url(url); + } + } else { + self.set_url(url); + } + + self.make_request(); + } + } +} diff --git a/src/command.rs b/src/command.rs new file mode 100644 index 0000000..4dc3615 --- /dev/null +++ b/src/command.rs @@ -0,0 +1,49 @@ +// This file is part of Germ <https://github.com/gemrest/sydney>. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only + +pub enum Command { + Quit, + Open(Option<String>), + Unknown, + Wrap(usize, Option<String>), +} +impl From<String> for Command { + fn from(s: String) -> Self { + let mut tokens = s.split(' '); + + match tokens.next() { + Some("open" | "o") => Self::Open(tokens.next().map(ToString::to_string)), + Some("quit" | "q") => Self::Quit, + Some("wrap") => + tokens.next().map_or_else( + || { + Self::Wrap( + 80, + Some("Missing width argument to wrap command".to_string()), + ) + }, + |at| { + match at.parse() { + Ok(at_parsed) => Self::Wrap(at_parsed, None), + Err(error) => Self::Wrap(80, Some(error.to_string())), + } + }, + ), + _ => Self::Unknown, + } + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..9a4c3e6 --- /dev/null +++ b/src/input.rs @@ -0,0 +1,267 @@ +// This file is part of Germ <https://github.com/gemrest/sydney>. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only + +use crossterm::event::KeyCode; +use url::Url; + +use crate::command::Command; + +pub enum Mode { + Normal, + Editing, +} + +fn handle_input_response( + mut app: &mut crate::App, + key: crossterm::event::KeyEvent, +) -> bool { + match key.code { + KeyCode::Enter => { + let new_url = match app.url.to_string().split('?').next() { + Some(base_url) => { + format!("{}?{}", base_url, app.response_input) + } + None => "".to_string(), + }; + + if new_url.is_empty() { + app.error = Some("Invalid base URL".to_string()); + + return false; + } + + match Url::parse(&new_url) { + Ok(url) => { + app.set_url(url); + app.make_request(); + app.response_input.clear(); + app.response_input_text.clear(); + + app.accept_response_input = false; + } + Err(error) => { + app.error = Some(error.to_string()); + } + } + } + KeyCode::Esc => { + app.accept_response_input = false; + + app.response_input.clear(); + app.response_input_text.clear(); + app.go_back(); + } + KeyCode::Char(c) => { + app.response_input.push(c); + } + KeyCode::Backspace => { + app.response_input.pop(); + } + _ => {} + } + + false +} + +fn handle_normal_input( + mut app: &mut crate::App, + key: crossterm::event::KeyEvent, +) -> bool { + match key.code { + KeyCode::Char(':') => { + app.input.push(':'); + + app.input_mode = Mode::Editing; + app.error = None; + } + KeyCode::Esc => app.items.unselect(), + KeyCode::Down | KeyCode::Char('j') => { + app.items.next(); + + app.error = None; + } + KeyCode::Up | KeyCode::Char('k') => { + app.items.previous(); + + app.error = None; + } + KeyCode::Char('h') | KeyCode::Left => { + app.go_back(); + } + KeyCode::Char('l') | KeyCode::Right => { + if let Some(url) = app.previous_capsule.clone() { + app.set_url(url); + + app.previous_capsule = None; + + app.make_request(); + } + } + KeyCode::Char('G') => app.items.last(), + KeyCode::Char('g') => + if app.command_stroke_history.contains(&key.code) { + app.items.first(); + app.command_stroke_history.clear(); + } else if app.command_stroke_history.is_empty() { + app.command_stroke_history.push(key.code); + }, + KeyCode::Backspace => app.error = None, + KeyCode::Enter => { + if let Some(link) = &app.items.items[app.items.selected].1 { + if !link.starts_with("gemini://") && link.contains("://") { + } else { + let the_url = &if link.starts_with('/') { + format!("gemini://{}{}", app.url.host_str().unwrap(), link) + } else if link.starts_with("gemini://") { + link.to_string() + } else if !link.starts_with('/') && !link.starts_with("gemini://") { + format!("{}/{}", app.url.to_string().trim_end_matches('/'), link) + } else { + app.url.to_string() + }; + + app.set_url(Url::parse(the_url).unwrap()); + app.make_request(); + } + } + } + _ => {} + } + + false +} + +fn handle_editing_input( + mut app: &mut crate::App, + key: crossterm::event::KeyEvent, +) -> bool { + match key.code { + KeyCode::Enter => { + if let Some(command) = app.input.get(1..) { + app.command_history.reverse(); + app.command_history.push(command.to_string()); + app.command_history.reverse(); + } + + match Command::from( + app.input.to_string().get(1..).unwrap_or("").to_string(), + ) { + Command::Quit => return true, + Command::Open(to) => { + if let Some(to) = to { + // Remove colon + app.input = app.input.chars().rev().collect(); + + app.input.pop(); + + app.input = app.input.chars().rev().collect(); + app.set_url( + Url::parse(&if to.starts_with("gemini://") { + to + } else { + format!("gemini://{}", to) + }) + .unwrap(), + ); + + app.make_request(); + } else { + app.error = Some("No URL provided for open command".to_string()); + } + } + Command::Unknown => + if app.input == ":" { + app.input_mode = Mode::Normal; + } else { + app.error = Some(format!( + "\"{}\" is not a valid command", + app.input.to_string().get(1..).unwrap_or("") + )); + }, + Command::Wrap(at, error) => + if let Some(error) = error { + app.error = Some(error); + } else { + app.error = None; + app.wrap_at = at; + }, + } + + app.input_mode = Mode::Normal; + app.command_history_cursor = 0; + + app.input.clear(); + } + KeyCode::Char(c) => { + app.input.push(c); + } + KeyCode::Up => { + if let Some(command) = app.command_history.get(app.command_history_cursor) + { + app.input = format!(":{}", command); + + if app.command_history_cursor + 1 < app.command_history.len() { + app.command_history_cursor += 1; + } + } + } + KeyCode::Down => { + let mut dead_set = false; + + if app.command_history_cursor > 0 { + app.command_history_cursor -= 1; + } else { + dead_set = true; + } + + if let Some(command) = app.command_history.get(app.command_history_cursor) + { + app.input = format!(":{}", command); + } + + if dead_set { + app.input = ":".to_string(); + } + } + KeyCode::Backspace => { + app.input.pop(); + } + KeyCode::Esc => { + app.input_mode = Mode::Normal; + + app.input.clear(); + } + _ => {} + } + + false +} + +pub fn handle_key_strokes( + app: &mut crate::App, + key: crossterm::event::KeyEvent, +) -> bool { + match app.input_mode { + Mode::Normal => + if app.accept_response_input { + handle_input_response(app, key) + } else { + handle_normal_input(app, key) + }, + Mode::Editing => handle_editing_input(app, key), + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..77841a9 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,106 @@ +// This file is part of Germ <https://github.com/gemrest/sydney>. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only + +#![deny( + warnings, + nonstandard_style, + unused, + future_incompatible, + rust_2018_idioms, + unsafe_code, + clippy::all, + clippy::nursery, + clippy::pedantic +)] +#![recursion_limit = "128"] + +mod app; +mod command; +mod input; +mod stateful_list; +mod ui; + +use app::App; +use crossterm::{event, execute, terminal}; + +fn main() -> Result<(), Box<dyn std::error::Error>> { + let mut args = std::env::args(); + let mut app = App::new(); + + if let Some(arg) = args.nth(1) { + match arg.as_str() { + "--version" | "-v" => { + println!("{}", env!("CARGO_PKG_VERSION")); + + return Ok(()); + } + "--help" | "-h" => { + println!( + r#"usage: {} [option, capsule_uri] +Options: + --version, -v show version text + --help, -h show help text + +Sample invocations: + {0} gemini://gem.rest/ + {0} --help + +Report bugs to https://github.com/gemrest/sydney/issues"#, + args + .next() + .unwrap_or_else(|| env!("CARGO_PKG_NAME").to_string()) + ); + + return Ok(()); + } + _ => { + app.url = url::Url::parse(&arg)?; + + app.make_request(); + } + } + } + + terminal::enable_raw_mode()?; + + let mut stdout = std::io::stdout(); + + execute!( + stdout, + terminal::EnterAlternateScreen, + event::EnableMouseCapture + )?; + + let mut terminal = + tui::Terminal::new(tui::backend::CrosstermBackend::new(stdout))?; + let result = + App::run(&mut terminal, app, std::time::Duration::from_millis(250)); + + terminal::disable_raw_mode()?; + execute!( + terminal.backend_mut(), + terminal::LeaveAlternateScreen, + event::DisableMouseCapture + )?; + terminal.show_cursor()?; + + if let Err(err) = result { + println!("{:?}", err); + } + + Ok(()) +} diff --git a/src/stateful_list.rs b/src/stateful_list.rs new file mode 100644 index 0000000..50c71cc --- /dev/null +++ b/src/stateful_list.rs @@ -0,0 +1,82 @@ +// This file is part of Germ <https://github.com/gemrest/sydney>. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only + +//! <https://github.com/fdehau/tui-rs/blob/master/examples/list.rs> + +use tui::widgets::ListState; + +pub struct StatefulList<T> { + pub state: ListState, + pub items: Vec<T>, + pub selected: usize, +} + +impl<T> StatefulList<T> { + pub fn with_items(items: Vec<T>) -> Self { + Self { + state: ListState::default(), + items, + selected: 0, + } + } + + pub fn next(&mut self) { + let i = match self.state.selected() { + Some(i) => + if i >= self.items.len() - 1 { + 0 + } else { + i + 1 + }, + None => 0, + }; + + self.selected = i; + + self.state.select(Some(i)); + } + + pub fn previous(&mut self) { + let i = match self.state.selected() { + Some(i) => + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + }, + None => 0, + }; + + self.selected = i; + + self.state.select(Some(i)); + } + + pub fn last(&mut self) { + self.state.select(Some(self.items.len() - 1)); + + self.selected = self.items.len() - 1; + } + + pub fn first(&mut self) { + self.state.select(Some(0)); + + self.selected = 0; + } + + pub fn unselect(&mut self) { self.state.select(None); } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..b43a119 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,142 @@ +// This file is part of Germ <https://github.com/gemrest/sydney>. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, version 3. +// +// This program is distributed in the hope that it will be useful, but +// WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +// General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see <http://www.gnu.org/licenses/>. +// +// Copyright (C) 2022-2022 Fuwn <[email protected]> +// SPDX-License-Identifier: GPL-3.0-only + +use tui::{ + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + widgets, + widgets::{ListItem, Paragraph}, +}; + +pub fn ui<B: tui::backend::Backend>( + f: &mut tui::Frame<'_, B>, + app: &mut crate::App, +) { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage(95), + Constraint::Percentage(4), + Constraint::Percentage(1), + ] + .as_ref(), + ) + .split(f.size()); + + let items: Vec<ListItem<'_>> = app + .items + .items + .iter() + .map(|(text_lines, _link)| { + let mut spans = vec![]; + + for line in text_lines { + spans.push(tui::text::Spans::from(line.as_str())); + } + + ListItem::new(spans) + }) + .collect(); + + let items = widgets::List::new(items).highlight_style( + Style::default() + .bg(Color::White) + .fg(Color::Black) + .add_modifier(tui::style::Modifier::BOLD), + ); + + f.render_stateful_widget(items, chunks[0], &mut app.items.state); + f.render_widget( + Paragraph::new(app.url.to_string()) + .style(Style::default().bg(Color::White).fg(Color::Black)), + chunks[1], + ); + + if let Some(error) = app.error.as_ref() { + f.render_widget( + Paragraph::new(&**error).style(Style::default().bg(Color::Red)), + chunks[2], + ); + } else if !app.input.is_empty() { + f.render_widget(Paragraph::new(&*app.input), chunks[2]); + } + + if app.accept_response_input { + let block = widgets::Block::default() + .title(app.url.to_string()) + .borders(widgets::Borders::ALL); + let area = centered_rect(60, 20, f.size()); + + f.render_widget(widgets::Clear, area); + f.render_widget(block.clone(), area); + f.render_widget( + Paragraph::new(format!( + "{} {}", + app.response_input_text.trim(), + app.response_input + )) + .wrap(widgets::Wrap { + trim: false + }), + block.inner(area), + ); + } + + if let Some(error) = &app.error { + let block = widgets::Block::default() + .title("Sydney") + .borders(widgets::Borders::ALL) + .style(Style::default().bg(Color::Cyan)); + let area = centered_rect(60, 20, f.size()); + + f.render_widget(widgets::Clear, area); + f.render_widget(block.clone(), area); + f.render_widget( + Paragraph::new(error.to_string()).wrap(widgets::Wrap { + trim: false + }), + block.inner(area), + ); + } +} + +fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints( + [ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ] + .as_ref(), + ) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints( + [ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ] + .as_ref(), + ) + .split(popup_layout[1])[1] +} |