Selkie.git | lib/Selkie/ | EffectiveBounds.rakumod


=begin pod

=head1 NAME

Selkie::EffectiveBounds - The on-screen rectangle a widget may safely paint into

=head1 SYNOPSIS

=begin code :lang<raku>

use Selkie::EffectiveBounds;

# Compute via Widget.effective-bounds — apps don't usually construct
# these directly:
my $eb = $some-widget.effective-bounds;

if $eb.is-empty {
    # Widget is entirely outside the visible region — don't paint
} else {
    # The visible rectangle is at ($eb.abs-y, $eb.abs-x), sized
    # $eb.rows by $eb.cols. The widget's own plane has $eb.clip-top
    # rows chopped off the top and $eb.clip-left cols off the left.
}

=end code

=head1 DESCRIPTION

Notcurses does B<not> clip a child plane's painted content to its
parent plane's bounds. A child plane sized larger than its parent
paints past the parent's edge into siblings or grandparents. Pixel
sprixels (the protocol-agnostic name covering Sixel, Kitty graphics,
and iTerm2 inline images) are even worse — they paint at absolute
terminal pixel coordinates regardless of any plane hierarchy.

C<Selkie::EffectiveBounds> is the value class returned by
L<Selkie::Widget>'s C<effective-bounds> method, which walks the parent
chain and computes the rectangular intersection of the widget's plane
with every ancestor's plane and the terminal viewport. The result is
the on-screen rectangle into which the widget may safely paint
pixels — anything outside this rectangle would bleed past an ancestor's
visible region.

L<Selkie::Widget::Image> uses this to size its blit-plane to the
visible intersection, ensuring sprixel pixels never overflow into
territory occupied by other widgets. Custom widgets that allocate
their own blit plane (following the L<Selkie::Widget::Image> pattern)
should do the same.

=head2 The clip-top / clip-left fields

When a widget is partially clipped on its top or left edge (typical
for a CardList card scrolled past the top of the viewport, or a
horizontal scroll), the visible rectangle's top-left does not coincide
with the widget's plane's top-left. C<clip-top> and C<clip-left> tell
the renderer how many rows / columns of its own plane fall outside
the visible region at the leading edges, so a sub-plane (like an
Image's blit-plane) can be positioned to land inside the visible
intersection rather than at the widget's own (0, 0).

=head1 SEE ALSO

=item L<Selkie::Widget> — owns C<effective-bounds> and C<clip-to-ancestors>
=item L<Selkie::Widget::Image> — sizes its blit-plane to these bounds and drives the surrounding sprixel destroy / re-blit lifecycle

=end pod

unit class Selkie::EffectiveBounds;

#| Top edge of the visible intersection in absolute screen coordinates.
has Int  $.abs-y     is required;

#| Left edge of the visible intersection in absolute screen coordinates.
has Int  $.abs-x     is required;

#| Height of the visible intersection in cells. Zero when the widget is
#| entirely outside its ancestors or the terminal.
has UInt $.rows      is required;

#| Width of the visible intersection in cells. Zero when the widget is
#| entirely outside its ancestors or the terminal.
has UInt $.cols      is required;

#| Number of rows of the widget's own plane that fall above the visible
#| intersection (chopped off the top by an ancestor's edge).
has UInt $.clip-top  = 0;

#| Number of columns of the widget's own plane that fall left of the
#| visible intersection (chopped off the left by an ancestor's edge).
has UInt $.clip-left = 0;

#| True when the widget has no on-screen visible area — entirely outside
#| an ancestor or the terminal viewport. Renderers should early-return
#| on C<is-empty> rather than emit any pixels.
method is-empty(--> Bool) { $!rows == 0 || $!cols == 0 }

method gist(--> Str) {
    "EffectiveBounds(y=$!abs-y, x=$!abs-x, "
    ~ "{$!rows}x{$!cols}, clip-top=$!clip-top, clip-left=$!clip-left)"
}

#|( Compute the rectangular intersection of two cell rectangles given as
    C<(abs-y, abs-x, rows, cols)> tuples. Returns a new
    C<Selkie::EffectiveBounds> with C<clip-top> and C<clip-left>
    reflecting how much of the first rectangle was chopped off its
    leading edges. C<is-empty> when the rectangles don't overlap. )
sub intersect-rect(
    Int :$ay!, Int :$ax!, UInt :$ah!, UInt :$aw!,
    Int :$by!, Int :$bx!, UInt :$bh!, UInt :$bw!,
    UInt :$clip-top  = 0,
    UInt :$clip-left = 0,
    --> Selkie::EffectiveBounds
) is export {
    my Int $top    = $ay max $by;
    my Int $left   = $ax max $bx;
    my Int $bottom = ($ay + $ah.Int) min ($by + $bh.Int);
    my Int $right  = ($ax + $aw.Int) min ($bx + $bw.Int);
    my UInt $rows  = ($bottom - $top) max 0;
    my UInt $cols  = ($right  - $left) max 0;
    Selkie::EffectiveBounds.new(
        abs-y     => $top,
        abs-x     => $left,
        :$rows, :$cols,
        clip-top  => $clip-top  + (($top  - $ay) max 0).UInt,
        clip-left => $clip-left + (($left - $ax) max 0).UInt,
    );
}

# --- Terminal viewport provider --------------------------------------------
#
# Class-level closure set by Selkie::App at init. Selkie::Widget's
# effective-bounds calls C<terminal-viewport()> as the final
# intersection step so off-screen widgets always have empty bounds
# without each container needing to know about the terminal.
# Lives in this module rather than on Selkie::Widget directly because
# Widget is a role with required methods, so methods on the role can't
# be called via class-method syntax C<Selkie::Widget.foo()> until the
# role is punned into a class — and that fails at the required-method
# check. A plain exported sub avoids the punning issue entirely.
my &TERMINAL-VIEWPORT-PROVIDER = -> { (1_000, 1_000) };

#|( Set the terminal viewport provider — a closure returning C<(rows, cols)>
    for the active terminal. C<Selkie::App> calls this on init so
    C<Selkie::Widget.effective-bounds> can intersect every widget
    against the terminal's visible area. Tests can pass a fixed-size
    closure to simulate a small terminal; pass C<{ (1_000, 1_000) }>
    to reset to the generous default. )
sub set-terminal-viewport-provider(&p --> Nil) is export {
    &TERMINAL-VIEWPORT-PROVIDER = &p;
}

#| Current terminal viewport dimensions as C<(rows, cols)>, queried
#| through the provider closure (set by L<Selkie::App> at init).
sub terminal-viewport(--> List) is export {
    TERMINAL-VIEWPORT-PROVIDER();
}