Selkie.git | lib/Selkie/Widget/ | PasswordStrength.rakumod


=begin pod

=head1 NAME

Selkie::Widget::PasswordStrength - Live password-strength meter bound to a TextInput

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Widget::TextInput;
use Selkie::Widget::PasswordStrength;
use Selkie::Sizing;

my $pw = Selkie::Widget::TextInput.new(
    sizing      => Sizing.fixed(1),
    placeholder => 'Enter password...',
    mask-char   => '*',
);

my $meter = Selkie::Widget::PasswordStrength.new(
    sizing => Sizing.fixed(1),
    input  => $pw,
);

$vbox.add($pw);
$vbox.add($meter);

=end code

=head1 DESCRIPTION

Non-focusable widget that subscribes to a C<TextInput>'s C<on-change>
Supply and renders a five-level strength meter in response.

Scoring is a simple length-plus-character-class heuristic — long
enough for "password1" to look weak and "Correct Horse Battery Staple
Example!" to look strong, without pulling in a dictionary or an
external dependency. For serious analysis use C<zxcvbn>; this is
meant to give users directional feedback while they type.

=head2 Levels

=item B<weak>        (score < 20)  — C<password-weak> theme slot
=item B<fair>        (score < 40)  — C<password-fair> theme slot
=item B<good>        (score < 60)  — C<password-good> theme slot
=item B<strong>      (score < 80)  — C<password-strong> theme slot
=item B<very strong> (score >= 80) — C<password-very-strong> theme slot

An empty password uses C<password-empty>. The unfilled part of the
bar uses C<text-dim>.

=head2 Score formula

    score = min(100, length * (1 + 0.5 * (classes-1)) * class-bonus)

Where C<classes> is the count of used character classes
(lowercase / uppercase / digits / symbols) and C<class-bonus> rewards
mixed-class passwords.

=head1 ATTRIBUTES

=item C<input> — the C<TextInput> to watch. Required.
=item C<show-label> — include the "weak"/"fair"/etc. label beside the
  bar. Default True.

=end pod

use Selkie::Widget;
use Selkie::Widget::TextInput;
use Selkie::Style;

use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;

unit class Selkie::Widget::PasswordStrength does Selkie::Widget;

has Selkie::Widget::TextInput $.input is required;
has Bool $.show-label = True;

has UInt $!score = 0;
has Str $!label = '';
has Str $!level-slot = 'password-empty';

my constant @LEVELS =
    { threshold =>   0, label => '',            slot => 'password-empty' },
    { threshold =>   1, label => 'weak',        slot => 'password-weak' },
    { threshold =>  20, label => 'fair',        slot => 'password-fair' },
    { threshold =>  40, label => 'good',        slot => 'password-good' },
    { threshold =>  60, label => 'strong',      slot => 'password-strong' },
    { threshold =>  80, label => 'very strong', slot => 'password-very-strong' },
;

#| Constructor. Defaults C<focusable> to False (the meter is purely
#| display). Required: C<:input> — the C<TextInput> to subscribe to.
#| Optional: C<:show-label> (default True) toggles the textual
#| level beside the bar.
method new(*%args --> Selkie::Widget::PasswordStrength) {
    %args<focusable> //= False;
    callwith(|%args);
}

submethod TWEAK() {
    # Bind to the watched input. Recompute on every keystroke.
    $!input.on-change.tap: -> $text {
        self!update($text);
    };
    # Initial compute in case the input already has content.
    self!update($!input.text);
}

method !update(Str $text) {
    $!score = score-password($text);
    my %lvl = level-for($!score);
    $!label = %lvl<label>;
    $!level-slot = %lvl<slot>;
    self.mark-dirty;
}

#| The most recent computed score, 0..100.
method score(--> UInt) { $!score }

#| The most recent level label ('', 'weak', ..., 'very strong').
method label(--> Str) { $!label }

method render() {
    return without self.plane;

    my UInt $cols = self.cols;
    return if $cols < 1;

    ncplane_erase(self.plane);

    # Reserve space for " <label>" if show-label and we have a label.
    my Str $suffix = ($!show-label && $!label.chars)
        ?? " {$!label}"
        !! '';
    my UInt $bar-width = ($cols - $suffix.chars) max 1;
    my UInt $filled = ($!score * $bar-width / 100).floor.UInt min $bar-width;

    my $level-style = self.theme.slot($!level-slot);
    self.apply-style($level-style);
    for ^$filled -> $x {
        ncplane_putstr_yx(self.plane, 0, $x, '█');
    }

    # Unfilled portion — dim.
    self.apply-style(self.theme.text-dim);
    for $filled ..^ $bar-width -> $x {
        ncplane_putstr_yx(self.plane, 0, $x, '░');
    }

    # Label (if any) in the level style.
    if $suffix.chars {
        self.apply-style($level-style.merge(Selkie::Style.new(bold => True)));
        ncplane_putstr_yx(self.plane, 0, $bar-width, $suffix);
    }

    self.clear-dirty;
}

# --- Scoring ------------------------------------------------------------

#|( Score a password 0..100 using a simple length + character-class
    heuristic. Intentionally simple and external-dependency-free.
    Not a substitute for zxcvbn; good enough to tell users
    directionally whether their choice is terrible, okay, or solid. )
sub score-password(Str $text --> UInt) is export(:DEFAULT, :scoring) {
    return 0 unless $text.chars;

    my $lower  = $text.contains(/<[a..z]>/);
    my $upper  = $text.contains(/<[A..Z]>/);
    my $digit  = $text.contains(/<[0..9]>/);
    my $symbol = $text.contains(/<-[a..z A..Z 0..9]>/);
    my $classes = ($lower, $upper, $digit, $symbol).grep(?*).elems;

    my $len = $text.chars;
    my $class-bonus = $classes >= 3 ?? 1.2
                    !! $classes == 2 ?? 1.0
                    !! 0.7;
    my $len-score = min($len * 4, 60);
    my $class-score = ($classes - 1) * 8;
    my $raw = ($len-score + $class-score) * $class-bonus;

    (min($raw, 100).Int max 0).UInt;
}

sub level-for(UInt $score --> Hash) is export(:scoring) {
    my %match = @LEVELS[0];
    for @LEVELS -> %l {
        %match = %l if $score >= %l<threshold>;
    }
    %match;
}