Selkie.git | lib/Selkie/Widget/ | Text.rakumod
=begin pod
=head1 NAME
Selkie::Widget::Text - Static styled text with word-wrap
=head1 SYNOPSIS
=begin code :lang<raku>
use Selkie::Widget::Text;
use Selkie::Style;
use Selkie::Sizing;
my $header = Selkie::Widget::Text.new(
text => ' My App',
sizing => Sizing.fixed(1),
style => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
);
# Mutate later
$header.set-text(' My App — logged in as Alice');
=end code
=head1 DESCRIPTION
A block of text rendered onto a single plane. Word-wraps automatically
when the text exceeds the widget's width — words longer than the line
are hard-broken at the character level.
Styled via the optional C<style> attribute. If omitted, inherits the
theme's C<text> slot. Pass C<theme-slot> for framework-built text that
should follow a semantic theme slot such as C<overlay-title>.
C<Text> implements C<render-region(offset, height)>, so it plays
correctly with C<Selkie::Widget::ScrollView> for long content.
=head2 Alignment
C<align> takes a L<Selkie::Align> C<TextAlign> and defaults to
C<TextLeft>, which is where C<Text> has always put its lines:
=begin code :lang<raku>
my $banner = Selkie::Widget::Text.new(
text => 'Selkie',
align => TextCenter,
sizing => Sizing.fixed(1),
);
$banner.set-align(TextRight); # marks dirty; repaints next frame
=end code
Three things worth knowing:
=item B<Lines align individually.> A wrapped paragraph under
C<TextCenter> comes out centred line by line — ragged on both sides,
not block-justified.
=item B<Alignment is an offset, not padding.> Selkie writes the line at
a column and leaves the rest of the row untouched, so those cells keep
showing the plane's base cell. Padding the line with spaces would paint
this widget's background across the whole row, which is exactly wrong
under a scrim, a gradient, or any transparent base.
=item B<A line wider than the widget starts at column 0> and clips on
the right, under every alignment. Wrapping normally prevents this; a
single-column widget holding multi-column text is the case that gets
there.
=head2 Widths are counted in characters
C<Text> measures every string with C<.chars> — wrapping, alignment,
C<logical-height>, all of it. For Latin, Greek, Cyrillic and the like
that is also the column count, so alignment lands where you expect.
It is B<not> the column count for East Asian characters, most emoji, or
anything else the terminal draws two cells wide: a centred line of CJK
will sit roughly half its width too far left, because Selkie counted 10
characters where the terminal drew 20 columns. Combining marks go the
other way — they cost a character but no column.
This is a whole-widget property, not an alignment quirk (wrapping has
always had it), and fixing it means a real C<wcswidth>-class width
table. If your content is wide-character text and the ragged edge
matters, size the widget to the text and let a container's
C<align-items> place the widget instead — that arithmetic is in cells,
not characters. See L<Selkie::Align>.
=head1 EXAMPLES
=head2 A header and footer
=begin code :lang<raku>
$vbox.add: Selkie::Widget::Text.new(
text => 'Selkie App',
sizing => Sizing.fixed(1),
style => Selkie::Style.new(fg => 0x7AA2F7, bold => True),
);
$vbox.add: $main-content;
$vbox.add: Selkie::Widget::Text.new(
text => 'Ctrl+Q: quit — ?: help',
sizing => Sizing.fixed(1),
style => Selkie::Style.new(fg => 0x888888),
);
=end code
=head2 Driven by the store
Set up a subscription that updates the text whenever state changes:
=begin code :lang<raku>
my $status = Selkie::Widget::Text.new(text => '', sizing => Sizing.fixed(1));
$app.store.subscribe-with-callback(
'status-line',
-> $s { "{$s.get-in('user', 'name') // 'guest'} — {$s.get-in('messages').elems} unread" },
-> $text { $status.set-text($text) },
$status,
);
=end code
=head2 A centred banner over a themed background
=begin code :lang<raku>
my $title = Selkie::Widget::Text.new(
text => 'Selkie',
align => TextCenter,
sizing => Sizing.fixed(1),
style => Selkie::Style.new(fg => 0xCBA6F7, bold => True),
);
# The cells either side of the word are never written, so a gradient
# or scrim painted on the plane beneath shows through them.
=end code
=head1 SEE ALSO
=item L<Selkie::Align> — C<TextAlign>, and the container-level C<CrossAlign>
=item L<Selkie::Widget::RichText> — styled spans within one block of text
=item L<Selkie::Widget::TextStream> — append-only log with ring buffer and auto-scroll
=end pod
use Notcurses::Native;
use Notcurses::Native::Types;
use Notcurses::Native::Plane;
use Selkie::Align;
use Selkie::Widget;
use Selkie::Style;
unit class Selkie::Widget::Text does Selkie::Widget;
#| The text to render. Can include newlines — each line is wrapped
#| independently.
has Str $.text = '';
#|( Horizontal alignment of each wrapped line within the widget's
width. Defaults to C<TextLeft> — every line at column 0, the way
C<Text> has always rendered.
Lines are aligned individually, so wrapped prose comes out
ragged-left under C<TextCenter> / C<TextRight> rather than
justified. See L<Selkie::Align>. )
has TextAlign $.align is rw = TextLeft;
#| Optional style override. If undefined, the theme's C<text> slot is used.
has Selkie::Style $.style;
#| Optional theme slot name to use when C<style> is not set.
has Str $.theme-slot;
has @!wrapped-lines;
#| Replace the displayed text. Re-wraps and marks the widget dirty.
method set-text(Str:D $t) {
$!text = $t;
self!rewrap;
self.mark-dirty;
}
#| Replace the style override. Pass an undefined Selkie::Style to
#| revert to the theme default.
method set-style(Selkie::Style $s) {
$!style = $s;
self.mark-dirty;
}
#| Replace the semantic theme slot used when C<style> is not set.
method set-theme-slot(Str $slot) {
$!theme-slot = $slot;
self.mark-dirty;
}
#| Change the horizontal alignment of the wrapped lines and mark the
#| widget dirty. No-op when the alignment is unchanged, so calling it
#| from a subscription callback every frame costs nothing.
method set-align(TextAlign:D $a --> Nil) {
return if $a === $!align;
$!align = $a;
self.mark-dirty;
}
#|( The column a C<$line-chars>-wide line starts at, in a
C<$cols>-wide widget, under alignment C<$a>:
=item C<TextLeft> — 0.
=item C<TextCenter> — C<((cols - chars) / 2).floor>; an odd slack
puts the extra column on the right.
=item C<TextRight> — C<cols - chars>.
Slack is floored at 0, so a line wider than the widget (which
C<!rewrap> only produces when the widget is one column wide and the
text is not) starts at column 0 and clips on the right, rather than
being pushed off the left edge.
Exposed as a class method — no plane, no instance — so alignment
arithmetic is testable and so custom widgets can reuse it:
Selkie::Widget::Text.align-column(TextCenter, 5, 11); # 3
)
method align-column(TextAlign $a, UInt $line-chars, UInt $cols --> UInt) {
my Int $slack = $cols.Int - $line-chars.Int;
return 0 if $slack <= 0;
given $a {
when TextCenter { ($slack / 2).floor.UInt }
when TextRight { $slack.UInt }
default { 0 }
}
}
#| Number of lines the text wraps to at the current width. Used by
#| C<ScrollView> to compute scrollable extent.
method logical-height(--> UInt) {
self!rewrap unless @!wrapped-lines;
@!wrapped-lines.elems;
}
method render() {
return without self.plane;
self!rewrap;
my $s = self!effective-style;
self.apply-style($s);
ncplane_erase(self.plane);
my UInt $visible = self.rows min @!wrapped-lines.elems;
for ^$visible -> $row {
self!put-line($row, @!wrapped-lines[$row]);
}
self.clear-dirty;
}
#|( Render only a slice of the wrapped lines, starting at C<offset> and
going for C<height> rows. Used by C<ScrollView> for partial-viewport
rendering. )
method render-region(UInt :$offset, UInt :$height) {
return without self.plane;
self!rewrap unless @!wrapped-lines;
my $s = self!effective-style;
self.apply-style($s);
ncplane_erase(self.plane);
my UInt $end = ($offset + $height) min @!wrapped-lines.elems;
my UInt $row = 0;
for $offset ..^ $end -> $line-idx {
self!put-line($row++, @!wrapped-lines[$line-idx]);
}
self.clear-dirty;
}
#|( Write one wrapped line at its aligned column.
Both render paths go through here so C<render-region> — the slice
C<ScrollView> asks for — aligns identically to a full C<render>.
Alignment is an offset, never padding: nothing is written to the
left or right of the line, so those cells keep showing the plane's
base cell. Space-padding would paint this widget's background over
them, which is visibly wrong under a scrim, a gradient, or any
transparent base. Empty lines therefore write nothing at all. )
method !put-line(UInt $row, Str $line --> Nil) {
return unless $line.chars;
ncplane_putstr_yx(self.plane, $row,
self.align-column($!align, $line.chars, self.cols), $line);
}
method !effective-style(--> Selkie::Style) {
$!style // ($!theme-slot.defined && $!theme-slot.chars
?? self.theme.slot($!theme-slot)
!! self.theme.text);
}
method !rewrap() {
my UInt $width = self.cols max 1;
@!wrapped-lines = ();
for $!text.lines -> $line {
if $line.chars <= $width {
@!wrapped-lines.push($line);
} else {
my @words = $line.comb(/ \S+ | \s+ /);
my $current = '';
for @words -> $word {
if $current.chars + $word.chars > $width && $current.chars > 0 {
@!wrapped-lines.push($current);
$current = '';
next if $word ~~ /^ \s+ $/;
}
if $word.chars > $width && $current.chars == 0 {
my $pos = 0;
while $pos < $word.chars {
my $chunk = $word.substr($pos, $width);
if $pos + $width < $word.chars {
@!wrapped-lines.push($chunk);
} else {
$current = $chunk;
}
$pos += $width;
}
} else {
$current ~= $word;
}
}
@!wrapped-lines.push($current) if $current.chars > 0;
}
}
@!wrapped-lines.push('') unless @!wrapped-lines;
}
method !on-resize() {
# Text wrapping depends on column width; when handle-resize fires
# for a width change, refresh the cached wrap so the next render
# lays the string out correctly.
self!rewrap;
}