use std::fmt;
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::{
ColorRole, Dp, Dpi, NativeDrawCommand, NativeDrawFill, NativeDrawIconCommand, NativeDrawPlan,
NativeDrawSecureTextCommand, NativeDrawTextCommand, NativeIconColorMode, Rect,
SemanticTextStyle, ZsIcon,
};
#[derive(Clone, Default, PartialEq, Eq)]
pub struct ZsPassword(Zeroizing<String>);
impl ZsPassword {
pub fn new(value: impl Into<String>) -> Self {
Self(Zeroizing::new(value.into()))
}
pub fn as_str(&self) -> &str {
self.0.as_str()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn char_count(&self) -> usize {
self.0.chars().count()
}
pub(crate) fn as_string_mut(&mut self) -> &mut String {
&mut self.0
}
}
impl fmt::Debug for ZsPassword {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("ZsPassword(<redacted>)")
}
}
impl From<String> for ZsPassword {
fn from(value: String) -> Self {
Self::new(value)
}
}
impl From<&str> for ZsPassword {
fn from(value: &str) -> Self {
Self::new(value)
}
}
impl From<&ZsPassword> for ZsPassword {
fn from(value: &ZsPassword) -> Self {
value.clone()
}
}
pub fn mask_password(value: &str) -> String {
"•".repeat(value.chars().count())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ZsPasswordRevealMode {
Hidden,
Peek,
Visible,
}
impl ZsPasswordRevealMode {
pub const fn platform_default() -> Self {
crate::ZsPlatformStyle::current().default_password_reveal_mode()
}
}
impl Default for ZsPasswordRevealMode {
fn default() -> Self {
Self::platform_default()
}
}
pub type ZsPasswordBoxPlatformStyle = crate::ZsPlatformStyle;
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub struct ZsPasswordBoxMetrics {
pub minimum_height: Dp,
pub radius: Dp,
pub text_inset: Dp,
pub reveal_width: Dp,
pub reveal_icon_size: Dp,
}
impl ZsPasswordBoxMetrics {
pub const fn for_platform(platform: ZsPasswordBoxPlatformStyle) -> Self {
crate::platform_component_profile::PlatformPasswordBoxProfile::for_platform(platform)
.metrics
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ZsPasswordBoxRenderPlan {
pub bounds: Rect,
pub text_bounds: Rect,
pub reveal_button: Option<Rect>,
pub reveal_icon: Option<Rect>,
pub radius: i32,
pub platform: ZsPasswordBoxPlatformStyle,
}
pub fn zs_password_box_render_plan(
bounds: Rect,
reveal_mode: ZsPasswordRevealMode,
has_value: bool,
platform: ZsPasswordBoxPlatformStyle,
dpi: Dpi,
) -> ZsPasswordBoxRenderPlan {
let metrics = ZsPasswordBoxMetrics::for_platform(platform);
let inset = metrics.text_inset.to_px(dpi).round_i32().max(0);
let reveal_width = metrics
.reveal_width
.to_px(dpi)
.round_i32()
.min(bounds.width.max(0));
let show_reveal = reveal_mode == ZsPasswordRevealMode::Peek && has_value && reveal_width > 0;
let reveal_button = show_reveal.then_some(Rect {
x: bounds
.x
.saturating_add(bounds.width.saturating_sub(reveal_width)),
y: bounds.y,
width: reveal_width,
height: bounds.height,
});
let reveal_icon = reveal_button.map(|button| {
let size = metrics
.reveal_icon_size
.to_px(dpi)
.round_i32()
.min(button.width)
.min(button.height)
.max(1);
Rect {
x: button.x + (button.width - size) / 2,
y: button.y + (button.height - size) / 2,
width: size,
height: size,
}
});
let trailing = reveal_button.map_or(0, |button| button.width);
ZsPasswordBoxRenderPlan {
bounds,
text_bounds: Rect {
x: bounds.x.saturating_add(inset),
y: bounds.y,
width: bounds
.width
.saturating_sub(trailing)
.saturating_sub(inset.saturating_mul(2))
.max(0),
height: bounds.height,
},
reveal_button,
reveal_icon,
radius: metrics.radius.to_px(dpi).round_i32().max(1),
platform,
}
}
pub fn zs_password_box_native_draw_plan(
plan: &ZsPasswordBoxRenderPlan,
value: &ZsPassword,
reveal_mode: ZsPasswordRevealMode,
peek_revealed: bool,
) -> NativeDrawPlan {
zs_password_box_native_draw_plan_with_placeholder(plan, value, reveal_mode, peek_revealed, None)
}
pub fn zs_password_box_native_draw_plan_with_placeholder(
plan: &ZsPasswordBoxRenderPlan,
value: &ZsPassword,
reveal_mode: ZsPasswordRevealMode,
peek_revealed: bool,
placeholder: Option<&str>,
) -> NativeDrawPlan {
let revealed = reveal_mode == ZsPasswordRevealMode::Visible
|| (reveal_mode == ZsPasswordRevealMode::Peek && peek_revealed);
let mut commands = vec![NativeDrawCommand::RoundRect {
rect: plan.bounds,
fill: NativeDrawFill::Role(ColorRole::Surface),
stroke: Some(NativeDrawFill::Role(ColorRole::Control)),
radius: plan.radius,
}];
if value.is_empty() {
let mut style = SemanticTextStyle::body();
style.color = ColorRole::SecondaryText;
commands.push(NativeDrawCommand::Text(NativeDrawTextCommand::new(
placeholder.unwrap_or_default(),
plan.text_bounds,
style,
)));
} else if revealed {
commands.push(NativeDrawCommand::SecureText(
NativeDrawSecureTextCommand::new(
value.clone(),
plan.text_bounds,
SemanticTextStyle::body(),
true,
),
));
} else {
commands.push(NativeDrawCommand::Text(NativeDrawTextCommand::new(
mask_password(value.as_str()),
plan.text_bounds,
SemanticTextStyle::body(),
)));
}
if let Some(icon) = plan.reveal_icon {
commands.push(NativeDrawCommand::Icon(NativeDrawIconCommand::new(
ZsIcon::PasswordReveal,
icon,
NativeIconColorMode::ThemeAware,
)));
}
NativeDrawPlan::new(commands)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_debug_and_json_never_contain_the_secret() {
let password = ZsPassword::from("s3cret🙂");
assert_eq!(format!("{password:?}"), "ZsPassword(<redacted>)");
let render = zs_password_box_render_plan(
Rect {
x: 0,
y: 0,
width: 180,
height: 32,
},
ZsPasswordRevealMode::Visible,
true,
ZsPasswordBoxPlatformStyle::Windows,
Dpi::standard(),
);
let draw = zs_password_box_native_draw_plan(
&render,
&password,
ZsPasswordRevealMode::Visible,
false,
);
let debug = format!("{draw:?}");
let json =
serde_json::to_string(&draw).expect("secure draw plan should serialize redacted");
assert!(!debug.contains(password.as_str()));
assert!(!json.contains(password.as_str()));
assert!(draw.commands.iter().any(|command| matches!(
command,
NativeDrawCommand::SecureText(command) if command.character_count() == 7
)));
}
#[test]
fn empty_password_uses_secondary_placeholder_without_secure_text() {
let render = zs_password_box_render_plan(
Rect {
x: 0,
y: 0,
width: 220,
height: 32,
},
ZsPasswordRevealMode::Peek,
false,
ZsPasswordBoxPlatformStyle::Windows,
Dpi::standard(),
);
let draw = zs_password_box_native_draw_plan_with_placeholder(
&render,
&ZsPassword::default(),
ZsPasswordRevealMode::Peek,
false,
Some("Enter password"),
);
assert!(draw.commands.iter().any(|command| matches!(
command,
NativeDrawCommand::Text(text)
if text.text == "Enter password"
&& text.style.color == ColorRole::SecondaryText
)));
assert!(!draw
.commands
.iter()
.any(|command| matches!(command, NativeDrawCommand::SecureText(_))));
}
#[test]
fn hidden_plan_contains_only_one_mask_per_unicode_scalar() {
let password = ZsPassword::from("a🙂ä¸");
let render = zs_password_box_render_plan(
Rect {
x: 0,
y: 0,
width: 180,
height: 34,
},
ZsPasswordRevealMode::Peek,
true,
ZsPasswordBoxPlatformStyle::Gtk,
Dpi::standard(),
);
let draw =
zs_password_box_native_draw_plan(&render, &password, ZsPasswordRevealMode::Peek, false);
assert!(draw.commands.iter().any(|command| matches!(
command,
NativeDrawCommand::Text(text) if text.text == "•••"
)));
assert!(!format!("{draw:?}").contains(password.as_str()));
assert!(render.reveal_button.is_some());
}
#[test]
fn platform_defaults_follow_native_secure_entry_conventions() {
let windows = ZsPasswordBoxMetrics::for_platform(ZsPasswordBoxPlatformStyle::Windows);
let macos = ZsPasswordBoxMetrics::for_platform(ZsPasswordBoxPlatformStyle::Macos);
let gtk = ZsPasswordBoxMetrics::for_platform(ZsPasswordBoxPlatformStyle::Gtk);
assert_eq!(windows.radius, Dp::new(4.0));
assert_eq!(macos.minimum_height, Dp::new(28.0));
assert_eq!(gtk.minimum_height, Dp::new(34.0));
}
}