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,
);
}