Selkie.git | lib/Selkie/ | Alpha.rakumod


=begin pod

=head1 NAME

Selkie::Alpha - The four alpha modes a cell's foreground or background can carry

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Alpha;
use Selkie::Style;

# A style that lets whatever is underneath show through, mixed 50/50
# with this style's own colours.
my $scrim = Selkie::Style.new(
    fg       => 0x000000,
    bg       => 0x000000,
    fg-alpha => AlphaBlend,
    bg-alpha => AlphaBlend,
);

# A style that contributes no background at all — the plane beneath
# paints the cell's background unchanged.
my $ghost = Selkie::Style.new(fg => 0xC0C0C0, bg-alpha => AlphaTransparent);

# The raw notcurses constant, if you're calling the bindings yourself.
alpha-constant(AlphaBlend);   # NCALPHA_BLEND

=end code

=head1 DESCRIPTION

B<Alpha in notcurses is a four-value enum, not a number.> There is no
C<0.35 opacity>. Each of a cell's two channels (foreground and
background) carries two bits, and those two bits select one of exactly
four behaviours:

=item B<C<AlphaOpaque>> — the default. This cell's colour is the colour. Nothing below shows through.
=item B<C<AlphaBlend>> — mix this cell's colour 50/50 with whatever the compositor has accumulated underneath it.
=item B<C<AlphaTransparent>> — contribute no colour at all; the accumulated colour below passes through untouched.
=item B<C<AlphaHighContrast>> — foreground only. Notcurses picks a foreground colour that contrasts with the resolved background, ignoring the style's own C<fg>.

Every widget starts fully opaque, so if you never touch these you get
exactly the rendering Selkie has always produced.

=head2 One blend layer over opaque is an exact 50/50 mix

C<AlphaBlend> is not "50% opacity" in the sense a compositing graphics
API would mean it. It is a single averaging step, applied once per plane
as notcurses walks the pile from top to bottom. Put one C<AlphaBlend>
plane over an opaque plane and the result is the exact arithmetic mean
of the two colours, per channel:

=begin code :lang<raku>

# Black scrim (0x000000, AlphaBlend) over a 0x808080 background
# resolves to 0x404040 — (0x00 + 0x80) / 2 per component.

=end code

The arithmetic is integer and truncating, so an odd sum rounds down:
white (0xFFFFFF) under the same black scrim comes out 0x7F7F7F, not
0x808080. Both channels blend independently — the scrim dims the glyphs
showing through it as well as the background behind them.

Stack a second C<AlphaBlend> plane on top of that and you average again
against the already-averaged result: 0x000000 blended onto 0x404040
gives 0x202020. So layers compound geometrically (½, ¼, ⅛ …) rather than
letting you dial in an arbitrary fraction.

=head2 There is no continuous alpha — interpolate colours, not alpha

This is the single most important thing to internalise before you write
a fade. You cannot animate C<AlphaOpaque → AlphaBlend> through
intermediate values, because there are none; the two bits either say
C<blend> or they don't. Anything that wants a smooth fade must
interpolate B<colour>:

=begin code :lang<raku>

# WRONG — there is no 30%-blend state to land on.
# my $a = lerp-alpha(AlphaOpaque, AlphaBlend, 0.3);

# RIGHT — hold the alpha mode fixed and lerp the RGB towards the
# background you're fading into.
sub lerp-rgb(UInt $from, UInt $to, Num() $t --> UInt) {
    my @f = ($from +> 16) +& 0xFF, ($from +> 8) +& 0xFF, $from +& 0xFF;
    my @t = ($to   +> 16) +& 0xFF, ($to   +> 8) +& 0xFF, $to   +& 0xFF;
    my @m = (^3).map: { (@f[$_] + (@t[$_] - @f[$_]) * $t).Int };
    (@m[0] +< 16) +| (@m[1] +< 8) +| @m[2];
}

my $faded = Selkie::Style.new(fg => lerp-rgb(0xC0C0C0, 0x1A1A2E, $t));

=end code

The same rule applies to "make the scrim darker": you do not add more
alpha, you pick a darker scrim colour. A pure-black C<AlphaBlend> scrim
is as dark as one blend layer gets; going further means either a second
layer or accepting the 50% and choosing your colours accordingly.

