File: pathbar.py

package info (click to toggle)
zim 0.65-4
  • links: PTS, VCS
  • area: main
  • in suites: stretch
  • size: 7,816 kB
  • ctags: 6,779
  • sloc: python: 54,978; xml: 1,137; makefile: 45; sh: 35
file content (577 lines) | stat: -rw-r--r-- 19,377 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
# -*- coding: utf-8 -*-

# Copyright 2008 Jaap Karssenberg <jaap.karssenberg@gmail.com>


import gtk
import gobject

from zim.gui.widgets import encode_markup_text


# Constants
DIR_FORWARD = 1
DIR_BACKWARD = -1


class ScrolledHBox(gtk.HBox):
	'''This class provides a widget that behaves like a HBox when there is
	enough space to render all child widgets. When space is limited it
	shows arrow buttons on the left and on the right to allow scrolling
	through the widgets.

	Note that this class does not (yet?) support packing options like
	'expand', 'fill' etc. All child widgets can just be added with 'add()'.

	TODO this class does not yet support homogeneous spacing
	'''

	# In order to display as many items as possible we use the following
	# scrolling algorithm:
	#
	# There is an attribute "anchor" which is a tuple of a direction and the index
	# of a child item. This anchor represents the last scrolling action.
	# We start filling the space by taking this anchor item as either left or right
	# side of the visible range (depending on the given direction) and start adding
	# other items from there. We can run out of items when we reach the end of the
	# item list, or we can run out of space when the next item is larger than the
	# space that is left.
	# Next we check if we can show more items by adding items in the opposite
	# direction. Possibly replacing our anchor as outer left or outer right item.
	# Once we know which items can be shown we start allocating space to the widgets.
	#
	# The indices of the first and last visible items are put in the attributes
	# "_first" and "_last". In subsequent scroll actions the new anchor item can be
	# determined based on these indices.Keep in mind that whether the items are rendered
	# left-to-right or right-to-left depends on the current locale. So the first and
	# last items can either be on the left or on the right.
	#
	# Slide buttons are shown unless all children fit in the given space. So
	# the space is calculated either with or without sliders. At the end we
	# check if the sliders are really needed. We choose to hide the slider if
	# it can't be used to scroll more instead of making it insensitive because
	# the arrows do not show very clear when they are sensitive and when not.
	# The space that is freed when a slider is hidden is not recycled because
	# that would pose the risk of clicking on a button when the slider suddenly
	# disappears

	# We also add the two scroll buttons to our child widgets, so when dealing
	# with the widgets that are scrolled one should use "get_children()[2:]".
	# Not sure if there is a cleaner way to do this

	# Actually wanted to inherit from gtk.Box instead of gtk.HBox, but seems
	# we can not call gtk.Box.__init__() to instantiate the object.

	__gsignals__ = {
		'size-request': 'override',
		'size-allocate': 'override',
	}

	initial_scroll_timeout = 300 # timeout before starting scrolling on button press
	scroll_timeout = 150 # timeout between scroll steps

	def __init__(self, spacing=0, homogeneous=False):
		gtk.HBox.__init__(self)
		self.set_spacing(spacing)
		self.set_homogeneous(homogeneous)
		self._scroll_timeout = None
		self._anchor = None # tuple of (direction, n) - used in allocate
		self._first = None # int for first item - set in allocate
		self._last = None # int for last item - set in allocate
		self._forw_button = ScrollButton(DIR_FORWARD)
		self._back_button = ScrollButton(DIR_BACKWARD)
		for button in (self._forw_button, self._back_button):
			self.add(button)
			button.connect('button-press-event', self._on_button_press)
			button.connect('button-release-event', self._on_button_release)
			button.connect('clicked', self._on_button_clicked)
		# TODO looks like gtk.widget_push_composite_child is intended
		# to flag internal children versus normal children
		# use this property + define forall to have sane API for these buttons

	def __del__(self):
		self.stop_scrolling()

	def _on_button_press(self, button, event):
		if event.button == 1:
			self.start_scrolling(button.direction, initial_timeout=True)

	def _on_button_release(self, button, event):
		self.stop_scrolling()

	def _on_button_clicked(self, button):
		self.scroll(button.direction)

	def scroll(self, direction, n=1):
		'''Scroll n items in either direction. Direction should be either
		DIR_FORWARD or DIR_BACKWARD, while n should be integer.
		Returns boolean for success.
		'''
		# returning boolean also controls timeout behavior

		index = None
		max = len(self.get_children()[2:])-1
		if direction == DIR_FORWARD:
			if self._last == max:
				return False
			else:
				index = self._last + n
		elif direction == DIR_BACKWARD:
			if self._first == 0:
				return False
			else:
				index = self._first - n
		else:
			assert False

		if index > max: index = max
		elif index < 0: index = 0

		self._anchor = (direction, index)
		self.queue_resize()
		return True

	def scroll_to_child(self, child):
		i = self.get_children()[2:].index(child)
		if i < self._first:
			self.scroll(DIR_BACKWARD, self._first - i)
		elif i > self._last:
			self.scroll(DIR_FORWARD, i - self._last)
		else:
			pass # child was visible already

	def start_scrolling(self, direction, initial_timeout=False):
		'''Start continues scrolling. Direction should be either
		DIR_FORWARD or DIR_BACKWARD. If we were scrolling already, stops
		this action before setting new scroll direction.
		'''
		self.stop_scrolling()
		if initial_timeout:
			self._scroll_timeout = \
				gobject.timeout_add(self.initial_scroll_timeout, self.start_scrolling, direction)
				# indirect recurs
		else:
			self._scroll_timeout = \
				gobject.timeout_add(self.scroll_timeout, self.scroll, direction)

		return False # make sure we are only called once from a timeout

	def stop_scrolling(self):
		'''Stop continues scrolling. Does not do anything if we were not
		scrolling.
		'''
		if not self._scroll_timeout is None:
			gobject.source_remove(self._scroll_timeout)
			self._scroll_timeout = None

	def do_size_request(self, requisition):
		# Determine minimum size needed and store it in requisition
		# Minimum size should be enough to render the largest child with
		# scroll buttons on both sides + spacing + border

		child_wh_tuples = [c.size_request() for c in self.get_children()[2:]]
		if child_wh_tuples:
			width = max([c[0] for c in child_wh_tuples])
			height = max([c[1] for c in child_wh_tuples])
		else:
			width = 0
			height = 0

		spacing = self.get_spacing()
		for button in (self._forw_button, self._back_button):
			w, h = button.size_request()
			if h > height:
				height = h
			width += w + spacing

		border = self.get_border_width()
		width += 2 * border
		height += 2 * border

		#~ print "Requesting WxH: %i x %i" % (width, height)
		requisition.height = height
		requisition.width = width

	def do_size_allocate(self, allocation):
		# Assign the available space to the child widgets
		# See discussion of allocation algorithm above

		#~ print "Allocated WxH: %i x %i" % (allocation.width, allocation.height)
		#~ print "At X,Y: %i, %i" % (allocation.x, allocation.y)

		gtk.HBox.do_size_allocate(self, allocation)

		children = self.get_children()[2:]
		if not children:
			self._forw_button.set_child_visible(False)
			self._back_button.set_child_visible(False)
			return # nothing to render



		direction, index = self._anchor or (DIR_FORWARD, len(children)-1)
		assert 0 <= index <= len(children)
		assert direction in (DIR_FORWARD, DIR_BACKWARD)
			# default (DIR_FORWARD, -1) should show the last item (right most)
			# and starts filling the space backward (to the left)

		spacing = self.get_spacing()
		border = self.get_border_width()

		widths = [c.get_child_requisition()[0] for c in children]
		total = reduce(int.__add__, widths) + len(widths) * spacing + 2 * border
		if total <= allocation.width:
			show_scroll_buttons = False
			first, last = 0, len(children)-1
		else:
			# determine which children to show
			show_scroll_buttons = True
			first, last = index, index
			available = allocation.width - widths[index]
			for button in (self._forw_button, self._back_button):
				available -= button.get_child_requisition()[0] + spacing
			if direction == DIR_FORWARD:
				# fill items from the direction we came from with last scroll
				for i in range(index-1, -1, -1):
					needed = widths[i] + spacing
					if needed > available:
						break
					else:
						first = i
						available -= needed
				# see if there is any space to fill items on the other side
				for i in range(index+1, len(children), 1):
					needed = widths[i] + spacing
					if needed > available:
						break
					else:
						last = i
						available -= needed
			else: # DIR_BACKWARD
				# fill items from the direction we came from with last scroll
				for i in range(index+1, len(children)):
					needed = widths[i] + spacing
					if needed > available:
						break
					else:
						last = i
						available -= needed
				# see if there is any space to fill items on the other side
				for i in range(index-1, -1, -1):
					needed = widths[i] + spacing
					if needed > available:
						break
					else:
						first = i
						available -= needed

		self._first, self._last = first, last

		# Allocate children
		y = allocation.y + border
		h = allocation.height - 2*border
		child_allocation = gtk.gdk.Rectangle(y=y, height=h)
			# y and height are the same for all
		if not self.get_direction() == gtk.TEXT_DIR_RTL:
			# Left to Right
			child_allocation.x = allocation.x + border

			if show_scroll_buttons and first != 0:
				child_allocation.width = self._back_button.get_child_requisition()[0]
				self._back_button.set_child_visible(True)
				self._back_button.size_allocate(child_allocation)
			else:
				self._back_button.set_child_visible(False)

			if show_scroll_buttons:
				# Reserve the space, even if hidden
				child_allocation.x += self._back_button.get_child_requisition()[0] + spacing

			for i in range(first, last+1):
				child_allocation.width = widths[i]
				children[i].set_child_visible(True)
				children[i].size_allocate(child_allocation)
				child_allocation.x += widths[i] + spacing # set x for next child

			if show_scroll_buttons and last != len(children)-1:
				# reset x - there may be space between last button and scroll button
				child_allocation.width = self._forw_button.get_child_requisition()[0]
				child_allocation.x = allocation.x + allocation.width - child_allocation.width - border
				self._forw_button.set_child_visible(True)
				self._forw_button.size_allocate(child_allocation)
			else:
				# hide scroll button
				self._forw_button.set_child_visible(False)
		else:
			# Right to Left
			child_allocation.x = allocation.x + allocation.width - border

			if show_scroll_buttons and first != 0:
				child_allocation.width = self._back_button.get_child_requisition()[0]
				child_allocation.x -= child_allocation.width
				self._back_button.set_child_visible(True)
				self._back_button.size_allocate(child_allocation)
				child_allocation.x -= spacing
			else:
				self._back_button.set_child_visible(False)
				if show_scroll_buttons:
					# Reserve the space, even if hidden
					child_allocation.x = self._back_button.get_child_requisition()[0] + spacing

			for i in range(first, last+1):
				child_allocation.width = widths[i]
				child_allocation.x -= child_allocation.width
				children[i].set_child_visible(True)
				children[i].size_allocate(child_allocation)
				child_allocation.x -= spacing # set x for next child

			if show_scroll_buttons and last != len(children)-1:
				# reset x - there may be space between last button and scroll button
				child_allocation.width = self._forw_button.get_child_requisition()[0]
				child_allocation.x = allocation.x + border
				self._forw_button.set_child_visible(True)
				self._forw_button.size_allocate(child_allocation)
			else:
				# hide scroll button
				self._forw_button.set_child_visible(False)

		# Hide remaining children
		for child in children[0:first]:
			child.set_child_visible(False)
		for child in children[last+1:]:
			child.set_child_visible(False)

	def do_focus(self, direction):
		# Overrule navigation for <Ctrl><Tab> while leaving
		# navigation with <Left> and <Right> in tact
		# (so do not "sub-navigate" with <Ctrl><Tab>).
		# Otherwise the user has to tab through all buttons before
		# he can tab to the next widget.
		if direction in (gtk.DIR_TAB_FORWARD, gtk.DIR_TAB_BACKWARD) \
		and self.focus_child is not None:
			return False # Let outer container go to next widget
		else:
			return gtk.HBox.do_focus(self, direction)

