Selkie.git | lib/Selkie/ | Style.rakumod


=begin pod

=head1 NAME

Selkie::Style - Text styling (colors + bold/italic/underline/strikethrough)

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::Style;

# Bright cyan bold text
my $s1 = Selkie::Style.new(fg => 0x7AA2F7, bold => True);

# White on dark background, italic
my $s2 = Selkie::Style.new(fg => 0xFFFFFF, bg => 0x1A1A2E, italic => True);

# Apply to a widget
my $text = Selkie::Widget::Text.new(
    text   => 'hello',
    style  => $s1,
    sizing => Sizing.fixed(1),
);

=end code

=head1 DESCRIPTION

C<Selkie::Style> represents the visual attributes of rendered text:
foreground color, background color, and a set of text-style flags (bold,
italic, underline, strikethrough). Colors are 24-bit RGB integers in the
form C<0xRRGGBB>.

Widgets apply styles to their planes via C<self.apply-style($style)> in
their C<render> method. The framework provides sensible defaults through
L<Selkie::Theme> — you usually get a style from the theme rather than
constructing one directly.

=head1 EXAMPLES

=head2 Using theme-provided styles

Most widgets should pull styles from the theme so the app's palette
stays consistent:

=begin code :lang<raku>

method render() {
    return without self.plane;
    self.apply-style(self.theme.text);            # default text
    ncplane_putstr_yx(self.plane, 0, 0, 'normal');

    self.apply-style(self.theme.text-highlight);  # emphasised
    ncplane_putstr_yx(self.plane, 1, 0, 'selected');

    self.clear-dirty;
}

=end code

=head2 Overlaying an override on a theme style

Combine a base theme style with widget-local tweaks via C<merge>:

=begin code :lang<raku>

my $base = self.theme.text;
my $warning-variant = $base.merge(Selkie::Style.new(fg => 0xFF5555, bold => True));
self.apply-style($warning-variant);

=end code

C<merge> takes the non-null values of the override, falling back to the
base for anything the override doesn't set. Bold/italic/underline/strike
are logical-OR — if either side has the flag, the result has it.

=head2 Alpha

A style also carries an alpha mode per channel: C<fg-alpha> and
C<bg-alpha>, both L<Selkie::Alpha>'s C<AlphaMode>. They follow the same
"undefined means don't-care" convention the colours do — an undefined
alpha resolves to C<AlphaOpaque>, which is what every widget has always
rendered with, so a style that ignores them is unchanged.

The enum lives in its own module, so bring it into scope alongside
C<Selkie::Style>. (C<use Selkie> loads it but does not import the enum's
names — same as C<Sizing> and the C<BorderKind> family; reach them as
C<Selkie::Alpha::AlphaBlend> or C<use Selkie::Alpha> for the short
names.)

=begin code :lang<raku>

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

# 50/50 mix with whatever is on the planes underneath.
my $scrim = Selkie::Style.new(
    fg => 0x000000, bg => 0x000000,
    fg-alpha => AlphaBlend, bg-alpha => AlphaBlend,
);

$scrim.effective-fg-alpha;                     # AlphaBlend
Selkie::Style.new.effective-bg-alpha;          # AlphaOpaque (undefined ⇒ opaque)

=end code

Read L<Selkie::Alpha> before reaching for these: alpha is a four-value
enum with no intermediate states, so "40% opacity" and "fade the alpha
out" are not expressible. Fades interpolate B<colour>.

C<AlphaHighContrast> is a foreground-only mode; notcurses refuses it on
a background channel. Constructing a style with C<bg-alpha =E<gt>
AlphaHighContrast> throws rather than handing notcurses a write it will
silently drop:

=begin code :lang<raku>

Selkie::Style.new(bg-alpha => AlphaHighContrast);   # dies
Selkie::Style.new(fg-alpha => AlphaHighContrast);   # fine

=end code

=head2 Greyscale

C<greyscale> returns a desaturated copy: both colours collapse onto the
grey axis, everything else — flags, alpha modes, and the "undefined
means inherit" state of a colour that was never set — is carried
through untouched.

=begin code :lang<raku>

my $s = Selkie::Style.new(fg => 0x00FF00, bg => 0x1A1A2E, bold => True);
my $g = $s.greyscale;

