Selkie.git | lib/Selkie/ | BorderStyle.rakumod


=begin pod

=head1 NAME

Selkie::BorderStyle - Box-drawing glyph sets and title alignment for framed widgets

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::BorderStyle;
use Selkie::Widget::Border;

# Pick a stock glyph set by kind.
my $panel = Selkie::Widget::Border.new(
    title        => 'Characters',
    border-style => BorderRounded,
    title-align  => TitleCenter,
);

# Or hand it a bespoke glyph table.
my $dashed = Selkie::BorderStyle::BorderGlyphs.new(
    top-left     => '.', top-right    => '.',
    bottom-left  => "'", bottom-right => "'",
    horizontal   => '-', vertical     => ':',
);
$panel.set-border-glyphs($dashed);

=end code

=head1 DESCRIPTION

Two enums and a value class, shared by every widget that paints a frame.

C<BorderKind> names the five stock glyph sets; C<BorderGlyphs> is the
six-glyph table one of those names resolves to (or that you build
yourself); C<TitleAlign> says where a title sits along its edge.

The glyph tables, in C<top-left top-right bottom-left bottom-right
horizontal vertical> order:

=begin code

BorderSingle    ┌ ┐ └ ┘ ─ │      ┌────────┐
BorderRounded   ╭ ╮ ╰ ╯ ─ │      │ single │
BorderDouble    ╔ ╗ ╚ ╝ ═ ║      └────────┘
BorderHeavy     ┏ ┓ ┗ ┛ ━ ┃
BorderAscii     + + + + - |      ╭────────╮
                                 │rounded │
                                 ╰────────╯

=end code

=head2 No automatic downgrade

Selkie never inspects the locale, C<TERM>, or the terminal's reported
capabilities to swap a Unicode glyph set for an ASCII one. Auto-downgrade
would make rendering — and therefore snapshot tests — depend on the
environment the process happens to run in, which is exactly the class of
bug snapshot tests exist to catch.

C<BorderAscii> is the escape hatch. If your app must run somewhere the
box-drawing block isn't available, decide that yourself (a config flag,
a C<--ascii> switch, a C<%*ENV> probe you own) and pass C<BorderAscii>:

=begin code :lang<raku>

my $kind = %*ENV<MYAPP_ASCII> ?? BorderAscii !! BorderRounded;
Selkie::Widget::Border.new(:title('Log'), border-style => $kind);

=end code

=head2 Custom glyph tables

Every glyph is a C<Str>, not a codepoint, so multi-codepoint clusters
work. Keep each one a single I<column> wide though — the frame painter
assumes one cell per glyph, and a double-width glyph (CJK, most emoji)
will push the frame out of alignment. Emitting a run of C<cols - 2>
horizontals is a single C<putstr>, so a wide horizontal glyph overflows
the right corner rather than being clipped.

=head1 EXAMPLES

=head2 Resolving a kind to its table

=begin code :lang<raku>

my $g = Selkie::BorderStyle::BorderGlyphs.for(BorderDouble);
say $g.top-left;      # ╔
say $g.horizontal;    # ═

# Resolution is cached — the same kind always returns the same object.
say BorderGlyphs.for(BorderDouble) === BorderGlyphs.for(BorderDouble);  # True

=end code

=head2 Deriving a table from a stock one

C<BorderGlyphs> is immutable; C<clone> is the way to vary one glyph.

=begin code :lang<raku>

my $studded = BorderGlyphs.for(BorderSingle).clone(
    top-left => '◤', top-right => '◥',
);

=end code

=head2 Aligning titles

=begin code :lang<raku>

$panel.set-title-align(TitleCenter);
$panel.set-bottom-title('↑/↓ scroll  q quit');
$panel.set-bottom-title-align(TitleRight);

=end code

=head1 SEE ALSO

=item L<Selkie::Widget::Border> — the widget that consumes all of this
=item L<Selkie::Theme> — C<border> / C<border-focused> slots pick the frame's I<colours>; the glyph set is orthogonal

=end pod

unit module Selkie::BorderStyle;

#|( The five stock box-drawing glyph sets.

    C<BorderSingle> is the default everywhere and the only one that was
    available before Selkie 0.11. C<BorderRounded> is the same weight
    with arc corners. C<BorderDouble> and C<BorderHeavy> read as
    emphasis. C<BorderAscii> uses nothing outside 7-bit ASCII and is the
    manual escape hatch for terminals or fonts without the box-drawing
    block — Selkie never selects it for you (see the module Pod). )
