Selkie.git | t/ | 89-modal-backdrop.rakutest


use Test;
use lib 'lib';

use Notcurses::Native::Types;
use Notcurses::Native::Channel;

use Selkie::Alpha;
use Selkie::Style;
use Selkie::Theme;
use Selkie::Widget::Modal;

=begin pod

C<Selkie::Widget::Modal>'s three backdrop modes, asserted where they are
actually decided: the plane base cells.

Two facts carry the whole feature. First, C<BackdropOpaque> must stay
B<byte-identical> to the pre-alpha Modal — it is what every existing
dialog in every downstream app renders with, and the base-cell word is
the single number that decides whether the screen behind is covered.
Second, the see-through modes need both halves of the mechanism at once:
transparent (or blended) channels B<and> an empty EGC. Channels alone
still paint blanks over the text underneath, because a space is a glyph;
gcluster 0 is the sentinel that makes notcurses's glyph search keep
looking further down the pile.

The expected channel words are hand-derived from the notcurses channel
layout and hard-coded, deliberately — computing them through the code
they are meant to guard would guard nothing.

Everything here is plane-free.

=end pod

plan 11;

# fchannel/bchannel for the default theme's `base` slot: 0x40000000
# ("this channel is an explicit RGB, not the terminal default") OR'd
# with the colour, fg in the high half and bg in the low half.
constant OPAQUE-BASE-CHANNELS = 0x40C0C0C0401A1A2E;

# Both halves 0x60000000: NCALPHA_TRANSPARENT (0x20000000) in the alpha
# field, plus the not-default bit that ncchannel_set_alpha sets for any
# non-opaque alpha. No colour is contributed at all, which is the point.
constant TRANSPARENT-BASE-CHANNELS = 0x6000000060000000;

# Both halves 0x50000000: NCALPHA_BLEND (0x10000000) over black
# (0x000000) with the not-default bit — a 50/50 mix with whatever the
# compositor has accumulated underneath, on both channels.
constant SCRIM-CHANNELS = 0x5000000050000000;

subtest "backdrop defaults to opaque" => {
    plan 3;
    my $m = Selkie::Widget::Modal.new;
    is $m.backdrop, BackdropOpaque, "BackdropOpaque by default";
    ok $m.dim-background, "dim-background reads True";
    is BackdropMode.enums.elems, 3, "three modes, no more";
};

subtest "dim-background => False maps to BackdropNone" => {
    plan 2;
    my $m = Selkie::Widget::Modal.new(dim-background => False);
    is $m.backdrop, BackdropNone, "legacy boolean lands on BackdropNone";
    nok $m.dim-background, "and reads back False";
};

subtest "dim-background is derived, never stored alongside backdrop" => {
    plan 3;
    ok Selkie::Widget::Modal.new(backdrop => BackdropOpaque).dim-background,
        "opaque dims";
    ok Selkie::Widget::Modal.new(backdrop => BackdropScrim).dim-background,
        "scrim still draws a backdrop plane";
    nok Selkie::Widget::Modal.new(backdrop => BackdropNone).dim-background,
        "none doesn't";
};

subtest "an explicit backdrop wins over dim-background" => {
    plan 3;
    my $m = Selkie::Widget::Modal.new(
        dim-background => False, backdrop => BackdropScrim,
    );
    is $m.backdrop, BackdropScrim, "explicit mode kept";
    ok $m.dim-background, "…and dim-background follows it";

    my $t = Selkie::Widget::Modal.new(
        dim-background => True, backdrop => BackdropNone,
    );
    is $t.backdrop, BackdropNone, "in both directions";
};

subtest "BYTE IDENTITY — the opaque base cell is unchanged" => {
    plan 4;
    # The modal's own plane is full-screen. If this word or this glyph
    # moves, every existing dialog either stops covering the screen or
    # covers it in the wrong colour.
    my $m = Selkie::Widget::Modal.new;
    is $m.base-channels, OPAQUE-BASE-CHANNELS,
        "opaque base-channels is byte-identical to the pre-alpha value";
    is $m.base-egc, ' ', "and the base glyph is still a space";
    is-deeply ($m.base-style.fg, $m.base-style.bg),
        (Selkie::Theme.default.base.fg, Selkie::Theme.default.base.bg),
        "base-style is the theme's base slot, untouched";
    is (ncchannels_fg_alpha($m.base-channels),
        ncchannels_bg_alpha($m.base-channels)),
        (NCALPHA_OPAQUE, NCALPHA_OPAQUE), "both channels opaque";
};

subtest "see-through modes make the modal's own plane contribute nothing" => {
    plan 8;
    for BackdropScrim, BackdropNone -> $mode {
        my $m = Selkie::Widget::Modal.new(backdrop => $mode);
        is $m.base-egc, '',
            "$mode: empty EGC — gcluster 0, so the glyph search falls through";
        is $m.base-style.effective-fg-alpha, AlphaTransparent,
            "$mode: fg transparent";
        is $m.base-style.effective-bg-alpha, AlphaTransparent,
            "$mode: bg transparent";
        is $m.base-channels, TRANSPARENT-BASE-CHANNELS,
            "$mode: base-channels is the transparent word";
    }
};