$g.fg;      # 0x959595
$g.bg;      # 0x1C1C1C
$g.bold;    # True    — flags survive
$g.fg-alpha;             # whatever $s had
Selkie::Style.new.greyscale.fg;   # UInt type object — still inherits

=end code

This is the same conversion notcurses's own C<ncplane_greyscale>
applies, reproduced bit-for-bit. Selkie doesn't call that function —
see L<Selkie::Widget>'s C<greyscale-plane> for why — but matching it
exactly is still the right target: it is the reference implementation
every terminal-side expectation is written against, and staying on it
means a style greyed here and a plane greyed by C land on identical
RGB values.

Matching it means matching its arithmetic. The weights are Rec. 601
luma (C<0.299 R + 0.587 G + 0.114 B>) and the result is B<truncated>,
not rounded, because C's C<(int)> cast truncates:

=begin code :lang<raku>

Selkie::Style.greyscale-rgb(0x00FF00);   # 0x959595 — 149, not 150
Selkie::Style.greyscale-rgb(0xFFFFFF);   # 0xFFFFFF
Selkie::Style.greyscale-rgb(0x7AA2F7);   # 0x9F9F9F — 159, not 160

=end code

=head2 The packed channel word

C<channels> renders a style into the single 64-bit C<channels> word
notcurses passes around — both colours and both alpha modes, in the
layout C<ncplane_set_base> and friends expect. It is pure, so it is the
thing to assert against when you want to know what a style will
actually produce on a plane without having a plane:

=begin code :lang<raku>

Selkie::Style.new(fg => 0xC0C0C0, bg => 0x1A1A2E).channels;
# 0x40C0C0C0401A1A2E

=end code

An undefined colour leaves its half of the word alone (notcurses reads
that as "use the terminal default"), and C<AlphaOpaque> is skipped
entirely because a zeroed channel already reads as opaque.

=head1 SEE ALSO

=item L<Selkie::Alpha> — the C<AlphaMode> enum and what each mode actually does
=item L<Selkie::Theme> — collects named styles into a palette
=item L<Selkie::Widget> — every widget's C<apply-style> method takes one of these; C<base-channels> and C<apply-disabled-effect> are built on C<channels> and C<greyscale>

=end pod

unit class Selkie::Style;

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

use Selkie::Alpha;

#| Foreground color as a 24-bit RGB integer (C<0xRRGGBB>). Leave undefined
#| to inherit from the surrounding context.
has UInt $.fg;

#| Background color as a 24-bit RGB integer (C<0xRRGGBB>). Leave undefined
#| to inherit.
has UInt $.bg;

#| Render text in bold.
has Bool $.bold = False;

#| Render text in italic.
has Bool $.italic = False;

#| Render text underlined.
has Bool $.underline = False;

#| Render text with strikethrough.
has Bool $.strikethrough = False;

#|( How this style's foreground composites against the planes beneath
    it. Leave undefined for C<AlphaOpaque> — the historical, and
    overwhelmingly common, behaviour. See L<Selkie::Alpha>. )
has AlphaMode $.fg-alpha;

#|( How this style's background composites against the planes beneath
    it. Leave undefined for C<AlphaOpaque>. C<AlphaHighContrast> is not
    a legal value here and throws at construction — notcurses defines it
    for foregrounds only. See L<Selkie::Alpha>. )
has AlphaMode $.bg-alpha;

submethod TWEAK() {
    # Fail at the construction site rather than at the FFI boundary.
    # ncchannels_set_bg_alpha returns -1 for NCALPHA_HIGHCONTRAST and
    # leaves the channel untouched, so without this the symptom is a
    # background alpha that silently refuses to take effect, several
    # layers away from the style that asked for it.
    if $!bg-alpha.defined && $!bg-alpha === AlphaHighContrast {
        die "Selkie::Style: AlphaHighContrast is a foreground-only alpha "
          ~ "mode — notcurses rejects it on a background channel. Use it "
          ~ "as fg-alpha, or pick AlphaBlend / AlphaTransparent for bg-alpha.";
    }
}

#|( This style's foreground alpha mode, resolving an undefined
    C<fg-alpha> to C<AlphaOpaque>. Use this rather than the raw
    attribute anywhere you're about to act on the value. )
method effective-fg-alpha(--> AlphaMode) { $!fg-alpha // AlphaOpaque }

#|( This style's background alpha mode, resolving an undefined
    C<bg-alpha> to C<AlphaOpaque>. )
