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