2019-12-18 23:57:16 +00:00
|
|
|
use std::io;
|
2019-12-16 22:40:10 +00:00
|
|
|
use std::io::{stdin, stdout, Write};
|
2019-12-19 02:00:23 +00:00
|
|
|
use std::process;
|
|
|
|
use std::process::Stdio;
|
2019-12-19 08:21:12 +00:00
|
|
|
use termion::color;
|
2019-12-16 22:40:10 +00:00
|
|
|
use termion::input::TermRead;
|
|
|
|
use termion::raw::IntoRawMode;
|
|
|
|
|
2019-12-16 23:54:20 +00:00
|
|
|
use gopher;
|
2019-12-19 05:21:40 +00:00
|
|
|
use gopher::io_error;
|
2019-12-16 23:54:20 +00:00
|
|
|
use gopher::Type;
|
2019-12-17 02:50:49 +00:00
|
|
|
use menu::MenuView;
|
2019-12-17 07:23:02 +00:00
|
|
|
use text::TextView;
|
2019-12-16 23:54:20 +00:00
|
|
|
|
2019-12-16 22:40:10 +00:00
|
|
|
pub type Key = termion::event::Key;
|
2019-12-16 20:45:27 +00:00
|
|
|
|
2019-12-18 00:25:09 +00:00
|
|
|
pub const SCROLL_LINES: usize = 15;
|
|
|
|
pub const MAX_COLS: usize = 72;
|
|
|
|
|
2019-12-16 20:45:27 +00:00
|
|
|
pub struct UI {
|
2019-12-19 03:04:51 +00:00
|
|
|
pages: Vec<Box<dyn View>>, // loaded views
|
|
|
|
page: usize, // currently focused view
|
|
|
|
dirty: bool, // redraw?
|
|
|
|
running: bool, // main ui loop running?
|
2019-12-19 08:21:12 +00:00
|
|
|
pub size: (usize, usize), // cols, rows
|
2019-12-16 20:45:27 +00:00
|
|
|
}
|
|
|
|
|
2019-12-16 22:40:10 +00:00
|
|
|
#[derive(Debug)]
|
|
|
|
pub enum Action {
|
2019-12-19 03:04:04 +00:00
|
|
|
None, // do nothing
|
|
|
|
Back, // back in history
|
|
|
|
Forward, // also history
|
|
|
|
Open(String), // url
|
|
|
|
Keypress(Key), // unknown keypress
|
|
|
|
Redraw, // redraw everything
|
|
|
|
Quit, // yup
|
|
|
|
Error(String), // error message
|
2019-12-16 22:40:10 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
pub trait View {
|
|
|
|
fn process_input(&mut self, c: Key) -> Action;
|
2019-12-18 02:52:14 +00:00
|
|
|
fn render(&self) -> String;
|
2019-12-17 08:44:44 +00:00
|
|
|
fn url(&self) -> String;
|
2019-12-18 02:52:14 +00:00
|
|
|
fn set_size(&mut self, cols: usize, rows: usize);
|
2019-12-16 22:40:10 +00:00
|
|
|
}
|
|
|
|
|
2019-12-16 20:45:27 +00:00
|
|
|
impl UI {
|
|
|
|
pub fn new() -> UI {
|
2019-12-19 08:59:08 +00:00
|
|
|
let mut size = (0, 0);
|
|
|
|
if let Ok((cols, rows)) = termion::terminal_size() {
|
|
|
|
size = (cols as usize, rows as usize);
|
|
|
|
}
|
2019-12-16 20:45:27 +00:00
|
|
|
UI {
|
|
|
|
pages: vec![],
|
|
|
|
page: 0,
|
2019-12-17 04:56:49 +00:00
|
|
|
dirty: true,
|
2019-12-17 09:03:20 +00:00
|
|
|
running: true,
|
2019-12-19 08:59:08 +00:00
|
|
|
size,
|
2019-12-16 20:45:27 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-16 22:40:10 +00:00
|
|
|
pub fn run(&mut self) {
|
2019-12-17 09:03:20 +00:00
|
|
|
while self.running {
|
2019-12-17 02:50:49 +00:00
|
|
|
self.draw();
|
|
|
|
self.update();
|
2019-12-16 21:49:06 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-17 04:56:49 +00:00
|
|
|
pub fn draw(&mut self) {
|
|
|
|
if self.dirty {
|
2019-12-18 22:15:08 +00:00
|
|
|
print!(
|
|
|
|
"{}{}{}{}",
|
|
|
|
termion::clear::All,
|
|
|
|
termion::cursor::Goto(1, 1),
|
|
|
|
termion::cursor::Hide,
|
|
|
|
self.render()
|
|
|
|
);
|
2019-12-17 04:56:49 +00:00
|
|
|
self.dirty = false;
|
|
|
|
}
|
2019-12-17 03:11:47 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
pub fn update(&mut self) {
|
2019-12-19 03:04:04 +00:00
|
|
|
let mut stdout = stdout().into_raw_mode().unwrap();
|
|
|
|
stdout.flush().unwrap();
|
2019-12-16 21:49:06 +00:00
|
|
|
|
2019-12-19 03:04:04 +00:00
|
|
|
let action = self.process_page_input();
|
2019-12-19 08:21:12 +00:00
|
|
|
self.process_action(action)
|
|
|
|
.map_err(|e| self.error(&e.to_string()));
|
|
|
|
}
|
|
|
|
|
2019-12-18 23:57:16 +00:00
|
|
|
pub fn open(&mut self, url: &str) -> io::Result<()> {
|
2019-12-19 03:41:59 +00:00
|
|
|
// non-gopher URL
|
|
|
|
if !url.starts_with("gopher://") {
|
|
|
|
return open_external(url);
|
|
|
|
}
|
|
|
|
|
|
|
|
// gopher URL
|
2019-12-19 08:59:08 +00:00
|
|
|
self.status(&format!(
|
|
|
|
"{}Loading...{}",
|
|
|
|
color::Fg(color::LightBlack),
|
|
|
|
termion::cursor::Show
|
|
|
|
));
|
2019-12-17 01:57:37 +00:00
|
|
|
let (typ, host, port, sel) = gopher::parse_url(url);
|
2019-12-18 23:57:16 +00:00
|
|
|
gopher::fetch(host, port, sel)
|
|
|
|
.and_then(|response| match typ {
|
2019-12-19 00:55:02 +00:00
|
|
|
Type::Menu | Type::Search => {
|
|
|
|
Ok(self.add_page(MenuView::from(url.to_string(), response)))
|
|
|
|
}
|
|
|
|
Type::Text | Type::HTML => {
|
|
|
|
Ok(self.add_page(TextView::from(url.to_string(), response)))
|
|
|
|
}
|
2019-12-19 05:21:40 +00:00
|
|
|
_ => Err(io_error(format!("Unsupported Gopher Response: {:?}", typ))),
|
2019-12-16 23:54:20 +00:00
|
|
|
})
|
2019-12-19 05:21:40 +00:00
|
|
|
.map_err(|e| io_error(format!("Error loading {}: {} ({:?})", url, e, e.kind())))
|
2019-12-16 21:13:00 +00:00
|
|
|
}
|
|
|
|
|
2019-12-19 03:04:04 +00:00
|
|
|
pub fn render(&mut self) -> String {
|
|
|
|
if let Ok((cols, rows)) = termion::terminal_size() {
|
2019-12-19 08:21:12 +00:00
|
|
|
self.set_size(cols as usize, rows as usize);
|
2019-12-19 03:04:04 +00:00
|
|
|
if !self.pages.is_empty() && self.page < self.pages.len() {
|
|
|
|
if let Some(page) = self.pages.get_mut(self.page) {
|
|
|
|
page.set_size(cols as usize, rows as usize);
|
|
|
|
return page.render();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
String::from("No content to display.")
|
|
|
|
} else {
|
|
|
|
format!(
|
|
|
|
"Error getting terminal size. Please file a bug: {}",
|
|
|
|
"https://github.com/dvkt/phetch/issues/new"
|
|
|
|
)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-19 08:21:12 +00:00
|
|
|
fn set_size(&mut self, cols: usize, rows: usize) {
|
|
|
|
self.size = (cols, rows);
|
|
|
|
}
|
|
|
|
|
2019-12-19 08:49:26 +00:00
|
|
|
// Display a status message to the user.
|
|
|
|
fn status(&self, s: &str) {
|
|
|
|
print!(
|
|
|
|
"{}{}{}{}{}",
|
|
|
|
"\x1b[93m",
|
|
|
|
termion::cursor::Goto(1, self.size.1 as u16),
|
|
|
|
termion::clear::CurrentLine,
|
|
|
|
s,
|
|
|
|
color::Fg(color::Reset)
|
|
|
|
);
|
|
|
|
stdout().flush();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Display an error message to the user.
|
|
|
|
fn error(&self, e: &str) {
|
|
|
|
print!(
|
|
|
|
"{}{}{}{}{}",
|
|
|
|
"\x1b[91m",
|
|
|
|
termion::cursor::Goto(1, self.size.1 as u16),
|
|
|
|
termion::clear::CurrentLine,
|
|
|
|
e,
|
|
|
|
color::Fg(color::Reset)
|
|
|
|
);
|
|
|
|
stdout().flush();
|
|
|
|
}
|
|
|
|
|
|
|
|
// Prompt user for input.
|
|
|
|
fn prompt(&self, prompt: &str) -> Option<String> {
|
|
|
|
print!(
|
|
|
|
"{}{}{}{}{}",
|
|
|
|
color::Fg(color::Reset),
|
|
|
|
termion::cursor::Goto(1, self.size.1 as u16),
|
|
|
|
termion::clear::CurrentLine,
|
|
|
|
prompt,
|
|
|
|
termion::cursor::Show,
|
|
|
|
);
|
|
|
|
stdout().flush();
|
|
|
|
|
|
|
|
let mut input = String::new();
|
|
|
|
for k in stdin().keys() {
|
|
|
|
if let Ok(key) = k {
|
|
|
|
match key {
|
|
|
|
Key::Char('\n') => {
|
|
|
|
print!("{}{}", termion::clear::CurrentLine, termion::cursor::Hide);
|
|
|
|
stdout().flush();
|
|
|
|
return Some(input);
|
|
|
|
}
|
|
|
|
Key::Char(c) => input.push(c),
|
|
|
|
Key::Esc | Key::Ctrl('c') => {
|
|
|
|
if input.is_empty() {
|
|
|
|
print!("{}{}", termion::clear::CurrentLine, termion::cursor::Hide);
|
|
|
|
stdout().flush();
|
|
|
|
return None;
|
|
|
|
} else {
|
|
|
|
input.clear();
|
|
|
|
}
|
|
|
|
}
|
|
|
|
Key::Backspace | Key::Delete => {
|
|
|
|
input.pop();
|
|
|
|
}
|
|
|
|
_ => {}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
break;
|
|
|
|
}
|
|
|
|
|
|
|
|
print!(
|
|
|
|
"{}{}{}{}",
|
|
|
|
termion::cursor::Goto(1, self.size.1 as u16),
|
|
|
|
termion::clear::CurrentLine,
|
|
|
|
prompt,
|
|
|
|
input,
|
|
|
|
);
|
|
|
|
stdout().flush();
|
|
|
|
}
|
|
|
|
|
|
|
|
if !input.is_empty() {
|
|
|
|
Some(input)
|
|
|
|
} else {
|
|
|
|
None
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-17 02:50:49 +00:00
|
|
|
fn add_page<T: View + 'static>(&mut self, view: T) {
|
2019-12-18 23:57:16 +00:00
|
|
|
self.dirty = true;
|
2019-12-18 22:47:36 +00:00
|
|
|
if !self.pages.is_empty() && self.page < self.pages.len() - 1 {
|
2019-12-17 05:33:01 +00:00
|
|
|
self.pages.truncate(self.page + 1);
|
|
|
|
}
|
2019-12-16 23:54:20 +00:00
|
|
|
self.pages.push(Box::from(view));
|
2019-12-16 21:49:06 +00:00
|
|
|
if self.pages.len() > 1 {
|
|
|
|
self.page += 1;
|
2019-12-16 21:13:00 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-19 03:04:04 +00:00
|
|
|
fn process_page_input(&mut self) -> Action {
|
|
|
|
if let Some(page) = self.pages.get_mut(self.page) {
|
|
|
|
if let Ok(key) = stdin()
|
|
|
|
.keys()
|
|
|
|
.nth(0)
|
|
|
|
.ok_or(Action::Error("stdin.keys() error".to_string()))
|
|
|
|
{
|
|
|
|
if let Ok(key) = key {
|
|
|
|
return page.process_input(key);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
Action::None
|
|
|
|
}
|
2019-12-17 04:56:49 +00:00
|
|
|
|
2019-12-19 03:04:04 +00:00
|
|
|
fn process_action(&mut self, action: Action) -> io::Result<()> {
|
|
|
|
match action {
|
|
|
|
Action::Quit | Action::Keypress(Key::Ctrl('q')) | Action::Keypress(Key::Ctrl('c')) => {
|
|
|
|
self.running = false
|
2019-12-17 06:19:21 +00:00
|
|
|
}
|
2019-12-19 05:21:40 +00:00
|
|
|
Action::Error(e) => return Err(io_error(e)),
|
2019-12-19 03:04:04 +00:00
|
|
|
Action::Redraw => self.dirty = true,
|
|
|
|
Action::Open(url) => self.open(&url)?,
|
|
|
|
Action::Back | Action::Keypress(Key::Left) | Action::Keypress(Key::Backspace) => {
|
2019-12-17 05:29:05 +00:00
|
|
|
if self.page > 0 {
|
|
|
|
self.dirty = true;
|
|
|
|
self.page -= 1;
|
|
|
|
}
|
|
|
|
}
|
2019-12-19 03:04:04 +00:00
|
|
|
Action::Forward | Action::Keypress(Key::Right) => {
|
2019-12-17 05:29:05 +00:00
|
|
|
if self.page < self.pages.len() - 1 {
|
|
|
|
self.dirty = true;
|
|
|
|
self.page += 1;
|
|
|
|
}
|
|
|
|
}
|
2019-12-19 08:49:26 +00:00
|
|
|
Action::Keypress(Key::Ctrl('g')) => {
|
|
|
|
if let Some(url) = self.prompt("Go to URL: ") {
|
|
|
|
if !url.contains("://") && !url.starts_with("gopher://") {
|
|
|
|
self.open(&format!("gopher://{}", url))?;
|
|
|
|
} else {
|
|
|
|
self.open(&url)?;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
2019-12-19 08:21:12 +00:00
|
|
|
Action::Keypress(Key::Ctrl('u')) => {
|
|
|
|
if let Some(page) = self.pages.get(self.page) {
|
|
|
|
self.status(&format!("Current URL: {}", page.url()));
|
|
|
|
}
|
|
|
|
}
|
2019-12-19 03:04:04 +00:00
|
|
|
Action::Keypress(Key::Ctrl('y')) => {
|
|
|
|
if let Some(page) = self.pages.get(self.page) {
|
2019-12-19 08:21:12 +00:00
|
|
|
copy_to_clipboard(&page.url())?;
|
|
|
|
self.status(&format!("Copied {} to clipboard.", page.url()));
|
2019-12-19 02:00:23 +00:00
|
|
|
}
|
2019-12-16 22:40:10 +00:00
|
|
|
}
|
2019-12-19 03:04:04 +00:00
|
|
|
_ => (),
|
2019-12-16 22:40:10 +00:00
|
|
|
}
|
2019-12-19 03:04:04 +00:00
|
|
|
Ok(())
|
2019-12-16 22:40:10 +00:00
|
|
|
}
|
2019-12-16 20:45:27 +00:00
|
|
|
}
|
2019-12-17 08:44:44 +00:00
|
|
|
|
2019-12-17 09:03:20 +00:00
|
|
|
impl Drop for UI {
|
|
|
|
fn drop(&mut self) {
|
|
|
|
print!("\x1b[?25h"); // show cursor
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-12-19 03:04:04 +00:00
|
|
|
fn copy_to_clipboard(data: &str) -> io::Result<()> {
|
|
|
|
spawn_os_clipboard()
|
|
|
|
.and_then(|mut child| {
|
2019-12-19 02:00:23 +00:00
|
|
|
let child_stdin = child.stdin.as_mut().unwrap();
|
2019-12-19 03:04:04 +00:00
|
|
|
child_stdin.write_all(data.as_bytes())
|
|
|
|
})
|
2019-12-19 05:21:40 +00:00
|
|
|
.map_err(|e| io_error(format!("Clipboard error: {}", e)))
|
2019-12-17 08:44:44 +00:00
|
|
|
}
|
|
|
|
|
2019-12-19 02:00:23 +00:00
|
|
|
fn spawn_os_clipboard() -> io::Result<process::Child> {
|
2019-12-17 08:44:44 +00:00
|
|
|
if cfg!(target_os = "macos") {
|
2019-12-19 02:00:23 +00:00
|
|
|
process::Command::new("pbcopy")
|
|
|
|
.stdin(Stdio::piped())
|
|
|
|
.spawn()
|
2019-12-17 08:44:44 +00:00
|
|
|
} else {
|
2019-12-19 02:00:23 +00:00
|
|
|
process::Command::new("xclip")
|
2019-12-17 08:44:44 +00:00
|
|
|
.args(&["-sel", "clip"])
|
|
|
|
.stdin(Stdio::piped())
|
|
|
|
.spawn()
|
|
|
|
}
|
|
|
|
}
|
2019-12-19 03:41:59 +00:00
|
|
|
|
|
|
|
// runs the `open` shell command
|
|
|
|
fn open_external(url: &str) -> io::Result<()> {
|
|
|
|
process::Command::new("open")
|
|
|
|
.arg(url)
|
|
|
|
.output()
|
|
|
|
.and_then(|_| Ok(()))
|
|
|
|
}
|