summaryrefslogtreecommitdiff
path: root/lib/Tickit/Widget/ScrollBox.pm
diff options
context:
space:
mode:
Diffstat (limited to 'lib/Tickit/Widget/ScrollBox.pm')
-rw-r--r--lib/Tickit/Widget/ScrollBox.pm690
1 files changed, 690 insertions, 0 deletions
diff --git a/lib/Tickit/Widget/ScrollBox.pm b/lib/Tickit/Widget/ScrollBox.pm
new file mode 100644
index 0000000..93dbae2
--- /dev/null
+++ b/lib/Tickit/Widget/ScrollBox.pm
@@ -0,0 +1,690 @@
+# You may distribute under the terms of either the GNU General Public License
+# or the Artistic License (the same terms as Perl itself)
+#
+# (C) Paul Evans, 2013-2016 -- leonerd@leonerd.org.uk
+
+package Tickit::Widget::ScrollBox;
+
+use strict;
+use warnings;
+use base qw( Tickit::SingleChildWidget );
+Tickit::Window->VERSION( '0.39' ); # ->scroll_with_children, default expose_after_scroll
+use Tickit::Style;
+
+our $VERSION = '0.07';
+
+use Carp;
+
+use List::Util qw( max );
+
+use Tickit::Widget::ScrollBox::Extent;
+use Tickit::RenderBuffer qw( LINE_DOUBLE CAP_BOTH );
+
+=head1 NAME
+
+C<Tickit::Widget::ScrollBox> - allow a single child widget to be scrolled
+
+=head1 SYNOPSIS
+
+ use Tickit;
+ use Tickit::Widget::ScrollBox;
+ use Tickit::Widget::Static;
+
+ my $scrollbox = Tickit::Widget::ScrollBox->new(
+ child => Tickit::Widget::Static->new(
+ text => join( "\n", map { "The content for line $_" } 1 .. 100 ),
+ ),
+ );
+
+ Tickit->new( root => $scrollbox )->run;
+
+=head1 DESCRIPTION
+
+This container widget draws a scrollbar beside a single child widget and
+allows a portion of it to be displayed by scrolling.
+
+=head1 STYLE
+
+Th following style pen prefixes are used:
+
+=over 4
+
+=item scrollbar => PEN
+
+The pen used to render the background of the scroll bar
+
+=item scrollmark => PEN
+
+The pen used to render the active scroll position in the scroll bar
+
+=item arrow => PEN
+
+The pen used to render the scrolling arrow buttons
+
+=back
+
+The following style keys are used:
+
+=over 4
+
+=item arrow_up => STRING
+
+=item arrow_down => STRING
+
+=item arrow_left => STRING
+
+=item arrow_right => STRING
+
+Each should be a single character to use for the scroll arrow buttons.
+
+=back
+
+The following style actions are used:
+
+=over 4
+
+=item up_1 (<Up>)
+
+=item down_1 (<Down>)
+
+=item left_1 (<Left>)
+
+=item right_1 (<Right>)
+
+Scroll by 1 line
+
+=item up_half (<PageUp>)
+
+=item down_half (<PageDown>)
+
+=item left_half (<C-Left>)
+
+=item right_half (<C-Right>)
+
+Scroll by half of the viewport
+
+=item to_top (<C-Home>)
+
+=item to_bottom (<C-End>)
+
+=item to_leftmost (<Home>)
+
+=item to_rightmost (<End>)
+
+Scroll to the edge of the area
+
+=back
+
+=cut
+
+style_definition base =>
+ scrollbar_fg => "blue",
+ scrollmark_bg => "blue",
+ arrow_rv => 1,
+ arrow_up => chr 0x25B4, # U+25B4 == Black up-pointing small triangle
+ arrow_down => chr 0x25BE, # U+25BE == Black down-pointing small triangle
+ arrow_left => chr 0x25C2, # U+25C2 == Black left-pointing small triangle
+ arrow_right => chr 0x25B8, # U+25B8 == Black right-pointing small triangle
+ '<Up>' => "up_1",
+ '<Down>' => "down_1",
+ '<Left>' => "left_1",
+ '<Right>' => "right_1",
+ '<PageUp>' => "up_half",
+ '<PageDown>' => "down_half",
+ '<C-Left>' => "left_half",
+ '<C-Right>' => "right_half",
+ '<C-Home>' => "to_top",
+ '<C-End>' => "to_bottom",
+ '<Home>' => "to_leftmost",
+ '<End>' => "to_rightmost",
+ ;
+
+use constant WIDGET_PEN_FROM_STYLE => 1;
+use constant KEYPRESSES_FROM_STYLE => 1;
+
+=head1 CONSTRUCTOR
+
+=cut
+
+=head2 new
+
+ $scrollbox = Tickit::Widget::ScrollBox->new( %args )
+
+Constructs a new C<Tickit::Widget::ScrollBox> object.
+
+Takes the following named arguments in addition to those taken by the base
+L<Tickit::SingleChildWidget> constructor:
+
+=over 8
+
+=item vertical => BOOL or "on_demand"
+
+=item horizontal => BOOL or "on_demand"
+
+Whether to apply a scrollbar in the vertical or horizontal directions. If not
+given, these default to vertical only.
+
+If given as the string C<on_demand> then the scrollbar will be optionally be
+displayed only if needed; if the space given to the widget is smaller than the
+child content necessary to display.
+
+=back
+
+=cut
+
+sub new
+{
+ my $class = shift;
+ my %args = @_;
+
+ my $vertical = delete $args{vertical} // 1;
+ my $horizontal = delete $args{horizontal};
+
+ my $child = delete $args{child};
+
+ my $self = $class->SUPER::new( %args );
+
+ $self->{vextent} = Tickit::Widget::ScrollBox::Extent->new( $self, "v" ) if $vertical;
+ $self->{hextent} = Tickit::Widget::ScrollBox::Extent->new( $self, "h" ) if $horizontal;
+
+ $self->{v_on_demand} = $vertical ||'' eq "on_demand";
+ $self->{h_on_demand} = $horizontal||'' eq "on_demand";
+
+ $self->add( $child ) if $child;
+
+ return $self;
+}
+
+=head1 ACCESSORS
+
+=cut
+
+sub lines
+{
+ my $self = shift;
+ return $self->child->lines + ( $self->hextent ? 1 : 0 );
+}
+
+sub cols
+{
+ my $self = shift;
+ return $self->child->cols + ( $self->vextent ? 1 : 0 );
+}
+
+=head2 vextent
+
+ $vextent = $scrollbox->vextent
+
+Returns the L<Tickit::Widget::ScrollBox::Extent> object representing the box's
+vertical scrolling extent.
+
+=cut
+
+sub vextent
+{
+ my $self = shift;
+ return $self->{vextent};
+}
+
+sub _v_visible
+{
+ my $self = shift;
+ return 0 unless my $vextent = $self->{vextent};
+ return 1 unless $self->{v_on_demand};
+ return $vextent->limit > 0;
+}
+
+=head2 hextent
+
+ $hextent = $scrollbox->hextent
+
+Returns the L<Tickit::Widget::ScrollBox::Extent> object representing the box's
+horizontal scrolling extent.
+
+=cut
+
+sub hextent
+{
+ my $self = shift;
+ return $self->{hextent};
+}
+
+sub _h_visible
+{
+ my $self = shift;
+ return 0 unless my $hextent = $self->{hextent};
+ return 1 unless $self->{h_on_demand};
+ return $hextent->limit > 0;
+}
+
+=head1 METHODS
+
+=cut
+
+sub children_changed
+{
+ my $self = shift;
+ if( my $child = $self->child ) {
+ my $scrollable = $self->{child_is_scrollable} = $child->can( "CAN_SCROLL" ) && $child->CAN_SCROLL;
+
+ if( $scrollable ) {
+ foreach my $method (qw( set_scrolling_extents scrolled )) {
+ $child->can( $method ) or croak "ScrollBox child cannot ->$method - do you implement it?";
+ }
+
+ my $vextent = $self->vextent;
+ my $hextent = $self->hextent;
+
+ $child->set_scrolling_extents( $vextent, $hextent );
+ defined $vextent->real_total or croak "ScrollBox child did not set vextent->total" if $vextent;
+ defined $hextent->real_total or croak "ScrollBox child did not set hextent->total" if $hextent;
+ }
+ }
+ $self->SUPER::children_changed;
+}
+
+sub reshape
+{
+ my $self = shift;
+
+ my $window = $self->window or return;
+ my $child = $self->child or return;
+
+ my $vextent = $self->vextent;
+ my $hextent = $self->hextent;
+
+ if( !$self->{child_is_scrollable} ) {
+ $vextent->set_total( $child->lines ) if $vextent;
+ $hextent->set_total( $child->cols ) if $hextent;
+ }
+
+ my $v_spare = ( $vextent ? $vextent->real_total : $window->lines-1 ) - $window->lines;
+ my $h_spare = ( $hextent ? $hextent->real_total : $window->cols-1 ) - $window->cols;
+
+ # visibility of each bar might depend on the visibility of the other, if it
+ # it was exactly at limit
+ $v_spare++ if $v_spare == 0 and $h_spare > 0;
+ $h_spare++ if $h_spare == 0 and $v_spare > 0;
+
+ my $v_visible = $vextent && ( !$self->{v_on_demand} || $v_spare > 0 );
+ my $h_visible = $hextent && ( !$self->{h_on_demand} || $h_spare > 0 );
+
+ my @viewportgeom = ( 0, 0,
+ $window->lines - ( $h_visible ? 1 : 0 ),
+ $window->cols - ( $v_visible ? 1 : 0 ) );
+
+ my $viewport;
+ if( $viewport = $self->{viewport} ) {
+ $viewport->change_geometry( @viewportgeom );
+ }
+ else {
+ $viewport = $window->make_sub( @viewportgeom );
+ $self->{viewport} = $viewport;
+ }
+
+ $vextent->set_viewport( $viewport->lines ) if $vextent;
+ $hextent->set_viewport( $viewport->cols ) if $hextent;
+
+ if( $self->{child_is_scrollable} ) {
+ $child->set_window( $viewport ) unless $child->window;
+ }
+ else {
+ my ( $childtop, $childlines ) =
+ $vextent ? ( -$vextent->start, $vextent->total )
+ : ( 0, max( $child->lines, $viewport->lines ) );
+
+ my ( $childleft, $childcols ) =
+ $hextent ? ( -$hextent->start, $hextent->total )
+ : ( 0, max( $child->cols, $viewport->cols ) );
+
+ my @childgeom = ( $childtop, $childleft, $childlines, $childcols );
+
+ if( my $childwin = $child->window ) {
+ $childwin->change_geometry( @childgeom );
+ }
+ else {
+ $childwin = $viewport->make_sub( @childgeom );
+ $child->set_window( $childwin );
+ }
+ }
+}
+
+sub window_lost
+{
+ my $self = shift;
+ $self->SUPER::window_lost( @_ );
+
+ $self->{viewport}->close if $self->{viewport};
+
+ undef $self->{viewport};
+}
+
+=head2 scroll
+
+ $scrollbox->scroll( $downward, $rightward )
+
+Requests the content be scrolled downward a number of lines and rightward a
+number of columns (either of which which may be negative).
+
+=cut
+
+sub scroll
+{
+ my $self = shift;
+ my ( $downward, $rightward ) = @_;
+ $self->vextent->scroll( $downward ) if $self->vextent and defined $downward;
+ $self->hextent->scroll( $rightward ) if $self->hextent and defined $rightward;
+}
+
+=head2 scroll_to
+
+ $scrollbox->scroll_to( $top, $left )
+
+Requests the content be scrolled such that the given line and column number of
+the child's content is the topmost visible in the container.
+
+=cut
+
+sub scroll_to
+{
+ my $self = shift;
+ my ( $top, $left ) = @_;
+ $self->vextent->scroll_to( $top ) if $self->vextent and defined $top;
+ $self->hextent->scroll_to( $left ) if $self->hextent and defined $left;
+}
+
+sub _extent_scrolled
+{
+ my $self = shift;
+ my ( $id, $delta, $value ) = @_;
+
+ my $vextent = $self->vextent;
+ my $hextent = $self->hextent;
+
+ if( my $win = $self->window ) {
+ if( $id eq "v" ) {
+ $win->expose( Tickit::Rect->new(
+ top => 0, lines => $win->lines,
+ left => $win->cols - 1, cols => 1,
+ ) );
+ }
+ elsif( $id eq "h" ) {
+ $win->expose( Tickit::Rect->new(
+ top => $win->lines - 1, lines => 1,
+ left => 0, cols => $win->cols,
+ ) );
+ }
+ }
+
+ # Extents use $delta = 0 to just request a redraw e.g. on change of total
+ return if $delta == 0;
+
+ my $child = $self->child or return;
+
+ my ( $downward, $rightward ) = ( 0, 0 );
+ if( $id eq "v" ) {
+ $downward = $delta;
+ }
+ elsif( $id eq "h" ) {
+ $rightward = $delta;
+ }
+
+ if( $self->{child_is_scrollable} ) {
+ $child->scrolled( $downward, $rightward, $id );
+ }
+ else {
+ my $childwin = $child->window or return;
+
+ $childwin->reposition( $vextent ? -$vextent->start : 0,
+ $hextent ? -$hextent->start : 0 );
+
+ my $viewport = $self->{viewport};
+ $viewport->scroll_with_children( $downward, $rightward );
+ }
+}
+
+sub render_to_rb
+{
+ my $self = shift;
+ my ( $rb, $rect ) = @_;
+ my $win = $self->window or return;
+
+ my $lines = $win->lines;
+ my $cols = $win->cols;
+
+ my $scrollbar_pen = $self->get_style_pen( "scrollbar" );
+ my $scrollmark_pen = $self->get_style_pen( "scrollmark" );
+ my $arrow_pen = $self->get_style_pen( "arrow" );
+
+ my $v_visible = $self->_v_visible;
+ my $h_visible = $self->_h_visible;
+
+ if( $v_visible and $rect->right == $cols ) {
+ my $vextent = $self->vextent;
+ my ( $bar_top, $mark_top, $mark_bottom, $bar_bottom ) =
+ $vextent->scrollbar_geom( 1, $lines - 2 - ( $h_visible ? 1 : 0 ) );
+ my $start = $vextent->start;
+
+ $rb->text_at ( 0, $cols-1,
+ $start > 0 ? $self->get_style_values( "arrow_up" ) : " ", $arrow_pen );
+ $rb->vline_at( $bar_top, $mark_top-1, $cols-1, LINE_DOUBLE, $scrollbar_pen, CAP_BOTH ) if $mark_top > $bar_top;
+ $rb->erase_at( $_, $cols-1, 1, $scrollmark_pen ) for $mark_top .. $mark_bottom-1;
+ $rb->vline_at( $mark_bottom, $bar_bottom-1, $cols-1, LINE_DOUBLE, $scrollbar_pen, CAP_BOTH ) if $bar_bottom > $mark_bottom;
+ $rb->text_at ( $bar_bottom, $cols-1,
+ $start < $vextent->limit ? $self->get_style_values( "arrow_down" ) : " ", $arrow_pen );
+ }
+
+ if( $h_visible and $rect->bottom == $lines ) {
+ my $hextent = $self->hextent;
+
+ my ( $bar_left, $mark_left, $mark_right, $bar_right ) =
+ $hextent->scrollbar_geom( 1, $cols - 2 - ( $v_visible ? 1 : 0 ) );
+ my $start = $hextent->start;
+
+ $rb->goto( $lines-1, 0 );
+
+ $rb->text_at( $lines-1, 0,
+ $start > 0 ? $self->get_style_values( "arrow_left" ) : " ", $arrow_pen );
+ $rb->hline_at( $lines-1, $bar_left, $mark_left-1, LINE_DOUBLE, $scrollbar_pen, CAP_BOTH ) if $mark_left > $bar_left;
+ $rb->erase_at( $lines-1, $mark_left, $mark_right - $mark_left, $scrollmark_pen );
+ $rb->hline_at( $lines-1, $mark_right, $bar_right-1, LINE_DOUBLE, $scrollbar_pen, CAP_BOTH ) if $bar_right > $mark_right;
+ $rb->text_at( $lines-1, $bar_right,
+ $start < $hextent->limit ? $self->get_style_values( "arrow_right" ) : " ", $arrow_pen );
+
+ $rb->erase_at( $lines-1, $cols-1, 1 ) if $v_visible;
+ }
+}
+
+sub key_up_1 { my $vextent = shift->vextent or return; $vextent->scroll( -1 ); 1 }
+sub key_down_1 { my $vextent = shift->vextent or return; $vextent->scroll( +1 ); 1 }
+sub key_left_1 { my $hextent = shift->hextent or return; $hextent->scroll( -1 ); 1 }
+sub key_right_1 { my $hextent = shift->hextent or return; $hextent->scroll( +1 ); 1 }
+
+sub key_up_half { my $vextent = shift->vextent or return; $vextent->scroll( -int( $vextent->viewport / 2 ) ); 1 }
+sub key_down_half { my $vextent = shift->vextent or return; $vextent->scroll( +int( $vextent->viewport / 2 ) ); 1 }
+sub key_left_half { my $hextent = shift->hextent or return; $hextent->scroll( -int( $hextent->viewport / 2 ) ); 1 }
+sub key_right_half { my $hextent = shift->hextent or return; $hextent->scroll( +int( $hextent->viewport / 2 ) ); 1 }
+
+sub key_to_top { my $vextent = shift->vextent or return; $vextent->scroll_to( 0 ); 1 }
+sub key_to_bottom { my $vextent = shift->vextent or return; $vextent->scroll_to( $vextent->limit ); 1 }
+sub key_to_leftmost { my $hextent = shift->hextent or return; $hextent->scroll_to( 0 ); 1 }
+sub key_to_rightmost { my $hextent = shift->hextent or return; $hextent->scroll_to( $hextent->limit ); 1 }
+
+sub on_mouse
+{
+ my $self = shift;
+ my ( $args ) = @_;
+
+ my $type = $args->type;
+ my $button = $args->button;
+
+ my $lines = $self->window->lines;
+ my $cols = $self->window->cols;
+
+ my $vextent = $self->vextent;
+ my $hextent = $self->hextent;
+
+ my $vlen = $lines - 2 - ( $self->_h_visible ? 1 : 0 );
+ my $hlen = $cols - 2 - ( $self->_v_visible ? 1 : 0 );
+
+ if( $type eq "press" and $button == 1 ) {
+ if( $vextent and $args->col == $cols-1 ) {
+ # Click in vertical scrollbar
+ my ( undef, $mark_top, $mark_bottom, $bar_bottom ) = $vextent->scrollbar_geom( 1, $vlen );
+ my $line = $args->line;
+
+ if( $line == 0 ) { # up arrow
+ $vextent->scroll( -1 );
+ }
+ elsif( $line < $mark_top ) { # above area
+ $vextent->scroll( -int( $vextent->viewport / 2 ) );
+ }
+ elsif( $line < $mark_bottom ) {
+ # press in mark - ignore for now - TODO: prelight?
+ }
+ elsif( $line < $bar_bottom ) { # below area
+ $vextent->scroll( +int( $vextent->viewport / 2 ) );
+ }
+ elsif( $line == $bar_bottom ) { # down arrow
+ $vextent->scroll( +1 );
+ }
+ return 1;
+ }
+ if( $hextent and $args->line == $lines-1 ) {
+ # Click in horizontal scrollbar
+ my ( undef, $mark_left, $mark_right, $bar_right ) = $hextent->scrollbar_geom( 1, $hlen );
+ my $col = $args->col;
+
+ if( $col == 0 ) { # left arrow
+ $hextent->scroll( -1 );
+ }
+ elsif( $col < $mark_left ) { # above area
+ $hextent->scroll( -int( $hextent->viewport / 2 ) );
+ }
+ elsif( $col < $mark_right ) {
+ # press in mark - ignore for now - TODO: prelight
+ }
+ elsif( $col < $bar_right ) { # below area
+ $hextent->scroll( +int( $hextent->viewport / 2 ) );
+ }
+ elsif( $col == $bar_right ) { # right arrow
+ $hextent->scroll( +1 );
+ }
+ return 1;
+ }
+ }
+ elsif( $type eq "drag_start" and $button == 1 ) {
+ if( $vextent and $args->col == $cols-1 ) {
+ # Drag in vertical scrollbar
+ my ( undef, $mark_top, $mark_bottom ) = $vextent->scrollbar_geom( 1, $vlen );
+ my $line = $args->line;
+
+ if( $line >= $mark_top and $line < $mark_bottom ) {
+ $self->{drag_offset} = $line - $mark_top;
+ $self->{drag_bar} = "v";
+ return 1;
+ }
+ }
+ if( $hextent and $args->line == $lines-1 ) {
+ # Drag in horizontal scrollbar
+ my ( undef, $mark_left, $mark_right ) = $hextent->scrollbar_geom( 1, $hlen );
+ my $col = $args->col;
+
+ if( $col >= $mark_left and $col < $mark_right ) {
+ $self->{drag_offset} = $col - $mark_left;
+ $self->{drag_bar} = "h";
+ return 1;
+ }
+ }
+ }
+ elsif( $type eq "drag" and $button == 1 and defined( $self->{drag_offset} ) ) {
+ if( $self->{drag_bar} eq "v" ) {
+ my $want_bar_top = $args->line - $self->{drag_offset} - 1;
+ my $want_top = int( $want_bar_top * $vextent->total / $vlen + 0.5 );
+ $vextent->scroll_to( $want_top );
+ }
+ if( $self->{drag_bar} eq "h" ) {
+ my $want_bar_left = $args->col - $self->{drag_offset} - 1;
+ my $want_left = int( $want_bar_left * $hextent->total / $hlen + 0.5 );
+ $hextent->scroll_to( $want_left );
+ }
+ }
+ elsif( $type eq "drag_stop" ) {
+ undef $self->{drag_offset};
+ }
+ elsif( $type eq "wheel" ) {
+ # Alt-wheel for horizontal
+ my $extent = $args->mod & 2 ? $self->hextent : $self->vextent;
+ $extent->scroll( -5 ) if $extent and $button eq "up";
+ $extent->scroll( +5 ) if $extent and $button eq "down";
+ return 1;
+ }
+}
+
+=head1 SMART SCROLLING
+
+If the child widget declares it supports smart scrolling, then the ScrollBox
+will not implement content scrolling on its behalf. Extra methods are used to
+co-ordinate the scroll position between the scrolling-aware child widget and
+the containing ScrollBox. This is handled by the following methods on the
+child widget.
+
+If smart scrolling is enabled for the child, then its window will be set to
+the viewport directly, and the child widget must offset its content within the
+window as appropriate. The child must indicate the range of its scrolling
+ability by using the C<set_total> method on the extent object it is given.
+
+=head2 $smart = $child->CAN_SCROLL
+
+If this method exists and returns a true value, the ScrollBox will use smart
+scrolling. This method must return a true value for this to work, allowing the
+method to itself be a proxy, for example, to proxy scrolling information
+through a single child widget container.
+
+=head2 $child->set_scrolling_extents( $vextent, $hextent )
+
+Gives the child widget the vertical and horizontal scrolling extents. The
+child widget should save thes values, and inspect the C<start> value of them
+any time it needs these to implement content offset position when
+rendering.
+
+=head2 $child->scrolled( $downward, $rightward, $h_or_v )
+
+Informs the child widget that one of the scroll positions has changed. It
+passes the delta (which may be negative) of each position, and a string which
+will be either C<"h"> or C<"v"> to indicate whether it was an adjustment of
+the horizontal or vertical scrollbar. The extent objects will already have
+been updated by this point, so the child may also inspect the C<start> value
+of them to obtain the new absolute offsets.
+
+=cut
+
+=head1 TODO
+
+=over 4
+
+=item *
+
+Choice of left/right and top/bottom bar positions.
+
+=item *
+
+Click-and-hold on arrow buttons for auto-repeat
+
+=item *
+
+Allow smarter cooperation with a scrolling-aware child widget; likely by
+setting extent objects on the child if it declares to be supported, and use
+that instead of an offset child window.
+
+=back
+
+=cut
+
+=head1 AUTHOR
+
+Paul Evans <leonerd@leonerd.org.uk>
+
+=cut
+
+0x55AA;