method effective-bg-alpha(--> AlphaMode) { $!bg-alpha // AlphaOpaque }

#|( Return the notcurses style bitmask for the set of boolean flags
    enabled on this style. Widgets use this internally via
    C<apply-style>; you don't normally need to call it. )
method styles(--> UInt) {
    my UInt $s = 0;
    $s +|= NCSTYLE_BOLD      if $!bold;
    $s +|= NCSTYLE_ITALIC    if $!italic;
    $s +|= NCSTYLE_UNDERLINE if $!underline;
    $s +|= NCSTYLE_STRUCK    if $!strikethrough;
    $s;
}

#|( The packed 64-bit notcurses C<channels> word for this style: both
    colours and both alpha modes, in the layout every C<ncplane_*> call
    that takes a C<channels> argument expects. Pure — no plane needed,
    nothing written — so it's what to assert against when you want to
    know what a style will actually put on a plane.

    Colours and alpha share the word. C<ncchannel_set> preserves the two
    alpha bits and C<ncchannel_set_alpha> preserves the RGB bits, so the
    writes compose in either order; the opaque case is skipped entirely
    because a zeroed channel already reads as C<NCALPHA_OPAQUE>, which
    keeps the produced word bit-identical to what Selkie emitted before
    alpha existed. An undefined C<fg> or C<bg> leaves that channel's
    "use the default colour" bit clear, exactly as before.

    This is the single implementation behind C<Selkie::Widget>'s
    C<base-channels> and C<Selkie::Widget::Modal>'s C<scrim-channels> —
    anywhere Selkie primes a plane's base cell, the word comes from
    here. )
method channels(--> UInt) {
    my uint64 $channels = 0;
    ncchannels_set_fg_rgb($channels, $!fg) if $!fg.defined;
    ncchannels_set_bg_rgb($channels, $!bg) if $!bg.defined;
    my $fg-alpha = self.effective-fg-alpha;
    ncchannels_set_fg_alpha($channels, alpha-constant($fg-alpha))
        unless $fg-alpha === AlphaOpaque;
    my $bg-alpha = self.effective-bg-alpha;
    ncchannels_set_bg_alpha($channels, alpha-constant($bg-alpha))
        unless $bg-alpha === AlphaOpaque;
    $channels;
}

#|( Collapse a 24-bit C<0xRRGGBB> colour onto the grey axis, returning
    another C<0xRRGGBB> with all three components equal.

    Deliberately reproduces C<ncplane_greyscale>'s arithmetic exactly:
    Rec. 601 luma weights (C<0.299 R + 0.587 G + 0.114 B>) and a
    B<truncating> conversion to an integer, because the C side ends in
    an C<(int)> cast. Rounding instead would be off by one on most
    colours — C<0x00FF00> greys to 149, not 150 — and the two paths
    would visibly disagree wherever Selkie greys a base cell in Raku
    next to cells notcurses greyed in C.

    Computed in exact integer arithmetic (C<299R + 587G + 114B> over
    1000) rather than floating point. That is not just tidier: it also
    keeps the achromatic identity C<grey(v,v,v) == v> that a naive
    C<0.299 + 0.587 + 0.114> in binary floating point loses, since the
    three weights sum to a hair under 1.0 and truncation then eats the
    last unit.

    Bits above the low 24 are ignored, matching notcurses's own
    component extraction.

    Invocant-independent — call it on the class:
    C<Selkie::Style.greyscale-rgb(0x7AA2F7)>. )
method greyscale-rgb(UInt $rgb --> UInt) {
    my UInt $r = ($rgb +> 16) +& 0xFF;
    my UInt $g = ($rgb +>  8) +& 0xFF;
    my UInt $b =  $rgb        +& 0xFF;
    my UInt $v = (299 * $r + 587 * $g + 114 * $b) div 1000;
    $v * 0x010101;
}

# Bit layout of one 32-bit half of a notcurses channels word. Neither
# mask is exported by Notcurses::Native, so they are named here rather
# than left as magic numbers at the point of use.
#
#   0x40000000  set = "this channel carries a real colour". CLEAR means
#               "use the default", which notcurses resolves by looking
#               further down the plane pile — the mechanism Selkie's
#               base cells and see-through widgets depend on.
#   0x08000000  set = the low bits are a palette index, not RGB.
#   0x30000000  the two alpha bits.
constant CHANNEL-NOT-DEFAULT = 0x40000000;
constant CHANNEL-PALETTE     = 0x08000000;
constant CHANNEL-RGB         = 0x00FFFFFF;
constant CHANNEL-FLAGS       = 0xFF000000;

