feat(text)!: add Masked to display secure data (#168)

Adds a new type Masked that can mask data with a mask character, and can
be used anywhere we expect Cow<'a, str> or Text<'a>. E.g. Paragraph,
ListItem, Table Cells etc.

BREAKING CHANGE:
Because Masked implements From for Text<'a>, code that binds
Into<Text<'a>> without type annotations may no longer compile
(e.g. `Paragraph::new("".as_ref())`)

To fix this, annotate or call to_string() / to_owned() / as_str()
This commit is contained in:
Josh McKinney 2023-05-09 10:59:24 -07:00 committed by GitHub
parent c7aca64ba1
commit 2f0d549a50
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 132 additions and 3 deletions

View file

@ -7,7 +7,7 @@ use ratatui::{
backend::{Backend, CrosstermBackend}, backend::{Backend, CrosstermBackend},
layout::{Alignment, Constraint, Direction, Layout}, layout::{Alignment, Constraint, Direction, Layout},
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Span, Spans}, text::{Masked, Span, Spans},
widgets::{Block, Borders, Paragraph, Wrap}, widgets::{Block, Borders, Paragraph, Wrap},
Frame, Terminal, Frame, Terminal,
}; };
@ -133,6 +133,13 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
.fg(Color::Green) .fg(Color::Green)
.add_modifier(Modifier::ITALIC), .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| { let create_block = |title| {

View file

@ -155,7 +155,7 @@ fn ui<B: Backend>(f: &mut Frame<B>, app: &App) {
let help_message = Paragraph::new(text); let help_message = Paragraph::new(text);
f.render_widget(help_message, chunks[0]); 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 { .style(match app.input_mode {
InputMode::Normal => Style::default(), InputMode::Normal => Style::default(),
InputMode::Editing => Style::default().fg(Color::Yellow), InputMode::Editing => Style::default().fg(Color::Yellow),

View file

@ -48,6 +48,7 @@
//! ``` //! ```
use crate::style::Style; use crate::style::Style;
use std::borrow::Cow; use std::borrow::Cow;
use std::fmt::{self, Debug, Display};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr; use unicode_width::UnicodeWidthStr;
@ -538,3 +539,124 @@ impl<'a> Extend<Spans<'a>> for Text<'a> {
self.lines.extend(iter); 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<Cow<'a, str>>, 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<Masked<'a>> 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<Masked<'a>> 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<str> = masked.borrow().into();
assert_eq!(cow, "xxxxx");
let cow: Cow<str> = masked.to_owned().into();
assert_eq!(cow, "xxxxx");
}
}

View file

@ -169,7 +169,7 @@ pub trait Widget {
/// terminal.draw(|f| { /// terminal.draw(|f| {
/// // The items managed by the application are transformed to something /// // The items managed by the application are transformed to something
/// // that is understood by ratatui. /// // that is understood by ratatui.
/// let items: Vec<ListItem>= events.items.iter().map(|i| ListItem::new(i.as_ref())).collect(); /// let items: Vec<ListItem>= events.items.iter().map(|i| ListItem::new(i.as_str())).collect();
/// // The `List` widget is then built with those items. /// // The `List` widget is then built with those items.
/// let list = List::new(items); /// let list = List::new(items);
/// // Finally the widget is rendered using the associated state. `events.state` is /// // Finally the widget is rendered using the associated state. `events.state` is