# Need to register classes defining gobject signals
gobject.type_register(ScrolledHBox)


class ScrollButton(gtk.Button):
	'''Arrow buttons used by ScrolledHBox'''

	def __init__(self, direction):
		gtk.Button.__init__(self)
		self.direction = direction
		if self.get_direction() != gtk.TEXT_DIR_RTL:
			# Left to Right
			if direction == DIR_FORWARD: arrow_dir = gtk.ARROW_RIGHT
			else: arrow_dir = gtk.ARROW_LEFT
		else:
			# Right to Left
			if direction == DIR_FORWARD: arrow_dir = gtk.ARROW_LEFT
			else: arrow_dir = gtk.ARROW_RIGHT

		self.add(gtk.Arrow(arrow_dir, gtk.SHADOW_OUT))
		self.set_relief(gtk.RELIEF_NONE)


class PathBar(ScrolledHBox):
	'''Base class for pathbars in the zim GUI, extends ScrolledHBox for usage
	with a list of ToggleButtons representing zim Path objects'''

	def __init__(self, ui, history=None, spacing=0, homogeneous=False):
		ScrolledHBox.__init__(self, spacing, homogeneous)
		self.ui = ui
		self.history = None
		self._selected = None
		if history:
			self.set_history(history)
		self.ui.connect_after('open-page', self._after_open_page)

	def set_history(self, history):
		self.history = history
		self.history.connect('changed', lambda o: self._update())
		self._update()
		self._select(history.get_current())

	def _after_open_page(self, ui, page, path):
		# Since we connect after open page, update has likely been done
		# already from the history 'changed' signal - if not trigger it here.
		self._select(path)
		if self._selected is None:
			self._update()
			self._select(path)

	def _update(self):
		if self.history is None:
			return

		for button in self.get_children()[2:]:
			self.remove(button)
		self._selected = None

		for path in self.get_paths():
			button = gtk.ToggleButton(label=path.basename)
			button.set_use_underline(False)
			button.zim_path = path
			button.connect('clicked', self.on_button_clicked)
			button.connect('popup-menu', self.on_button_popup_menu)
			button.connect('button-release-event', self.do_button_release_event)
			# TODO Drag n drop support also nice to have
			button.show()
			self.add(button)

		# FIXME tooltips seem not to work - not sure why
		if gtk.gtk_version >= (2, 12) \
		and gtk.pygtk_version >= (2, 12):
			for button in self.get_children()[2:]:
				button.set_tooltip_text(button.zim_path.name)
		else:
			tooltips = gtk.Tooltips()
			for button in self.get_children()[2:]:
				tooltips.set_tip(button, button.zim_path.name)

	def get_paths(self):
		'''To be implemented by the sub class, should return a list
		(or iterable) of notebook paths to show in the pathbar.
		'''
		raise NotImplemented

	def _select(self, path):
		def set_active(button, active):
			button.handler_block_by_func(self.on_button_clicked)
			button.set_active(active)
			label = button.get_child()
			if active:
				label.set_markup('<b>'+encode_markup_text(label.get_text())+'</b>')
			else:
				label.set_text(label.get_text())
					# get_text() gives string without markup
			button.handler_unblock_by_func(self.on_button_clicked)

		if not self._selected is None:
			set_active(self._selected, False)

		for button in reversed(self.get_children()[2:]):
			if button.zim_path == path:
				self._selected = button
				break
		else:
			self._selected = None

		if not self._selected is None:
			set_active(self._selected, True)

	def on_button_clicked(self, button):
		self.ui.notebook.flush_page_cache(button.zim_path)
		self.ui.open_page(button.zim_path)

	def do_button_release_event(self, button, event):
		'''Handler for button-release-event, triggers popup menu'''
		if event.button == 3:
			button.emit('popup-menu') # FIXME do we need to pass x/y and button ?
			return True

	def on_button_popup_menu(self, button):
		menu = gtk.Menu()
		self.ui.populate_popup('page_popup', menu, button.zim_path)
		menu.popup(None, None, None, 3, 0)
		return True

	def get_selected_path(self):
		'''Returns path currently selected or None'''
		if self._selected:
			return self._selected.zim_path
		else:
			return None

