diff --git a/examples/paragraph.rs b/examples/paragraph.rs index 7338e11a..0c8b92d8 100644 --- a/examples/paragraph.rs +++ b/examples/paragraph.rs @@ -7,7 +7,7 @@ use ratatui::{ backend::{Backend, CrosstermBackend}, layout::{Alignment, Constraint, Direction, Layout}, style::{Color, Modifier, Style}, - text::{Span, Spans}, + text::{Masked, Span, Spans}, widgets::{Block, Borders, Paragraph, Wrap}, Frame, Terminal, }; @@ -133,6 +133,13 @@ fn ui(f: &mut Frame, app: &App) { .fg(Color::Green) .add_modifier(Modifier::ITALIC), )), + Spans::from(vec![ + Span::raw("Masked text: "), + Span::styled( + Masked::new("password", '*'), + Style::default().fg(Color::Red), + ), + ]), ]; let create_block = |title| { diff --git a/examples/user_input.rs b/examples/user_input.rs index 0024b404..37cd3428 100644 --- a/examples/user_input.rs +++ b/examples/user_input.rs @@ -155,7 +155,7 @@ fn ui(f: &mut Frame, app: &App) { let help_message = Paragraph::new(text); f.render_widget(help_message, chunks[0]); - let input = Paragraph::new(app.input.as_ref()) + let input = Paragraph::new(app.input.as_str()) .style(match app.input_mode { InputMode::Normal => Style::default(), InputMode::Editing => Style::default().fg(Color::Yellow), diff --git a/src/text.rs b/src/text.rs index 859083b9..5439d296 100644 --- a/src/text.rs +++ b/src/text.rs @@ -48,6 +48,7 @@ //! ``` use crate::style::Style; use std::borrow::Cow; +use std::fmt::{self, Debug, Display}; use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; @@ -538,3 +539,124 @@ impl<'a> Extend> for Text<'a> { self.lines.extend(iter); } } + +/// A wrapper around a string that is masked when displayed. +/// +/// The masked string is displayed as a series of the same character. +/// This might be used to display a password field or similar secure data. +/// +/// # Examples +/// +/// ```rust +/// use ratatui::{buffer::Buffer, layout::Rect, text::Masked, widgets::{Paragraph, Widget}}; +/// +/// let mut buffer = Buffer::empty(Rect::new(0, 0, 5, 1)); +/// let password = Masked::new("12345", 'x'); +/// +/// Paragraph::new(password).render(buffer.area, &mut buffer); +/// assert_eq!(buffer, Buffer::with_lines(vec!["xxxxx"])); +/// ``` +#[derive(Clone)] +pub struct Masked<'a> { + inner: Cow<'a, str>, + mask_char: char, +} + +impl<'a> Masked<'a> { + pub fn new(s: impl Into>, mask_char: char) -> Self { + Self { + inner: s.into(), + mask_char, + } + } + + /// The character to use for masking. + pub fn mask_char(&self) -> char { + self.mask_char + } + + /// The underlying string, with all characters masked. + pub fn value(&self) -> Cow<'a, str> { + self.inner.chars().map(|_| self.mask_char).collect() + } +} + +impl Debug for Masked<'_> { + /// Debug representation of a masked string is the underlying string + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.inner).map_err(|_| fmt::Error) + } +} + +impl Display for Masked<'_> { + /// Display representation of a masked string is the masked string + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.value()).map_err(|_| fmt::Error) + } +} + +impl<'a> From<&'a Masked<'a>> for Cow<'a, str> { + fn from(masked: &'a Masked) -> Cow<'a, str> { + masked.value() + } +} + +impl<'a> From> for Cow<'a, str> { + fn from(masked: Masked<'a>) -> Cow<'a, str> { + masked.value() + } +} + +impl<'a> From<&'a Masked<'_>> for Text<'a> { + fn from(masked: &'a Masked) -> Text<'a> { + Text::raw(masked.value()) + } +} + +impl<'a> From> for Text<'a> { + fn from(masked: Masked<'a>) -> Text<'a> { + Text::raw(masked.value()) + } +} + +#[cfg(test)] +mod tests { + use std::borrow::Borrow; + + use super::*; + + #[test] + fn test_masked_value() { + let masked = Masked::new("12345", 'x'); + assert_eq!(masked.value(), "xxxxx"); + } + + #[test] + fn test_masked_debug() { + let masked = Masked::new("12345", 'x'); + assert_eq!(format!("{masked:?}"), "12345"); + } + + #[test] + fn test_masked_display() { + let masked = Masked::new("12345", 'x'); + assert_eq!(format!("{masked}"), "xxxxx"); + } + + #[test] + fn test_masked_conversions() { + let masked = Masked::new("12345", 'x'); + + let text: Text = masked.borrow().into(); + assert_eq!(text.lines, vec![Spans::from("xxxxx")]); + + let text: Text = masked.to_owned().into(); + assert_eq!(text.lines, vec![Spans::from("xxxxx")]); + + let cow: Cow = masked.borrow().into(); + assert_eq!(cow, "xxxxx"); + + let cow: Cow = masked.to_owned().into(); + assert_eq!(cow, "xxxxx"); + } +} diff --git a/src/widgets/mod.rs b/src/widgets/mod.rs index 320b67e3..00e21642 100644 --- a/src/widgets/mod.rs +++ b/src/widgets/mod.rs @@ -169,7 +169,7 @@ pub trait Widget { /// terminal.draw(|f| { /// // The items managed by the application are transformed to something /// // that is understood by ratatui. -/// let items: Vec= events.items.iter().map(|i| ListItem::new(i.as_ref())).collect(); +/// let items: Vec= events.items.iter().map(|i| ListItem::new(i.as_str())).collect(); /// // The `List` widget is then built with those items. /// let list = List::new(items); /// // Finally the widget is rendered using the associated state. `events.state` is