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/menu.rs

789 lines
25 KiB
Rust

use crate::gopher::{self, Type};
use crate::ui::{Action, Key, View, MAX_COLS, SCROLL_LINES};
use std::{
fmt,
io::{stdout, Write},
};
5 years ago
pub struct Menu {
pub url: String, // gopher url
pub lines: Vec<Line>, // lines
pub links: Vec<usize>, // links (index of line in lines vec)
pub longest: usize, // size of the longest line
pub raw: String, // raw response
pub input: String, // user's inputted value
pub link: usize, // selected link
pub scroll: usize, // scrolling offset
pub searching: bool, // search mode?
pub size: (usize, usize), // cols, rows
5 years ago
pub wide: bool, // in wide mode?
5 years ago
}
5 years ago
pub struct Line {
pub name: String,
pub url: String,
pub typ: Type,
pub link: usize, // link #, if any
}
// direction of a given link relative to the visible screen
#[derive(PartialEq)]
5 years ago
enum LinkPos {
Above,
Below,
Visible,
}
5 years ago
impl fmt::Display for Menu {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.url())
}
}
5 years ago
impl View for Menu {
5 years ago
fn raw(&self) -> String {
5 years ago
self.raw.to_string()
5 years ago
}
fn render(&self) -> String {
self.render_lines()
5 years ago
}
5 years ago
fn respond(&mut self, key: Key) -> Action {
self.process_key(key)
5 years ago
}
5 years ago
fn term_size(&mut self, cols: usize, rows: usize) {
self.size = (cols, rows);
5 years ago
}
5 years ago
fn url(&self) -> String {
5 years ago
self.url.to_string()
5 years ago
}
5 years ago
}
5 years ago
impl Menu {
pub fn from(url: String, response: String) -> Menu {
Self::parse(url, response)
5 years ago
}
5 years ago
fn _cols(&self) -> usize {
self.size.0
}
fn rows(&self) -> usize {
self.size.1
}
5 years ago
fn link(&self, i: usize) -> Option<&Line> {
5 years ago
if let Some(line) = self.links.get(i) {
self.lines.get(*line)
5 years ago
} else {
None
}
5 years ago
}
/// Is the given link visible on screen?
fn is_visible(&self, link: usize) -> bool {
self.link_visibility(link) == Some(LinkPos::Visible)
}
/// Where is the given link relative to the screen?
5 years ago
fn link_visibility(&self, i: usize) -> Option<LinkPos> {
5 years ago
if let Some(&pos) = self.links.get(i) {
5 years ago
Some(if pos < self.scroll {
5 years ago
LinkPos::Above
5 years ago
} else if pos >= self.scroll + self.rows() - 1 {
5 years ago
LinkPos::Below
} else {
5 years ago
LinkPos::Visible
})
} else {
None
}
}
fn render_lines(&self) -> String {
5 years ago
let mut out = String::new();
let (cols, rows) = self.size;
5 years ago
macro_rules! push {
($c:expr, $e:expr) => {{
5 years ago
out.push_str("\x1b[");
out.push_str($c);
out.push_str("m");
5 years ago
out.push_str(&$e);
5 years ago
out.push_str("\x1b[0m");
5 years ago
}};
}
5 years ago
let iter = self.lines.iter().skip(self.scroll).take(rows - 1);
let longest = if self.longest > MAX_COLS {
5 years ago
MAX_COLS
} else {
5 years ago
self.longest
5 years ago
};
let indent = if longest > cols {
String::from("")
} else {
5 years ago
let left = (cols - longest) / 2;
5 years ago
if left > 6 {
" ".repeat(left - 6)
} else {
String::from("")
}
};
5 years ago
for line in iter {
5 years ago
if !self.wide {
out.push_str(&indent);
}
5 years ago
if line.typ == Type::Info {
5 years ago
out.push_str(" ");
5 years ago
} else {
if line.link == self.link {
out.push_str("\x1b[97;1m*\x1b[0m")
5 years ago
} else {
out.push(' ');
}
5 years ago
out.push(' ');
out.push_str("\x1b[95m");
5 years ago
if line.link < 9 {
5 years ago
out.push(' ');
}
5 years ago
out.push_str(&(line.link + 1).to_string());
5 years ago
out.push_str(".\x1b[0m ");
}
5 years ago
// truncate long lines, instead of wrapping
let name = if line.name.len() > MAX_COLS {
5 years ago
line.name.chars().take(MAX_COLS).collect::<String>()
5 years ago
} else {
5 years ago
line.name.to_string()
5 years ago
};
5 years ago
match line.typ {
5 years ago
Type::Text => push!("96", name),
Type::Menu => push!("94", name),
Type::Info => push!("93", name),
Type::HTML => push!("92", name),
Type::Error => push!("91", name),
5 years ago
Type::Telnet => push!("4;97;90", name),
Type::Telnet3270 | Type::Mirror | Type::CSOEntity => push!("107;30", name),
5 years ago
typ if typ.is_download() => push!("4;97", name),
_ => push!("0", name),
5 years ago
}
out.push('\n');
5 years ago
}
if self.searching {
out.push_str(&self.render_input());
}
5 years ago
out
5 years ago
}
fn render_input(&self) -> String {
format!(
"{}Find:\x1b[0m {}{}{}",
5 years ago
termion::cursor::Goto(1, self.rows() as u16),
self.input,
termion::cursor::Show,
termion::clear::AfterCursor,
)
}
fn redraw_input(&self) -> Action {
if self.searching {
print!("{}", self.render_input());
} else {
print!("{}{}", termion::clear::CurrentLine, termion::cursor::Hide);
}
5 years ago
stdout().flush();
5 years ago
Action::None
}
5 years ago
fn action_page_down(&mut self) -> Action {
5 years ago
let lines = self.lines.len();
// fewer lines than visible rows, just select last link
5 years ago
if lines < self.rows() {
5 years ago
if !self.links.is_empty() {
5 years ago
self.link = self.links.len() - 1;
return Action::Redraw;
}
return Action::None;
}
let padding = self.padding_bottom();
if self.scroll <= padding {
self.scroll += SCROLL_LINES;
if self.scroll > padding {
self.scroll = padding;
if !self.links.is_empty() {
self.link = self.links.len() - 1;
return Action::Redraw;
}
}
5 years ago
if let Some(dir) = self.link_visibility(self.link) {
match dir {
5 years ago
LinkPos::Above => {
5 years ago
let scroll = self.scroll;
5 years ago
if let Some(&pos) =
5 years ago
self.links.iter().skip(self.link).find(|&&i| i >= scroll)
5 years ago
{
5 years ago
self.link = self.lines.get(pos).unwrap().link;
5 years ago
}
}
5 years ago
LinkPos::Below => {}
LinkPos::Visible => {}
5 years ago
}
}
5 years ago
Action::Redraw
} else {
Action::None
}
}
fn action_page_up(&mut self) -> Action {
if self.scroll > 0 {
if self.scroll > SCROLL_LINES {
self.scroll -= SCROLL_LINES;
} else {
5 years ago
self.scroll = 0;
}
5 years ago
if self.link == 0 {
return Action::Redraw;
}
if let Some(dir) = self.link_visibility(self.link) {
match dir {
5 years ago
LinkPos::Below => {
5 years ago
let scroll = self.scroll;
if let Some(&pos) = self
5 years ago
.links
5 years ago
.iter()
.take(self.link)
.rev()
5 years ago
.find(|&&i| i < (self.rows() + scroll - 1))
5 years ago
{
5 years ago
self.link = self.lines.get(pos).unwrap().link;
5 years ago
}
}
5 years ago
LinkPos::Above => {}
LinkPos::Visible => {}
5 years ago
}
}
5 years ago
Action::Redraw
5 years ago
} else if self.link > 0 {
self.link = 0;
Action::Redraw
5 years ago
} else {
Action::None
}
}
5 years ago
fn action_up(&mut self) -> Action {
5 years ago
if self.link == 0 {
5 years ago
return if self.scroll > 0 {
self.scroll -= 1;
Action::Redraw
} else {
Action::None
};
5 years ago
}
// if text is entered, find previous match
if !self.input.is_empty() {
if let Some(pos) = self.rlink_matching(self.link, &self.input) {
return self.action_select_link(pos);
} else {
return Action::None;
}
}
5 years ago
let new_link = self.link - 1;
5 years ago
if let Some(dir) = self.link_visibility(new_link) {
5 years ago
match dir {
5 years ago
LinkPos::Above => {
5 years ago
// scroll up by 1
if self.scroll > 0 {
self.scroll -= 1;
}
// select it if it's visible now
if self.is_visible(new_link) {
self.link = new_link;
5 years ago
}
}
5 years ago
LinkPos::Below => {
5 years ago
// jump to link....
5 years ago
if let Some(&pos) = self.links.get(new_link) {
5 years ago
self.scroll = pos;
5 years ago
self.link = new_link;
}
}
5 years ago
LinkPos::Visible => {
5 years ago
// select next link up
self.link = new_link;
5 years ago
// scroll if we are within 5 lines of the top
if let Some(&pos) = self.links.get(self.link) {
if self.scroll > 0 && pos < self.scroll + 5 {
5 years ago
self.scroll -= 1;
}
}
5 years ago
}
}
5 years ago
Action::Redraw
} else {
Action::None
}
}
// how many rows to pad with blank lines at the end of the page
fn padding_bottom(&self) -> usize {
4 years ago
let padding = (self.rows() as f64 * 0.9) as usize;
5 years ago
if self.lines.len() > padding {
self.lines.len() - padding
} else {
0
}
}
// search through links to find a match based on the pattern,
// starting at link position `start`. returns the link position.
fn link_matching(&self, start: usize, pattern: &str) -> Option<usize> {
self.link_match_with_iter(pattern, &mut self.links.iter().skip(start))
}
// search backwards
fn rlink_matching(&self, start: usize, pattern: &str) -> Option<usize> {
self.link_match_with_iter(pattern, &mut self.links.iter().take(start).rev())
}
fn link_match_with_iter<'a, T>(&self, pattern: &str, it: &mut T) -> Option<usize>
where
T: std::iter::Iterator<Item = &'a usize>,
{
let pattern = pattern.to_ascii_lowercase();
for &pos in it {
let line = self.lines.get(pos)?;
if line.name.to_ascii_lowercase().contains(&pattern) {
return Some(line.link);
}
}
None
}
5 years ago
fn action_down(&mut self) -> Action {
5 years ago
let new_link = self.link + 1;
5 years ago
// no links or final link selected already
if self.links.is_empty() || new_link >= self.links.len() {
5 years ago
// if there are more rows, scroll down
if self.lines.len() >= self.rows() && self.scroll < self.padding_bottom() {
5 years ago
self.scroll += 1;
return Action::Redraw;
} else {
return Action::None;
}
5 years ago
}
// if text is entered, find next match
if !self.input.is_empty() {
if let Some(pos) = self.link_matching(self.link + 1, &self.input) {
return self.action_select_link(pos);
} else {
return Action::None;
}
}
5 years ago
if self.link < self.links.len() {
5 years ago
if let Some(dir) = self.link_visibility(new_link) {
match dir {
5 years ago
LinkPos::Above => {
// jump to link....
5 years ago
if let Some(&pos) = self.links.get(new_link) {
5 years ago
self.scroll = pos;
5 years ago
self.link = new_link;
}
}
5 years ago
LinkPos::Below => {
// scroll down by 1
self.scroll += 1;
// select it if it's visible now
if self.is_visible(new_link) {
self.link = new_link;
}
}
5 years ago
LinkPos::Visible => {
// select next link down
5 years ago
self.link = new_link;
5 years ago
// scroll if we are within 5 lines of the end
if let Some(&pos) = self.links.get(self.link) {
if self.lines.len() >= self.rows() // dont scroll if content too small
&& pos >= self.scroll + self.rows() - 6
{
5 years ago
self.scroll += 1;
}
}
}
}
Action::Redraw
} else {
Action::None
}
5 years ago
} else {
Action::None
}
}
5 years ago
5 years ago
fn action_select_link(&mut self, link: usize) -> Action {
if let Some(&pos) = self.links.get(link) {
5 years ago
if self.link_visibility(link) != Some(LinkPos::Visible) {
if pos > 5 {
self.scroll = pos - 5;
} else {
self.scroll = 0;
5 years ago
}
}
self.link = link;
5 years ago
Action::Redraw
} else {
Action::None
}
}
5 years ago
fn action_follow_link(&mut self, link: usize) -> Action {
5 years ago
self.action_select_link(link);
self.action_open()
5 years ago
}
fn scroll_to(&mut self, link: usize) -> Action {
if self.link_visibility(self.link) != Some(LinkPos::Visible) {
if let Some(&pos) = self.links.get(link) {
if pos > 5 {
self.scroll = pos - 5;
} else {
self.scroll = 0;
}
return Action::Redraw;
}
}
Action::None
}
fn action_open(&mut self) -> Action {
// if the selected link isn't visible, jump to it:
if !self.is_visible(self.link) {
return self.scroll_to(self.link);
}
self.searching = false;
5 years ago
self.input.clear();
5 years ago
if let Some(line) = self.link(self.link) {
let url = line.url.to_string();
let (typ, _, _, _) = gopher::parse_url(&url);
match typ {
Type::Search => {
let prompt = format!("{}> ", line.name);
Action::Prompt(
prompt.clone(),
Box::new(move |query| {
Action::Open(
format!("{}{}", prompt, query),
format!("{}?{}", url, query),
)
}),
)
5 years ago
}
Type::Error => Action::Error(line.name.to_string()),
Type::Telnet => Action::Error("Telnet support coming soon".into()),
_ => Action::Open(line.name.to_string(), url),
5 years ago
}
} else {
Action::None
}
}
// self.searching == true
fn process_search_mode_char(&mut self, c: char) -> Action {
if c == '\n' {
if self.link_matching(0, &self.input).is_some() {
return self.action_open();
} else {
let input = self.input.clone();
self.searching = false;
self.input.clear();
return Action::Error(format!("No links match: {}", input));
}
}
self.input.push(c);
if let Some(pos) = self.link_matching(0, &self.input) {
self.action_select_link(pos)
} else {
self.redraw_input()
}
}
5 years ago
fn process_key(&mut self, key: Key) -> Action {
if self.searching {
if let Key::Char(c) = key {
return self.process_search_mode_char(c);
}
}
5 years ago
match key {
Key::Char('\n') => self.action_open(),
Key::Up | Key::Ctrl('p') | Key::Char('p') | Key::Ctrl('k') | Key::Char('k') => {
self.action_up()
}
Key::Down | Key::Ctrl('n') | Key::Char('n') | Key::Ctrl('j') | Key::Char('j') => {
self.action_down()
}
4 years ago
Key::PageUp | Key::Ctrl('-') | Key::Char('-') => self.action_page_up(),
Key::PageDown | Key::Ctrl(' ') | Key::Char(' ') => self.action_page_down(),
Key::Char('f') | Key::Ctrl('f') | Key::Char('/') | Key::Char('i') | Key::Ctrl('i') => {
self.searching = true;
Action::Redraw
}
Key::Char('w') | Key::Ctrl('w') => {
5 years ago
self.wide = !self.wide;
Action::Redraw
}
5 years ago
Key::Backspace | Key::Delete => {
if self.searching {
5 years ago
self.input.pop();
self.redraw_input()
} else {
Action::Keypress(key)
5 years ago
}
}
Key::Esc | Key::Ctrl('c') => {
if self.searching {
if self.input.is_empty() {
self.searching = false;
} else {
self.input.clear();
}
5 years ago
self.redraw_input()
5 years ago
} else {
Action::Keypress(key)
5 years ago
}
}
Key::Char(c) => {
if !c.is_digit(10) {
return Action::Keypress(key);
}
5 years ago
self.input.push(c);
// jump to number
let s = self
.input
.chars()
.take(self.input.len())
.collect::<String>();
if let Ok(num) = s.parse::<usize>() {
if num > 0 && num <= self.links.len() {
if self.links.len() < (num * 10) {
return self.action_follow_link(num - 1);
} else {
return self.action_select_link(num - 1);
5 years ago
}
}
}
Action::None
5 years ago
}
_ => Action::Keypress(key),
5 years ago
}
}
5 years ago
// parse gopher response into a Menu object
pub fn parse(url: String, raw: String) -> Menu {
let mut lines = vec![];
let mut links = vec![];
let mut longest = 0;
for line in raw.split_terminator('\n') {
if let Some(c) = line.chars().nth(0) {
5 years ago
let typ = match Type::from(c) {
5 years ago
Some(t) => t,
None => continue,
};
// assemble line info
let parts: Vec<&str> = line.split_terminator('\t').collect();
let mut name = String::from("");
if !parts[0].is_empty() {
name.push_str(&parts[0][1..]);
}
if name.len() > longest {
longest = name.len();
}
// check for URL:<url> syntax
5 years ago
if parts.len() > 1 && parts[1].starts_with("URL:") {
lines.push(Line {
name,
url: parts[1].trim_start_matches("URL:").to_string(),
typ,
link: links.len(),
5 years ago
});
if typ != Type::Info {
5 years ago
links.push(lines.len() - 1);
}
5 years ago
continue;
5 years ago
}
// assemble regular, gopher-style URL
let mut url = String::from("gopher://");
// host
5 years ago
if parts.len() > 2 {
url.push_str(parts[2]);
5 years ago
}
// port
if parts.len() > 3 {
let port = parts[3].trim_end_matches('\r');
if port != "70" {
url.push(':');
url.push_str(parts[3].trim_end_matches('\r'));
}
}
// auto-prepend gopher type to selector
if let Some(first_char) = parts[0].chars().nth(0) {
url.push_str("/");
url.push(first_char);
// add trailing / if the selector is blank
5 years ago
if parts.is_empty() || parts.len() > 1 && parts[1].is_empty() {
5 years ago
url.push('/');
}
}
// selector
5 years ago
if parts.len() > 1 {
let mut sel = parts[1].to_string();
if !sel.starts_with('/') {
sel.insert(0, '/');
}
url.push_str(&sel);
5 years ago
}
lines.push(Line {
name,
url,
typ,
link: links.len(),
5 years ago
});
if typ != Type::Info {
5 years ago
links.push(lines.len() - 1);
}
5 years ago
}
}
Menu {
url,
lines,
links,
longest,
raw,
input: String::new(),
link: 0,
scroll: 0,
searching: false,
5 years ago
size: (0, 0),
5 years ago
wide: false,
5 years ago
}
}
}
#[cfg(test)]
mod tests {
use super::*;
macro_rules! parse {
($s:literal) => {
Menu::parse("test".to_string(), $s.to_string());
};
}
#[test]
fn test_simple_menu() {
let menu = parse!(
"
i---------------------------------------------------------
1SDF PHLOGOSPHERE (297 phlogs) /phlogs/ gopher.club 70
1SDF GOPHERSPACE (1303 ACTIVE users) /maps/ sdf.org 70
i---------------------------------------------------------
"
);
assert_eq!(menu.lines.len(), 4);
assert_eq!(menu.links.len(), 2);
assert_eq!(menu.lines[1].url, "gopher://gopher.club/1/phlogs/");
assert_eq!(menu.lines[2].url, "gopher://sdf.org/1/maps/");
}
#[test]
fn test_no_path() {
let menu = parse!("1Circumlunar Space circumlunar.space 70");
assert_eq!(menu.links.len(), 1);
assert_eq!(menu.lines[0].url, "gopher://circumlunar.space/1/");
}
5 years ago
#[test]
fn test_find_links() {
let mut menu = parse!(
"
i________________________________G_O_P_H_E_R_______________________________ Err bitreich.org 70
iHelp us building a nice sorted directory of the gopherspace: Err bitreich.org 70
1THE GOPHER LAWN THE gopher directory /lawn bitreich.org 70
i Err bitreich.org 70
1Gopher Tutorials Project /tutorials bitreich.org 70
i Err bitreich.org 70
iRun more gopherholes on tor! Err bitreich.org 70
1The Gopher Onion Initiative /onion bitreich.org 70
i Err bitreich.org 70
1You are missing a gopher client? Use our kiosk mode. /kiosk bitreich.org 70
hssh kiosk@bitreich.org URL:ssh://kiosk@bitreich.org bitreich.org 70
i Err bitreich.org 70
iFurther gopherspace links: Err bitreich.org 70
1The Gopher Project / gopherproject.org 70
7Search the global gopherspace at Veronica II /v2/vs gopher.floodgap.com 70
i Err bitreich.org 70
iBest viewed using: Err bitreich.org 70
1sacc /scm/sacc bitreich.org 70
1clic /scm/clic bitreich.org 70
i Err bitreich.org 70"
);
menu.term_size(80, 40);
assert_eq!(menu.links.len(), 9);
assert_eq!(menu.link(0).unwrap().url, "gopher://bitreich.org/1/lawn");
assert_eq!(
menu.link(1).unwrap().url,
"gopher://bitreich.org/1/tutorials"
);
assert_eq!(menu.link(2).unwrap().url, "gopher://bitreich.org/1/onion");
assert_eq!(menu.link(3).unwrap().url, "gopher://bitreich.org/1/kiosk");
assert_eq!(menu.link, 0);
let ssh = menu.link(4).unwrap();
assert_eq!(ssh.url, "ssh://kiosk@bitreich.org");
assert_eq!(ssh.typ, Type::HTML);
5 years ago
menu.action_down();
assert_eq!(menu.link, 1);
assert_eq!(menu.link(menu.link).unwrap().link, 1);
menu.action_down();
assert_eq!(menu.link, 2);
assert_eq!(menu.link(menu.link).unwrap().link, 2);
menu.action_page_down();
assert_eq!(menu.link, 8);
assert_eq!(menu.link(menu.link).unwrap().link, 8);
menu.action_up();
assert_eq!(menu.link, 7);
assert_eq!(menu.link(menu.link).unwrap().link, 7);
assert_eq!(menu.scroll, 0);
menu.action_page_up();
assert_eq!(menu.link, 0);
assert_eq!(menu.link(menu.link).unwrap().link, 0);
}
5 years ago
}