class HistoryPathBar(PathBar):

	# Get last X paths from history, add buttons
	# Clicking in the pathbar will always add another entry for that
	# path to the last position

	def get_paths(self):
		# TODO enforce max number of paths shown
		paths = list(self.history.get_history())
		paths.reverse()
		return paths


class RecentPathBar(PathBar):

	# Get last X unique paths from history, add buttons
	# When a button is clicked we do not want to change the view
	# So on open page we need to check if the page was in the list
	# already or not

	def get_paths(self):
		# TODO enforce max number of paths shown
		paths = list(self.history.get_recent())
		paths.reverse()
		return paths


class RecentChangesPathBar(PathBar):

	def __init__(self, *arg, **kwarg):
		PathBar.__init__(self, *arg, **kwarg)
		self.ui.notebook.connect_after('stored-page', self.on_stored_page)

	def on_stored_page(self, *a):
		self._update()
		current = self.history.get_current()
		if current:
			self._select(current)

	def get_paths(self):
		index = self.ui.notebook.index
		return reversed(list(
			index.list_recent_pages(offset=0, limit=10)))


class NamespacePathBar(PathBar):

	# Add buttons for namespace up to and including current page
	# Use history to query for sub-pages to show as well
	# Clicking a button in the pathbar should not change the view

	def get_paths(self):
		# no need to enforce a max number of paths here
		current = self.history.get_current()
		if not current:
			return []
		path = self.history.get_grandchild(current) or current
		paths = list(path.parents())
		paths.reverse()
		paths.pop(0) # remove root
		paths.append(path) # add leaf
		return paths


############################

class TestPath(object):

	def __init__(self, name):
		self.name = name
		self.basename = name

class TestPathBar(PathBar):

	def __init__(self):
		PathBar.__init__(self, None, None)
		self.history = 'XXX'
		self._update()

	def get_paths(self):
		for path in ('foo', 'bar', 'baz', 'looooooongggggggg item here', 'dus', 'ja', 'hmm'):
			yield TestPath(path)

if __name__ == '__main__':
	window = gtk.Window()
	window.connect('destroy', lambda o: gtk.main_quit())
	window.add(TestPathBar())
	window.show_all()
	gtk.main()