//! 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 ::Config ,
gopher ::{ self , Type } ,
terminal ,
ui ::{ self , Action , Key , View , MAX_COLS , SCROLL_LINES } ,
} ;
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 < 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.
pub scroll : 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 ,
}
/// 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 < 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 )
}
}
}
/// 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 . tor ,
wide : config . wide ,
mode : config . mode ,
.. parse ( url , response )
}
}
/// 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 {
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
}
}
}
/// 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 ( & 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 . scroll ) . take ( limit ) ;
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 & & self . show_cursor ( ) {
out . push_str ( color ! ( Bold ) ) ;
out . push ( '*' ) ;
out . push_str ( color ! ( Reset ) ) ;
} else {
out . push ( ' ' ) ;
}
out . push ( ' ' ) ;
out . push_str ( color ! ( Magenta ) ) ;
if line . link < 9 {
out . push ( ' ' ) ;
}
let num = ( line . link + 1 ) . to_string ( ) ;
out . push_str ( & num ) ;
out . push_str ( ". " ) ;
out . push_str ( color ! ( Reset ) ) ;
}
// truncate long lines, instead of wrapping
let text = line . text_truncated ( ) ;
// color the line
if line . typ . is_media ( ) {
out . push_str ( color ! ( Underline ) ) ;
out . push_str ( color ! ( Green ) ) ;
} else if line . typ . is_download ( ) {
out . push_str ( color ! ( Underline ) ) ;
out . push_str ( color ! ( White ) ) ;
} else if ! line . typ . is_supported ( ) {
out . push_str ( color ! ( WhiteBG ) ) ;
out . push_str ( color ! ( Red ) ) ;
} else {
out . push_str ( & match line . typ {
Type ::Text = > color ! ( Cyan ) ,
Type ::Menu = > color ! ( Blue ) ,
Type ::Info = > color ! ( Yellow ) ,
Type ::HTML = > color ! ( Green ) ,
Type ::Error = > color ! ( Red ) ,
Type ::Telnet = > color ! ( Grey ) ,
Type ::Search = > color ! ( White ) ,
_ = > color ! ( Red ) ,
} ) ;
}
out . push_str ( & text ) ;
out . push_str ( color ! ( Reset ) ) ;
// 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 < 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! (
"{}\x1b[97;1m*\x1b[0m{}" ,
terminal ::Goto ( x , y ) ,
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 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 . 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 . scroll > = self . final_scroll ( ) {
self . scroll = self . final_scroll ( ) ;
if ! self . links . is_empty ( ) {
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 . line ( 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 . 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 . 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 . 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 < 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 . 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 . 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 . spans . 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 ( ) ;
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 . 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 ::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 . 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 ) ;
}
}
}
Action ::None
}
_ = > Action ::Keypress ( key ) ,
}
}
}
/// Parse gopher response into a Menu object.
pub fn parse ( url : & str , raw : String ) -> 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 = = "" {
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 ( ) ,
scroll : 0 ,
searching : false ,
size : ( 0 , 0 ) ,
tls : false ,
tor : false ,
wide : false ,
}
}
/// 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`
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 ( ) ) ;
} ;
}
#[ test ]
fn test_simple_menu ( ) {
let menu = parse ! (
"
i - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
1 SDF PHLOGOSPHERE ( 297 phlogs ) / phlogs / gopher . club 70
1 SDF GOPHERSPACE ( 1303 ACTIVE users ) / maps / sdf . org 70
1 Geosphere Geosphere earth . rice . edu
iwacky links
i - - - - - - - - - - - spacer
8 DJ ' 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
1 THE GOPHER LAWN – THE gopher directory / lawn bitreich . org 70
i Err bitreich . org 70
1 Gopher Tutorials Project / tutorials bitreich . org 70
i Err bitreich . org 70
iRun more gopherholes on tor ! Err bitreich . org 70
1 The Gopher Onion Initiative / onion bitreich . org 70
i Err bitreich . org 70
1 You 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
1 The Gopher Project / gopherproject . org 70
7 Search the global gopherspace at Veronica II / v2 / vs gopher . floodgap . com 70
i Err bitreich . org 70
iBest viewed using : Err bitreich . org 70
1 sacc / scm / sacc bitreich . org 70
1 clic / 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 ) ;
}
#[ 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 ( )
) ;
}
}