mirror of
https://github.com/xvxx/phetch
synced 2024-11-10 13:10:54 +00:00
902 lines
29 KiB
Rust
902 lines
29 KiB
Rust
use crate::gopher::{self, Type};
|
||
use crate::ui::{Action, Key, View, MAX_COLS, SCROLL_LINES};
|
||
use std::fmt;
|
||
use termion::{clear, cursor};
|
||
|
||
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 tls: bool, // retrieved via tls?
|
||
pub size: (usize, usize), // cols, rows
|
||
pub wide: bool, // in wide mode?
|
||
}
|
||
|
||
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)]
|
||
enum LinkPos {
|
||
Above,
|
||
Below,
|
||
Visible,
|
||
}
|
||
|
||
impl fmt::Display for Menu {
|
||
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||
write!(f, "{}", self.url())
|
||
}
|
||
}
|
||
|
||
impl View for Menu {
|
||
fn is_tls(&self) -> bool {
|
||
self.tls
|
||
}
|
||
|
||
fn raw(&self) -> String {
|
||
self.raw.to_string()
|
||
}
|
||
|
||
fn render(&self) -> String {
|
||
self.render_lines()
|
||
}
|
||
|
||
fn respond(&mut self, key: Key) -> Action {
|
||
self.process_key(key)
|
||
}
|
||
|
||
fn term_size(&mut self, cols: usize, rows: usize) {
|
||
self.size = (cols, rows);
|
||
}
|
||
|
||
fn url(&self) -> String {
|
||
self.url.to_string()
|
||
}
|
||
}
|
||
|
||
impl Menu {
|
||
pub fn from(url: String, response: String, tls: bool) -> Menu {
|
||
let mut menu = Self::parse(url, response);
|
||
menu.tls = tls;
|
||
menu
|
||
}
|
||
|
||
fn cols(&self) -> usize {
|
||
self.size.0
|
||
}
|
||
|
||
fn rows(&self) -> usize {
|
||
self.size.1
|
||
}
|
||
|
||
/// Calculated size of left margin.
|
||
fn indent(&self) -> usize {
|
||
if self.wide {
|
||
return 0;
|
||
}
|
||
let cols = self.cols();
|
||
let longest = if self.longest > MAX_COLS {
|
||
MAX_COLS
|
||
} else {
|
||
self.longest
|
||
};
|
||
if longest > cols {
|
||
0
|
||
} else {
|
||
let left = (cols - longest) / 2;
|
||
if left > 6 {
|
||
left - 6
|
||
} else {
|
||
0
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Find a link by its link index.
|
||
fn link(&self, i: usize) -> Option<&Line> {
|
||
let line = self.links.get(i)?;
|
||
self.lines.get(*line)
|
||
}
|
||
|
||
/// 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?
|
||
fn link_visibility(&self, i: usize) -> Option<LinkPos> {
|
||
let &pos = self.links.get(i)?;
|
||
Some(if pos < self.scroll {
|
||
LinkPos::Above
|
||
} else if pos >= self.scroll + self.rows() - 1 {
|
||
LinkPos::Below
|
||
} else {
|
||
LinkPos::Visible
|
||
})
|
||
}
|
||
|
||
/// The x and y position of a given link on screen.
|
||
fn screen_coords(&self, link: usize) -> Option<(u16, u16)> {
|
||
if !self.is_visible(link) {
|
||
return None;
|
||
}
|
||
let &pos = self.links.get(link)?;
|
||
let x = self.indent() + 1;
|
||
let y = if self.scroll > pos {
|
||
pos + 1
|
||
} else {
|
||
pos + 1 - self.scroll
|
||
};
|
||
|
||
Some((x as u16, y as u16))
|
||
}
|
||
|
||
fn render_lines(&self) -> String {
|
||
let mut out = String::new();
|
||
|
||
macro_rules! push {
|
||
($c:expr, $e:expr) => {{
|
||
out.push_str("\x1b[");
|
||
out.push_str($c);
|
||
out.push_str("m");
|
||
out.push_str(&$e);
|
||
out.push_str("\x1b[0m");
|
||
}};
|
||
}
|
||
|
||
let iter = self.lines.iter().skip(self.scroll).take(self.rows() - 1);
|
||
let indent = self.indent();
|
||
let left_margin = " ".repeat(indent);
|
||
|
||
for line in iter {
|
||
out.push_str(&left_margin);
|
||
|
||
if line.typ == Type::Info {
|
||
out.push_str(" ");
|
||
} else {
|
||
if line.link == self.link {
|
||
out.push_str("\x1b[97;1m*\x1b[0m")
|
||
} else {
|
||
out.push(' ');
|
||
}
|
||
out.push(' ');
|
||
out.push_str("\x1b[95m");
|
||
if line.link < 9 {
|
||
out.push(' ');
|
||
}
|
||
let num = (line.link + 1).to_string();
|
||
out.push_str(&num);
|
||
out.push_str(".\x1b[0m ");
|
||
}
|
||
|
||
// truncate long lines, instead of wrapping
|
||
let name = if line.name.len() > MAX_COLS {
|
||
line.name.chars().take(MAX_COLS).collect::<String>()
|
||
} else {
|
||
line.name.to_string()
|
||
};
|
||
match line.typ {
|
||
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),
|
||
Type::Telnet => push!("4;97;90", name),
|
||
typ if typ.is_download() => push!("4;97", name),
|
||
typ if !typ.is_supported() => push!("107;91", name),
|
||
_ => push!("0", name),
|
||
}
|
||
|
||
// clear rest of line
|
||
out.push_str(&format!("{}", clear::UntilNewline));
|
||
out.push_str("\r\n");
|
||
}
|
||
|
||
// clear remainder of screen
|
||
out.push_str(&format!("{}", clear::AfterCursor));
|
||
|
||
out
|
||
}
|
||
|
||
/// Clear and re-draw the cursor.
|
||
fn reset_cursor(&mut self, old_link: usize) -> Action {
|
||
if self.links.is_empty() {
|
||
return Action::None;
|
||
}
|
||
let mut out = String::new();
|
||
if let Some(clear) = self.clear_cursor(old_link) {
|
||
out.push_str(clear.as_ref());
|
||
}
|
||
if let Some(cursor) = self.draw_cursor() {
|
||
out.push_str(cursor.as_ref());
|
||
}
|
||
Action::Draw(out)
|
||
}
|
||
|
||
/// Clear the cursor, if it's on screen.
|
||
fn clear_cursor(&self, link: usize) -> Option<String> {
|
||
if self.links.is_empty() || !self.is_visible(link) {
|
||
return None;
|
||
}
|
||
let (x, y) = self.screen_coords(link)?;
|
||
Some(format!("{} {}", cursor::Goto(x, y), cursor::Hide))
|
||
}
|
||
|
||
/// Print this string to draw the cursor on screen.
|
||
/// Returns None if no is link selected.
|
||
fn draw_cursor(&self) -> Option<String> {
|
||
if self.links.is_empty() {
|
||
return None;
|
||
}
|
||
let (x, y) = self.screen_coords(self.link)?;
|
||
Some(format!(
|
||
"{}\x1b[97;1m*\x1b[0m{}",
|
||
cursor::Goto(x, y),
|
||
cursor::Hide
|
||
))
|
||
}
|
||
|
||
/// User input field.
|
||
fn render_input(&self) -> String {
|
||
format!("Find: {}{}", self.input, cursor::Show)
|
||
}
|
||
|
||
fn redraw_input(&self) -> Action {
|
||
if self.searching {
|
||
Action::Status(self.render_input())
|
||
} else {
|
||
Action::Status(format!("{}", cursor::Hide))
|
||
}
|
||
}
|
||
|
||
/// Scroll down by SCROLL_LINES, if possible.
|
||
fn action_page_down(&mut self) -> Action {
|
||
// If there are fewer menu items than screen lines, just
|
||
// select the final link and do nothing else.
|
||
if self.lines.len() < self.rows() {
|
||
if !self.links.is_empty() {
|
||
self.link = self.links.len() - 1;
|
||
return Action::Redraw;
|
||
}
|
||
return Action::None;
|
||
}
|
||
|
||
// If we've already scrolled too far, select the final link
|
||
// and do nothing.
|
||
if self.scroll >= self.final_scroll() {
|
||
self.scroll = self.final_scroll();
|
||
self.link = self.links.len() - 1;
|
||
return Action::Redraw;
|
||
}
|
||
|
||
// Scroll...
|
||
self.scroll += SCROLL_LINES;
|
||
|
||
// ...but don't go past the final line.
|
||
if self.scroll > self.final_scroll() {
|
||
self.scroll = self.final_scroll();
|
||
}
|
||
|
||
// If the selected link isn't visible...
|
||
if Some(LinkPos::Above) == self.link_visibility(self.link) {
|
||
// ...find the next one that is.
|
||
if let Some(&next_link_pos) = self
|
||
.links
|
||
.iter()
|
||
.skip(self.link + 1)
|
||
.find(|&&i| i >= self.scroll)
|
||
{
|
||
if let Some(next_link_line) = self.lines.get(next_link_pos) {
|
||
self.link = next_link_line.link;
|
||
}
|
||
}
|
||
}
|
||
|
||
Action::Redraw
|
||
}
|
||
|
||
fn action_page_up(&mut self) -> Action {
|
||
if self.scroll > 0 {
|
||
if self.scroll > SCROLL_LINES {
|
||
self.scroll -= SCROLL_LINES;
|
||
} else {
|
||
self.scroll = 0;
|
||
}
|
||
if self.link == 0 {
|
||
return Action::Redraw;
|
||
}
|
||
if let Some(dir) = self.link_visibility(self.link) {
|
||
match dir {
|
||
LinkPos::Below => {
|
||
let scroll = self.scroll;
|
||
if let Some(&pos) = self
|
||
.links
|
||
.iter()
|
||
.take(self.link)
|
||
.rev()
|
||
.find(|&&i| i < (self.rows() + scroll - 1))
|
||
{
|
||
self.link = self.lines.get(pos).unwrap().link;
|
||
}
|
||
}
|
||
LinkPos::Above => {}
|
||
LinkPos::Visible => {}
|
||
}
|
||
}
|
||
Action::Redraw
|
||
} else if self.link > 0 {
|
||
self.link = 0;
|
||
Action::Redraw
|
||
} else {
|
||
Action::None
|
||
}
|
||
}
|
||
|
||
fn action_up(&mut self) -> Action {
|
||
// no links, just scroll up
|
||
if self.link == 0 {
|
||
return if self.scroll > 0 {
|
||
self.scroll -= 1;
|
||
Action::Redraw
|
||
} else if !self.links.is_empty() {
|
||
self.link = self.links.len() - 1;
|
||
self.scroll_to(self.link);
|
||
Action::Redraw
|
||
} else {
|
||
Action::None
|
||
};
|
||
}
|
||
|
||
// if text is entered, find previous match
|
||
if self.searching && !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;
|
||
}
|
||
}
|
||
|
||
let new_link = self.link - 1;
|
||
if let Some(dir) = self.link_visibility(new_link) {
|
||
match dir {
|
||
LinkPos::Above => {
|
||
// 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;
|
||
}
|
||
}
|
||
LinkPos::Below => {
|
||
// jump to link....
|
||
if let Some(&pos) = self.links.get(new_link) {
|
||
self.scroll = pos;
|
||
self.link = new_link;
|
||
}
|
||
}
|
||
LinkPos::Visible => {
|
||
// select next link up
|
||
let old_link = self.link;
|
||
self.link = new_link;
|
||
// 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 {
|
||
self.scroll -= 1;
|
||
} else {
|
||
// otherwise redraw just the cursor
|
||
return self.reset_cursor(old_link);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Action::Redraw
|
||
} else {
|
||
Action::None
|
||
}
|
||
}
|
||
|
||
/// Final `self.scroll` value.
|
||
fn final_scroll(&self) -> usize {
|
||
let padding = (self.rows() as f64 * 0.9) as usize;
|
||
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 through all links.
|
||
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
|
||
}
|
||
|
||
fn action_down(&mut self) -> Action {
|
||
let new_link = self.link + 1;
|
||
|
||
// no links or final link selected already
|
||
if self.links.is_empty() || new_link >= self.links.len() {
|
||
// if there are more rows, scroll down
|
||
if self.lines.len() >= self.rows() && self.scroll < self.final_scroll() {
|
||
self.scroll += 1;
|
||
return Action::Redraw;
|
||
} else if !self.links.is_empty() {
|
||
// wrap around
|
||
self.link = 0;
|
||
self.scroll = 0;
|
||
return Action::Redraw;
|
||
}
|
||
}
|
||
|
||
// if text is entered, find next match
|
||
if self.searching && !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;
|
||
}
|
||
}
|
||
|
||
if self.link < self.links.len() {
|
||
if let Some(dir) = self.link_visibility(new_link) {
|
||
match dir {
|
||
LinkPos::Above => {
|
||
// jump to link....
|
||
if let Some(&pos) = self.links.get(new_link) {
|
||
self.scroll = pos;
|
||
self.link = new_link;
|
||
}
|
||
}
|
||
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;
|
||
}
|
||
}
|
||
LinkPos::Visible => {
|
||
// link is visible, so select it
|
||
if let Some(&pos) = self.links.get(self.link) {
|
||
let old_link = self.link;
|
||
self.link = new_link;
|
||
|
||
// scroll if we are within 5 lines of the end
|
||
if self.lines.len() >= self.rows() // dont scroll if content too small
|
||
&& pos >= self.scroll + self.rows() - 6
|
||
{
|
||
self.scroll += 1;
|
||
} else {
|
||
// otherwise try to just re-draw the cursor
|
||
return self.reset_cursor(old_link);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Action::Redraw
|
||
} else {
|
||
Action::None
|
||
}
|
||
} else {
|
||
Action::None
|
||
}
|
||
}
|
||
|
||
/// Select and optionally scroll to a link.
|
||
fn action_select_link(&mut self, link: usize) -> Action {
|
||
if let Some(&pos) = self.links.get(link) {
|
||
let old_link = self.link;
|
||
self.link = link;
|
||
if self.is_visible(link) {
|
||
if !self.input.is_empty() {
|
||
Action::List(vec![self.redraw_input(), self.reset_cursor(old_link)])
|
||
} else {
|
||
self.reset_cursor(old_link)
|
||
}
|
||
} else {
|
||
if pos > 5 {
|
||
self.scroll = pos - 5;
|
||
} else {
|
||
self.scroll = 0;
|
||
}
|
||
if !self.input.is_empty() {
|
||
Action::List(vec![self.redraw_input(), Action::Redraw])
|
||
} else {
|
||
Action::Redraw
|
||
}
|
||
}
|
||
} else {
|
||
Action::None
|
||
}
|
||
}
|
||
|
||
/// Select and open link.
|
||
fn action_follow_link(&mut self, link: usize) -> Action {
|
||
self.action_select_link(link);
|
||
self.action_open()
|
||
}
|
||
|
||
/// Scroll to a link if it's not visible.
|
||
fn scroll_to(&mut self, link: usize) -> Action {
|
||
if !self.is_visible(link) {
|
||
if let Some(&pos) = self.links.get(link) {
|
||
if pos > 5 {
|
||
self.scroll = pos - 5;
|
||
} else {
|
||
self.scroll = 0;
|
||
}
|
||
if self.scroll > self.final_scroll() {
|
||
self.scroll = self.final_scroll();
|
||
}
|
||
return Action::Redraw;
|
||
}
|
||
}
|
||
Action::None
|
||
}
|
||
|
||
/// Open the currently selected link.
|
||
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;
|
||
self.input.clear();
|
||
|
||
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),
|
||
)
|
||
}),
|
||
)
|
||
}
|
||
Type::Error => Action::Error(line.name.to_string()),
|
||
Type::Telnet => Action::Error("Telnet support coming soon".into()),
|
||
t if !t.is_supported() => Action::Error(format!("{:?} not supported", t)),
|
||
_ => Action::Open(line.name.to_string(), url),
|
||
}
|
||
} 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()
|
||
}
|
||
}
|
||
|
||
/// Respond to user input.
|
||
fn process_key(&mut self, key: Key) -> Action {
|
||
if self.searching {
|
||
if let Key::Char(c) = key {
|
||
return self.process_search_mode_char(c);
|
||
}
|
||
}
|
||
|
||
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()
|
||
}
|
||
Key::PageUp | Key::Ctrl('-') | Key::Char('-') => self.action_page_up(),
|
||
Key::PageDown | Key::Ctrl(' ') | Key::Char(' ') => self.action_page_down(),
|
||
Key::Home => {
|
||
self.scroll = 0;
|
||
self.link = 0;
|
||
Action::Redraw
|
||
}
|
||
Key::End => {
|
||
self.scroll = self.final_scroll();
|
||
if !self.links.is_empty() {
|
||
self.link = self.links.len() - 1;
|
||
}
|
||
Action::Redraw
|
||
}
|
||
Key::Char('f') | Key::Ctrl('f') | Key::Char('/') | Key::Char('i') | Key::Ctrl('i') => {
|
||
self.searching = true;
|
||
self.input.clear();
|
||
self.redraw_input()
|
||
}
|
||
Key::Char('w') | Key::Ctrl('w') => {
|
||
self.wide = !self.wide;
|
||
Action::Redraw
|
||
}
|
||
Key::Backspace | Key::Delete => {
|
||
if self.searching {
|
||
self.input.pop();
|
||
self.redraw_input()
|
||
} else {
|
||
Action::Keypress(key)
|
||
}
|
||
}
|
||
Key::Esc | Key::Ctrl('c') => {
|
||
if self.searching {
|
||
if self.input.is_empty() {
|
||
self.searching = false;
|
||
} else {
|
||
self.input.clear();
|
||
}
|
||
self.redraw_input()
|
||
} else {
|
||
Action::Keypress(key)
|
||
}
|
||
}
|
||
Key::Char(c) => {
|
||
if !c.is_digit(10) {
|
||
return Action::Keypress(key);
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
}
|
||
|
||
Action::None
|
||
}
|
||
_ => Action::Keypress(key),
|
||
}
|
||
}
|
||
|
||
/// 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) {
|
||
let typ = match Type::from(c) {
|
||
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..].trim_end_matches('\r'));
|
||
}
|
||
if name.len() > longest {
|
||
longest = name.len();
|
||
}
|
||
// check for URL:<url> syntax
|
||
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(),
|
||
});
|
||
if typ != Type::Info {
|
||
links.push(lines.len() - 1);
|
||
}
|
||
continue;
|
||
}
|
||
|
||
// assemble regular, gopher-style URL
|
||
let mut url = String::from("gopher://");
|
||
// host
|
||
if parts.len() > 2 {
|
||
url.push_str(parts[2]);
|
||
}
|
||
// 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'));
|
||
}
|
||
}
|
||
// selector
|
||
if parts.len() > 1 {
|
||
let sel = parts[1].to_string();
|
||
if !sel.is_empty() {
|
||
// auto-prepend gopher type to selector
|
||
if let Some(first_char) = parts[0].chars().nth(0) {
|
||
url.push_str("/");
|
||
url.push(first_char);
|
||
}
|
||
url.push_str(&sel);
|
||
}
|
||
}
|
||
lines.push(Line {
|
||
name,
|
||
url,
|
||
typ,
|
||
link: links.len(),
|
||
});
|
||
if typ != Type::Info {
|
||
links.push(lines.len() - 1);
|
||
}
|
||
}
|
||
}
|
||
|
||
Menu {
|
||
url,
|
||
lines,
|
||
links,
|
||
longest,
|
||
raw,
|
||
input: String::new(),
|
||
link: 0,
|
||
scroll: 0,
|
||
searching: false,
|
||
size: (0, 0),
|
||
tls: false,
|
||
wide: false,
|
||
}
|
||
}
|
||
}
|
||
|
||
#[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
|
||
1Geosphere Geosphere earth.rice.edu
|
||
i---------------------------------------------------------
|
||
"
|
||
);
|
||
assert_eq!(menu.lines.len(), 5);
|
||
assert_eq!(menu.links.len(), 3);
|
||
assert_eq!(menu.lines[1].url, "gopher://gopher.club/1/phlogs/");
|
||
assert_eq!(menu.lines[2].url, "gopher://sdf.org/1/maps/");
|
||
assert_eq!(menu.lines[3].url, "gopher://earth.rice.edu/1Geosphere");
|
||
}
|
||
|
||
#[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");
|
||
}
|
||
|
||
#[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);
|
||
|
||
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);
|
||
}
|
||
}
|