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

499 lines
15 KiB
Rust

5 years ago
mod action;
5 years ago
mod view;
5 years ago
pub use self::action::Action;
5 years ago
pub use self::view::View;
use crate::bookmarks;
use crate::gopher;
use crate::gopher::Type;
use crate::help;
use crate::history;
use crate::menu::Menu;
use crate::text::Text;
use crate::utils;
5 years ago
use std::io::{stdin, stdout, Result, Write};
5 years ago
use std::process;
use std::process::Stdio;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
5 years ago
use termion::color;
5 years ago
use termion::input::TermRead;
use termion::raw::IntoRawMode;
5 years ago
use termion::terminal_size;
5 years ago
pub type Key = termion::event::Key;
pub type Page = Box<dyn View>;
5 years ago
5 years ago
pub const SCROLL_LINES: usize = 15;
5 years ago
pub const MAX_COLS: usize = 77;
5 years ago
5 years ago
pub struct UI {
views: Vec<Page>, // loaded views
focused: usize, // currently focused view
dirty: bool, // redraw?
running: bool, // main ui loop running?
pub size: (usize, usize), // cols, rows
status: String, // status message, if any
5 years ago
}
impl UI {
pub fn new() -> UI {
UI {
5 years ago
views: vec![],
5 years ago
focused: 0,
5 years ago
dirty: true,
running: true,
5 years ago
size: (0, 0),
status: String::new(),
5 years ago
}
}
5 years ago
pub fn run(&mut self) {
5 years ago
self.startup();
while self.running {
5 years ago
self.draw();
self.update();
5 years ago
}
self.shutdown();
5 years ago
}
5 years ago
pub fn draw(&mut self) {
if self.dirty {
5 years ago
print!(
"{}{}{}{}{}",
5 years ago
termion::clear::All,
termion::cursor::Goto(1, 1),
termion::cursor::Hide,
self.render(),
5 years ago
self.render_status().unwrap_or_else(|| "".into()),
5 years ago
);
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();
if let Err(e) = self.process_action(action) {
self.set_status(format!("{}{}", color::Fg(color::LightRed), e));
}
5 years ago
}
pub fn open(&mut self, title: &str, url: &str) -> Result<()> {
5 years ago
// no open loops
5 years ago
if let Some(page) = self.views.get(self.focused) {
5 years ago
if page.url() == url {
5 years ago
return Ok(());
}
}
5 years ago
// non-gopher URL
5 years ago
if url.contains("://") && !url.starts_with("gopher://") {
5 years ago
self.dirty = true;
return if confirm(&format!("Open external URL? {}", url)) {
open_external(url)
} else {
Ok(())
};
5 years ago
}
5 years ago
// binary downloads
let (typ, _, _, _) = gopher::parse_url(url);
if typ.is_download() {
self.dirty = true;
return if confirm(&format!("Download {}?", url)) {
self.download(url)
} else {
Ok(())
};
5 years ago
}
self.fetch(title, url).and_then(|page| {
5 years ago
self.add_page(page);
Ok(())
})
5 years ago
}
5 years ago
fn download(&mut self, url: &str) -> Result<()> {
let url = url.to_string();
self.spinner(&format!("Downloading {}", url), move || {
gopher::download_url(&url)
})
.and_then(|res| res)
.and_then(|(path, bytes)| {
self.set_status(format!(
"Download complete! {} saved to {}",
5 years ago
utils::human_bytes(bytes),
path
));
Ok(())
})
}
fn fetch(&mut self, title: &str, url: &str) -> Result<Page> {
5 years ago
// on-line help
if url.starts_with("gopher://phetch/") {
return self.fetch_internal(url);
5 years ago
}
// record history urls
let hurl = url.to_string();
let hname = title.to_string();
thread::spawn(move || history::save(&hname, &hurl));
// request thread
let thread_url = url.to_string();
let res = self.spinner("", move || gopher::fetch_url(&thread_url))??;
5 years ago
let (typ, _, _, _) = gopher::parse_url(&url);
5 years ago
match typ {
Type::Menu | Type::Search => Ok(Box::new(Menu::from(url.to_string(), res))),
Type::Text | Type::HTML => Ok(Box::new(Text::from(url.to_string(), res))),
5 years ago
_ => Err(error!("Unsupported Gopher Response: {:?}", typ)),
5 years ago
}
5 years ago
}
// get Menu for on-line help, home page, etc, ex: gopher://home/1/help/types
fn fetch_internal(&mut self, url: &str) -> Result<Page> {
5 years ago
if let Some(source) = help::lookup(
&url.trim_start_matches("gopher://phetch/")
5 years ago
.trim_start_matches("1/"),
) {
5 years ago
Ok(Box::new(Menu::from(url.to_string(), source)))
5 years ago
} else {
Err(error!("phetch URL not found: {}", url))
5 years ago
}
}
5 years ago
fn rows(&self) -> u16 {
self.size.1 as u16
}
fn startup(&mut self) {}
fn shutdown(&self) {}
5 years ago
fn term_size(&mut self, cols: usize, rows: usize) {
self.size = (cols, rows);
}
// Show a spinner while running a thread. Used to make gopher requests or
// download files.
fn spinner<T: Send + 'static, F: 'static + Send + FnOnce() -> T>(
&mut self,
label: &str,
work: F,
5 years ago
) -> Result<T> {
let req = thread::spawn(work);
let (tx, rx) = mpsc::channel();
let label = label.to_string();
thread::spawn(move || loop {
for i in 0..=3 {
if rx.try_recv().is_ok() {
return;
}
print!(
5 years ago
"\r{}{}{}{}{}{}",
termion::cursor::Hide,
label,
".".repeat(i),
termion::clear::AfterCursor,
5 years ago
color::Fg(color::Reset),
termion::cursor::Show,
);
stdout().flush();
5 years ago
thread::sleep(Duration::from_millis(500));
}
});
let result = req.join();
tx.send(true); // stop spinner
self.dirty = true;
5 years ago
result.map_err(|e| error!("Spinner error: {:?}", e))
}
pub fn render(&mut self) -> String {
5 years ago
if let Ok((cols, rows)) = terminal_size() {
5 years ago
self.term_size(cols as usize, rows as usize);
5 years ago
if !self.views.is_empty() && self.focused < self.views.len() {
if let Some(page) = self.views.get_mut(self.focused) {
5 years ago
page.term_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_status(&mut self, status: String) {
self.status = status;
self.dirty = true;
}
5 years ago
fn render_status(&self) -> Option<String> {
if self.status.is_empty() {
None
} else {
Some(format!(
"{}{}{}{}",
termion::cursor::Goto(1, self.rows()),
termion::clear::CurrentLine,
self.status,
color::Fg(color::Reset)
))
}
5 years ago
}
5 years ago
fn add_page(&mut self, page: Page) {
self.dirty = true;
5 years ago
if !self.views.is_empty() && self.focused < self.views.len() - 1 {
self.views.truncate(self.focused + 1);
5 years ago
}
5 years ago
self.views.push(page);
5 years ago
if self.views.len() > 1 {
5 years ago
self.focused += 1;
5 years ago
}
}
fn process_page_input(&mut self) -> Action {
5 years ago
if let Some(page) = self.views.get_mut(self.focused) {
if let Ok(key) = stdin()
.keys()
.nth(0)
5 years ago
.ok_or_else(|| Action::Error("stdin.keys() error".to_string()))
{
if let Ok(key) = key {
5 years ago
return page.respond(key);
}
}
}
Action::None
}
5 years ago
5 years ago
fn process_action(&mut self, action: Action) -> Result<()> {
let cleared = if !self.status.is_empty() {
self.status.clear();
self.dirty = true;
true
} else {
false
};
match action {
Action::Keypress(Key::Ctrl('c')) | Action::Keypress(Key::Esc) => {
if !cleared {
self.running = false
}
5 years ago
}
Action::Keypress(Key::Ctrl('q')) => self.running = false,
5 years ago
Action::Error(e) => return Err(error!(e)),
Action::Redraw => self.dirty = true,
Action::Open(title, url) => self.open(&title, &url)?,
5 years ago
Action::Keypress(Key::Left) | Action::Keypress(Key::Backspace) => {
5 years ago
if self.focused > 0 {
5 years ago
self.dirty = true;
5 years ago
self.focused -= 1;
5 years ago
}
}
5 years ago
Action::Keypress(Key::Right) => {
5 years ago
if self.focused < self.views.len() - 1 {
5 years ago
self.dirty = true;
5 years ago
self.focused += 1;
5 years ago
}
}
5 years ago
Action::Keypress(Key::Ctrl('r')) => {
5 years ago
if let Some(page) = self.views.get(self.focused) {
5 years ago
let url = page.url();
let raw = page.raw();
let mut text = Text::from(url, raw);
text.wide = true;
self.add_page(Box::new(text));
5 years ago
}
}
5 years ago
Action::Keypress(Key::Ctrl('g')) => {
5 years ago
if let Some(url) = prompt("Go to URL: ") {
5 years ago
if !url.contains("://") && !url.starts_with("gopher://") {
self.open(&url, &format!("gopher://{}", url))?;
5 years ago
} else {
self.open(&url, &url)?;
5 years ago
}
}
}
Action::Keypress(Key::Ctrl('h')) => self.open("Help", "gopher://phetch/1/help")?,
Action::Keypress(Key::Ctrl('a')) => {
self.open("History", "gopher://phetch/1/history")?
}
Action::Keypress(Key::Ctrl('b')) => {
self.open("Bookmarks", "gopher://phetch/1/bookmarks")?
}
5 years ago
Action::Keypress(Key::Ctrl('s')) => {
if let Some(page) = self.views.get(self.focused) {
let url = page.url();
match bookmarks::save(&url, &url) {
Ok(()) => self.set_status(format!("Saved bookmark: {}", url)),
Err(e) => return Err(error!("Save failed: {}", e)),
}
5 years ago
}
}
5 years ago
Action::Keypress(Key::Ctrl('u')) => {
5 years ago
if let Some(page) = self.views.get(self.focused) {
5 years ago
let url = page.url();
self.set_status(format!("Current URL: {}", url));
5 years ago
}
}
Action::Keypress(Key::Ctrl('y')) => {
5 years ago
if let Some(page) = self.views.get(self.focused) {
5 years ago
let url = page.url();
copy_to_clipboard(&url)?;
self.set_status(format!("Copied {} to clipboard.", url));
5 years ago
}
5 years ago
}
5 years ago
Action::Keypress(key) => {
return Err(error!("Unknown keypress: {:?}", key));
}
_ => (),
5 years ago
}
Ok(())
5 years ago
}
5 years ago
}
5 years ago
5 years ago
impl Default for UI {
fn default() -> Self {
UI::new()
}
}
impl Drop for UI {
fn drop(&mut self) {
print!("\x1b[?25h"); // show cursor
5 years ago
stdout().flush();
}
}
5 years ago
fn copy_to_clipboard(data: &str) -> Result<()> {
5 years ago
spawn_os_clipboard().and_then(|mut child| {
let child_stdin = child.stdin.as_mut().unwrap();
child_stdin.write_all(data.as_bytes())
})
5 years ago
}
5 years ago
fn spawn_os_clipboard() -> 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
5 years ago
fn open_external(url: &str) -> Result<()> {
5 years ago
let output = process::Command::new("open").arg(url).output()?;
if output.stderr.is_empty() {
Ok(())
} else {
Err(error!(
"`open` error: {}",
String::from_utf8(output.stderr)
.unwrap_or_else(|_| "?".into())
5 years ago
.trim_end()
))
}
5 years ago
}
5 years ago
/// Ask user to confirm action with ENTER or Y.
pub fn confirm(question: &str) -> bool {
let (_cols, rows) = terminal_size().unwrap();
print!(
"{}{}{}{} [Y/n]: {}",
color::Fg(color::Reset),
termion::cursor::Goto(1, rows),
termion::clear::CurrentLine,
question,
termion::cursor::Show,
);
stdout().flush();
if let Some(Ok(key)) = stdin().keys().next() {
match key {
Key::Char('\n') => true,
Key::Char('y') | Key::Char('Y') => true,
_ => false,
}
} else {
false
}
}
5 years ago
/// Prompt user for input and return what was entered, if anything.
pub fn prompt(prompt: &str) -> Option<String> {
5 years ago
let (_cols, rows) = terminal_size().unwrap();
5 years ago
print!(
"{}{}{}{}{}",
color::Fg(color::Reset),
termion::cursor::Goto(1, rows),
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, rows),
termion::clear::CurrentLine,
prompt,
input,
);
stdout().flush();
}
if !input.is_empty() {
Some(input)
} else {
None
}
}