You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
phetch/src/ui.rs

256 lines
7.3 KiB
Rust

use std::io;
5 years ago
use std::io::{stdin, stdout, Write};
5 years ago
use std::process;
use std::process::Stdio;
5 years ago
use termion::color;
5 years ago
use termion::input::TermRead;
use termion::raw::IntoRawMode;
use gopher;
use gopher::io_error;
use gopher::Type;
5 years ago
use menu::MenuView;
5 years ago
use text::TextView;
5 years ago
pub type Key = termion::event::Key;
5 years ago
5 years ago
pub const SCROLL_LINES: usize = 15;
pub const MAX_COLS: usize = 72;
5 years ago
pub struct UI {
5 years ago
pages: Vec<Box<dyn View>>, // loaded views
page: usize, // currently focused view
dirty: bool, // redraw?
running: bool, // main ui loop running?
5 years ago
pub size: (usize, usize), // cols, rows
5 years ago
}
5 years ago
#[derive(Debug)]
pub enum Action {
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
5 years ago
}
pub trait View {
fn process_input(&mut self, c: Key) -> Action;
fn render(&self) -> String;
5 years ago
fn url(&self) -> String;
fn set_size(&mut self, cols: usize, rows: usize);
5 years ago
}
5 years ago
impl UI {
pub fn new() -> UI {
UI {
pages: vec![],
page: 0,
5 years ago
dirty: true,
running: true,
5 years ago
size: (0, 0),
5 years ago
}
}
5 years ago
pub fn run(&mut self) {
while self.running {
5 years ago
self.draw();
self.update();
5 years ago
}
}
5 years ago
pub fn draw(&mut self) {
if self.dirty {
5 years ago
print!(
"{}{}{}{}",
termion::clear::All,
termion::cursor::Goto(1, 1),
termion::cursor::Hide,
self.render()
);
5 years ago
self.dirty = false;
}
5 years ago
}
pub fn update(&mut self) {
let mut stdout = stdout().into_raw_mode().unwrap();
stdout.flush().unwrap();
5 years ago
let action = self.process_page_input();
5 years ago
self.process_action(action)
.map_err(|e| self.error(&e.to_string()));
}
// 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();
5 years ago
}
pub fn open(&mut self, url: &str) -> io::Result<()> {
5 years ago
// non-gopher URL
if !url.starts_with("gopher://") {
return open_external(url);
}
// gopher URL
5 years ago
self.status("\x1b[90mLoading...");
5 years ago
let (typ, host, port, sel) = gopher::parse_url(url);
gopher::fetch(host, port, sel)
.and_then(|response| match typ {
5 years ago
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)))
}
_ => Err(io_error(format!("Unsupported Gopher Response: {:?}", typ))),
})
.map_err(|e| io_error(format!("Error loading {}: {} ({:?})", url, e, e.kind())))
5 years ago
}
pub fn render(&mut self) -> String {
if let Ok((cols, rows)) = termion::terminal_size() {
5 years ago
self.set_size(cols as usize, rows as usize);
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"
)
}
}
5 years ago
fn set_size(&mut self, cols: usize, rows: usize) {
self.size = (cols, rows);
}
5 years ago
fn add_page<T: View + 'static>(&mut self, view: T) {
self.dirty = true;
if !self.pages.is_empty() && self.page < self.pages.len() - 1 {
5 years ago
self.pages.truncate(self.page + 1);
}
self.pages.push(Box::from(view));
5 years ago
if self.pages.len() > 1 {
self.page += 1;
5 years ago
}
}
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
}
5 years ago
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
5 years ago
}
Action::Error(e) => return Err(io_error(e)),
Action::Redraw => self.dirty = true,
Action::Open(url) => self.open(&url)?,
Action::Back | Action::Keypress(Key::Left) | Action::Keypress(Key::Backspace) => {
5 years ago
if self.page > 0 {
self.dirty = true;
self.page -= 1;
}
}
Action::Forward | Action::Keypress(Key::Right) => {
5 years ago
if self.page < self.pages.len() - 1 {
self.dirty = true;
self.page += 1;
}
}
5 years ago
Action::Keypress(Key::Ctrl('u')) => {
if let Some(page) = self.pages.get(self.page) {
self.status(&format!("Current URL: {}", page.url()));
}
}
Action::Keypress(Key::Ctrl('y')) => {
if let Some(page) = self.pages.get(self.page) {
5 years ago
copy_to_clipboard(&page.url())?;
self.status(&format!("Copied {} to clipboard.", page.url()));
5 years ago
}
5 years ago
}
_ => (),
5 years ago
}
Ok(())
5 years ago
}
5 years ago
}
5 years ago
impl Drop for UI {
fn drop(&mut self) {
print!("\x1b[?25h"); // show cursor
}
}
fn copy_to_clipboard(data: &str) -> io::Result<()> {
spawn_os_clipboard()
.and_then(|mut child| {
5 years ago
let child_stdin = child.stdin.as_mut().unwrap();
child_stdin.write_all(data.as_bytes())
})
.map_err(|e| io_error(format!("Clipboard error: {}", e)))
5 years ago
}
5 years ago
fn spawn_os_clipboard() -> io::Result<process::Child> {
5 years ago
if cfg!(target_os = "macos") {
5 years ago
process::Command::new("pbcopy")
.stdin(Stdio::piped())
.spawn()
5 years ago
} else {
5 years ago
process::Command::new("xclip")
5 years ago
.args(&["-sel", "clip"])
.stdin(Stdio::piped())
.spawn()
}
}
5 years ago
// runs the `open` shell command
fn open_external(url: &str) -> io::Result<()> {
process::Command::new("open")
.arg(url)
.output()
.and_then(|_| Ok(()))
}