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'";
}
}
}