enum BorderKind is export (
    BorderSingle  => 'single',
    BorderRounded => 'rounded',
    BorderDouble  => 'double',
    BorderHeavy   => 'heavy',
    BorderAscii   => 'ascii',
);

#|( Where a title sits along the edge it's drawn on.

    C<TitleLeft> is the historical (and default) placement: two columns
    in from the left corner. C<TitleCenter> centres the decorated title
    across the full width. C<TitleRight> ends it two columns short of
    the right corner. All three are clamped so the corner glyphs are
    never overwritten. )
enum TitleAlign is export (
    TitleLeft   => 'left',
    TitleCenter => 'center',
    TitleRight  => 'right',
);

#|( The six glyphs it takes to paint a box: four corners, one
    horizontal, one vertical.

    Build one with C<.for(BorderKind)> for a stock set, C<.new> for a
    bespoke table, or C<.clone> off a stock set to vary a glyph or two.
    Instances are immutable value objects and safe to share between
    widgets. )
class BorderGlyphs is export {
    #| Top-left corner glyph, e.g. C<┌>.
    has Str:D $.top-left     is required;
    #| Top-right corner glyph, e.g. C<┐>.
    has Str:D $.top-right    is required;
    #| Bottom-left corner glyph, e.g. C<└>.
    has Str:D $.bottom-left  is required;
    #| Bottom-right corner glyph, e.g. C<┘>.
    has Str:D $.bottom-right is required;
    #| Glyph tiled along the top and bottom edges, e.g. C<─>.
    has Str:D $.horizontal   is required;
    #| Glyph drawn down the left and right edges, e.g. C<│>.
    has Str:D $.vertical     is required;

    # Resolution cache. Stock sets are immutable, so handing out the
    # same instance for repeated .for() calls is safe and keeps the
    # per-render lookup in Border free of allocation.
    my %cache{BorderKind};

    #| The C<┌┐└┘─│> set. Selkie's default, and byte-identical to what
    #| C<Border> painted before glyph sets existed.
    method single(--> BorderGlyphs:D) {
        BorderGlyphs.new(
            top-left => '┌', top-right    => '┐',
            bottom-left => '└', bottom-right => '┘',
            horizontal  => '─', vertical     => '│',
        );
    }

    #| The C<╭╮╰╯─│> set — single-weight edges, arc corners.
    method rounded(--> BorderGlyphs:D) {
        BorderGlyphs.new(
            top-left => '╭', top-right    => '╮',
            bottom-left => '╰', bottom-right => '╯',
            horizontal  => '─', vertical     => '│',
        );
    }

    #| The C<╔╗╚╝═║> set — double-ruled edges.
    method double(--> BorderGlyphs:D) {
        BorderGlyphs.new(
            top-left => '╔', top-right    => '╗',
            bottom-left => '╚', bottom-right => '╝',
            horizontal  => '═', vertical     => '║',
        );
    }

    #| The C<┏┓┗┛━┃> set — heavy-weight edges.
    method heavy(--> BorderGlyphs:D) {
        BorderGlyphs.new(
            top-left => '┏', top-right    => '┓',
            bottom-left => '┗', bottom-right => '┛',
            horizontal  => '━', vertical     => '┃',
        );
    }

    #| The C<+ + + + - |> set — 7-bit ASCII only.
    method ascii(--> BorderGlyphs:D) {
        BorderGlyphs.new(
            top-left => '+', top-right    => '+',
            bottom-left => '+', bottom-right => '+',
            horizontal  => '-', vertical     => '|',
        );
    }

    #|( Resolve a C<BorderKind> to its glyph table. Cached: the same
        kind always yields the very same object, so callers can compare
        with C<===> and render loops don't allocate. )
    method for(BorderKind:D $kind --> BorderGlyphs:D) {
        %cache{$kind} //= do given $kind {
            when BorderSingle  { BorderGlyphs.single  }
            when BorderRounded { BorderGlyphs.rounded }
            when BorderDouble  { BorderGlyphs.double  }
            when BorderHeavy   { BorderGlyphs.heavy   }
            when BorderAscii   { BorderGlyphs.ascii   }
            default {
                die "Selkie::BorderStyle: no glyph table for BorderKind '$kind'";
            }
        };
    }
}