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

1207 lines
38 KiB
Rust

4 years ago
//! The Menu is a View representing a Gopher menu. It renders the
//! colorful representation, manages the cursor and selection state,
//! and responds to input like the UP and DOWN arrows or other key
//! combinations.
//!
//! The Menu doesn't draw or perform any actions on its own, instead
//! it returns an Action to the UI representing its intent.
use crate::{
config::SharedConfig as Config,
gopher::{self, Type},
terminal,
ui::{self, Action, Key, View, MAX_COLS},
};
use std::fmt;
4 years ago
/// The Menu holds our Gopher Lines, a list of links, and maintains
/// both where the cursor is on screen and which lines need to be
/// drawn on screen. While the main UI can be used to prompt the user
/// for input, the Menu maintains its own `input` for the "quick
/// navigation" feature using number entry and the "incremental search"
/// (over menu links) feature using text entry.
4 years ago
pub struct Menu {
/// Gopher URL
pub url: String,
/// Lines in the menu. Not all are links. Use the `lines()` iter
/// or `line(N)` or `link(N)` to access one.
spans: Vec<LineSpan>,
/// Indexes of links in the `lines` vector. Pauper's pointers.
pub links: Vec<usize>,
/// Currently selected link. Index of the `links` vec.
pub link: usize,
/// Size of the longest line, for wrapping purposes
pub longest: usize,
/// Actual Gopher response
pub raw: String,
/// User input on a prompt() line
pub input: String,
/// UI mode. Interactive (Run), Printing, Raw mode...
pub mode: ui::Mode,
/// Scrolling offset, in rows. 0 = full screen
pub offset: usize,
/// Incremental search mode?
pub searching: bool,
/// Was this menu retrieved via TLS?
tls: bool,
/// Retrieved via Tor?
tor: bool,
/// Size of the screen currently, cols and rows
pub size: (usize, usize),
/// Wide mode?
wide: bool,
/// Scroll by how many lines?
scroll: usize,
2 years ago
/// Global config
config: Config,
4 years ago
}
/// Represents a line in a Gopher menu. Provides the actual text of
/// the line, vs LineSpan which is just location data.
pub struct Line<'line, 'txt: 'line> {
span: &'line LineSpan,
text: &'txt str,
4 years ago
}
impl<'line, 'txt> Line<'line, 'txt> {
fn new(span: &'line LineSpan, text: &'txt str) -> Line<'line, 'txt> {
Line { span, text }
}
/// Visible line as text. What appeared in the raw Gopher
/// response.
pub fn text(&self) -> &str {
if self.start < self.text_end {
&self.text[self.start + 1..self.text_end]
} else {
""
}
}
/// Truncated version of the line, according to visible characters
/// and MAX_COLS.
pub fn text_truncated(&self) -> String {
self.text().chars().take(self.truncated_len).collect()
}
/// URL for this line, if it's a link.
pub fn url(&self) -> String {
if !self.typ.is_link() || self.text_end >= self.end {
return String::from("");
}
let line = &self.text[self.text_end..self.end].trim_end_matches('\r');
let mut sel = "(null)";
let mut host = "localhost";
let mut port = "70";
for (i, chunk) in line.split('\t').enumerate() {
match i {
0 => {}
1 => sel = chunk,
2 => host = chunk,
3 => port = chunk,
_ => break,
}
}
if self.typ.is_html() {
sel.trim_start_matches('/')
.trim_start_matches("URL:")
.to_string()
} else if self.typ.is_telnet() {
format!("telnet://{}:{}", host, port)
} else {
let mut path = format!("/{}{}", self.typ, sel);
if sel.is_empty() || sel == "/" {
path.clear();
}
if port == "70" {
format!("gopher://{}{}", host, path)
} else {
format!("gopher://{}:{}{}", host, port, path)
}
}
}
}
/// Line wraps LineSpan.
impl<'line, 'txt: 'line> std::ops::Deref for Line<'line, 'txt> {
type Target = LineSpan;
fn deref(&self) -> &Self::Target {
2 years ago
self.span
}
}
/// The LineSpan represents a single line's location in a Gopher menu.
/// It only exists in the context of a Menu struct - its `link`
/// field is its index in the Menu's `links` Vec, and
/// start/end/text_end point to locations in Menu's `raw` Gopher
/// response.
/// You won't really interact with this directly, instead call
/// `menu.lines()` get an iter over `Line` or `menu.line(idx)` to get
/// a single Line.
pub struct LineSpan {
/// Gopher Item Type.
pub typ: Type,
/// Where this line starts in its Menu's `raw` Gopher response.
start: usize,
/// Where this line ends in Menu.raw.
end: usize,
/// Where the text/label of this line ends. Might be the same as
/// `end`, or might be earlier.
text_end: usize,
/// Length of visible text, ignoring ANSI escape codes (colors).
visible_len: usize,
/// How many chars() to grab from text() if we want to only show
/// `MAX_COLS` visible chars on screen, aka ignore ANSI escape
/// codes and colors.
truncated_len: usize,
/// Index of this link in the Menu::links vector, if it's a
/// `gopher::Type.is_link()`
pub link: usize,
}
impl LineSpan {
/// Get the length of this line's text field.
pub fn text_len(&self) -> usize {
self.visible_len
}
}
/// Iterator over (dynamically created) Line structs.
pub struct LinesIter<'menu> {
spans: &'menu [LineSpan],
text: &'menu str,
curr: usize,
}
impl<'menu> LinesIter<'menu> {
fn new(spans: &'menu [LineSpan], text: &'menu str) -> LinesIter<'menu> {
LinesIter {
spans,
text,
curr: 0,
}
}
}
impl<'menu> Iterator for LinesIter<'menu> {
type Item = Line<'menu, 'menu>;
fn next(&mut self) -> Option<Self::Item> {
if self.curr >= self.spans.len() {
None
} else {
let line_with = Line::new(&self.spans[self.curr], self.text);
self.curr += 1;
Some(line_with)
}
}
}
4 years ago
/// Direction of a given link relative to the visible screen.
#[derive(PartialEq)]
4 years ago
enum LinkPos {
Above,
Below,
Visible,
}
4 years ago
impl fmt::Display for Menu {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "{}", self.url())
}
}
4 years ago
impl View for Menu {
fn is_tls(&self) -> bool {
self.tls
}
4 years ago
fn is_tor(&self) -> bool {
self.tor
}
fn raw(&self) -> &str {
self.raw.as_ref()
4 years ago
}
fn render(&mut self) -> String {
self.render_lines()
4 years ago
}
4 years ago
fn respond(&mut self, key: Key) -> Action {
self.process_key(key)
4 years ago
}
fn set_wide(&mut self, wide: bool) {
self.wide = wide;
}
fn wide(&mut self) -> bool {
self.wide
}
4 years ago
fn term_size(&mut self, cols: usize, rows: usize) {
self.size = (cols, rows);
4 years ago
}
4 years ago
fn url(&self) -> &str {
self.url.as_ref()
4 years ago
}
4 years ago
}
4 years ago
impl Menu {
4 years ago
/// Create a representation of a Gopher Menu from a raw Gopher
/// response and a few options.
pub fn from(url: &str, response: String, config: Config, tls: bool) -> Menu {
4 years ago
Menu {
tls,
tor: config.read().unwrap().tor,
wide: config.read().unwrap().wide,
scroll: config.read().unwrap().scroll,
mode: config.read().unwrap().mode,
2 years ago
..parse(url, response, config.clone())
4 years ago
}
4 years ago
}
/// Lines in this menu. Main iterator for getting Line with text.
pub fn lines(&self) -> LinesIter {
LinesIter::new(&self.spans, &self.raw)
}
/// Get a single Line in this menu by index.
pub fn line(&self, idx: usize) -> Option<Line> {
if idx >= self.spans.len() {
None
} else {
Some(Line::new(&self.spans[idx], &self.raw))
}
}
/// Find a link by its link index.
pub fn link(&self, idx: usize) -> Option<Line> {
let line = self.links.get(idx)?;
self.line(*line)
}
fn cols(&self) -> usize {
4 years ago
self.size.0
}
fn rows(&self) -> usize {
self.size.1
}
fn scroll_by(&self) -> usize {
if self.scroll == 0 {
self.rows() - 1
} else {
self.scroll
}
}
/// 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
}
}
}
/// 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?
4 years ago
fn link_visibility(&self, i: usize) -> Option<LinkPos> {
4 years ago
let &pos = self.links.get(i)?;
Some(if pos < self.offset {
4 years ago
LinkPos::Above
} else if pos >= self.offset + self.rows() - 1 {
4 years ago
LinkPos::Below
} else {
4 years ago
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.offset > pos {
pos + 1
} else {
pos + 1 - self.offset
};
Some((x as u16, y as u16))
}
fn render_lines(&mut self) -> String {
4 years ago
let mut out = String::new();
let limit = if self.mode == ui::Mode::Run {
// only show as many lines as screen rows minus one
// (status bar is always last line)
self.rows() - 1
} else {
self.spans.len()
};
let iter = self.lines().skip(self.offset).take(limit);
let indent = self.indent();
let left_margin = " ".repeat(indent);
4 years ago
for line in iter {
out.push_str(&left_margin);
2 years ago
let config = self.config.read().unwrap();
4 years ago
if line.typ == Type::Info {
4 years ago
out.push_str(" ");
4 years ago
} else {
if line.link == self.link && self.show_cursor() {
2 years ago
out.push_str(&config.theme.ui_cursor);
out.push('*');
2 years ago
out.push_str(reset_color!());
4 years ago
} else {
out.push(' ');
}
4 years ago
out.push(' ');
2 years ago
out.push_str(&config.theme.ui_number);
4 years ago
if line.link < 9 {
4 years ago
out.push(' ');
}
let num = (line.link + 1).to_string();
out.push_str(&num);
out.push_str(". ");
2 years ago
out.push_str(reset_color!());
4 years ago
}
4 years ago
// truncate long lines, instead of wrapping
let text = line.text_truncated();
4 years ago
// color the line
if line.typ.is_media() {
2 years ago
out.push_str(&config.theme.item_media);
} else if line.typ.is_download() {
2 years ago
out.push_str(&config.theme.item_download);
} else if !line.typ.is_supported() {
2 years ago
out.push_str(&self.config.read().unwrap().theme.item_unsupported);
} else {
2 years ago
out.push_str(match line.typ {
2 years ago
Type::Text => &config.theme.item_text,
Type::Menu => &config.theme.item_menu,
Type::Info => &config.theme.ui_menu,
Type::HTML => &config.theme.item_external,
Type::Error => &config.theme.item_error,
Type::Telnet => &config.theme.item_telnet,
Type::Search => &config.theme.item_search,
_ => &config.theme.item_error,
});
}
out.push_str(&text);
2 years ago
out.push_str(reset_color!());
// clear rest of line
out.push_str(terminal::ClearUntilNewline.as_ref());
out.push_str("\r\n");
4 years ago
}
// clear remainder of screen
out.push_str(terminal::ClearAfterCursor.as_ref());
4 years ago
out
4 years ago
}
/// 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!("{} {}", terminal::Goto(x, y), terminal::HideCursor))
}
/// 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() || !self.show_cursor() {
return None;
}
let (x, y) = self.screen_coords(self.link)?;
Some(format!(
2 years ago
"{}{}*\x1b[0m{}",
terminal::Goto(x, y),
2 years ago
self.config.read().unwrap().theme.ui_cursor,
terminal::HideCursor
))
}
/// Should we show the cursor? Not when printing.
fn show_cursor(&self) -> bool {
self.mode == ui::Mode::Run
}
/// User input field.
fn render_input(&self) -> String {
format!("Find: {}{}", self.input, terminal::ShowCursor)
}
fn redraw_input(&self) -> Action {
if self.searching {
Action::Status(self.render_input())
} else {
Action::Status(terminal::HideCursor.to_string())
}
4 years ago
}
/// Scroll down by a page, if possible.
4 years ago
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.spans.len() < self.rows() {
4 years ago
if !self.links.is_empty() {
4 years ago
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.offset >= self.final_offset() {
self.offset = self.final_offset();
if !self.links.is_empty() {
self.link = self.links.len() - 1;
}
4 years ago
return Action::Redraw;
}
// Scroll...
self.offset += self.scroll_by();
// ...but don't go past the final line.
if self.offset > self.final_offset() {
self.offset = self.final_offset();
}
// 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.offset)
{
if let Some(next_link_line) = self.line(next_link_pos) {
self.link = next_link_line.link;
4 years ago
}
}
4 years ago
}
Action::Redraw
4 years ago
}
fn action_page_up(&mut self) -> Action {
if self.offset > 0 {
if self.offset > self.scroll_by() {
self.offset -= self.scroll_by();
} else {
self.offset = 0;
4 years ago
}
4 years ago
if self.link == 0 {
return Action::Redraw;
}
if let Some(dir) = self.link_visibility(self.link) {
match dir {
4 years ago
LinkPos::Below => {
let scroll = self.offset;
4 years ago
if let Some(&pos) = self
4 years ago
.links
4 years ago
.iter()
.take(self.link)
.rev()
4 years ago
.find(|&&i| i < (self.rows() + scroll - 1))
4 years ago
{
self.link = self.line(pos).unwrap().link;
4 years ago
}
}
4 years ago
LinkPos::Above => {}
LinkPos::Visible => {}
4 years ago
}
}
4 years ago
Action::Redraw
4 years ago
} else if self.link > 0 {
self.link = 0;
Action::Redraw
4 years ago
} else {
Action::None
}
}
4 years ago
fn action_up(&mut self) -> Action {
// no links, just scroll up
4 years ago
if self.link == 0 {
return if self.offset > 0 {
self.offset -= 1;
4 years ago
Action::Redraw
} else if !self.links.is_empty() {
self.link = self.links.len() - 1;
self.scroll_to(self.link);
Action::Redraw
4 years ago
} else {
Action::None
};
4 years ago
}
// 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;
}
}
4 years ago
let new_link = self.link - 1;
4 years ago
if let Some(dir) = self.link_visibility(new_link) {
4 years ago
match dir {
4 years ago
LinkPos::Above => {
4 years ago
// scroll up by 1
if self.offset > 0 {
self.offset -= 1;
4 years ago
}
// select it if it's visible now
if self.is_visible(new_link) {
self.link = new_link;
4 years ago
}
}
4 years ago
LinkPos::Below => {
4 years ago
// jump to link....
4 years ago
if let Some(&pos) = self.links.get(new_link) {
self.offset = pos;
4 years ago
self.link = new_link;
}
}
4 years ago
LinkPos::Visible => {
4 years ago
// select next link up
let old_link = self.link;
4 years ago
self.link = new_link;
4 years ago
// scroll if we are within 5 lines of the top
if let Some(&pos) = self.links.get(self.link) {
if self.offset > 0 && pos < self.offset + 5 {
self.offset -= 1;
} else {
// otherwise redraw just the cursor
return self.reset_cursor(old_link);
4 years ago
}
}
4 years ago
}
}
4 years ago
Action::Redraw
} else {
Action::None
}
}
/// Final `self.offset` value.
fn final_offset(&self) -> usize {
4 years ago
let padding = (self.rows() as f64 * 0.9) as usize;
if self.spans.len() > padding {
self.spans.len() - padding
} else {
0
}
}
4 years ago
/// 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))
}
4 years ago
/// 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.line(pos)?;
if line.text().to_ascii_lowercase().contains(&pattern) {
return Some(line.link);
}
}
None
}
4 years ago
fn action_down(&mut self) -> Action {
4 years ago
let new_link = self.link + 1;
4 years ago
// no links or final link selected already
if self.links.is_empty() || new_link >= self.links.len() {
4 years ago
// if there are more rows, scroll down
if self.spans.len() >= self.rows() && self.offset < self.final_offset() {
self.offset += 1;
4 years ago
return Action::Redraw;
} else if !self.links.is_empty() {
// wrap around
self.link = 0;
self.offset = 0;
return Action::Redraw;
4 years ago
}
4 years ago
}
// 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;
}
}
4 years ago
if self.link < self.links.len() {
4 years ago
if let Some(dir) = self.link_visibility(new_link) {
match dir {
4 years ago
LinkPos::Above => {
// jump to link....
4 years ago
if let Some(&pos) = self.links.get(new_link) {
self.offset = pos;
4 years ago
self.link = new_link;
}
}
4 years ago
LinkPos::Below => {
// scroll down by 1
self.offset += 1;
// select it if it's visible now
if self.is_visible(new_link) {
self.link = new_link;
}
}
4 years ago
LinkPos::Visible => {
// link is visible, so select it
4 years ago
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.spans.len() >= self.rows() // dont scroll if content too small
&& pos >= self.offset + self.rows() - 6
{
self.offset += 1;
} else {
// otherwise try to just re-draw the cursor
return self.reset_cursor(old_link);
4 years ago
}
}
}
}
Action::Redraw
} else {
Action::None
}
4 years ago
} else {
Action::None
}
}
4 years ago
/// Select and optionally scroll to a link.
4 years ago
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.offset = pos - 5;
} else {
self.offset = 0;
4 years ago
}
if !self.input.is_empty() {
Action::List(vec![self.redraw_input(), Action::Redraw])
} else {
Action::Redraw
}
4 years ago
}
4 years ago
} else {
Action::None
}
}
4 years ago
/// Select and open link.
4 years ago
fn action_follow_link(&mut self, link: usize) -> Action {
4 years ago
self.action_select_link(link);
self.action_open()
4 years ago
}
4 years ago
/// Scroll to a link if it's not visible.
fn scroll_to(&mut self, link: usize) -> Action {
4 years ago
if !self.is_visible(link) {
if let Some(&pos) = self.links.get(link) {
if pos > 5 {
self.offset = pos - 5;
} else {
self.offset = 0;
}
if self.offset > self.final_offset() {
self.offset = self.final_offset();
}
return Action::Redraw;
}
}
Action::None
}
4 years ago
/// 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;
4 years ago
self.input.clear();
4 years ago
if let Some(line) = self.link(self.link) {
let url = line.url();
let typ = gopher::type_for_url(&url);
match typ {
Type::Search => {
let prompt = format!("{}> ", line.text());
Action::Prompt(
prompt.clone(),
Box::new(move |query| {
Action::Open(
format!("{}{}", prompt, query),
format!("{}?{}", url, query),
)
}),
)
4 years ago
}
Type::Error => Action::Error(line.text().to_string()),
t if !t.is_supported() => Action::Error(format!("{:?} not supported", t)),
_ => Action::Open(line.text().to_string(), url),
4 years ago
}
} else {
Action::None
}
}
4 years ago
/// 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()
}
}
4 years ago
/// Respond to user input.
4 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);
}
}
4 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(),
4 years ago
Key::Home => {
self.offset = 0;
self.link = 0;
4 years ago
Action::Redraw
}
Key::End => {
self.offset = self.final_offset();
if !self.links.is_empty() {
self.link = self.links.len() - 1;
}
4 years ago
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()
}
4 years ago
Key::Backspace | Key::Delete => {
if self.searching {
4 years ago
self.input.pop();
self.redraw_input()
} else {
Action::Keypress(key)
4 years ago
}
}
Key::Esc | Key::Ctrl('c') => {
if self.searching {
if self.input.is_empty() {
self.searching = false;
} else {
self.input.clear();
}
4 years ago
self.redraw_input()
4 years ago
} else {
Action::Keypress(key)
4 years ago
}
}
Key::Char(c) => {
2 years ago
if !c.is_ascii_digit() {
return Action::Keypress(key);
}
4 years ago
self.input.push(c);
// jump to number
let s = self
.input
.chars()
.take(self.input.chars().count())
.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);
4 years ago
}
}
}
Action::None
4 years ago
}
_ => Action::Keypress(key),
4 years ago
}
}
}
4 years ago
/// Parse gopher response into a Menu object.
2 years ago
pub fn parse(url: &str, raw: String, config: Config) -> Menu {
let mut spans = vec![];
let mut links = vec![];
let mut longest = 0;
let mut start = 0;
for line in raw.split_terminator('\n') {
// Check for Gopher's weird "end of response" message.
if line == ".\r" || line == "." {
break;
}
2 years ago
if line.is_empty() {
start += 1;
continue;
}
if let Some(mut span) = parse_line(start, &raw) {
if span.text_len() > longest {
longest = span.text_len();
}
if span.typ.is_link() {
span.link = links.len();
links.push(spans.len());
4 years ago
}
spans.push(span);
4 years ago
}
start += line.len() + 1;
}
4 years ago
Menu {
url: url.into(),
spans,
links,
longest,
raw,
input: String::new(),
link: 0,
mode: Default::default(),
offset: 0,
searching: false,
size: (0, 0),
tls: false,
tor: false,
wide: false,
scroll: 0,
2 years ago
config,
4 years ago
}
}
/// Parses a single line from a Gopher menu into a `LineSpan` struct.
pub fn parse_line(start: usize, raw: &str) -> Option<LineSpan> {
if raw.is_empty() || start >= raw.len() {
return None;
}
let line = &raw[start..];
let end = line.find('\n').unwrap_or_else(|| line.chars().count()) + start;
let line = &raw[start..end]; // constrain \t search
let text_end = if let Some(i) = line.find('\t') {
i + start
} else if let Some(i) = line.find('\r') {
i + start
} else {
end
};
let typ = Type::from(line.chars().next()?).unwrap_or(Type::Binary);
let mut truncated_len = if text_end - start > MAX_COLS {
MAX_COLS + 1
} else {
text_end - start
};
let mut visible_len = truncated_len;
// if this line contains colors, calculate the visible length and
// where to truncate when abiding by `MAX_COLS`
4 years ago
if raw[start..text_end].contains("\x1b[") {
let mut is_color = false;
let mut iter = raw[start..text_end].char_indices();
visible_len = 0;
while let Some((i, c)) = iter.next() {
if is_color {
if c == 'm' {
is_color = false;
}
4 years ago
} else if c == '\x1b' {
if let Some((_, '[')) = iter.next() {
is_color = true;
}
4 years ago
} else if visible_len < MAX_COLS {
truncated_len = i;
visible_len += 1;
} else {
truncated_len = i;
visible_len = MAX_COLS + 1;
break;
}
}
}
Some(LineSpan {
start,
end,
text_end,
truncated_len,
visible_len,
typ,
link: 0,
})
}
4 years ago
#[cfg(test)]
mod tests {
use super::*;
macro_rules! parse {
($s:expr) => {
2 years ago
parse("test", $s.to_string(), Config::default())
4 years ago
};
}
#[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
4 years ago
iwacky links
i----------- spacer
8DJ's place a bbs.impakt.net 6502
hgit tree /URL:https://github.com/my/code (null) 70
4 years ago
i----------- spacer localhost 70
4 years ago
i---------------------------------------------------------
"
);
assert_eq!(menu.spans.len(), 10);
assert_eq!(menu.links.len(), 5);
assert_eq!(
menu.lines().nth(1).unwrap().url(),
"gopher://gopher.club/1/phlogs/"
);
assert_eq!(
menu.lines().nth(2).unwrap().url(),
"gopher://sdf.org/1/maps/"
);
assert_eq!(
menu.lines().nth(3).unwrap().url(),
"gopher://earth.rice.edu/1Geosphere"
);
assert_eq!(menu.lines().nth(4).unwrap().text(), "wacky links");
assert_eq!(menu.lines().nth(5).unwrap().text(), "-----------");
assert_eq!(
menu.lines().nth(6).unwrap().url(),
"telnet://bbs.impakt.net:6502"
);
assert_eq!(
menu.lines().nth(7).unwrap().url(),
"https://github.com/my/code"
);
assert_eq!(menu.lines().nth(8).unwrap().text(), "-----------");
4 years ago
}
#[test]
fn test_no_path() {
let menu = parse!("1Circumlunar Space circumlunar.space 70");
assert_eq!(menu.links.len(), 1);
assert_eq!(
menu.lines().next().unwrap().url(),
"gopher://circumlunar.space"
);
4 years ago
}
4 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
"
4 years ago
);
menu.term_size(80, 40);
assert_eq!(menu.links.len(), 9);
assert_eq!(menu.link(0).unwrap().url(), "gopher://bitreich.org/1/lawn");
4 years ago
assert_eq!(
menu.link(1).unwrap().url(),
4 years ago
"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");
4 years ago
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);
4 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.offset, 0);
4 years ago
menu.action_page_up();
assert_eq!(menu.link, 0);
assert_eq!(menu.link(menu.link).unwrap().link, 0);
}
#[test]
fn test_color_lines() {
let long_color_line = "ihi there. \x1b[1mthis\x1b[0m is a preeeeeety long line with \x1b[93mcolors \x1b[92mthat make it \x1b[91mseem longer than it is\x1b[0m /kiosk bitreich.org 70";
let menu = parse!(long_color_line);
let line = menu.lines().next().unwrap();
assert_eq!(long_color_line.chars().count(), 139);
assert_eq!(line.visible_len, MAX_COLS + 1);
assert_eq!(line.truncated_len, 100);
assert_eq!(
line.text_truncated(),
"hi there. \x1b[1mthis\x1b[0m is a preeeeeety long line with \x1b[93mcolors \x1b[92mthat make it \x1b[91mseem longer".to_string()
);
let long_reg_line = "1This is a regular line that is long but also has links and stuff. You are missing a gopher client? Use our kiosk mode. Thanks for coming. Hope you enjoy the fish, it's freshly grown in our lab! /kiosk bitreich.org 70";
let menu = parse!(long_reg_line);
let line = menu.lines().next().unwrap();
assert_eq!(long_color_line.chars().count(), 139);
assert_eq!(line.visible_len, MAX_COLS + 1);
assert_eq!(line.truncated_len, MAX_COLS + 1);
assert_eq!(
line.text_truncated(),
"This is a regular line that is long but also has links and stuff. You are miss"
.to_string()
);
}
4 years ago
}