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

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

//! 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<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,
/// 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<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.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<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
}
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<LinkPos> {
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<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[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<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.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::<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, 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<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(), 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()
);
}
}