#|( Grey one 32-bit channel — half of a C<channels> word — leaving
    everything that is not a colour exactly as it was.

    Three cases, and the two that do nothing are the important ones:

    =item A channel marked "use the default colour" is B<returned untouched>. A default channel is not a colour; notcurses resolves it against the planes underneath, which is how a widget inherits its background from its own base cell (and how see-through widgets work at all). Greying it would mean picking a colour — and since a default channel reads back as C<0, 0, 0>, that colour would be black. notcurses's own C<ncplane_greyscale> does exactly that, which is why Selkie does not use it.
    =item A palette-indexed channel is returned untouched: an index into a terminal-defined palette has no RGB to average, and rewriting it as RGB would silently opt the cell out of the palette.
    =item Anything else has its RGB greyed and its flag bits — alpha included — copied straight through.

    Pure and invocant-independent: C<Selkie::Style.greyscale-channel($c)>. )
method greyscale-channel(UInt $channel --> UInt) {
    return $channel unless $channel +& CHANNEL-NOT-DEFAULT;
    return $channel if     $channel +& CHANNEL-PALETTE;
    ($channel +& CHANNEL-FLAGS)
        +| self.greyscale-rgb($channel +& CHANNEL-RGB);
}

#|( Grey a full 64-bit C<channels> word — foreground in the high half,
    background in the low half — through C<greyscale-channel>.

    This is what turns a rendered cell grey. C<Selkie::Widget>'s
    disabled support reads each cell's channels, runs them through
    here, and stains the result back; a word that comes out equal to
    what went in (already grey, or nothing but defaults) skips the
    write entirely. )
method greyscale-channels(UInt $channels --> UInt) {
    my UInt $fg = self.greyscale-channel(($channels +> 32) +& 0xFFFFFFFF);
    my UInt $bg = self.greyscale-channel($channels +& 0xFFFFFFFF);
    ($fg +< 32) +| $bg;
}

#|( A desaturated copy of this style: both colours run through
    C<greyscale-rgb>, everything else preserved.

    Flags (bold / italic / underline / strikethrough) and both alpha
    modes carry over unchanged — greying is a colour operation, and a
    blended overlay that goes grey must stay blended or it would
    suddenly occlude what it was tinting. An undefined colour B<stays>
    undefined rather than greying to black: "inherit from the
    surrounding context" is not a colour, and turning it into one would
    silently opt a widget out of its parent's palette.

    Used by C<Selkie::Widget.apply-disabled-effect> to re-stamp a
    greyed base cell, which the framebuffer walk cannot reach. )
method greyscale(--> Selkie::Style) {
    Selkie::Style.new(
        fg            => $!fg.defined ?? self.greyscale-rgb($!fg) !! UInt,
        bg            => $!bg.defined ?? self.greyscale-rgb($!bg) !! UInt,
        bold          => $!bold,
        italic        => $!italic,
        underline     => $!underline,
        strikethrough => $!strikethrough,
        fg-alpha      => $!fg-alpha,
        bg-alpha      => $!bg-alpha,
    );
}

#|( Combine this style with an override, producing a new style. Any
    color on the override takes precedence; any flag set on either
    side is set on the result (logical OR). Useful for producing
    variants of a theme style without replacing the whole thing.

    Alpha modes follow the colours, not the flags: an alpha the
    override sets wins outright, an alpha it leaves undefined inherits
    from the base. Because C<AlphaOpaque> is a value like any other,
    an override that explicitly asks for C<AlphaOpaque> pins the result
    opaque even over a blended base — that's the escape hatch for
    opting a single element out of a translucent parent style. )
method merge(Selkie::Style $override --> Selkie::Style) {
    Selkie::Style.new(
        fg            => $override.fg            // $!fg,
        bg            => $override.bg            // $!bg,
        bold          => $override.bold          || $!bold,
        italic        => $override.italic        || $!italic,
        underline     => $override.underline     || $!underline,
        strikethrough => $override.strikethrough || $!strikethrough,
        fg-alpha      => $override.fg-alpha      // $!fg-alpha,
        bg-alpha      => $override.bg-alpha      // $!bg-alpha,
    );
}