# Copyright 2006, 2007 Kevin Ryde # This file is part of Gtk2::Ex::TickerView. # # Gtk2::Ex::TickerView is free software; you can redistribute it and/or # modify it under the terms of the GNU General Public License as published # by the Free Software Foundation; either version 3, or (at your option) any # later version. # # Gtk2::Ex::TickerView is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. # # You should have received a copy of the GNU General Public License along # with this program. If not, see . package Gtk2::Ex::TickerView; use strict; use warnings; use POSIX (); # no imports use Glib; use Gtk2; use Gtk2::Ex::IdleObject; use Gtk2::Ex::SignalObject; use Gtk2::Ex::TimerObject; use List::Util qw(min max); use base 'Gtk2::Ex::CellLayout::Base'; use constant { DEFAULT_FRAME_RATE => 4, # times per second DEFAULT_SPEED => 25, # pixels per second }; # not wrapped in gtk2-perl version 1.16 use constant GTK_PRIORITY_REDRAW => (Glib::G_PRIORITY_HIGH_IDLE + 20); # set this to 1 or 2 for some diagnostic prints use constant DEBUG => 1; use Glib::Object::Subclass Gtk2::DrawingArea::, signals => { menu_created => { param_types => ['Gtk2::Menu'], return_type => undef }, expose_event => \&_do_expose_event, }, properties => [Glib::ParamSpec->object ('model', 'model', 'Gtk2::TreeModel for the items to display.', 'Gtk2::TreeModel', Glib::G_PARAM_READWRITE), Glib::ParamSpec->boolean ('run', 'run', 'Whether to run the ticker, ie. scroll across.', 1, # default yes Glib::G_PARAM_READWRITE), Glib::ParamSpec->float ('frame-rate', 'frame-rate', 'How many times per second to redraw the text.', 0, 999999, DEFAULT_FRAME_RATE, Glib::G_PARAM_READWRITE), Glib::ParamSpec->float ('speed', 'speed', 'Speed to move the ticker text across, in pixels per second.', 0, 1e6, DEFAULT_SPEED, Glib::G_PARAM_READWRITE), Glib::ParamSpec->boolean ('fixed-height-mode', 'fixed-height-mode', 'Assume all cells have the same desired height.', 0, # default no Glib::G_PARAM_READWRITE), ]; #------------------------------------------------------------------------------ sub _update_desired_height { my ($self) = @_; my $win_height = -1; if (DEBUG) { print "$self desired height\n"; } my $model = $self->{'model'}; my $cell_list = $self->_cell_list_ref; if ($model && @$cell_list) { my $rows = $model->iter_n_children (undef); if ($self->get('fixed-height-mode')) { # look just at first element if fixed height mode $rows = min ($rows, 1); } foreach my $index (0 .. $rows-1) { foreach my $cell (@$cell_list) { $self->_cell_setup ($cell, $index); my ($x_offset, $y_offset, $width, $height) = $cell->get_size ($self, undef); $win_height = max ($win_height, $height); } } } if (DEBUG) { print " decide $win_height\n"; } $self->set_size_request (-1, $win_height); } #------------------------------------------------------------------------------ sub _cell_list_changed { my ($self) = @_; _update_desired_height ($self); _update_timer ($self); $self->queue_draw; } sub _cell_attributes_changed { my ($self) = @_; _update_desired_height ($self); $self->queue_draw; } #------------------------------------------------------------------------------ sub _do_timer_callback { my ($timer, $self) = @_; if (! $self->is_drag_active) { my $step = $self->get('speed') / $self->get('frame-rate'); $self->scroll_pixels ($step); if (DEBUG >= 2) { print "scroll $step, to ", $self->{'offset'}, "\n"; } } return 1; # continue timer } # start or stop the scroll timer according to the various settings sub _update_timer { my ($self) = @_; my $want_timer = $self->get('run') && $self->mapped && $self->{'visibility_state'} ne 'fully-obscured' && @{$self->_cell_list_ref} && $self->{'model'} && $self->{'model'}->iter_n_children (undef); if (DEBUG) { print $self->get_name, " run ", $self->get('run'), " mapped ", $self->mapped ? 1 : 0, " visibility ", $self->{'visibility_state'}, " model ", $self->get('model') || '(none)', " length ", $self->get('model') ? $self->get('model')->iter_n_children(undef) : '', " --> want ", $want_timer ? 1 : 0, "\n"; } if ($want_timer) { $self->{'timer'} ||= do { my $period = 1000 / $self->get('frame-rate'); if (DEBUG) { print $self->get_name, " start timer, $period ms\n"; } Gtk2::Ex::TimerObject->new_for_widget ($period, \&_do_timer_callback, $self) }; } else { if (DEBUG && $self->{'timer'}) { print $self->get_name, " stop timer ", $self->{'timer'}, "\n"; } delete $self->{'timer'}; } } # return a procedure to be called &$func($index,$width), it returns true # unless and until it sees that all $index values have $width==0 # sub _make_all_zeros_proc { my $seen_nonzero = 0; my $count_index0 = 0; return sub { my ($index, $width) = @_; if ($width != 0) { $seen_nonzero = 1; } if ($index == 0) { $count_index0++; } return (! $seen_nonzero) && ($count_index0 >= 2); } } sub _do_expose_event { my ($self, $event) = @_; if (DEBUG >= 2) { print $self->get_name, " expose\n"; } my $model = $self->{'model'}; if (! $model) { if (DEBUG) { print "$self->get_name no model to draw\n"; } return 0; # propagate event } my $rows = $model->iter_n_children (undef); if ($rows == 0) { return 0; } # propagate event my $cell_list = $self->_cell_list_ref; if (! @$cell_list) { if (DEBUG) { print "$self->get_name no cell renderers to draw with\n"; } return 0; # propagate event } my $exposerect = $event->area; my $ltor = $self->get_direction eq 'ltr'; my $win = $self->window; my ($win_width, $win_height) = $win->get_size; my $style = $self->style; my $state = $self->state; my $area = $event->area; my $attributes = $self->{'attributes'}; my $x = POSIX::floor ($self->{'offset'}); $self->{'prev_x'} = $x; my $index = $self->{'index'}; # if model change has moved if ($index >= $rows) { $index = $rows-1; } my $all_zeros = _make_all_zeros_proc(); # If a scroll has moved the starting offset into the window then step # backwards by enough model items to get to the left edge of the window, # ie. x <= 0. # while ($x > 0) { $index--; if ($index < 0) { $index = $rows-1; } foreach my $cell (@$cell_list) { $self->_cell_setup ($cell, $index); my ($x_offset, $y_offset, $width, $height) = $cell->get_size ($self, undef); if (&$all_zeros ($index, $width)) { last; } $x -= $width; $self->{'offset'} -= $width; } $self->{'index'} = $index; $self->{'prev_x'} = $x; } while ($x < $win_width) { my $total_width = 0; foreach my $cell (@$cell_list) { $self->_cell_setup ($cell, $index); my ($x_offset, $y_offset, $width, $height) = $cell->get_size ($self, undef); if ($x + $width > 0) { # skip if still off left edge my $rect = $ltor ? Gtk2::Gdk::Rectangle->new ($x, 0, $width, $win_height) : Gtk2::Gdk::Rectangle->new ($win_width - 1 - $x - $width, 0, $width, $win_height); $cell->render ($win, $self, $rect, $rect, $exposerect, []); } $x += $width; $total_width += $width; } if (&$all_zeros ($index, $total_width)) { if (DEBUG) { print "$self all cell widths on all rows are zero\n"; } $self->{'offset'} = 0; last; # avoid infinite loop! } $index++; if ($index >= $rows) { $index = 0; } if ($x <= 0) { # The cells we just processed are still all off the left edge of the # screen, so step index and offset to skip that for the next expose. $self->{'index'} = $index; $self->{'offset'} += $total_width; $self->{'prev_x'} = $x; } } return 0; # propagate } sub _do_visibility_notify { my ($self, $event) = @_; if (DEBUG) { print $self->get_name, " visibility ", $event->state, "\n"; } $self->{'visibility_state'} = $event->state; _update_timer ($self); return 0; # propagate event } sub _do_scroll_by_copy { my ($idleobj, $self) = @_; if (DEBUG >= 2) { print "scroll idle\n"; } my $win = $self->window; if (! $win) { # not realized, so nothing to draw return 0; # remove idle call } my $x = POSIX::floor ($self->{'offset'}); my $prev_x = $self->{'prev_x'}; $self->{'prev_x'} = undef; my ($win_width, $win_height) = $win->get_size; my $redraw_x = 0; my $redraw_width = $win_width; if ($self->get_direction eq 'rtl') { $x = $win_width - 1 - $x; $prev_x = $win_width - 1 - $prev_x; } if (defined $prev_x) { my $step = $prev_x - $x; if ($step < 0) { # moving to the right, gap at start $step = -$step; if ($step < $win_width) { my $gc = $self->get_style->white_gc; $win->draw_drawable ($gc, $win, 0,0, # src $step,0, # dst $win_width-$step, $win_height); $redraw_x = 0; $redraw_width = $step; } } elsif ($step > 0) { if ($step < $win_width) { # moving to the left, gap at end my $gc = $self->get_style->white_gc; $win->draw_drawable ($gc, $win, $step,0, # src 0,0, # dst $win_width-$step, $win_height); $redraw_x = $win_width-$step; $redraw_width = $step; } } } $self->queue_draw_area ($redraw_x, 0, $redraw_width, $win_height); $self->{'scroll_idle'} = undef; return 0; # remove idle call } sub _scroll_to_pos { my ($self, $x, $index) = @_; if (DEBUG >= 2) { print "scroll to offset $x index $index\n"; } my $prev_index = $self->{'index'}; $self->{'offset'} = $x; $self->{'index'} = $index; if ($index == $prev_index && $self->{'visibility_state'} eq 'unobscured') { $self->{'scroll_idle'} ||= Gtk2::Ex::IdleObject->new_for_widget (\&_do_scroll_by_copy, $self, GTK_PRIORITY_REDRAW+1); } else { $self->{'scroll_idle'} = undef; $self->queue_draw; } } sub scroll_pixels { my ($self, $pixels) = @_; _scroll_to_pos ($self, $self->{'offset'} - $pixels, $self->{'index'}); } sub scroll_to_start { my ($self) = @_; _scroll_to_pos ($self, 0, 0); } sub is_drag_active { my ($self) = @_; return defined $self->{'drag_x'}; } sub _do_button_press_event { my ($self, $event) = @_; if ($event->button == 1) { $self->{'drag_x'} = $event->x; } elsif ($event->button == 3) { $self->menu->popup (undef, undef, undef, undef, $event->button, $event->time); } return 0; # propagate event } sub _do_motion_notify_event { my ($self, $event) = @_; if ($self->is_drag_active) { my $base = $self->{'drag_x'}; my $x = $event->x; my $step = $base - $x; if ($self->get_direction eq 'rtl') { $step = -$step; } $self->scroll_pixels ($step); $self->{'drag_x'} = $x; } return 0; # propagate event } sub _do_button_release_event { my ($self, $event) = @_; if ($event->button == 1) { _do_motion_notify_event ($self, $event); delete $self->{'drag_x'}; } return 0; # propagate event } sub _do_show { my ($self) = @_; _update_timer ($self); } sub _do_direction_changed { my ($self, $prev_dir) = @_; $self->queue_draw; } sub _do_run_menu_item_toggled { my ($item, $self) = @_; $self->set (run => $item->get_active); } sub menu { my ($self) = @_; return $self->{'menu'} ||= do { my $menu = Gtk2::Menu->new; my $item = $self->{'run_menu_item'} = Gtk2::CheckMenuItem->new_with_label ('Run'); $item->set_active (1); Gtk2::Ex::SignalObject::signal_connect_weak ($item, 'toggled', \&_do_run_menu_item_toggled, $self); $menu->append ($item); $menu->show_all; if (DEBUG) { print "$self menu $menu\n"; } $self->signal_emit ('menu-created', $menu); $menu; } } sub _do_row_changed { my ($model, $path, $iter, $self) = @_; my ($ins_index) = $path->get_indices; $self->{'prev_x'} = undef; $self->queue_draw; } sub _do_row_inserted { my ($model, $path, $iter, $self) = @_; my ($ins_index) = $path->get_indices; # If inserted before current display point then advance that point to keep # the display the same. Redraw always in case displayed portion wraps # around to the new elem. # if ($ins_index < $self->{'index'}) { $self->{'index'}++; } $self->{'prev_x'} = undef; $self->queue_draw; } sub _do_row_deleted { my ($model, $path, $self) = @_; my ($del_index) = $path->get_indices; # If delete before current display point then decrement that point to keep # the display the same. Redraw always in case displayed portion wraps # around to pass over the removed elem. # if ($del_index < $self->{'index'}) { $self->{'index'}--; if ($self->{'index'} < 0) { my $rows = $model->iter_n_children (undef); $self->{'index'} = max (0, $rows - 1); } } $self->{'prev_x'} = undef; $self->queue_draw; } sub _do_rows_reordered { my ($model, $path, $iter, $aref, $self) = @_; $self->{'prev_x'} = undef; $self->queue_draw; } sub SET_PROPERTY { my ($self, $pspec, $newval) = @_; my $pname = $pspec->get_name; $self->{$pname} = $newval; # per default GET_PROPERTY if (DEBUG) { print "$self set $pname $newval\n"; } if ($pname eq 'run') { if (my $item = $self->{'run_menu_item'}) { $item->set_active ($newval); } } if ($pname eq 'model') { my $model = $newval; _update_desired_height ($self); $self->{'row_changed_sig'} = $model && Gtk2::Ex::SignalObject->new_for_widget ($model, 'row-changed', \&_do_row_changed, $self); $self->{'row_inserted_sig'} = $model && Gtk2::Ex::SignalObject->new_for_widget ($model, 'row-inserted', \&_do_row_inserted, $self); $self->{'row_deleted_sig'} = $model && Gtk2::Ex::SignalObject->new_for_widget ($model, 'row-deleted', \&_do_row_deleted, $self); $self->{'row_reordered_sig'} = $model && Gtk2::Ex::SignalObject->new_for_widget ($model, 'rows-reordered', \&_do_rows_reordered, $self); } if ($pname eq 'frame_rate') { delete $self->{'timer'}; # new rate } if ($pname eq 'model' || $pname eq 'run' || $pname eq 'frame_rate') { _update_timer ($self); } $self->queue_draw; } sub INIT_INSTANCE { my ($self) = @_; $self->{'index'} = 0; $self->{'offset'} = 0; $self->{'visibility_state'} = 'initial'; $self->add_events (['visibility-notify-mask', 'button-press-mask', 'button-motion-mask', 'button-release-mask']); $self->signal_connect (visibility_notify_event => \&_do_visibility_notify); $self->signal_connect (button_press_event => \&_do_button_press_event); $self->signal_connect (motion_notify_event => \&_do_motion_notify_event); $self->signal_connect (button_release_event => \&_do_button_release_event); $self->signal_connect (show => \&_do_show); $self->signal_connect (direction_changed => \&_do_direction_changed); _update_timer ($self); } 1; __END__ =head1 NAME Gtk2::Ex::TickerView -- ticker display widget =head1 SYNOPSIS use Gtk2::Ex::TickerView; my $ticker = Gtk2::Ex::TickerView->new (model => $model, renderer => $renderer, ...); =head1 WIDGET HIERARCHY C is a subclass of C, but that might change so it's recommended you only rely on C. Gtk2::Widget Gtk2::DrawingArea Gtk2::Ex::TickerView =head1 DESCRIPTION A C widget displays items from a C scrolling horizontally across the window, like a news bar or stock ticker. Items are drawn using one or more C objects set into the view as per the C interface. For scrolling text you would use C. If more than one renderer is set then they're drawn one after the other for each item (ie. row of the model). For example you could have a C to draw an icon then a C to draw some text and they scroll across together. (The icon could use the model's data, or be just a fixed blob to go before every item.) The display and scrolling direction follows the widget text direction (the C method). For C mode item 0 starts at the left of the window and items scroll to the left. In C item 0 starts at the right of the window and items scroll to the right. Any text or drawing direction within the cell renderers is a matter for them. For C Pango will normally recognise right-to-left scripts such as Arabic based on the characters (utf-8), and so shouldn't need any special setups. Currently only the topmost level of the C data is drawn, so a single level model like C suits. But the intention for the future will probably be to descend into and draw child rows too (if that doesn't make maintaining a current position too difficult). The whole Gtk model/view/layout/renderer/attributes stuff as used here is ridiculously complicated. Its power comes when showing a big list or wanting customized drawing, but the amount of code to get to something on the screen is daunting. Have a look at "Tree and List Widget Overview" in the Gtk reference manual if you haven't already. And then F in the C sources is more or less the smallest amount of code to actually display something. =head1 FUNCTIONS =over 4 =item C<< Gtk2::Ex::TickerView->new (key => value, ...) >> Create and return a C widget. Optional key/value pairs can be given to set initial properties as per C<< Glib::Object->new >>. =item C<< $ticker->menu() >> Return the C which is popped up by mouse button 3 in the ticker. An application can add items to this, such as "Hide", "Help" or "Quit", or perhaps a submenu to change what's displayed. =item C<< $ticker->scroll_pixels ($n) >> Scroll the ticker contents across by C<$n> pixels. Postive C<$n> moves in the normal scrolled direction, or a negative value goes backwards. =item C<< $ticker->scroll_to_start () >> Scroll the ticker contents back to the start, ie. the first element in the model. =item C<< $ticker->is_drag_active () >> Return true if the user is dragging the display with the mouse. =back =head1 PROPERTIES =over 4 =item C (object implementing C, default undef) This is any object implementing the C interface, for example a C, which will supply the data to be displayed. Until this is set the ticker is blank. =item C (boolean, default 1) Whether to run the ticker, ie. to scroll it across the screen with a timer. The default button-3 menu has a "Run" item which turns this on and off (and displays its current state). =item C (floating point pixels per second, default 25) The speed the items scroll across, in pixels per second. =item C (floating point frames per second, default 4) The number of times the ticker redraws each second. On each redraw it moves according to the C property. (The speed is pixels per second, so each redraw moves by C divided by C pixels.) =item C (boolean, default false) If true then assume all rows in the model will come one as the same height. This means when calculating its desired height the ticker widget can ask the cell renderer(s) about just one row from the model, instead of going through all of them. If the model is big this speeds up size negotiation with the ticker's parent, if that parent asks the ticker what size it wants (which is so for most containers). =back The cell renderer(s) are not set in a property but instead are added with the C etc methods of the C interface. Until a renderer is added (plus some C to get data from the model) the ticker is blank. The widget text direction used for the ticker display and scrolling is not a property but instead is accessed with the usual C and C methods. =head1 SEE ALSO C, C