=head2 C<AlphaHighContrast> is foreground-only

Notcurses rejects C<NCALPHA_HIGHCONTRAST> outright on a background
channel — C<ncchannels_set_bg_alpha> returns C<-1> and the write is
silently dropped. Selkie does not let you get that far: constructing a
L<Selkie::Style> with C<bg-alpha =E<gt> AlphaHighContrast> throws, so the
mistake surfaces at the construction site with a name attached rather
than as a background that mysteriously refuses to change.

On a foreground it means "compute a colour that reads against whatever
ends up behind this glyph" — useful for text over unpredictable content
(an image, a gradient), and the one case where the style's own C<fg> is
ignored.

=head2 Transparent backgrounds and the glyph search

C<AlphaTransparent> on a background makes the cell contribute no
background colour. It does not make the cell's I<glyph> disappear — a
space is still a space, and it will still cover whatever is beneath it.
The way to let a glyph from a lower plane show through is to give the
cell no glyph at all: a base cell primed with the empty string has
gcluster 0, which is the sentinel notcurses's glyph search treats as
"keep looking further down the pile". That combination — empty EGC plus
transparent or blended channels — is what makes a see-through overlay
possible. See L<Selkie::Widget>'s C<base-egc> hook.

=head1 EXAMPLES

=head2 Dimming a whole screen behind an overlay

=begin code :lang<raku>

# On the backdrop plane: no glyph of its own, black at 50%.
my $scrim = Selkie::Style.new(
    fg => 0x000000, bg => 0x000000,
    fg-alpha => AlphaBlend, bg-alpha => AlphaBlend,
);

=end code

=head2 A label that keeps the panel's background

Leaving C<bg> undefined inherits whatever background was last applied to
the plane. C<AlphaTransparent> is stronger: it removes this cell from
the background computation entirely, so the plane I<beneath> this one
supplies the colour.

=begin code :lang<raku>

my $over-image = Selkie::Style.new(fg-alpha => AlphaHighContrast, bg-alpha => AlphaTransparent);

=end code

=head1 SEE ALSO

=item L<Selkie::Style> — carries C<fg-alpha> / C<bg-alpha> and merges them
=item L<Selkie::Widget> — C<apply-style> pushes them to the plane; C<base-style> / C<base-egc> control the base cell

=end pod

unit module Selkie::Alpha;

use Notcurses::Native::Types;

#|( The four alpha behaviours a channel can carry.

    Notcurses stores this in two bits per channel, so these four values
    are the entire space — there is nothing between C<AlphaOpaque> and
    C<AlphaBlend>. See the module Pod for what that means for fades.

    C<AlphaHighContrast> is legal on a foreground only; L<Selkie::Style>
    throws if you put it on a background. )
enum AlphaMode is export (
    AlphaOpaque       => 'opaque',
    AlphaBlend        => 'blend',
    AlphaTransparent  => 'transparent',
    AlphaHighContrast => 'high-contrast',
);

#|( The raw C<NCALPHA_*> constant for an C<AlphaMode>, ready to hand to
    C<ncplane_set_fg_alpha>, C<ncchannels_set_bg_alpha>, and friends.

    You only need this if you're calling the notcurses bindings
    directly; C<Selkie::Widget.apply-style> does the translation for
    styled output, and C<base-style> does it for the plane's base cell.

        alpha-constant(AlphaOpaque);       # 0x00000000
        alpha-constant(AlphaBlend);        # 0x10000000
        alpha-constant(AlphaTransparent);  # 0x20000000
        alpha-constant(AlphaHighContrast); # 0x30000000 )
sub alpha-constant(AlphaMode:D $mode --> UInt) is export {
    given $mode {
        when AlphaOpaque       { NCALPHA_OPAQUE       }
        when AlphaBlend        { NCALPHA_BLEND        }
        when AlphaTransparent  { NCALPHA_TRANSPARENT  }
        when AlphaHighContrast { NCALPHA_HIGHCONTRAST }
        default {
            die "Selkie::Alpha: no notcurses constant for AlphaMode '$mode'";
        }
    }
}