1
2# Copyright 2012-2018 Jaap Karssenberg <jaap.karssenberg@gmail.com>
3
4from gi.repository import Gtk
5from gi.repository import Gdk
6from gi.repository import GObject
7from gi.repository import Pango
8
9import re
10import datetime
11import logging
12
13logger = logging.getLogger('zim.plugins.tableofcontents')
14
15
16from zim.plugins import PluginClass
17from zim.signals import ConnectorMixin, DelayedCallback
18from zim.notebook import Path
19from zim.tokenparser import collect_untill_end_token, tokens_to_text
20from zim.formats import HEADING, LINE
21
22from zim.gui.pageview import PageViewExtension
23from zim.gui.widgets import LEFT_PANE, PANE_POSITIONS, BrowserTreeView, populate_popup_add_separator, \
24	WindowSidePaneWidget, widget_set_css
25from zim.gui.pageview import FIND_REGEX, SCROLL_TO_MARK_MARGIN, _is_heading_tag, LineSeparatorAnchor
26
27LINE_LEVEL = 2  # assume level 1 is page heading, level 2 is topic break within page
28
29# FIXME, these methods should be supported by pageview - need anchors - now it is a HACK
30
31def _is_heading_or_line(iter, include_hr):
32	if list(filter(_is_heading_tag, iter.get_tags())):
33		return True
34	elif not include_hr:
35		return False
36	else:
37		anchor = iter.get_child_anchor()
38		if anchor and  isinstance(anchor, LineSeparatorAnchor):
39			return True
40		else:
41			return False
42
43
44def find_heading(buffer, n, include_hr):
45	'''Find the C{n}th heading in the buffer
46	@param buffer: the C{Gtk.TextBuffer}
47	@param n: an integer
48	@returns: a C{Gtk.TextIter} for the line start of the heading or C{None}
49	'''
50	iter = buffer.get_start_iter()
51	i = 1 if _is_heading_or_line(iter, include_hr) else 0
52	while i < n:
53		iter.forward_line()
54		while not _is_heading_or_line(iter, include_hr):
55			if not iter.forward_line():
56				return None
57		i += 1
58	return iter
59
60
61def select_heading(buffer, n, include_hr):
62	'''Select the C{n}th heading in the buffer'''
63	iter = find_heading(buffer, n, include_hr)
64	if iter:
65		buffer.place_cursor(iter)
66		buffer.select_line()
67		return True
68	else:
69		return False
70
71
72def get_headings(parsetree, include_hr):
73	tokens = parsetree.iter_tokens()
74	stack = [(0, None, [])]
75	for t in tokens:
76		if t[0] == HEADING:
77			level = int(t[1]['level'])
78			text = tokens_to_text(
79						collect_untill_end_token(tokens, HEADING) )
80			assert level > 0 # just to be sure
81			while stack[-1][0] >= level:
82				stack.pop()
83			node = (level, text, [])
84			stack[-1][2].append(node)
85			stack.append(node)
86		elif include_hr and t[0] == LINE:
87			while stack[-1][0] >= LINE_LEVEL:
88				stack.pop()
89			node = (LINE_LEVEL, '\u2500\u2500\u2500\u2500', [])
90				# \u2500 == "BOX DRAWINGS LIGHT HORIZONTAL"
91			stack[-1][2].append(node)
92			stack.append(node)
93		else:
94			pass
95
96	return stack[0][-1]
97
98
99class ToCPlugin(PluginClass):
100
101	plugin_info = {
102		'name': _('Table of Contents'), # T: plugin name
103		'description': _('''\
104This plugin adds an extra widget showing a table of
105contents for the current page.
106
107This is a core plugin shipping with zim.
108'''), # T: plugin description
109		'author': 'Jaap Karssenberg',
110		'help': 'Plugins:Table Of Contents',
111	}
112	# TODO add controls for changing levels in ToC
113
114	plugin_preferences = (
115		# key, type, label, default
116		('pane', 'choice', _('Position in the window'), LEFT_PANE, PANE_POSITIONS),
117			# T: option for plugin preferences
118		('floating', 'bool', _('Show ToC as floating widget instead of in sidepane'), True),
119			# T: option for plugin preferences
120		('show_h1', 'bool', _('Show the page title heading in the ToC'), False),
121			# T: option for plugin preferences
122		('include_hr', 'bool', _('Include horizontal lines in the ToC'), True),
123			# T: option for plugin preferences
124		('fontsize', 'int', _('Set ToC fontsize'), 0, (0, 24)),
125			# T: option for plugin preferences
126	)
127	# TODO disable pane setting if not embedded
128
129
130class ToCPageViewExtension(PageViewExtension):
131
132	def __init__(self, plugin, pageview):
133		PageViewExtension.__init__(self, plugin, pageview)
134		self.tocwidget = None
135		self.on_preferences_changed(plugin.preferences)
136		self.connectto(plugin.preferences, 'changed', self.on_preferences_changed)
137
138	def on_preferences_changed(self, preferences):
139		widgetclass = FloatingToC if preferences['floating'] else SidePaneToC
140		if not isinstance(self.tocwidget, widgetclass):
141			if isinstance(self.tocwidget, SidePaneToC):
142				self.remove_sidepane_widget(self.tocwidget)
143			elif self.tocwidget:
144				self.tocwidget.destroy()
145
146			self.tocwidget = widgetclass(self.pageview)
147
148			if isinstance(self.tocwidget, SidePaneToC):
149				self.add_sidepane_widget(self.tocwidget, 'pane')
150
151		self.tocwidget.set_preferences(
152			preferences['show_h1'],
153			preferences['include_hr'],
154			preferences['fontsize']
155		)
156
157
158TEXT_COL = 0
159
160class ToCTreeView(BrowserTreeView):
161
162	def __init__(self, ellipsis, fontsize):
163		BrowserTreeView.__init__(self, ToCTreeModel())
164		self.set_headers_visible(False)
165		self.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE)
166			# Allow select multiple
167
168		cell_renderer = Gtk.CellRendererText()
169		if fontsize > 0:
170			cell_renderer.set_property('size-points', fontsize)
171		if ellipsis:
172			cell_renderer.set_property('ellipsize', Pango.EllipsizeMode.END)
173		column = Gtk.TreeViewColumn('_heading_', cell_renderer, text=TEXT_COL)
174		column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE)
175			# Without this sizing, column width only grows and never shrinks
176		self._cell_renderer = cell_renderer
177		self.append_column(column)
178
179	def set_fontsize(self, fontsize):
180		if fontsize != 0:
181			self._cell_renderer.set_property('size-points', fontsize)
182
183
184class ToCTreeModel(Gtk.TreeStore):
185
186	def __init__(self):
187		Gtk.TreeStore.__init__(self, str) # TEXT_COL
188		self.is_empty = True
189		self.hidden_h1 = False
190
191	def clear(self):
192		self.is_empty = True
193		Gtk.TreeStore.clear(self)
194
195	def walk(self, iter=None):
196		if iter is not None:
197			yield iter
198			child = self.iter_children(iter)
199		else:
200			child = self.get_iter_first()
201
202		while child:
203			if self.iter_has_child(child):
204				for i in self.walk(child):
205					yield i
206			else:
207				yield child
208			child = self.iter_next(child)
209
210	def get_nth_heading(self, path):
211		n = 1 if self.hidden_h1 else 0
212		for iter in self.walk():
213			n += 1
214			if self.get_path(iter) == path:
215				break
216		return n
217
218	def update(self, headings, show_h1):
219		if not show_h1 \
220		and len(headings) == 1 \
221		and headings[0][0] == 1:
222			# do not show first heading
223			headings = headings[0][2]
224			self.hidden_h1 = True
225		else:
226			self.hidden_h1 = False
227
228		if not headings:
229			self.clear()
230			return
231
232		if self.is_empty:
233			self._insert_headings(headings)
234		else:
235			self._update_headings(headings)
236
237		self.is_empty = False
238
239	def _update_headings(self, headings, parent=None):
240		iter = self.iter_children(parent)
241		for level, text, children in headings:
242			if iter:
243				# Compare to model
244				self[iter] = (text,)
245				if children:
246					if self.iter_has_child(iter):
247						self._update_headings(children, iter)
248					else:
249						self._insert_headings(children, iter)
250				elif self.iter_has_child(iter):
251					self._clear_children(iter)
252				else:
253					pass
254
255				iter = self.iter_next(iter)
256			else:
257				# Model ran out
258				myiter = self.append(parent, (text,))
259				if children:
260					self._insert_headings(children, myiter)
261
262		# Remove trailing items
263		if iter:
264			while self.remove(iter):
265				pass
266
267	def _clear_children(self, parent):
268		iter = self.iter_children(parent)
269		if iter:
270			while self.remove(iter):
271				pass
272
273	def _insert_headings(self, headings, parent=None):
274		for level, text, children in headings:
275			iter = self.append(parent, (text,))
276			if children:
277				self._insert_headings(children, iter)
278
279
280class ToCWidget(ConnectorMixin, Gtk.ScrolledWindow):
281
282	__gsignals__ = {
283		'changed': (GObject.SignalFlags.RUN_LAST, None, ()),
284	}
285
286	def __init__(self, pageview, ellipsis, show_h1=False, include_hr=True, fontsize=0):
287		GObject.GObject.__init__(self)
288		self.show_h1 = show_h1
289		self.include_hr = include_hr
290		self.fontsize = fontsize
291
292		self.treeview = ToCTreeView(ellipsis, fontsize)
293		self.treeview.connect('row-activated', self.on_heading_activated)
294		self.treeview.connect('populate-popup', self.on_populate_popup)
295		self.add(self.treeview)
296
297		self.connectto(pageview, 'page-changed')
298		self.connectto(pageview.notebook, 'store-page')
299
300		self.pageview = pageview
301		if self.pageview.page:
302			self.on_page_changed(self.pageview, self.pageview.page)
303
304	def set_preferences(self, show_h1, include_hr, fontsize):
305		changed = (show_h1, include_hr, fontsize) != (self.show_h1, self.include_hr, self.fontsize)
306		self.show_h1 = show_h1
307		self.include_hr = include_hr
308		self.fontsize = fontsize
309		self.treeview.set_fontsize(fontsize)
310		if changed and self.pageview.page:
311			self.load_page(self.pageview.page)
312
313	def on_page_changed(self, pageview, page):
314		self.load_page(page)
315		self.treeview.expand_all()
316
317	def on_store_page(self, notebook, page):
318		if page == self.pageview.page:
319			self.load_page(page)
320
321	def load_page(self, page):
322		model = self.treeview.get_model()
323		tree = page.get_parsetree()
324		if tree is None:
325			model.clear()
326		else:
327			if model is not None:
328				model.update(get_headings(tree, self.include_hr), self.show_h1)
329		self.emit('changed')
330
331	def on_heading_activated(self, treeview, path, column):
332		self.select_heading(path)
333
334	def select_heading(self, path):
335		'''Returns a C{Gtk.TextIter} for a C{Gtk.TreePath} pointing to a heading
336		or C{None}.
337		'''
338		model = self.treeview.get_model()
339		n = model.get_nth_heading(path)
340
341		textview = self.pageview.textview
342		buffer = textview.get_buffer()
343		if select_heading(buffer, n, self.include_hr):
344			textview.scroll_to_mark(buffer.get_insert(), SCROLL_TO_MARK_MARGIN, False, 0, 0)
345			return True
346		else:
347			return False
348
349	def select_section(self, buffer, path):
350		'''Select all text between two headings
351		@param buffer: the C{Gtk.TextBuffer} to select in
352		@param path: the C{Gtk.TreePath} for the heading of the section
353		'''
354		model = self.treeview.get_model()
355		n = model.get_nth_heading(path)
356
357		nextpath = Gtk.TreePath(path[:-1] + [path[-1] + 1])
358		try:
359			aiter = model.get_iter(nextpath)
360		except ValueError:
361			endtext = None
362		else:
363			endtext = model[aiter][TEXT_COL]
364
365		textview = self.pageview.textview
366		buffer = textview.get_buffer()
367		start = find_heading(buffer, n, self.include_hr)
368		if start is None:
369			return
370		end = find_heading(buffer, n + 1, self.include_hr)
371		if end is None:
372			end = buffer.get_end_iter()
373
374		buffer.select_range(start, end)
375
376	def on_populate_popup(self, treeview, menu):
377		model, paths = treeview.get_selection().get_selected_rows()
378		if not paths:
379			can_promote = False
380			can_demote = False
381		else:
382			can_promote = self.can_promote(paths)
383			can_demote = self.can_demote(paths)
384
385		populate_popup_add_separator(menu, prepend=True)
386		for text, sensitive, handler in (
387			(_('Demote'), can_demote, self.on_demote),
388				# T: action to lower level of heading in the text
389			(_('Promote'), can_promote, self.on_promote),
390				# T: action to raise level of heading in the text
391		):
392			item = Gtk.MenuItem.new_with_mnemonic(text)
393			menu.prepend(item)
394			if sensitive:
395				item.connect('activate', handler)
396			else:
397				item.set_sensitive(False)
398
399		menu.show_all()
400
401	def can_promote(self, paths):
402		# All headings have level larger than 1
403		return paths and all(len(p) > 1 for p in paths)
404
405	def on_promote(self, *a):
406		# Promote selected paths and all their children
407		model, paths = self.treeview.get_selection().get_selected_rows()
408		if not self.can_promote(paths):
409			return False
410
411		seen = set()
412		for path in paths:
413			iter = model.get_iter(path)
414			for i in model.walk(iter):
415				p = model.get_path(i)
416				key = tuple(p)
417				if not key in seen:
418					if self.show_h1:
419						newlevel = len(p) - 1
420					else:
421						newlevel = len(p)
422					self._format(p, newlevel)
423				seen.add(key)
424
425		self.load_page(self.pageview.page)
426		return True
427
428	def can_demote(self, paths):
429		# All headings below max level and all have a potential parent
430		# Potential parents should be on the same level above the selected
431		# path, so as long as the path is not the first on it's level it
432		# has one.
433		# Or the current parent path also has to be in the list
434		if not paths \
435		or any(len(p) >= 6 for p in paths):
436			return False
437
438		paths = list(map(tuple, paths))
439		for p in paths:
440			if p[-1] == 0 and not p[:-1] in paths:
441					return False
442		else:
443			return True
444
445	def on_demote(self, *a):
446		# Demote selected paths and all their children
447		# note can not demote below level 6
448		model, paths = self.treeview.get_selection().get_selected_rows()
449		if not self.can_demote(paths):
450			return False
451
452		seen = set()
453		for path in paths:
454			# FIXME parent may have different real level if levels are
455			# inconsistent - this should result in an offset being applied
456			# But need to check actual heading tags being used to know for sure
457			iter = model.get_iter(path)
458			for i in model.walk(iter):
459				p = model.get_path(i)
460				key = tuple(p)
461				if not key in seen:
462					if self.show_h1:
463						newlevel = len(p) + 1
464					else:
465						newlevel = len(p) + 2
466
467					self._format(p, newlevel)
468				seen.add(key)
469
470		self.load_page(self.pageview.page)
471		return True
472
473	def _format(self, path, level):
474		assert level > 0 and level < 7
475		if self.select_heading(path):
476			self.pageview.toggle_format('h' + str(level))
477		else:
478			logger.warn('Failed to select heading for path: %', path)
479
480
481class SidePaneToC(ToCWidget, WindowSidePaneWidget):
482
483	title = _('T_oC') # T: widget label
484
485	def __init__(self, pageview):
486		ToCWidget.__init__(self, pageview, ellipsis=True)
487		self.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
488		self.set_shadow_type(Gtk.ShadowType.IN)
489		self.set_size_request(-1, 200) # Fixed Height
490
491
492class MyEventBox(Gtk.EventBox):
493
494		def do_button_press_event(self, event):
495			return True # Prevent propagating event to parent textview
496
497		def do_button_release_event(self, event):
498			return True # Prevent propagating event to parent textview
499
500
501class FloatingToC(Gtk.VBox, ConnectorMixin):
502
503	# This class puts the floating window in the pageview overlay layer
504	# and adjusts it's size on the fly
505
506	MARGIN_END = 12 # offset right side textview
507	MARGIN_TOP = 12 # offset top textview
508	SCROLL_MARGIN = 10 # margin inside the toc for scrollbars
509
510	def __init__(self, pageview):
511		GObject.GObject.__init__(self)
512
513		self.head = Gtk.Label(label=_('ToC'))
514		self.head.set_padding(5, 1)
515
516		self.tocwidget = ToCWidget(pageview, ellipsis=False)
517		self.tocwidget.set_shadow_type(Gtk.ShadowType.NONE)
518
519		self._head_event_box = MyEventBox()
520		self._head_event_box.add(self.head)
521		self._head_event_box.connect('button-release-event', self.on_toggle)
522		self._head_event_box.get_style_context().add_class(Gtk.STYLE_CLASS_BACKGROUND)
523
524		self.pack_start(self._head_event_box, False, True, 0)
525		self.pack_start(self.tocwidget, True, True, 0)
526
527		widget_set_css(self, 'zim-toc-widget', 'border: 1px solid @fg_color')
528		widget_set_css(self.head, 'zim-toc-head', 'border-bottom: 1px solid @fg_color')
529
530		self.set_halign(Gtk.Align.END)
531		self.set_margin_end(self.MARGIN_END)
532		self.set_valign(Gtk.Align.START)
533		self.set_margin_top(self.MARGIN_TOP)
534		pageview.overlay.add_overlay(self)
535
536		self._textview = pageview.textview
537		self.connectto(self._textview,
538			'size-allocate',
539			handler=DelayedCallback(10, self.update_size_and_position),
540				# Callback wrapper to prevent glitches for fast resizing of the window
541		)
542		self.connectto(self.tocwidget, 'changed', handler=self.update_size_and_position_after_change)
543
544		self.show_all()
545
546	def set_preferences(self, show_h1, include_hr, fontsize):
547		self.tocwidget.set_preferences(show_h1, include_hr, fontsize)
548
549	def disconnect_all(self):
550		self.tocwidget.disconnect_all()
551		ConnectorMixin.disconnect_all(self)
552
553	def on_toggle(self, *a):
554		self.tocwidget.set_visible(
555			not self.tocwidget.get_visible()
556		)
557		self.update_size_and_position()
558
559	def update_size_and_position_after_change(self, *a):
560		self.tocwidget.treeview.expand_all()
561		self.update_size_and_position()
562
563	def update_size_and_position(self, *a):
564		model = self.tocwidget.treeview.get_model()
565		if model is None or model.is_empty:
566			self.hide()
567			return
568		else:
569			self.show()
570
571		text_window = self._textview.get_window(Gtk.TextWindowType.WIDGET)
572		if text_window is None:
573			return
574
575		text_x, text_y, text_w, text_h = text_window.get_geometry()
576		max_w = 0.5 * text_w - self.MARGIN_END
577		max_h = 0.7 * text_h - self.MARGIN_TOP
578
579		head_minimum, head_natural = self.head.get_preferred_width()
580		view_minimum, view_natural = self.tocwidget.treeview.get_preferred_width()
581		if self.tocwidget.get_visible():
582			my_width = max(head_natural, view_natural + self.SCROLL_MARGIN)
583			width = min(my_width, max_w)
584		else:
585			width = head_natural
586
587		head_minimum, head_natural = self.head.get_preferred_height()
588		view_minimum, view_natural = self.tocwidget.treeview.get_preferred_height()
589		if self.tocwidget.get_visible():
590			my_height = head_natural + view_natural + self.SCROLL_MARGIN
591			height = min(my_height, max_h)
592		else:
593			height = head_natural
594
595		self.set_size_request(width, height)
596