mod action;
mod mode;
mod view;
pub use self::{action::Action, mode::Mode, view::View};
use crate::{
bookmarks,
config::{Config, SharedConfig},
encoding::Encoding,
gopher::{self, Type},
help, history,
menu::Menu,
terminal,
text::Text,
theme, utils, BUG_URL,
};
use std::{
io::{stdin, stdout, Result, Write},
process::{self, Stdio},
sync::{
mpsc::{channel, Receiver, Sender},
Arc, Mutex, RwLock,
},
thread,
time::Duration,
};
use termion::{input::TermRead, terminal_size};
pub type Key = termion::event::Key;
pub type KeyReceiver = Arc<Mutex<Receiver<Key>>>;
pub const MAX_COLS: usize = 77;
const ERR_SCREEN: &str = "Fatal Error using Alternate Screen.";
const ERR_STDOUT: &str = "Fatal Error writing to STDOUT.";
lazy_static! {
static ref RESIZE_SENDER: Arc<Mutex<Option<Sender<Key>>>> = Arc::new(Mutex::new(None));
}
fn resize_handler(_: i32) {
if let Some(sender) = &*RESIZE_SENDER.lock().unwrap() {
sender.send(Key::F(5)).unwrap();
}
}
fn sigint_handler(_: i32) {}
fn sigcont_handler(_: i32) {
terminal::enable_raw_mode().expect("Fatal Error entering Raw Mode.");
}
pub struct UI {
views: Vec<Box<dyn View>>,
focused: usize,
dirty: bool,
running: bool,
pub size: (usize, usize),
status: String,
config: SharedConfig,
keys: KeyReceiver,
}
impl UI {
pub fn new(config: Config) -> UI {
let mut size = (0, 0);
if let Ok((cols, rows)) = terminal_size() {
size = (cols as usize, rows as usize);
};
UI {
views: vec![],
focused: 0,
dirty: true,
running: true,
size,
config: Arc::new(RwLock::new(config)),
status: String::new(),
keys: Self::spawn_keyboard_listener(),
}
}
pub fn run(&mut self) -> Result<()> {
while self.running {
self.draw()?;
self.update();
}
Ok(())
}
pub fn draw(&mut self) -> Result<()> {
let status = self.render_status();
let mut out = stdout();
if self.dirty {
let screen = self.render()?;
write!(
out,
"{}{}{}{}",
terminal::Goto(1, 1),
terminal::HideCursor,
screen,
status,
)?;
out.flush()?;
self.dirty = false;
} else {
out.write_all(status.as_ref())?;
out.flush()?;
}
Ok(())
}
pub fn update(&mut self) {
let action = self.process_view_input();
if !action.is_none() {
self.status.clear();
}
if let Err(e) = self.process_action(action) {
self.set_status(&format!(
"{}{}{}",
&self.config.read().unwrap().theme.item_error,
e,
terminal::HideCursor
));
}
}
pub fn reload(&mut self, title: &str, url: &str) -> Result<()> {
let mut rest = if self.views.len() > self.focused + 1 {
self.views.drain(self.focused..).collect()
} else {
vec![self.views.remove(self.views.len() - 1)]
};
if self.focused > 0 {
self.focused -= 1;
}
self.open(title, url)?;
if rest.len() > 1 {
rest.remove(0); self.views.append(&mut rest);
}
Ok(())
}
pub fn open(&mut self, title: &str, url: &str) -> Result<()> {
if let Some(view) = self.views.get(self.focused) {
if view.url() == url {
return self.reload(title, url);
}
}
if url.starts_with("telnet://") {
return self.telnet(url);
}
if url.contains("://") && !url.starts_with("gopher://") {
self.dirty = true;
return if self.confirm(&format!("Open external URL? {}", url)) {
utils::open_external(url)
} else {
Ok(())
};
}
let typ = gopher::type_for_url(url);
if typ.is_media() && self.config.read().unwrap().media.is_some() {
self.dirty = true;
return if self.config.read().unwrap().autoplay
|| self.confirm(&format!("Open in media player? {}", url))
{
utils::open_media(self.config.read().unwrap().media.as_ref().unwrap(), url)
} else {
Ok(())
};
}
if typ.is_download() {
self.dirty = true;
return if self.confirm(&format!("Download {}?", url)) {
self.download(url)
} else {
Ok(())
};
}
self.load(title, url).map(|view| {
self.add_view(view);
})
}
fn download(&mut self, url: &str) -> Result<()> {
let url = url.to_string();
let (tls, tor) = (
self.config.read().unwrap().tls,
self.config.read().unwrap().tor,
);
let chan = self.keys.clone();
self.spinner(&format!("Downloading {}", url), move || {
gopher::download_url(&url, tls, tor, chan)
})
.and_then(|res| res)
.map(|(path, bytes)| {
self.set_status(
format!(
"Download complete! {} saved to {}",
utils::human_bytes(bytes),
path
)
.as_ref(),
);
})
}
fn load(&mut self, title: &str, url: &str) -> Result<Box<dyn View>> {
if url.starts_with("gopher://phetch/") {
return self.load_internal(url);
}
let hurl = url.to_string();
let hname = title.to_string();
thread::spawn(move || history::save(&hname, &hurl));
let thread_url = url.to_string();
let (tls, tor) = (
self.config.read().unwrap().tls,
self.config.read().unwrap().tor,
);
let (tls, res) = if self.views.is_empty() {
gopher::fetch_url(&thread_url, tls, tor)?
} else {
self.spinner("", move || gopher::fetch_url(&thread_url, tls, tor))??
};
let typ = gopher::type_for_url(url);
match typ {
Type::Menu | Type::Search => Ok(Box::new(Menu::from(
url,
gopher::response_to_string(&res),
self.config.clone(),
tls,
))),
Type::Text | Type::HTML => Ok(Box::new(Text::from(url, res, self.config.clone(), tls))),
_ => Err(error!("Unsupported Gopher Response: {:?}", typ)),
}
}
fn load_internal(&mut self, url: &str) -> Result<Box<dyn View>> {
if let Some(source) = help::lookup(
url.trim_start_matches("gopher://phetch/")
.trim_start_matches("1/"),
) {
Ok(Box::new(Menu::from(
url,
source,
self.config.clone(),
false,
)))
} else {
Err(error!("phetch URL not found: {}", url))
}
}
fn cols(&self) -> u16 {
self.size.0 as u16
}
fn rows(&self) -> u16 {
self.size.1 as u16
}
fn term_size(&mut self, cols: usize, rows: usize) {
self.size = (cols, rows);
}
fn spinner<T: Send + 'static, F: 'static + Send + FnOnce() -> T>(
&mut self,
label: &str,
work: F,
) -> Result<T> {
let req = thread::spawn(work);
let (tx, rx) = channel();
let label = label.to_string();
let rows = self.rows() as u16;
thread::spawn(move || loop {
for i in 0..=3 {
if rx.try_recv().is_ok() {
return;
}
print!(
"{}{}{}{}{}{}{}",
terminal::Goto(1, rows),
terminal::HideCursor,
label,
".".repeat(i),
terminal::ClearUntilNewline,
theme::color::Reset,
terminal::ShowCursor,
);
stdout().flush().expect(ERR_STDOUT);
thread::sleep(Duration::from_millis(500));
}
});
let result = req.join();
tx.send(true).expect("Fatal Error in Spinner channel."); self.dirty = true;
result.map_err(|e| error!("Spinner error: {:?}", e))
}
pub fn render(&mut self) -> Result<String> {
if let Ok((cols, rows)) = terminal_size() {
self.term_size(cols as usize, rows as usize);
if !self.views.is_empty() && self.focused < self.views.len() {
if let Some(view) = self.views.get_mut(self.focused) {
view.term_size(cols as usize, rows as usize);
return Ok(view.render());
}
}
Err(error!(
"fatal: No focused View. Please file a bug: {}",
BUG_URL
))
} else {
Err(error!(
"fatal: Can't get terminal size. Please file a bug: {}",
BUG_URL
))
}
}
fn set_status(&mut self, status: &str) {
self.status = status.replace('\n', "\\n").replace('\r', "\\r");
}
fn render_conn_status(&self) -> Option<String> {
let view = self.views.get(self.focused)?;
let mut status = vec![];
if matches!(view.encoding(), Encoding::CP437) {
status.push("CP439");
}
if view.is_tls() {
if self.config.read().unwrap().emoji {
status.push("🔐");
} else {
status.push("TLS");
}
} else if view.is_tor() {
if self.config.read().unwrap().emoji {
status.push("🧅");
} else {
status.push("TOR");
}
}
if status.is_empty() {
None
} else {
let len = status.iter().fold(0, |a, s| a + s.len());
let len = len + status.len();
Some(format!(
"{}{}",
terminal::Goto(self.cols() - len as u16, self.rows()),
status
.iter()
.map(|s| theme::to_color("bold white") + s + reset_color!())
.collect::<Vec<_>>()
.join(" "),
))
}
}
fn render_status(&self) -> String {
format!(
"{}{}{}{}{}{}",
terminal::HideCursor,
terminal::Goto(1, self.rows()),
terminal::ClearCurrentLine,
self.status,
self.render_conn_status().unwrap_or_else(|| "".into()),
theme::color::Reset,
)
}
fn add_view(&mut self, view: Box<dyn View>) {
self.dirty = true;
if !self.views.is_empty() && self.focused < self.views.len() - 1 {
self.views.truncate(self.focused + 1);
}
self.views.push(view);
if self.views.len() > 1 {
self.focused += 1;
}
}
fn confirm(&self, question: &str) -> bool {
let rows = self.rows();
let mut out = stdout();
write!(
out,
"{}{}{}{} [Y/n]: {}",
theme::color::Reset,
terminal::Goto(1, rows),
terminal::ClearCurrentLine,
question,
terminal::ShowCursor,
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
if let Ok(key) = self.keys.lock().unwrap().recv() {
matches!(key, Key::Char('\n') | Key::Char('y') | Key::Char('Y'))
} else {
false
}
}
fn prompt(&self, prompt: &str, value: &str) -> Option<String> {
let rows = self.rows();
let mut input = value.to_string();
let mut out = stdout();
write!(
out,
"{}{}{}{}{}{}",
theme::color::Reset,
terminal::Goto(1, rows),
terminal::ClearCurrentLine,
prompt,
input,
terminal::ShowCursor,
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
let keys = self.keys.lock().unwrap();
for key in keys.iter() {
match key {
Key::Char('\n') => {
write!(
out,
"{}{}",
terminal::ClearCurrentLine,
terminal::HideCursor
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
return Some(input);
}
Key::Char(c) => input.push(c),
Key::Esc | Key::Ctrl('c') => {
write!(
out,
"{}{}",
terminal::ClearCurrentLine,
terminal::HideCursor
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
return None;
}
Key::Backspace | Key::Delete => {
input.pop();
}
_ => {}
}
write!(
out,
"{}{}{}{}",
terminal::Goto(1, rows),
terminal::ClearCurrentLine,
prompt,
input,
)
.expect(ERR_STDOUT);
out.flush().expect(ERR_STDOUT);
}
if !input.is_empty() {
Some(input)
} else {
None
}
}
fn telnet(&mut self, url: &str) -> Result<()> {
let gopher::Url { host, port, .. } = gopher::parse_url(url);
terminal::disable_raw_mode()?;
let mut cmd = process::Command::new("telnet")
.arg(host)
.arg(port)
.stdin(Stdio::inherit())
.stdout(Stdio::inherit())
.spawn()?;
cmd.wait()?;
terminal::enable_raw_mode()?;
self.dirty = true;
Ok(())
}
fn process_view_input(&mut self) -> Action {
if let Some(view) = self.views.get_mut(self.focused) {
if let Ok(key) = self.keys.lock().unwrap().recv() {
return view.respond(key);
}
}
Action::Error("No Gopher page loaded.".into())
}
fn spawn_keyboard_listener() -> KeyReceiver {
let (sender, receiver) = channel();
*RESIZE_SENDER.lock().unwrap() = Some(sender.clone());
unsafe {
libc::signal(libc::SIGWINCH, resize_handler as usize);
libc::signal(libc::SIGINT, sigint_handler as usize);
libc::signal(libc::SIGCONT, sigcont_handler as usize);
}
thread::spawn(move || {
for key in stdin().keys().flatten() {
sender.send(key).unwrap();
}
});
Arc::new(Mutex::new(receiver))
}
fn suspend(&mut self) {
terminal::disable_raw_mode().expect("Fatal Error disabling Raw Mode");
let mut out = stdout();
write!(out, "{}", terminal::ToMainScreen).expect(ERR_SCREEN);
out.flush().expect(ERR_STDOUT);
unsafe { libc::raise(libc::SIGTSTP) };
write!(out, "{}", terminal::ToAlternateScreen).expect(ERR_SCREEN);
out.flush().expect(ERR_STDOUT);
self.dirty = true;
}
fn process_action(&mut self, action: Action) -> Result<()> {
match action {
Action::List(actions) => {
for action in actions {
self.process_action(action)?;
}
}
Action::Keypress(Key::Ctrl('c')) => {
self.status = "\x1b[90m(Use q to quit)\x1b[0m".into()
}
Action::Keypress(Key::Ctrl('z')) => self.suspend(),
Action::Keypress(Key::Esc) => {}
Action::Error(e) => return Err(error!(e)),
Action::Redraw => self.dirty = true,
Action::Draw(s) => {
let mut out = stdout();
out.write_all(s.as_ref())?;
out.flush()?;
}
Action::Status(s) => self.set_status(&s),
Action::Open(title, url) => self.open(&title, &url)?,
Action::Prompt(query, fun) => {
if let Some(response) = self.prompt(&query, "") {
self.process_action(fun(response))?;
}
}
Action::Keypress(Key::F(5)) => self.dirty = true,
Action::Keypress(Key::Left) | Action::Keypress(Key::Backspace) => {
if self.focused > 0 {
self.dirty = true;
self.focused -= 1;
}
}
Action::Keypress(Key::Right) => {
if self.focused < self.views.len() - 1 {
self.dirty = true;
self.focused += 1;
}
}
Action::Keypress(Key::Char(key)) | Action::Keypress(Key::Ctrl(key)) => match key {
'a' => self.open("History", "gopher://phetch/1/history")?,
'b' => self.open("Bookmarks", "gopher://phetch/1/bookmarks")?,
'g' => {
if let Some(url) = self.prompt("Go to URL: ", "") {
self.open(&url, &url)?;
}
}
'h' => self.open("Help", "gopher://phetch/1/help")?,
'r' => {
if let Some(view) = self.views.get(self.focused) {
let url = view.url();
let mut text =
Text::from(url, view.raw().into(), self.config.clone(), view.is_tls());
text.wide = true;
self.add_view(Box::new(text));
}
}
'R' => {
if let Some(view) = self.views.get(self.focused) {
let url = view.url().to_owned();
self.open(&url, &url)?;
}
}
's' => {
if let Some(view) = self.views.get(self.focused) {
let url = view.url();
match bookmarks::save(url, url) {
Ok(()) => {
let msg = format!("Saved bookmark: {}", url);
self.set_status(&msg);
}
Err(e) => return Err(error!("Save failed: {}", e)),
}
}
}
'u' => {
if let Some(view) = self.views.get(self.focused) {
let current_url = view.url();
if let Some(url) = self.prompt("Current URL: ", current_url) {
self.open(&url, &url)?;
}
}
}
'y' => {
if let Some(view) = self.views.get(self.focused) {
let url = view.url();
utils::copy_to_clipboard(url)?;
let msg = format!("Copied {} to clipboard.", url);
self.set_status(&msg);
}
}
'w' => {
let wide = self.config.read().unwrap().wide;
self.config.write().unwrap().wide = !wide;
if let Some(view) = self.views.get_mut(self.focused) {
let w = view.wide();
view.set_wide(!w);
self.dirty = true;
}
}
'q' => self.running = false,
c => return Err(error!("Unknown keypress: {}", c)),
},
_ => (),
}
Ok(())
}
}