| // Copyright 2022 The Fuchsia Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| //! Structured user interface (SUI). |
| //! |
| //! Provides a wrapper around a Text UI (TUI) to support terminal, GUI, and |
| //! machine wrappers. |
| //! |
| //! Note: this is being developed within pbms as a proof of concept. The intent |
| //! is to move this code when it's further along. Potentially using it for |
| //! all ffx UI. |
| |
| use anyhow::Result; |
| use cfg_if::cfg_if; |
| use serde::{Deserialize, Serialize}; |
| use std::io::{BufRead, BufReader, Read, Write}; |
| use unicode_segmentation::UnicodeSegmentation; |
| |
| // Magic terminal escape codes. |
| const CLEAR_TO_EOL: &'static str = "\x1b[J"; |
| |
| /// Move the terminal cursor 'up' N rows. |
| fn cursor_move_up<W: ?Sized>(output: &mut W, rows: usize) -> Result<()> |
| where |
| W: Write + Send + Sync, |
| { |
| write!(output, "\x1b[{}A", rows)?; |
| Ok(()) |
| } |
| |
| /// Return the ratio of progress over total step in a user readable format. |
| fn progress_percentage(at_: u64, of_: u64) -> f32 { |
| if of_ == 0 { |
| return 100.0; |
| } |
| at_ as f32 / of_ as f32 * 100.0 |
| } |
| |
| /// A single topic of progress, which can be nested within other topics. |
| /// E.g. coping file 3 of 100, and being on byte 2000 of 9000 within that file |
| /// is two ProgressEntry records. |
| #[derive(Clone, Debug, Default, Deserialize, Serialize)] |
| pub struct ProgressEntry { |
| /// The current task description. |
| name: String, |
| |
| /// How far along the progress is, as compared to `of`. |
| at: u64, |
| |
| /// The point at which `at` is 100% complete. E.g. "at 50 of 100 steps". |
| of: u64, |
| |
| /// What is represented by `at` and `of`. E.g. "bytes", "seconds", "steps". |
| units: String, |
| } |
| |
| /// A wrapper around one or more ProgressEntry records. |
| #[derive(Clone, Debug, Default, Deserialize, Serialize)] |
| pub struct Progress { |
| /// Always "progress". |
| kind: String, |
| |
| /// A overall description. E.g. "Copying files". |
| title: String, |
| |
| /// An ordered list of progress entries. |
| entries: Vec<ProgressEntry>, |
| } |
| |
| impl Progress { |
| pub fn builder() -> Self { |
| Progress { kind: "progress".to_string(), ..Default::default() } |
| } |
| |
| /// A label shown prominently, such as the dialog or window title in a GUI. |
| pub fn title<'a>(&'a mut self, title: &'a str) -> &'a mut Self { |
| self.title = title.to_string(); |
| self |
| } |
| |
| /// Push another `ProgressEntry` to show nested progress. |
| pub fn entry<'a>( |
| &'a mut self, |
| name: &'a str, |
| at: u64, |
| of: u64, |
| units: &'a str, |
| ) -> &'a mut Self { |
| let entry = ProgressEntry { name: name.to_string(), at, of, units: units.to_string() }; |
| self.entries.push(entry); |
| self |
| } |
| } |
| |
| /// A basic presentation used for alerts and short prompts for data. |
| #[derive(Debug, Default, Deserialize, Serialize)] |
| pub struct SimplePresentation { |
| /// One of "string_prompt" or "alert". |
| kind: String, |
| |
| /// A overall description. E.g. "Copying files". |
| title: Option<String>, |
| |
| /// The body of the message to the user. |
| message: Option<String>, |
| |
| /// The specific question or call-to-action to the user. |
| prompt: String, |
| } |
| |
| impl SimplePresentation { |
| pub fn builder() -> Self { |
| SimplePresentation { kind: "string_prompt".to_string(), ..Default::default() } |
| } |
| |
| /// A label shown prominently, such as the dialog or window title in a GUI. |
| pub fn title<'a, S>(&'a mut self, title: S) -> &'a mut Self |
| where |
| S: Into<String>, |
| { |
| self.title = Some(title.into()); |
| self |
| } |
| |
| pub fn message<'a, S>(&'a mut self, message: S) -> &'a mut Self |
| where |
| S: Into<String>, |
| { |
| self.message = Some(message.into()); |
| self |
| } |
| |
| pub fn prompt<'a>(&'a mut self, prompt: &'a str) -> &'a mut Self { |
| self.prompt = prompt.to_string(); |
| self |
| } |
| } |
| |
| /// A single horizontal row of a table. |
| #[derive(Clone, Debug, Default, Deserialize, Serialize)] |
| pub struct RowPresentation { |
| /// If the row is used in a menu or selectable list, the `id` is used to |
| /// identify which selection was made. Note: if the `id` is not unique, it |
| /// may be difficult to know which selection was made. |
| id: Option<String>, |
| |
| /// The entries that make up the row. |
| columns: Vec<String>, |
| } |
| |
| /// A menu or table of one or more rows. |
| /// |
| /// It's advisable, though not required, to set the `header` and each of the |
| /// `rows` with the same number of columns. |
| #[derive(Clone, Debug, Default, Deserialize, Serialize)] |
| pub struct TableRows { |
| /// Always "table_rows". |
| kind: String, |
| |
| /// A short label presented at the top (or prominently). In a GUI, often the |
| /// title of the tab, dialog, or window displaying the table. |
| title: Option<String>, |
| |
| /// Appears above the table. Often descriptive text. |
| /// Compare to `note`. |
| message: Option<String>, |
| |
| /// The header is the top row of the table. It's commonly used to label the |
| /// columns in the table and normally doesn't contain data itself. |
| header: RowPresentation, |
| |
| /// Rows are the body of the table. This is where the actual data in the |
| /// table is presented. |
| rows: Vec<RowPresentation>, |
| |
| /// Appears below the table. Often a list and description of special |
| /// symbols used in the table. Common examples are asterisk, |
| /// double-asterisk, dagger, etc. |
| /// Compare to 'message'. |
| note: Option<String>, |
| |
| /// The `id` of the `rows` entry which is selected by default. |
| default: Option<String>, |
| |
| /// Often a question for the user, especially if the table shows a menu of |
| /// options to choose from. |
| prompt: Option<String>, |
| |
| /// The highest number of columns in any of the rows/header for this table. |
| max_columns: usize, |
| } |
| |
| impl TableRows { |
| pub fn builder() -> Self { |
| TableRows::default() |
| } |
| |
| /// Add a title. |
| pub fn title<'a, S>(&'a mut self, title: S) -> &'a mut Self |
| where |
| S: Into<String>, |
| { |
| self.title = Some(title.into()); |
| self |
| } |
| |
| /// Add a header to the top of the table. |
| /// |
| /// It's advisable, though not required, to set the `header` and each `row` |
| /// with the same number of columns. |
| pub fn header<'a, S>(&'a mut self, columns: Vec<S>) -> &'a mut Self |
| where |
| S: AsRef<str>, |
| { |
| let columns = columns.iter().map(|s| s.as_ref().to_string()).collect::<Vec<String>>(); |
| self.max_columns = std::cmp::max(self.max_columns, columns.len()); |
| self.header = RowPresentation { id: None, columns }; |
| self |
| } |
| |
| /// Append a row. |
| /// |
| /// This call may be repeated. The rows will be displayed in the order they |
| /// are added with this call. |
| /// |
| /// It's advisable, though not required, to set the `header` and each `row` |
| /// with the same number of columns. |
| pub fn row<'a, S>(&'a mut self, columns: Vec<S>) -> &'a mut Self |
| where |
| S: AsRef<str>, |
| { |
| let columns = columns.iter().map(|s| s.as_ref().to_string()).collect::<Vec<String>>(); |
| self.max_columns = std::cmp::max(self.max_columns, columns.len()); |
| self.rows.push(RowPresentation { id: None, columns }); |
| self |
| } |
| |
| /// Append a row with an id value. |
| /// |
| /// The `id` should be unique and provides a way to refer to a row. |
| /// |
| /// This call may be repeated. The rows will be displayed in the order they |
| /// are added with this call. |
| /// |
| /// It's advisable, though not required, to set the `header` and each `row` |
| /// with the same number of columns. |
| pub fn row_with_id<'a, S>(&'a mut self, id: S, columns: Vec<S>) -> &'a mut Self |
| where |
| S: AsRef<str>, |
| { |
| let columns = columns.iter().map(|s| s.as_ref().to_string()).collect::<Vec<String>>(); |
| self.rows.push(RowPresentation { id: Some(id.as_ref().to_string()), columns }); |
| self |
| } |
| |
| /// Add a note. |
| pub fn note<'a, S>(&'a mut self, note: S) -> &'a mut Self |
| where |
| S: Into<String>, |
| { |
| self.note = Some(note.into()); |
| self |
| } |
| } |
| |
| /// A message used for informing the user of important information. |
| /// |
| /// A notice differs from an alert in that a notice does not ask for |
| /// acknowledgement. Instead the notice is presented until the action or state |
| /// described by the notice completes. |
| /// |
| /// This is similar to a progress where the progress is unknown (like spinner). |
| /// The notice is shown until the user cancels the action or the action |
| /// completes. |
| #[derive(Debug, Default, Deserialize, Serialize)] |
| pub struct Notice { |
| /// Always "notice". |
| kind: String, |
| |
| /// A overall description. E.g. "Copying files". |
| title: Option<String>, |
| |
| /// The body of the message to the user. |
| message: Option<String>, |
| } |
| |
| impl Notice { |
| pub fn builder() -> Self { |
| Notice { kind: "notice".to_string(), ..Default::default() } |
| } |
| |
| /// A label shown prominently, such as the dialog or window title in a GUI. |
| pub fn title<'a, S>(&'a mut self, title: S) -> &'a mut Self |
| where |
| S: Into<String>, |
| { |
| self.title = Some(title.into()); |
| self |
| } |
| |
| pub fn message<'a, S>(&'a mut self, message: S) -> &'a mut Self |
| where |
| S: Into<String>, |
| { |
| self.message = Some(message.into()); |
| self |
| } |
| |
| pub fn get_title(&self) -> Option<String> { |
| self.title.clone() |
| } |
| pub fn get_message(&self) -> Option<String> { |
| self.message.clone() |
| } |
| } |
| |
| #[derive(Debug, Deserialize, Serialize)] |
| pub enum Presentation { |
| Notice(Notice), |
| Progress(Progress), |
| StringPrompt(SimplePresentation), |
| Table(TableRows), |
| } |
| |
| /// User response to a request. |
| #[derive(Debug, Deserialize, Serialize)] |
| pub enum Response { |
| /// The default action was chosen, i.e. pressed "Enter" or "Return". |
| Default, |
| |
| /// Leave/close without making a choice, i.e. "Cancel" or "Esc". |
| NoChoice, |
| |
| /// One of the choice keys passed into the presentation. |
| Choice(String), |
| |
| /// All further steps are to be skipped (aka abort or terminate). |
| Quit, |
| } |
| |
| pub trait Interface { |
| fn present(&self, output: &Presentation) -> Result<Response>; |
| } |
| |
| /// A text based UI, likely a terminal. |
| pub struct InnerTextUi<'a> { |
| /// E.g. stdin. |
| #[allow(unused)] |
| input: &'a mut (dyn Read + Send + Sync + 'a), |
| |
| /// E.g. stdout. |
| output: &'a mut (dyn Write + Send + Sync + 'a), |
| |
| /// E.g. stderr. |
| #[allow(unused)] |
| error_output: &'a mut (dyn Write + 'a), |
| |
| /// Some text UI overwrites itself at each iteration other than the first. |
| /// Track how many lines to overwrite. |
| overwrite_line_count: usize, |
| } |
| |
| #[allow(dead_code)] |
| mod mock_atty { |
| pub(super) fn always_a_tty(_: atty::Stream) -> bool { |
| true |
| } |
| } |
| |
| cfg_if! { |
| if #[cfg(test)] { |
| use mock_atty::always_a_tty as is; |
| } else { |
| use atty::is; |
| } |
| } |
| |
| pub struct TextUi<'a> { |
| inner: std::sync::Mutex<InnerTextUi<'a>>, |
| } |
| |
| impl<'a> TextUi<'a> { |
| pub fn new<R, W, E>(input: &'a mut R, output: &'a mut W, error_output: &'a mut E) -> Self |
| where |
| R: Read + Send + Sync + 'a, |
| W: Write + Send + Sync + 'a, |
| E: Write + 'a, |
| { |
| Self { |
| inner: std::sync::Mutex::new(InnerTextUi { |
| input, |
| output, |
| error_output, |
| overwrite_line_count: 0, |
| }), |
| } |
| } |
| |
| fn present_progress(&self, progress: &Progress) -> Result<Response> { |
| // We only print the progress text if it's going to a TTY terminal, |
| // since the shell control sequences don't make sense otherwise. |
| if !is(atty::Stream::Stdout) { |
| return Ok(Response::Default); |
| } |
| let mut inner = self.inner.lock().expect("present_progress lock"); |
| // Move back to overwrite the previous progress rendering. |
| let mut lines_to_overwrite = inner.overwrite_line_count; |
| if lines_to_overwrite > 0 { |
| cursor_move_up(inner.output, lines_to_overwrite)?; |
| } |
| inner.overwrite_line_count = 0; |
| write!(inner.output, "Progress for \"{}\"{}\n", progress.title, CLEAR_TO_EOL)?; |
| inner.overwrite_line_count += 1; |
| let term_width = termion::terminal_size().unwrap_or((80, 40)).0 as usize; |
| const MARGINS: usize = /*indent=*/ 2 + /*right_side=*/ 1; |
| let limit = term_width.saturating_sub(MARGINS); |
| for entry in &progress.entries { |
| write!( |
| inner.output, |
| " {}{}\n", |
| ellipsis(&entry.name, limit, Some('/')), |
| CLEAR_TO_EOL |
| )?; |
| write!( |
| inner.output, |
| " {} of {} {} ({:.2}%){}\n", |
| entry.at, |
| entry.of, |
| entry.units, |
| progress_percentage(entry.at, entry.of), |
| CLEAR_TO_EOL |
| )?; |
| inner.overwrite_line_count += 2; |
| } |
| while lines_to_overwrite > inner.overwrite_line_count { |
| write!(inner.output, "{}\n", CLEAR_TO_EOL)?; |
| lines_to_overwrite -= 1; |
| } |
| Ok(Response::Default) |
| } |
| |
| fn present_notice(&self, element: &Notice) -> Result<Response> { |
| let mut inner = self.inner.lock().expect("present_string_prompt lock"); |
| if let Some(title) = &element.title { |
| writeln!(inner.output, "{}", title)?; |
| } |
| if let Some(message) = &element.message { |
| writeln!(inner.output, "{}", message)?; |
| } |
| Ok(Response::Default) |
| } |
| |
| fn present_string_prompt(&self, element: &SimplePresentation) -> Result<Response> { |
| if !is(atty::Stream::Stdout) { |
| // If the terminal is non-interactive, it's not reasonable to prompt |
| // the user. |
| return Ok(Response::NoChoice); |
| } |
| let mut inner = self.inner.lock().expect("present_string_prompt lock"); |
| if let Some(title) = &element.title { |
| writeln!(inner.output, "{}", title)?; |
| } |
| if let Some(message) = &element.message { |
| writeln!(inner.output, "{}", message)?; |
| } |
| writeln!(inner.output, "{}: ", element.prompt)?; |
| let mut buf_reader = BufReader::new(&mut inner.input); |
| let mut choice = String::new(); |
| buf_reader.read_line(&mut choice).expect("reading string input line"); |
| if choice.is_empty() { |
| Ok(Response::Default) |
| } else { |
| Ok(Response::Choice(choice)) |
| } |
| } |
| |
| fn present_table(&self, table: &TableRows) -> Result<Response> { |
| let mut inner = self.inner.lock().expect("present_table lock"); |
| if let Some(title) = &table.title { |
| writeln!(inner.output, "{}", title)?; |
| } |
| if let Some(message) = &table.message { |
| inner.output.write_all(message.as_bytes())?; |
| } |
| let mut max_lengths = vec![0; table.max_columns]; |
| for (index, column) in table.header.columns.iter().enumerate() { |
| max_lengths[index] = std::cmp::max(0, column.len()); |
| } |
| for row in &table.rows { |
| for (index, column) in row.columns.iter().enumerate() { |
| max_lengths[index] = std::cmp::max(max_lengths[index], column.len()); |
| } |
| } |
| for row in &table.rows { |
| for (index, column) in row.columns.iter().enumerate() { |
| write!(inner.output, "{:width$} ", column, width = max_lengths[index])?; |
| } |
| writeln!(inner.output, "")?; |
| } |
| if let Some(note) = &table.note { |
| inner.output.write_all(note.as_bytes())?; |
| } |
| Ok(Response::Default) |
| } |
| } |
| |
| impl<'a> Interface for TextUi<'a> { |
| fn present(&self, presentation: &Presentation) -> Result<Response> { |
| match presentation { |
| Presentation::Notice(p) => self.present_notice(p), |
| Presentation::Progress(p) => self.present_progress(p), |
| Presentation::StringPrompt(p) => self.present_string_prompt(p), |
| Presentation::Table(p) => self.present_table(p), |
| } |
| } |
| } |
| |
| /// If the string is longer than `limit`, ellipsis the string in the middle so |
| /// that the overall len is `limit` in length. |
| fn ellipsis(s: &str, limit: usize, prefer: Option<char>) -> String { |
| // UX has determined that this should be Chicago manual style "..." (without |
| // extra spaces) rather than MLA style "[...]". |
| const ELLIPSE: &str = "..."; |
| // Optimization: if the byte length is less than limit, it's very unlikely |
| // that the grapheme count will be larger. (I think that's not possible.) |
| // i.e. there would need to be a single byte utf8 which is rendered in |
| // multiple fixed-width font cells. |
| if s.len() <= limit { |
| return s.to_string(); |
| } |
| let total = s.graphemes(/*is_extended=*/ true).count(); |
| if total <= limit { |
| return s.to_string(); |
| } |
| if limit < ELLIPSE.len() { |
| return ELLIPSE[..limit].to_string(); |
| } |
| // Determine offsets (end of first piece and start of second piece). |
| let mut first = (limit - ELLIPSE.len()) / 2; |
| if let Some(ch) = prefer { |
| if let Some(n) = s[..first].rfind(ch) { |
| first = n + 1; |
| } |
| } |
| let mut second = total - (limit - ELLIPSE.len() - first); |
| if let Some(ch) = prefer { |
| if let Some(n) = s[second..].find(ch) { |
| second += n; |
| } |
| } |
| // Build the new string. |
| s.graphemes(/*is_extended=*/ true) |
| .take(first) |
| .chain(ELLIPSE.graphemes(/*is_extended=*/ true)) |
| .chain(s.graphemes(true).skip(second).take(total)) |
| .collect() |
| } |
| |
| pub struct MockUi {} |
| |
| impl MockUi { |
| pub fn new() -> Self { |
| Self {} |
| } |
| |
| fn present_notice(&self, _notice: &Notice) -> Result<Response> { |
| Ok(Response::Default) |
| } |
| |
| fn present_progress(&self, _progress: &Progress) -> Result<Response> { |
| Ok(Response::Default) |
| } |
| |
| fn present_string_prompt(&self, _element: &SimplePresentation) -> Result<Response> { |
| Ok(Response::Default) |
| } |
| |
| fn present_table(&self, _table: &TableRows) -> Result<Response> { |
| Ok(Response::Default) |
| } |
| } |
| |
| impl Interface for MockUi { |
| fn present(&self, presentation: &Presentation) -> Result<Response> { |
| match presentation { |
| Presentation::Notice(p) => self.present_notice(p), |
| Presentation::Progress(p) => self.present_progress(p), |
| Presentation::StringPrompt(p) => self.present_string_prompt(p), |
| Presentation::Table(p) => self.present_table(p), |
| } |
| } |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use super::*; |
| |
| #[test] |
| fn test_notice() { |
| let mut input = "".as_bytes(); |
| let mut output: Vec<u8> = Vec::new(); |
| let mut err_out: Vec<u8> = Vec::new(); |
| let ui = TextUi::new(&mut input, &mut output, &mut err_out); |
| let mut notice = Notice::builder(); |
| notice.title("foo"); |
| notice.message("Test message for notice."); |
| ui.present(&Presentation::Notice(notice)).expect("present notice"); |
| let output = String::from_utf8(output).expect("string form utf8"); |
| assert!(output.contains("foo")); |
| assert!(output.contains("Test message for notice")); |
| assert!(output.contains("notice")); |
| } |
| |
| #[test] |
| fn test_progress() { |
| let mut input = "".as_bytes(); |
| let mut output: Vec<u8> = Vec::new(); |
| let mut err_out: Vec<u8> = Vec::new(); |
| let ui = TextUi::new(&mut input, &mut output, &mut err_out); |
| let mut progress = Progress::builder(); |
| progress.title("foo"); |
| progress.entry("bushel", /*at=*/ 20, /*of=*/ 100, "pieces"); |
| progress.entry("apple", /*at=*/ 5, /*of=*/ 10, "bites"); |
| ui.present(&Presentation::Progress(progress)).expect("present progress"); |
| let output = String::from_utf8(output).expect("string form utf8"); |
| assert!(output.contains("foo")); |
| assert!(output.contains("bushel")); |
| assert!(output.contains("apple")); |
| assert!(output.contains("pieces")); |
| assert!(output.contains("bites")); |
| } |
| |
| #[test] |
| fn test_table() { |
| let mut input = "".as_bytes(); |
| let mut output: Vec<u8> = Vec::new(); |
| let mut err_out: Vec<u8> = Vec::new(); |
| let ui = TextUi::new(&mut input, &mut output, &mut err_out); |
| let mut table = TableRows::builder(); |
| table.title("foo"); |
| table.header(vec!["type", "count", "notes"]); |
| table.row(vec!["fruit", "5", "orange"]); |
| table.row_with_id(/*id=*/ "a", vec!["car", "10", "red"]); |
| table.note("bar"); |
| ui.present(&Presentation::Table(table)).expect("present table"); |
| let output = String::from_utf8(output).expect("string form utf8"); |
| println!("{}", output); |
| assert!(output.contains("foo")); |
| assert!(output.contains("bar")); |
| assert!(output.contains("fruit")); |
| assert!(output.contains("orange")); |
| assert!(output.contains("car")); |
| assert!(output.contains("red")); |
| } |
| |
| #[test] |
| fn test_ellipsis() { |
| assert_eq!(ellipsis("cake/drought/fins", 100, /*prefer=*/ None), "cake/drought/fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 17, /*prefer=*/ None), "cake/drought/fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 16, /*prefer=*/ None), "cake/d...ht/fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 15, /*prefer=*/ None), "cake/d...t/fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 14, /*prefer=*/ None), "cake/...t/fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 12, /*prefer=*/ None), "cake.../fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 11, /*prefer=*/ None), "cake...fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 5, /*prefer=*/ None), "c...s"); |
| assert_eq!(ellipsis("cake/drought/fins", 4, /*prefer=*/ None), "...s"); |
| assert_eq!(ellipsis("cake/drought/fins", 3, /*prefer=*/ None), "..."); |
| assert_eq!(ellipsis("cake/drought/fins", 1, /*prefer=*/ None), "."); |
| assert_eq!(ellipsis("cake/drought/fins", 0, /*prefer=*/ None), ""); |
| |
| assert_eq!(ellipsis("cake/drought/fins", 100, Some('/')), "cake/drought/fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 17, Some('/')), "cake/drought/fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 16, Some('/')), "cake/.../fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 15, Some('/')), "cake/.../fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 14, Some('/')), "cake/.../fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 12, Some('/')), "cake.../fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 11, Some('/')), "cake...fins"); |
| assert_eq!(ellipsis("cake/drought/fins", 5, Some('/')), "c...s"); |
| assert_eq!(ellipsis("cake/drought/fins", 4, Some('/')), "...s"); |
| assert_eq!(ellipsis("cake/drought/fins", 3, Some('/')), "..."); |
| assert_eq!(ellipsis("cake/drought/fins", 1, Some('/')), "."); |
| assert_eq!(ellipsis("cake/drought/fins", 0, Some('/')), ""); |
| |
| assert_eq!(ellipsis("/x/cake/drought_fins", 20, Some('/')), "/x/cake/drought_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 19, Some('/')), "/x/cake/...ght_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 18, Some('/')), "/x/...drought_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 17, Some('/')), "/x/...rought_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 16, Some('/')), "/x/...ought_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 15, Some('/')), "/x/...ught_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 14, Some('/')), "/x/...ght_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 13, Some('/')), "/x/...ht_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 12, Some('/')), "/x/...t_fins"); |
| assert_eq!(ellipsis("/x/cake/drought_fins", 11, Some('/')), "/x/..._fins"); |
| } |
| } |