File Coverage

blib/lib/Tickit/Widget/ScrollBox.pm
Criterion Covered Total %
statement 9 9 100.0
branch n/a
condition n/a
subroutine 3 3 100.0
pod n/a
total 12 12 100.0


line stmt bran cond sub pod time code
1             # You may distribute under the terms of either the GNU General Public License
2             # or the Artistic License (the same terms as Perl itself)
3             #
4             # (C) Paul Evans, 2013-2016 -- leonerd@leonerd.org.uk
5              
6             package Tickit::Widget::ScrollBox;
7              
8 1     1   792 use strict;
  1         1  
  1         27  
9 1     1   5 use warnings;
  1         1  
  1         32  
10 1     1   12 use base qw( Tickit::SingleChildWidget );
  1         2  
  1         780  
11             Tickit::Window->VERSION( '0.39' ); # ->scroll_with_children, default expose_after_scroll
12             use Tickit::Style;
13              
14             our $VERSION = '0.07';
15              
16             use Carp;
17              
18             use List::Util qw( max );
19              
20             use Tickit::Widget::ScrollBox::Extent;
21             use Tickit::RenderBuffer qw( LINE_DOUBLE CAP_BOTH );
22              
23             =head1 NAME
24              
25             C - allow a single child widget to be scrolled
26              
27             =head1 SYNOPSIS
28              
29             use Tickit;
30             use Tickit::Widget::ScrollBox;
31             use Tickit::Widget::Static;
32              
33             my $scrollbox = Tickit::Widget::ScrollBox->new(
34             child => Tickit::Widget::Static->new(
35             text => join( "\n", map { "The content for line $_" } 1 .. 100 ),
36             ),
37             );
38              
39             Tickit->new( root => $scrollbox )->run;
40              
41             =head1 DESCRIPTION
42              
43             This container widget draws a scrollbar beside a single child widget and
44             allows a portion of it to be displayed by scrolling.
45              
46             =head1 STYLE
47              
48             Th following style pen prefixes are used:
49              
50             =over 4
51              
52             =item scrollbar => PEN
53              
54             The pen used to render the background of the scroll bar
55              
56             =item scrollmark => PEN
57              
58             The pen used to render the active scroll position in the scroll bar
59              
60             =item arrow => PEN
61              
62             The pen used to render the scrolling arrow buttons
63              
64             =back
65              
66             The following style keys are used:
67              
68             =over 4
69              
70             =item arrow_up => STRING
71              
72             =item arrow_down => STRING
73              
74             =item arrow_left => STRING
75              
76             =item arrow_right => STRING
77              
78             Each should be a single character to use for the scroll arrow buttons.
79              
80             =back
81              
82             The following style actions are used:
83              
84             =over 4
85              
86             =item up_1 ()
87              
88             =item down_1 ()
89              
90             =item left_1 ()
91              
92             =item right_1 ()
93              
94             Scroll by 1 line
95              
96             =item up_half ()
97              
98             =item down_half ()
99              
100             =item left_half ()
101              
102             =item right_half ()
103              
104             Scroll by half of the viewport
105              
106             =item to_top ()
107              
108             =item to_bottom ()
109              
110             =item to_leftmost ()
111              
112             =item to_rightmost ()
113              
114             Scroll to the edge of the area
115              
116             =back
117              
118             =cut
119              
120             style_definition base =>
121             scrollbar_fg => "blue",
122             scrollmark_bg => "blue",
123             arrow_rv => 1,
124             arrow_up => chr 0x25B4, # U+25B4 == Black up-pointing small triangle
125             arrow_down => chr 0x25BE, # U+25BE == Black down-pointing small triangle
126             arrow_left => chr 0x25C2, # U+25C2 == Black left-pointing small triangle
127             arrow_right => chr 0x25B8, # U+25B8 == Black right-pointing small triangle
128             '' => "up_1",
129             '' => "down_1",
130             '' => "left_1",
131             '' => "right_1",
132             '' => "up_half",
133             '' => "down_half",
134             '' => "left_half",
135             '' => "right_half",
136             '' => "to_top",
137             '' => "to_bottom",
138             '' => "to_leftmost",
139             '' => "to_rightmost",
140             ;
141              
142             use constant WIDGET_PEN_FROM_STYLE => 1;
143             use constant KEYPRESSES_FROM_STYLE => 1;
144              
145             =head1 CONSTRUCTOR
146              
147             =cut
148              
149             =head2 new
150              
151             $scrollbox = Tickit::Widget::ScrollBox->new( %args )
152              
153             Constructs a new C object.
154              
155             Takes the following named arguments in addition to those taken by the base
156             L constructor:
157              
158             =over 8
159              
160             =item vertical => BOOL or "on_demand"
161              
162             =item horizontal => BOOL or "on_demand"
163              
164             Whether to apply a scrollbar in the vertical or horizontal directions. If not
165             given, these default to vertical only.
166              
167             If given as the string C then the scrollbar will be optionally be
168             displayed only if needed; if the space given to the widget is smaller than the
169             child content necessary to display.
170              
171             =back
172              
173             =cut
174              
175             sub new
176             {
177             my $class = shift;
178             my %args = @_;
179              
180             my $vertical = delete $args{vertical} // 1;
181             my $horizontal = delete $args{horizontal};
182              
183             my $child = delete $args{child};
184              
185             my $self = $class->SUPER::new( %args );
186              
187             $self->{vextent} = Tickit::Widget::ScrollBox::Extent->new( $self, "v" ) if $vertical;
188             $self->{hextent} = Tickit::Widget::ScrollBox::Extent->new( $self, "h" ) if $horizontal;
189              
190             $self->{v_on_demand} = $vertical ||'' eq "on_demand";
191             $self->{h_on_demand} = $horizontal||'' eq "on_demand";
192              
193             $self->add( $child ) if $child;
194              
195             return $self;
196             }
197              
198             =head1 ACCESSORS
199              
200             =cut
201              
202             sub lines
203             {
204             my $self = shift;
205             return $self->child->lines + ( $self->hextent ? 1 : 0 );
206             }
207              
208             sub cols
209             {
210             my $self = shift;
211             return $self->child->cols + ( $self->vextent ? 1 : 0 );
212             }
213              
214             =head2 vextent
215              
216             $vextent = $scrollbox->vextent
217              
218             Returns the L object representing the box's
219             vertical scrolling extent.
220              
221             =cut
222              
223             sub vextent
224             {
225             my $self = shift;
226             return $self->{vextent};
227             }
228              
229             sub _v_visible
230             {
231             my $self = shift;
232             return 0 unless my $vextent = $self->{vextent};
233             return 1 unless $self->{v_on_demand};
234             return $vextent->limit > 0;
235             }
236              
237             =head2 hextent
238              
239             $hextent = $scrollbox->hextent
240              
241             Returns the L object representing the box's
242             horizontal scrolling extent.
243              
244             =cut
245              
246             sub hextent
247             {
248             my $self = shift;
249             return $self->{hextent};
250             }
251              
252             sub _h_visible
253             {
254             my $self = shift;
255             return 0 unless my $hextent = $self->{hextent};
256             return 1 unless $self->{h_on_demand};
257             return $hextent->limit > 0;
258             }
259              
260             =head1 METHODS
261              
262             =cut
263              
264             sub children_changed
265             {
266             my $self = shift;
267             if( my $child = $self->child ) {
268             my $scrollable = $self->{child_is_scrollable} = $child->can( "CAN_SCROLL" ) && $child->CAN_SCROLL;
269              
270             if( $scrollable ) {
271             foreach my $method (qw( set_scrolling_extents scrolled )) {
272             $child->can( $method ) or croak "ScrollBox child cannot ->$method - do you implement it?";
273             }
274              
275             my $vextent = $self->vextent;
276             my $hextent = $self->hextent;
277              
278             $child->set_scrolling_extents( $vextent, $hextent );
279             defined $vextent->real_total or croak "ScrollBox child did not set vextent->total" if $vextent;
280             defined $hextent->real_total or croak "ScrollBox child did not set hextent->total" if $hextent;
281             }
282             }
283             $self->SUPER::children_changed;
284             }
285              
286             sub reshape
287             {
288             my $self = shift;
289              
290             my $window = $self->window or return;
291             my $child = $self->child or return;
292              
293             my $vextent = $self->vextent;
294             my $hextent = $self->hextent;
295              
296             if( !$self->{child_is_scrollable} ) {
297             $vextent->set_total( $child->lines ) if $vextent;
298             $hextent->set_total( $child->cols ) if $hextent;
299             }
300              
301             my $v_spare = ( $vextent ? $vextent->real_total : $window->lines-1 ) - $window->lines;
302             my $h_spare = ( $hextent ? $hextent->real_total : $window->cols-1 ) - $window->cols;
303              
304             # visibility of each bar might depend on the visibility of the other, if it
305             # it was exactly at limit
306             $v_spare++ if $v_spare == 0 and $h_spare > 0;
307             $h_spare++ if $h_spare == 0 and $v_spare > 0;
308              
309             my $v_visible = $vextent && ( !$self->{v_on_demand} || $v_spare > 0 );
310             my $h_visible = $hextent && ( !$self->{h_on_demand} || $h_spare > 0 );
311              
312             my @viewportgeom = ( 0, 0,
313             $window->lines - ( $h_visible ? 1 : 0 ),
314             $window->cols - ( $v_visible ? 1 : 0 ) );
315              
316             my $viewport;
317             if( $viewport = $self->{viewport} ) {
318             $viewport->change_geometry( @viewportgeom );
319             }
320             else {
321             $viewport = $window->make_sub( @viewportgeom );
322             $self->{viewport} = $viewport;
323             }
324              
325             $vextent->set_viewport( $viewport->lines ) if $vextent;
326             $hextent->set_viewport( $viewport->cols ) if $hextent;
327              
328             if( $self->{child_is_scrollable} ) {
329             $child->set_window( $viewport ) unless $child->window;
330             }
331             else {
332             my ( $childtop, $childlines ) =
333             $vextent ? ( -$vextent->start, $vextent->total )
334             : ( 0, max( $child->lines, $viewport->lines ) );
335              
336             my ( $childleft, $childcols ) =
337             $hextent ? ( -$hextent->start, $hextent->total )
338             : ( 0, max( $child->cols, $viewport->cols ) );
339              
340             my @childgeom = ( $childtop, $childleft, $childlines, $childcols );
341              
342             if( my $childwin = $child->window ) {
343             $childwin->change_geometry( @childgeom );
344             }
345             else {
346             $childwin = $viewport->make_sub( @childgeom );
347             $child->set_window( $childwin );
348             }
349             }
350             }
351              
352             sub window_lost
353             {
354             my $self = shift;
355             $self->SUPER::window_lost( @_ );
356              
357             $self->{viewport}->close if $self->{viewport};
358              
359             undef $self->{viewport};
360             }
361              
362             =head2 scroll
363              
364             $scrollbox->scroll( $downward, $rightward )
365              
366             Requests the content be scrolled downward a number of lines and rightward a
367             number of columns (either of which which may be negative).
368              
369             =cut
370              
371             sub scroll
372             {
373             my $self = shift;
374             my ( $downward, $rightward ) = @_;
375             $self->vextent->scroll( $downward ) if $self->vextent and defined $downward;
376             $self->hextent->scroll( $rightward ) if $self->hextent and defined $rightward;
377             }
378              
379             =head2 scroll_to
380              
381             $scrollbox->scroll_to( $top, $left )
382              
383             Requests the content be scrolled such that the given line and column number of
384             the child's content is the topmost visible in the container.
385              
386             =cut
387              
388             sub scroll_to
389             {
390             my $self = shift;
391             my ( $top, $left ) = @_;
392             $self->vextent->scroll_to( $top ) if $self->vextent and defined $top;
393             $self->hextent->scroll_to( $left ) if $self->hextent and defined $left;
394             }
395              
396             sub _extent_scrolled
397             {
398             my $self = shift;
399             my ( $id, $delta, $value ) = @_;
400              
401             my $vextent = $self->vextent;
402             my $hextent = $self->hextent;
403              
404             if( my $win = $self->window ) {
405             if( $id eq "v" ) {
406             $win->expose( Tickit::Rect->new(
407             top => 0, lines => $win->lines,
408             left => $win->cols - 1, cols => 1,
409             ) );
410             }
411             elsif( $id eq "h" ) {
412             $win->expose( Tickit::Rect->new(
413             top => $win->lines - 1, lines => 1,
414             left => 0, cols => $win->cols,
415             ) );
416             }
417             }
418              
419             # Extents use $delta = 0 to just request a redraw e.g. on change of total
420             return if $delta == 0;
421              
422             my $child = $self->child or return;
423              
424             my ( $downward, $rightward ) = ( 0, 0 );
425             if( $id eq "v" ) {
426             $downward = $delta;
427             }
428             elsif( $id eq "h" ) {
429             $rightward = $delta;
430             }
431              
432             if( $self->{child_is_scrollable} ) {
433             $child->scrolled( $downward, $rightward, $id );
434             }
435             else {
436             my $childwin = $child->window or return;
437              
438             $childwin->reposition( $vextent ? -$vextent->start : 0,
439             $hextent ? -$hextent->start : 0 );
440              
441             my $viewport = $self->{viewport};
442             $viewport->scroll_with_children( $downward, $rightward );
443             }
444             }
445              
446             sub render_to_rb
447             {
448             my $self = shift;
449             my ( $rb, $rect ) = @_;
450             my $win = $self->window or return;
451              
452             my $lines = $win->lines;
453             my $cols = $win->cols;
454              
455             my $scrollbar_pen = $self->get_style_pen( "scrollbar" );
456             my $scrollmark_pen = $self->get_style_pen( "scrollmark" );
457             my $arrow_pen = $self->get_style_pen( "arrow" );
458              
459             my $v_visible = $self->_v_visible;
460             my $h_visible = $self->_h_visible;
461              
462             if( $v_visible and $rect->right == $cols ) {
463             my $vextent = $self->vextent;
464             my ( $bar_top, $mark_top, $mark_bottom, $bar_bottom ) =
465             $vextent->scrollbar_geom( 1, $lines - 2 - ( $h_visible ? 1 : 0 ) );
466             my $start = $vextent->start;
467              
468             $rb->text_at ( 0, $cols-1,
469             $start > 0 ? $self->get_style_values( "arrow_up" ) : " ", $arrow_pen );
470             $rb->vline_at( $bar_top, $mark_top-1, $cols-1, LINE_DOUBLE, $scrollbar_pen, CAP_BOTH ) if $mark_top > $bar_top;
471             $rb->erase_at( $_, $cols-1, 1, $scrollmark_pen ) for $mark_top .. $mark_bottom-1;
472             $rb->vline_at( $mark_bottom, $bar_bottom-1, $cols-1, LINE_DOUBLE, $scrollbar_pen, CAP_BOTH ) if $bar_bottom > $mark_bottom;
473             $rb->text_at ( $bar_bottom, $cols-1,
474             $start < $vextent->limit ? $self->get_style_values( "arrow_down" ) : " ", $arrow_pen );
475             }
476              
477             if( $h_visible and $rect->bottom == $lines ) {
478             my $hextent = $self->hextent;
479              
480             my ( $bar_left, $mark_left, $mark_right, $bar_right ) =
481             $hextent->scrollbar_geom( 1, $cols - 2 - ( $v_visible ? 1 : 0 ) );
482             my $start = $hextent->start;
483              
484             $rb->goto( $lines-1, 0 );
485              
486             $rb->text_at( $lines-1, 0,
487             $start > 0 ? $self->get_style_values( "arrow_left" ) : " ", $arrow_pen );
488             $rb->hline_at( $lines-1, $bar_left, $mark_left-1, LINE_DOUBLE, $scrollbar_pen, CAP_BOTH ) if $mark_left > $bar_left;
489             $rb->erase_at( $lines-1, $mark_left, $mark_right - $mark_left, $scrollmark_pen );
490             $rb->hline_at( $lines-1, $mark_right, $bar_right-1, LINE_DOUBLE, $scrollbar_pen, CAP_BOTH ) if $bar_right > $mark_right;
491             $rb->text_at( $lines-1, $bar_right,
492             $start < $hextent->limit ? $self->get_style_values( "arrow_right" ) : " ", $arrow_pen );
493              
494             $rb->erase_at( $lines-1, $cols-1, 1 ) if $v_visible;
495             }
496             }
497              
498             sub key_up_1 { my $vextent = shift->vextent or return; $vextent->scroll( -1 ); 1 }
499             sub key_down_1 { my $vextent = shift->vextent or return; $vextent->scroll( +1 ); 1 }
500             sub key_left_1 { my $hextent = shift->hextent or return; $hextent->scroll( -1 ); 1 }
501             sub key_right_1 { my $hextent = shift->hextent or return; $hextent->scroll( +1 ); 1 }
502              
503             sub key_up_half { my $vextent = shift->vextent or return; $vextent->scroll( -int( $vextent->viewport / 2 ) ); 1 }
504             sub key_down_half { my $vextent = shift->vextent or return; $vextent->scroll( +int( $vextent->viewport / 2 ) ); 1 }
505             sub key_left_half { my $hextent = shift->hextent or return; $hextent->scroll( -int( $hextent->viewport / 2 ) ); 1 }
506             sub key_right_half { my $hextent = shift->hextent or return; $hextent->scroll( +int( $hextent->viewport / 2 ) ); 1 }
507              
508             sub key_to_top { my $vextent = shift->vextent or return; $vextent->scroll_to( 0 ); 1 }
509             sub key_to_bottom { my $vextent = shift->vextent or return; $vextent->scroll_to( $vextent->limit ); 1 }
510             sub key_to_leftmost { my $hextent = shift->hextent or return; $hextent->scroll_to( 0 ); 1 }
511             sub key_to_rightmost { my $hextent = shift->hextent or return; $hextent->scroll_to( $hextent->limit ); 1 }
512              
513             sub on_mouse
514             {
515             my $self = shift;
516             my ( $args ) = @_;
517              
518             my $type = $args->type;
519             my $button = $args->button;
520              
521             my $lines = $self->window->lines;
522             my $cols = $self->window->cols;
523              
524             my $vextent = $self->vextent;
525             my $hextent = $self->hextent;
526              
527             my $vlen = $lines - 2 - ( $self->_h_visible ? 1 : 0 );
528             my $hlen = $cols - 2 - ( $self->_v_visible ? 1 : 0 );
529              
530             if( $type eq "press" and $button == 1 ) {
531             if( $vextent and $args->col == $cols-1 ) {
532             # Click in vertical scrollbar
533             my ( undef, $mark_top, $mark_bottom, $bar_bottom ) = $vextent->scrollbar_geom( 1, $vlen );
534             my $line = $args->line;
535              
536             if( $line == 0 ) { # up arrow
537             $vextent->scroll( -1 );
538             }
539             elsif( $line < $mark_top ) { # above area
540             $vextent->scroll( -int( $vextent->viewport / 2 ) );
541             }
542             elsif( $line < $mark_bottom ) {
543             # press in mark - ignore for now - TODO: prelight?
544             }
545             elsif( $line < $bar_bottom ) { # below area
546             $vextent->scroll( +int( $vextent->viewport / 2 ) );
547             }
548             elsif( $line == $bar_bottom ) { # down arrow
549             $vextent->scroll( +1 );
550             }
551             return 1;
552             }
553             if( $hextent and $args->line == $lines-1 ) {
554             # Click in horizontal scrollbar
555             my ( undef, $mark_left, $mark_right, $bar_right ) = $hextent->scrollbar_geom( 1, $hlen );
556             my $col = $args->col;
557              
558             if( $col == 0 ) { # left arrow
559             $hextent->scroll( -1 );
560             }
561             elsif( $col < $mark_left ) { # above area
562             $hextent->scroll( -int( $hextent->viewport / 2 ) );
563             }
564             elsif( $col < $mark_right ) {
565             # press in mark - ignore for now - TODO: prelight
566             }
567             elsif( $col < $bar_right ) { # below area
568             $hextent->scroll( +int( $hextent->viewport / 2 ) );
569             }
570             elsif( $col == $bar_right ) { # right arrow
571             $hextent->scroll( +1 );
572             }
573             return 1;
574             }
575             }
576             elsif( $type eq "drag_start" and $button == 1 ) {
577             if( $vextent and $args->col == $cols-1 ) {
578             # Drag in vertical scrollbar
579             my ( undef, $mark_top, $mark_bottom ) = $vextent->scrollbar_geom( 1, $vlen );
580             my $line = $args->line;
581              
582             if( $line >= $mark_top and $line < $mark_bottom ) {
583             $self->{drag_offset} = $line - $mark_top;
584             $self->{drag_bar} = "v";
585             return 1;
586             }
587             }
588             if( $hextent and $args->line == $lines-1 ) {
589             # Drag in horizontal scrollbar
590             my ( undef, $mark_left, $mark_right ) = $hextent->scrollbar_geom( 1, $hlen );
591             my $col = $args->col;
592              
593             if( $col >= $mark_left and $col < $mark_right ) {
594             $self->{drag_offset} = $col - $mark_left;
595             $self->{drag_bar} = "h";
596             return 1;
597             }
598             }
599             }
600             elsif( $type eq "drag" and $button == 1 and defined( $self->{drag_offset} ) ) {
601             if( $self->{drag_bar} eq "v" ) {
602             my $want_bar_top = $args->line - $self->{drag_offset} - 1;
603             my $want_top = int( $want_bar_top * $vextent->total / $vlen + 0.5 );
604             $vextent->scroll_to( $want_top );
605             }
606             if( $self->{drag_bar} eq "h" ) {
607             my $want_bar_left = $args->col - $self->{drag_offset} - 1;
608             my $want_left = int( $want_bar_left * $hextent->total / $hlen + 0.5 );
609             $hextent->scroll_to( $want_left );
610             }
611             }
612             elsif( $type eq "drag_stop" ) {
613             undef $self->{drag_offset};
614             }
615             elsif( $type eq "wheel" ) {
616             # Alt-wheel for horizontal
617             my $extent = $args->mod & 2 ? $self->hextent : $self->vextent;
618             $extent->scroll( -5 ) if $extent and $button eq "up";
619             $extent->scroll( +5 ) if $extent and $button eq "down";
620             return 1;
621             }
622             }
623              
624             =head1 SMART SCROLLING
625              
626             If the child widget declares it supports smart scrolling, then the ScrollBox
627             will not implement content scrolling on its behalf. Extra methods are used to
628             co-ordinate the scroll position between the scrolling-aware child widget and
629             the containing ScrollBox. This is handled by the following methods on the
630             child widget.
631              
632             If smart scrolling is enabled for the child, then its window will be set to
633             the viewport directly, and the child widget must offset its content within the
634             window as appropriate. The child must indicate the range of its scrolling
635             ability by using the C method on the extent object it is given.
636              
637             =head2 $smart = $child->CAN_SCROLL
638              
639             If this method exists and returns a true value, the ScrollBox will use smart
640             scrolling. This method must return a true value for this to work, allowing the
641             method to itself be a proxy, for example, to proxy scrolling information
642             through a single child widget container.
643              
644             =head2 $child->set_scrolling_extents( $vextent, $hextent )
645              
646             Gives the child widget the vertical and horizontal scrolling extents. The
647             child widget should save thes values, and inspect the C value of them
648             any time it needs these to implement content offset position when
649             rendering.
650              
651             =head2 $child->scrolled( $downward, $rightward, $h_or_v )
652              
653             Informs the child widget that one of the scroll positions has changed. It
654             passes the delta (which may be negative) of each position, and a string which
655             will be either C<"h"> or C<"v"> to indicate whether it was an adjustment of
656             the horizontal or vertical scrollbar. The extent objects will already have
657             been updated by this point, so the child may also inspect the C value
658             of them to obtain the new absolute offsets.
659              
660             =cut
661              
662             =head1 TODO
663              
664             =over 4
665              
666             =item *
667              
668             Choice of left/right and top/bottom bar positions.
669              
670             =item *
671              
672             Click-and-hold on arrow buttons for auto-repeat
673              
674             =item *
675              
676             Allow smarter cooperation with a scrolling-aware child widget; likely by
677             setting extent objects on the child if it declares to be supported, and use
678             that instead of an offset child window.
679              
680             =back
681              
682             =cut
683              
684             =head1 AUTHOR
685              
686             Paul Evans
687              
688             =cut
689              
690             0x55AA;