subtest "scrim-channels comes from the modal-scrim theme slot" => {
    plan 5;
    my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
    is $m.scrim-channels, SCRIM-CHANNELS,
        "default scrim is black, blended on both channels";
    is ncchannels_fg_alpha($m.scrim-channels), NCALPHA_BLEND, "fg blends";
    is ncchannels_bg_alpha($m.scrim-channels), NCALPHA_BLEND, "bg blends";
    is ncchannels_fg_rgb($m.scrim-channels), 0x000000, "fg is black";
    is ncchannels_bg_rgb($m.scrim-channels), 0x000000, "bg is black";
};

subtest "a darker scrim is a darker colour, not more alpha" => {
    plan 3;
    # The whole point of the "no fractional alpha" rule: the only knob
    # is the slot's RGB. Alpha stays AlphaBlend either way.
    my $theme = Selkie::Theme.default.clone(
        modal-scrim => Selkie::Style.new(
            fg => 0x101018, bg => 0x101018,
            fg-alpha => AlphaBlend, bg-alpha => AlphaBlend,
        ),
    );
    my $m = Selkie::Widget::Modal.new(backdrop => BackdropScrim);
    $m.set-theme($theme);
    is ncchannels_bg_rgb($m.scrim-channels), 0x101018, "custom scrim colour lands";
    is ncchannels_bg_alpha($m.scrim-channels), NCALPHA_BLEND, "still one blend layer";
    isnt $m.scrim-channels, SCRIM-CHANNELS, "…and a different word than the default";
};

subtest "set-backdrop re-syncs the base cell and marks dirty" => {
    plan 8;
    my $m = Selkie::Widget::Modal.new;
    $m.clear-dirty;
    $m.set-backdrop(BackdropScrim);
    is $m.backdrop, BackdropScrim, "mode switched";
    ok $m.is-dirty, "marked dirty";
    is $m.base-egc, '', "base EGC re-derived";
    is $m.base-channels, TRANSPARENT-BASE-CHANNELS, "base channels re-derived";

    $m.clear-dirty;
    $m.set-backdrop(BackdropOpaque);
    ok $m.is-dirty, "marked dirty going back";
    is $m.base-egc, ' ', "base EGC back to a space";
    is $m.base-channels, OPAQUE-BASE-CHANNELS, "base channels back to the theme base";

    $m.clear-dirty;
    $m.set-backdrop(BackdropOpaque);
    nok $m.is-dirty, "no-op when the mode is already active";
};

subtest "backdrop follows a theme swap" => {
    plan 2;
    my $theme = Selkie::Theme.default.clone(
        base => Selkie::Style.new(fg => 0x102030, bg => 0x405060),
    );
    my $m = Selkie::Widget::Modal.new;
    $m.set-theme($theme);
    is ncchannels_bg_rgb($m.base-channels), 0x405060,
        "opaque base tracks the live theme";

    $m.set-backdrop(BackdropNone);
    is $m.base-channels, TRANSPARENT-BASE-CHANNELS,
        "…and a see-through mode ignores it entirely";
};

subtest "theme slots default so framing changes nothing visually" => {
    plan 4;
    my $t = Selkie::Theme.default;
    is-deeply ($t.modal-frame.fg, $t.modal-frame.bg),
        ($t.border.fg, $t.border.bg), "modal-frame matches border";
    is-deeply ($t.modal-title.fg, $t.modal-title.bold),
        ($t.overlay-title.fg, $t.overlay-title.bold),
        "modal-title matches overlay-title";
    is-deeply ($t.modal-key.fg, $t.modal-key.bold),
        ($t.overlay-key.fg, $t.overlay-key.bold),
        "modal-key matches overlay-key";
    # A theme that predates these slots gets them for free.
    my $legacy = Selkie::Theme.new(
        base              => Selkie::Style.new(fg => 0x111111, bg => 0x222222),
        border            => Selkie::Style.new(fg => 0x333333),
        border-focused    => Selkie::Style.new(fg => 0x444444),
        text              => Selkie::Style.new(fg => 0x555555),
        text-dim          => Selkie::Style.new(fg => 0x666666),
        text-highlight    => Selkie::Style.new(fg => 0x777777),
        input             => Selkie::Style.new(fg => 0x888888),
        input-focused     => Selkie::Style.new(fg => 0x999999),
        input-placeholder => Selkie::Style.new(fg => 0xAAAAAA),
        scrollbar-track   => Selkie::Style.new(fg => 0xBBBBBB),
        scrollbar-thumb   => Selkie::Style.new(fg => 0xCCCCCC),
        divider           => Selkie::Style.new(fg => 0xDDDDDD),
        tab-active        => Selkie::Style.new(fg => 0xEEEEEE),
        tab-inactive      => Selkie::Style.new(fg => 0xFFFFFF),
    );
    is-deeply ($legacy.slot('modal-frame').fg,
               $legacy.slot('modal-title').fg,
               $legacy.slot('modal-key').fg,
               $legacy.slot('modal-scrim').bg-alpha),
        (0x333333, 0x444444, 0x777777, AlphaBlend),
        "a pre-S4 theme derives all four slots";
};