//! 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; /// 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. 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, /// Indexes of links in the `lines` vector. Pauper's pointers. pub links: Vec, /// 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, /// Global config config: Config, } /// 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, } 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 { 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 { 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) } } } /// 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 is_tor(&self) -> bool { self.tor } fn raw(&self) -> &str { self.raw.as_ref() } fn render(&mut self) -> String { self.render_lines() } fn respond(&mut self, key: Key) -> Action { self.process_key(key) } fn set_wide(&mut self, wide: bool) { self.wide = wide; } fn wide(&mut self) -> bool { self.wide } fn term_size(&mut self, cols: usize, rows: usize) { self.size = (cols, rows); } fn url(&self) -> &str { self.url.as_ref() } } impl Menu { /// 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 { Menu { tls, tor: config.read().unwrap().tor, wide: config.read().unwrap().wide, scroll: config.read().unwrap().scroll, mode: config.read().unwrap().mode, ..parse(url, response, config.clone()) } } /// 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 { 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 { let line = self.links.get(idx)?; self.line(*line) } fn cols(&self) -> usize { 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? fn link_visibility(&self, i: usize) -> Option { let &pos = self.links.get(i)?; Some(if pos < self.offset { LinkPos::Above } else if pos >= self.offset + 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.offset > pos { pos + 1 } else { pos + 1 - self.offset }; Some((x as u16, y as u16)) } fn render_lines(&mut self) -> String { 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); for line in iter { out.push_str(&left_margin); let config = self.config.read().unwrap(); if line.typ == Type::Info { out.push_str(" "); } else { if line.link == self.link && self.show_cursor() { out.push_str(&config.theme.ui_cursor); out.push('*'); out.push_str(reset_color!()); } else { out.push(' '); } out.push(' '); out.push_str(&config.theme.ui_number); if line.link < 9 { out.push(' '); } let num = (line.link + 1).to_string(); out.push_str(&num); out.push_str(". "); out.push_str(reset_color!()); } // truncate long lines, instead of wrapping let text = line.text_truncated(); // color the line if line.typ.is_media() { out.push_str(&config.theme.item_media); } else if line.typ.is_download() { out.push_str(&config.theme.item_download); } else if !line.typ.is_supported() { out.push_str(&self.config.read().unwrap().theme.item_unsupported); } else { out.push_str(match line.typ { 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); out.push_str(reset_color!()); // clear rest of line out.push_str(terminal::ClearUntilNewline.as_ref()); out.push_str("\r\n"); } // clear remainder of screen out.push_str(terminal::ClearAfterCursor.as_ref()); 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 { 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 { if self.links.is_empty() || !self.show_cursor() { return None; } let (x, y) = self.screen_coords(self.link)?; Some(format!( "{}{}*\x1b[0m{}", terminal::Goto(x, y), 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()) } } /// Scroll down by a page, 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.spans.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.offset >= self.final_offset() { self.offset = self.final_offset(); if !self.links.is_empty() { self.link = self.links.len() - 1; } 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; } } } Action::Redraw } 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; } if self.link == 0 { return Action::Redraw; } if let Some(dir) = self.link_visibility(self.link) { match dir { LinkPos::Below => { let scroll = self.offset; if let Some(&pos) = self .links .iter() .take(self.link) .rev() .find(|&&i| i < (self.rows() + scroll - 1)) { self.link = self.line(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.offset > 0 { self.offset -= 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.offset > 0 { self.offset -= 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.offset = 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.offset > 0 && pos < self.offset + 5 { self.offset -= 1; } else { // otherwise redraw just the cursor return self.reset_cursor(old_link); } } } } Action::Redraw } else { Action::None } } /// Final `self.offset` value. fn final_offset(&self) -> usize { let padding = (self.rows() as f64 * 0.9) as usize; if self.spans.len() > padding { self.spans.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 { 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 { 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 where T: std::iter::Iterator, { 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 } 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.spans.len() >= self.rows() && self.offset < self.final_offset() { self.offset += 1; return Action::Redraw; } else if !self.links.is_empty() { // wrap around self.link = 0; self.offset = 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.offset = pos; self.link = new_link; } } 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; } } 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.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); } } } } 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.offset = pos - 5; } else { self.offset = 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.offset = pos - 5; } else { self.offset = 0; } if self.offset > self.final_offset() { self.offset = self.final_offset(); } 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(); 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), ) }), ) } 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), } } 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.offset = 0; self.link = 0; Action::Redraw } Key::End => { self.offset = self.final_offset(); 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::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_ascii_digit() { return Action::Keypress(key); } self.input.push(c); // jump to number let s = self .input .chars() .take(self.input.chars().count()) .collect::(); if let Ok(num) = s.parse::() { 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: &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; } 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()); } spans.push(span); } start += line.len() + 1; } 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, config, } } /// Parses a single line from a Gopher menu into a `LineSpan` struct. pub fn parse_line(start: usize, raw: &str) -> Option { 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` 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; } } else if c == '\x1b' { if let Some((_, '[')) = iter.next() { is_color = true; } } 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, }) } #[cfg(test)] mod tests { use super::*; macro_rules! parse { ($s:expr) => { parse("test", $s.to_string(), Config::default()) }; } #[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 iwacky links i----------- spacer 8DJ's place a bbs.impakt.net 6502 hgit tree /URL:https://github.com/my/code (null) 70 i----------- spacer localhost 70 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(), "-----------"); } #[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" ); } #[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.offset, 0); 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() ); } }