Add example code for scrolling widget
[panucci.git] / src / panucci / widgets.py
blobab714c5820d098ec4723168aabc435dfdb1fab73
2 # -*- coding: utf-8 -*-
4 # Additional GTK Widgets for use in Panucci
5 # Copyright (c) 2009 Thomas Perl <thpinfo.com>
7 # Panucci is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
12 # Panucci is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU General Public License for more details.
17 # You should have received a copy of the GNU General Public License
18 # along with Panucci. If not, see <http://www.gnu.org/licenses/>.
21 import gtk
22 import gobject
23 import pango
25 class DualActionButton(gtk.Button):
26 """
27 This button allows the user to carry out two different actions,
28 depending on how long the button is pressed. In order for this
29 to work, you need to specify two widgets that will be displayed
30 inside this DualActionButton and two callbacks (actions) that
31 will be called from the UI thread when the button is released
32 and a valid action is detected.
34 You normally create such a button like this:
36 def action_a():
37 ...
38 def action_b():
39 ...
41 DURATION = 1.0 # in seconds
42 b = DualActionButton(gtk.Label('Default Action'), action_a,
43 gtk.Label('Longpress Action'), action_b,
44 duration=DURATION)
45 window.add(b)
46 """
47 (DEFAULT, LONGPRESS) = range(2)
49 def __init__(self, default_widget, default_action,
50 longpress_widget=None, longpress_action=None,
51 duration=0.5, longpress_enabled=True):
52 gtk.Button.__init__(self)
54 default_widget.show()
55 if longpress_widget is not None:
56 longpress_widget.show()
58 if longpress_widget is None or longpress_action is None:
59 longpress_enabled = False
61 self.__default_widget = default_widget
62 self.__longpress_widget = longpress_widget
63 self.__default_action = default_action
64 self.__longpress_action = longpress_action
65 self.__duration = duration
66 self.__current_state = -1
67 self.__timeout_id = None
68 self.__pressed_state = False
69 self.__inside = False
70 self.__longpress_enabled = longpress_enabled
72 self.connect('pressed', self.__pressed)
73 self.connect('released', self.__released)
74 self.connect('enter', self.__enter)
75 self.connect('leave', self.__leave)
76 self.connect_after('expose-event', self.__expose_event)
78 self.set_state(self.DEFAULT)
80 def set_longpress_enabled(self, longpress_enabled):
81 self.__longpress_enabled = longpress_enabled
83 def get_longpress_enabled(self):
84 return self.__longpress_enabled
86 def set_duration(self, duration):
87 self.__duration = duration
89 def get_duration(self):
90 return self.__duration
92 def set_state(self, state):
93 if state != self.__current_state:
94 if not self.__longpress_enabled and state == self.LONGPRESS:
95 return False
96 self.__current_state = state
97 child = self.get_child()
98 if child is not None:
99 self.remove(child)
100 if state == self.DEFAULT:
101 self.add(self.__default_widget)
102 elif state == self.LONGPRESS:
103 self.add(self.__longpress_widget)
104 else:
105 raise ValueError('State has to be either DEFAULT or LONGPRESS.')
107 return False
109 def get_state(self):
110 return self.__current_state
112 def add_timeout(self):
113 self.remove_timeout()
114 self.__timeout_id = gobject.timeout_add(int(1000*self.__duration),
115 self.set_state, self.LONGPRESS)
117 def remove_timeout(self):
118 if self.__timeout_id is not None:
119 gobject.source_remove(self.__timeout_id)
120 self.__timeout_id = None
122 def __pressed(self, widget):
123 self.__pressed_state = True
124 self.add_timeout()
125 self.__force_redraw()
127 def __released(self, widget):
128 self.__pressed_state = False
129 self.remove_timeout()
130 state = self.get_state()
131 self.__force_redraw()
133 if self.__inside:
134 if state == self.DEFAULT:
135 self.__default_action()
136 elif state == self.LONGPRESS:
137 self.__longpress_action()
139 self.set_state(self.DEFAULT)
141 def __enter(self, widget):
142 self.__inside = True
143 self.__force_redraw()
144 if self.__pressed_state:
145 self.add_timeout()
147 def __leave(self, widget):
148 self.__inside = False
149 self.__force_redraw()
150 if self.__pressed_state:
151 self.set_state(self.DEFAULT)
152 self.remove_timeout()
154 def __force_redraw(self):
155 self.window.invalidate_rect(self.__get_draw_rect(True), False)
157 def __get_draw_rect(self, for_invalidation=False):
158 rect = self.get_allocation()
159 width, height, BORDER = 10, 5, 6
160 brx, bry = (rect.x+rect.width-BORDER-width,
161 rect.y+rect.height-BORDER-height)
163 displacement_x = self.style_get_property('child_displacement-x')
164 displacement_y = self.style_get_property('child_displacement-y')
166 if for_invalidation:
167 # For redraw (rect invalidate), update both the "normal"
168 # and the "pressed" area by simply adding the displacement
169 # to the size (to remove the "other" drawing, too
170 return gtk.gdk.Rectangle(brx, bry, width+displacement_x,
171 height+displacement_y)
172 else:
173 if self.__pressed_state and self.__inside:
174 brx += displacement_x
175 bry += displacement_y
176 return gtk.gdk.Rectangle(brx, bry, width, height)
178 def __expose_event(self, widget, event):
179 style = self.get_style()
180 rect = self.__get_draw_rect()
181 if self.__longpress_enabled:
182 style.paint_handle(self.window, gtk.STATE_NORMAL, gtk.SHADOW_NONE,
183 rect, self, 'Detail', rect.x, rect.y, rect.width,
184 rect.height, gtk.ORIENTATION_HORIZONTAL)
187 class ScrollingLabel(gtk.DrawingArea):
188 """ A simple scrolling label widget - if the text doesn't fit in the
189 container, it will scroll back and forth at a pre-determined interval.
192 LEFT, NO_CHANGE, RIGHT = [ -1, 0, 1 ]
194 def __init__( self, pango_markup, update_interval=100, pixel_jump=1,
195 delay_btwn_scrolls=0, delay_halfway=0 ):
196 """ Creates a new ScrollingLabel widget.
198 pixel_jump: the amount of pixels the text moves in one update
199 pango_markup: the markup that is displayed
200 update_interval: the amount of time (in milliseconds) between
201 scrolling updates
202 delay_btwn_scrolls: The amount of time (in milliseconds) to
203 wait after a scroll has completed and the next one begins
204 delay_halfway: The amount of time (in milliseconds) to wait
205 when the text reaches the far right.
207 Scrolling is controlled by the 'scrolling' property, it must be
208 set to True for the text to start moving. Updating of the pango
209 markup is supported by setting the 'markup' property.
211 Note: the properties can be read from to get their current status
214 gtk.DrawingArea.__init__(self)
215 self.__x_offset = 0
216 self.__x_direction = self.LEFT
217 self.__x_alignment = 0
218 self.__y_alignment = 0.5
219 self.__scrolling_timer_id = None
220 self.__scrolling_possible = False
221 self.__scrolling = False
223 # user-defined parameters (can be changed on-the-fly)
224 self.update_interval = update_interval
225 self.delay_btwn_scrolls = delay_btwn_scrolls
226 self.delay_halfway = delay_halfway
227 self.pixel_jump = pixel_jump
229 self.__graphics_context = None
230 self.__pango_layout = self.create_pango_layout('')
231 self.markup = pango_markup
233 self.connect('expose-event', self.__on_expose_event)
234 self.connect('size-allocate', lambda w,a: self.__reset_widget() )
235 self.connect('map', lambda w: self.__reset_widget() )
237 def __set_scrolling(self, value):
238 if value:
239 self.__start_scrolling()
240 else:
241 self.__stop_scrolling()
243 def set_markup(self, markup):
244 """ Set the pango markup to be displayed by the widget """
245 self.__pango_layout.set_markup(markup)
246 self.__reset_widget()
248 def get_markup( self ):
249 """ Returns the current markup contained in the widget """
250 return self.__pango_layout.get_text()
252 def set_alignment(self, x, y):
253 """ Set the text's alignment on the x axis when it's not possible to
254 scroll. The y axis setting does nothing atm. """
256 for i in x,y:
257 assert isinstance( i, (float, int) ) and 0 <= i <= 1
259 self.__x_alignment = x
260 self.__y_alignment = y
262 def get_alignment( self ):
263 """ Returns the current alignment settings (x, y). """
264 return self.__x_alignment, self.__y_alignment
266 scrolling = property( lambda s: s.__scrolling, __set_scrolling )
267 markup = property( get_markup, set_markup )
268 alignment = property( get_alignment, lambda s,i: s.set_alignment(*i) )
270 def __on_expose_event( self, widget, event ):
271 """ Draws the text on the widget. This should be called indirectly by
272 using self.queue_draw() """
274 if self.__graphics_context is None:
275 self.__graphics_context = self.window.new_gc()
277 self.window.draw_layout( self.__graphics_context,
278 int(self.__x_offset), 0, self.__pango_layout )
280 def __reset_widget( self ):
281 """ Reset the offset and find out whether or not it's even possible
282 to scroll the current text. Useful for when the window gets resized
283 or when new markup is set. """
285 # if there's no window, we can ignore this reset request because
286 # when the window is created this will be called again.
287 if self.window is None:
288 return
290 win_x, win_y = self.window.get_size()
291 lbl_x, lbl_y = self.__pango_layout.get_pixel_size()
293 # If we don't request the proper height, the label might get squashed
294 # down to 0px. (this could still happen on the horizontal axis but we
295 # aren't affected by this problem in Panucci *yet*)
296 self.set_size_request( -1, lbl_y )
298 self.__scrolling_possible = lbl_x > win_x
300 # Remove any lingering scrolling timers
301 self.__scrolling_timer = None
303 if self.__scrolling:
304 self.__start_scrolling()
306 if self.__scrolling_possible:
307 self.__x_offset = 0
308 else:
309 self.__x_offset = (win_x - lbl_x) * self.__x_alignment
311 self.queue_draw()
313 def __scroll_once(self, halfway_callback=None, finished_callback=None):
314 """ Moves the text by 'self.pixel_jump' every time this is called.
315 Returns False when the text has completed one scrolling period and
316 'finished_callback' is run.
317 Returns False halfway through (when the text is all the way to the
318 right) if 'halfway_callback' is set after running the callback.
321 # prevent an accidental scroll (there's a race-condition somewhere
322 # but I'm too lazy to find out where).
323 if not self.__scrolling_possible:
324 return False
326 rtn = True
327 win_x, win_y = self.window.get_size()
328 lbl_x, lbl_y = self.__pango_layout.get_pixel_size()
330 self.__x_offset += self.pixel_jump * self.__x_direction
332 if self.__x_direction == self.LEFT and self.__x_offset < win_x - lbl_x:
333 self.__x_direction = self.RIGHT
334 # set the offset to the maximum left bound otherwise some
335 # characters might get chopped off if using large pixel_jump's
336 self.__x_offset = win_x - lbl_x
338 if halfway_callback is not None:
339 halfway_callback()
340 rtn = False
342 elif self.__x_direction == self.RIGHT and self.__x_offset > 0:
343 # end of scroll period; reset direction
344 self.__x_direction = self.LEFT
345 # don't allow the offset to be greater than 0
346 self.__x_offset = 0
347 if finished_callback is not None:
348 finished_callback()
350 # return False because at this point we've completed one scroll
351 # period; this kills off any timers running this function
352 rtn = False
354 self.queue_draw()
355 return rtn
357 def __scroll_wait_callback(self, delay):
358 # Waits 'delay', then calls the scroll function
359 self.__scrolling_timer = gobject.timeout_add( delay, self.__scroll )
361 def __scroll(self):
362 """ When called, scrolls the text back and forth indefinitely while
363 waiting self.delay_btwn_scrolls between each scroll period. """
365 if self.__scrolling_possible:
366 self.__scrolling_timer = gobject.timeout_add(
367 self.update_interval, self.__scroll_once,
368 lambda: self.__scroll_wait_callback(self.delay_halfway),
369 lambda: self.__scroll_wait_callback(self.delay_btwn_scrolls) )
370 else:
371 self.__scrolling_timer = None
373 def __scrolling_timer_get(self):
374 return self.__scrolling_timer_id
376 def __scrolling_timer_set(self, val):
377 """ When changing the scrolling timer id, make sure that only one
378 timer is running at a time. This removes the previous timer
379 before adding a new one. """
381 if self.__scrolling_timer_id is not None:
382 gobject.source_remove( self.__scrolling_timer_id )
383 self.__scrolling_timer_id = None
385 self.__scrolling_timer_id = val
387 __scrolling_timer = property( __scrolling_timer_get, __scrolling_timer_set )
389 def __start_scrolling(self):
390 """ Make the text start scrolling """
391 self.__scrolling = True
392 if self.__scrolling_timer is None and self.__scrolling_possible:
393 self.__scroll()
395 def __stop_scrolling(self):
396 """ Make the text stop scrolling """
397 self.__scrolling = False
398 self.__scrolling_timer = None
401 if __name__ == '__main__':
402 w = gtk.Window()
403 w.set_geometry_hints(w, 100, 20)
404 hb = gtk.HBox(homogeneous=True, spacing=1)
405 w.add(hb)
407 # scroll 7 pixels per 0.2 seconds, wait halfway for 0.5 seconds and finally
408 # wait 2 seconds after a complete scroll. wash, rinse, repeat.
409 l = ScrollingLabel('N/A', 200, 7, 2000, 500)
410 l.markup = 'some random text 1234'
411 hb.pack_end(l)
413 btn = gtk.Button('start/stop')
414 hb.pack_start(btn)
415 btn.connect('clicked', lambda w,e: setattr(e,'scrolling', not e.scrolling), l)
417 w.connect('destroy', gtk.main_quit)
418 w.show_all()
419 gtk.main()