1 2# Copyright 2010 Fabian Stanke 3# Copyright 2011-2021 Jaap Karssenberg 4 5 6from gi.repository import GObject 7from gi.repository import Gtk 8from gi.repository import Pango 9 10import logging 11 12from functools import partial 13 14from zim.plugins import PluginClass 15from zim.signals import ConnectorMixin 16from zim.plugins.pageindex import PageTreeStore, PageTreeStoreBase, PageTreeView, \ 17 NAME_COL, PATH_COL, EXISTS_COL, STYLE_COL, WEIGHT_COL, N_CHILD_COL, TIP_COL 18from zim.notebook import Path 19from zim.notebook.index import IndexNotFoundError 20from zim.notebook.index.pages import PageIndexRecord 21from zim.notebook.index.tags import IS_PAGE, IS_TAG, \ 22 TagsView, TaggedPagesTreeModelMixin, TagsTreeModelMixin, IndexTag 23from zim.utils import natural_sort_key 24 25from zim.gui.notebookview import NotebookViewExtension 26from zim.gui.widgets import LEFT_PANE, PANE_POSITIONS, populate_popup_add_separator, ScrolledWindow, encode_markup_text, \ 27 WindowSidePaneWidget 28 29 30logger = logging.getLogger('zim.plugins.tags') 31 32 33 34class TagsPlugin(PluginClass): 35 36 plugin_info = { 37 'name': _('Tags'), # T: plugin name 38 'description': _('''\ 39This plugin provides a page index filtered by means of selecting tags in a cloud. 40'''), # T: plugin description 41 'author': 'Fabian Stanke & Jaap Karssenberg', 42 'help': 'Plugins:Tags', 43 } 44 45 plugin_preferences = ( 46 # key, type, label, default 47 ('pane', 'choice', _('Position in the window'), LEFT_PANE, PANE_POSITIONS), 48 # T: option for plugin preferences 49 ('autoexpand', 'bool', _('Automatically expand sections on open page'), True), 50 # T: preferences option 51 ('autocollapse', 'bool', _('Automatically collapse sections on close page'), True), 52 # T: preferences option 53 ('use_hscroll', 'bool', _('Use horizontal scrollbar (may need restart)'), False), 54 # T: preferences option 55 ('use_tooltip', 'bool', _('Use tooltips'), True), 56 # T: preferences option 57 ) 58 59 60class TagsNotebookViewExtension(NotebookViewExtension): 61 62 def __init__(self, plugin, pageview): 63 NotebookViewExtension.__init__(self, plugin, pageview) 64 65 self.widget = TagsPluginWidget( 66 pageview.notebook, 67 self.navigation, 68 self.uistate 69 ) 70 71 self.add_sidepane_widget(self.widget, 'pane') 72 73 self.uistate.setdefault('vpane_pos', 150) 74 self.widget.set_position(self.uistate['vpane_pos']) 75 def update_uistate(*a): 76 self.uistate['vpane_pos'] = self.widget.get_position() 77 self.widget.connect('notify::position', update_uistate) 78 79 #self.connectto_all(ui, ( # XXX 80 # ('start-index-update', lambda o: self.disconnect_model()), 81 # ('end-index-update', lambda o: self.reconnect_model()), 82 #)) 83 self.connectto(pageview, 'page-changed', lambda o, p: self.widget.set_page(p)) 84 85 self.on_preferences_changed(self.plugin.preferences) 86 self.plugin.preferences.connect('changed', self.on_preferences_changed) 87 88 def on_preferences_changed(self, preferences): 89 self.widget.treeview.set_use_tooltip(preferences['use_tooltip']) 90 self.widget.treeview.set_use_ellipsize(not preferences['use_hscroll']) 91 # To use horizontal scrolling, turn off ellipsize 92 self.widget.treeview.set_autoexpand(preferences['autoexpand'], preferences['autocollapse']) 93 94 95class TagsPluginWidget(Gtk.VPaned, WindowSidePaneWidget): 96 '''Widget combining a tag cloud and a tag based page treeview''' 97 98 title = _('T_ags') # T: title for sidepane tab 99 100 def __init__(self, notebook, navigation, uistate): 101 GObject.GObject.__init__(self) 102 self.notebook = notebook 103 self.index = notebook.index 104 self.uistate = uistate 105 106 self.uistate.setdefault('treeview', 'tags', {'tagged', 'tags'}) 107 self.uistate.setdefault('tagcloud_sorting', 'score', {'alpha', 'score'}) 108 self.uistate.setdefault('show_full_page_name', True) 109 110 self.tagcloud = TagCloudWidget(self.index, sorting=self.uistate['tagcloud_sorting']) 111 self.pack1(ScrolledWindow(self.tagcloud), shrink=False) 112 113 self.treeview = TagsPageTreeView(notebook, navigation) 114 self.pack2(ScrolledWindow(self.treeview), shrink=False) 115 116 self.treeview.connect('populate-popup', self.on_populate_popup) 117 self.tagcloud.connect('selection-changed', self.on_cloud_selection_changed) 118 self.tagcloud.connect('sorting-changed', self.on_cloud_sortin_changed) 119 120 self.reload_model() 121 122 def set_page(self, page): 123 treepath = self.treeview.set_current_page(page, vivificate=True) 124 if treepath: 125 selected_path = self.treeview.get_selected_path() 126 if page != selected_path: 127 self.treeview.select_treepath(treepath) 128 129 def toggle_treeview(self): 130 '''Toggle the treeview type in the widget''' 131 if self.uistate['treeview'] == 'tagged': 132 self.uistate['treeview'] = 'tags' 133 else: 134 self.uistate['treeview'] = 'tagged' 135 136 model = self.treeview.get_model() 137 if not isinstance(model, TaggedPageTreeStore): 138 self.reload_model() 139 140 def toggle_show_full_page_name(self): 141 self.uistate['show_full_page_name'] = not self.uistate['show_full_page_name'] 142 self.reload_model() 143 144 def on_populate_popup(self, treeview, menu): 145 # Add a popup menu item to switch the treeview mode 146 populate_popup_add_separator(menu, prepend=True) 147 148 item = Gtk.CheckMenuItem(_('Show full page name')) # T: menu option 149 item.set_active(self.uistate['show_full_page_name']) 150 item.connect_object('toggled', self.__class__.toggle_show_full_page_name, self) 151 menu.prepend(item) 152 153 item = Gtk.CheckMenuItem(_('Sort pages by tags')) # T: menu option 154 item.set_active(self.uistate['treeview'] == 'tags') 155 item.connect_object('toggled', self.__class__.toggle_treeview, self) 156 model = self.treeview.get_model() 157 if isinstance(model, TaggedPageTreeStore): 158 item.set_sensitive(False) # with tag selection toggle does nothing 159 menu.prepend(item) 160 161 menu.show_all() 162 163 def on_cloud_selection_changed(self, cloud): 164 self.reload_model() 165 # FIXME - allow updating selection, requires signals for all added / removed pages 166 167 def on_cloud_sortin_changed(self, cloud, sorting): 168 self.uistate['tagcloud_sorting'] = sorting 169 170 def disconnect_model(self): 171 '''Stop the model from listening to the index. Used to 172 unhook the model before reloading the index. Typically 173 should be followed by reload_model(). 174 ''' 175 self.treeview.disconnect_index() 176 self.tagcloud.disconnect_index() 177 178 def reconnect_model(self): 179 self.tagcloud.connect_index(self.index) 180 self.reload_model() 181 182 def reload_model(self): 183 '''Re-initialize the treeview model. This is called when 184 reloading the index to get rid of out-of-sync model errors 185 without need to close the app first. 186 ''' 187 assert self.uistate['treeview'] in ('tagged', 'tags') 188 self.treeview.disconnect_index() 189 190 tags = [t.name for t in self.tagcloud.get_tag_filter()] 191 if tags: 192 model = TaggedPageTreeStore(self.index, tags, self.uistate['show_full_page_name']) 193 elif self.uistate['treeview'] == 'tags': 194 model = TagsPageTreeStore(self.index, (), self.uistate['show_full_page_name']) 195 else: 196 model = PageTreeStore(self.index) 197 198 self.treeview.set_model(model) 199 200 def handler(o, *a): 201 signal = a[-1] 202 path = a[0].to_string() 203 for signal in ('row-inserted', 'row-changed', 'row-deleted', 'row-has-child-toggled'): 204 model.connect(signal, handler, signal) 205 206 207class DuplicatePageTreeStore(PageTreeStoreBase): 208 '''Sub-class of PageTreeStore that allows for the same page appearing 209 multiple times in the tree. 210 ''' 211 212 def set_current_page(self, path): 213 '''Since there may be duplicates of each page, highlight all of them''' 214 oldpath = self.current_page 215 self.current_page = path 216 217 for mypath in (oldpath, path): 218 if mypath: 219 for treepath in self.find_all(mypath): 220 if treepath: 221 treeiter = self.get_iter(treepath) 222 self.emit('row-changed', treepath, treeiter) 223 224 def get_indexpath(self, treeiter): 225 '''Get an L{PageIndexRecord} for a C{Gtk.TreeIter} 226 227 @param treeiter: a C{Gtk.TreeIter} 228 @returns: an L{PageIndexRecord} object 229 ''' 230 mytreeiter = self.get_user_data(treeiter) 231 if mytreeiter.hint == IS_PAGE: 232 return PageIndexRecord(mytreeiter.row) 233 elif mytreeiter.hint == IS_TAG: 234 return IndexTag(mytreeiter.row['name'], mytreeiter.row['id']) 235 else: 236 raise ValueError 237 238 239class TagsPageTreeStore(TagsTreeModelMixin, DuplicatePageTreeStore): 240 '''Subclass of the PageTreeStore that shows tags as the top level 241 for sub-sets of the page tree. 242 ''' 243 244 def __init__(self, index, tags=None, show_full_page_name=True): 245 TagsTreeModelMixin.__init__(self, index, tags) 246 PageTreeStoreBase.__init__(self) 247 self.show_full_page_name = show_full_page_name 248 249 def on_get_value(self, iter, column): 250 '''Returns the data for a specific column''' 251 if iter.hint == IS_TAG: 252 if column == NAME_COL: 253 return iter.row['name'] 254 elif column == TIP_COL: 255 return encode_markup_text(iter.row['name']) 256 elif column == PATH_COL: 257 return IndexTag(*iter.row) 258 elif column == EXISTS_COL: 259 return True 260 elif column == STYLE_COL: 261 return Pango.Style.NORMAL 262 elif column == WEIGHT_COL: 263 return Pango.Weight.NORMAL 264 elif column == N_CHILD_COL: 265 return iter.n_children 266 else: 267 if self.show_full_page_name \ 268 and column == NAME_COL and len(iter.treepath) == 2: 269 # Show top level pages with full contex 270 # top level tree is tags, so top level pages len(path) is 2 271 return iter.row['name'] 272 else: 273 return PageTreeStoreBase.on_get_value(self, iter, column) 274 275 276class TaggedPageTreeStore(TaggedPagesTreeModelMixin, DuplicatePageTreeStore): 277 '''A TreeModel that lists all Zim pages in a flat list. 278 Pages with associated sub-pages still show them as sub-nodes. 279 Intended to be filtered by tags. 280 ''' 281 282 def __init__(self, index, tags, show_full_page_name=True): 283 TaggedPagesTreeModelMixin.__init__(self, index, tags) 284 PageTreeStoreBase.__init__(self) 285 self.show_full_page_name = show_full_page_name 286 287 def on_get_value(self, iter, column): 288 '''Returns the data for a specific column''' 289 if self.show_full_page_name \ 290 and column == NAME_COL and len(iter.treepath) == 1: 291 # Show top level pages with full contex 292 return iter.row['name'] 293 else: 294 return PageTreeStoreBase.on_get_value(self, iter, column) 295 296 297class TagsPageTreeView(PageTreeView): 298 299 def set_current_page(self, path, vivificate=False): 300 '''Set the current page in the treeview 301 302 @param path: a notebook L{Path} object for the page 303 @keyword vivificate: when C{True} the path is created 304 temporarily when it did not yet exist 305 306 @returns: a gtk TreePath (tuple of intergers) or C{None} 307 ''' 308 #~ print('!! SELECT', path) 309 model = self.get_model() 310 if model is None: 311 return None # index not yet initialized ... 312 313 try: 314 treepath = model.find(path) 315 model.set_current_page(path) # highlight in model 316 except IndexNotFoundError: 317 pass 318 else: 319 return treepath 320 321 322class TagCloudItem(Gtk.ToggleButton): 323 '''Button item used on the tag cloud widget''' 324 325 def __init__(self, indextag): 326 Gtk.ToggleButton.__init__(self, indextag.name, use_underline=False) 327 self.set_relief(Gtk.ReliefStyle.NONE) 328 self.indextag = indextag 329 330 def update_label(self): 331 # Make button text bold when active 332 label = self.get_child() 333 if self.get_active(): 334 label.set_markup('<b>' + label.get_text() + '</b>') 335 else: 336 label.set_text(label.get_text()) 337 # get_text() gives string without markup 338 339 self.connect_after('toggled', update_label) 340 341 342class TagCloudWidget(ConnectorMixin, Gtk.TextView): 343 '''Text-view based list of tags, where each tag is represented by a 344 button inserted as a child in the textview. 345 346 @signal: C{selection-changed ()}: emitted when tag selection changes 347 @signal: C{sorting-changed ()}: emitted when tag sorting changes 348 ''' 349 350 # define signals we want to use - (closure type, return type and arg types) 351 __gsignals__ = { 352 'selection-changed': (GObject.SignalFlags.RUN_LAST, None, ()), 353 'sorting-changed': (GObject.SignalFlags.RUN_LAST, None, (object,)), 354 } 355 356 def __init__(self, index, sorting='score'): 357 GObject.GObject.__init__(self) 358 self.set_name('zim-tags-tagcloud') 359 self.index = None 360 361 self.set_editable(False) 362 self.set_cursor_visible(False) 363 self.set_wrap_mode(Gtk.WrapMode.CHAR) 364 365 self.set_sorting(sorting) 366 self.connect_index(index) 367 368 def set_sorting(self, sorting): 369 self._alphabetically = (sorting == 'alpha') 370 371 def connect_index(self, index): 372 '''Connect to an Index object''' 373 self.disconnect_index() # just to be sure 374 self.index = index 375 self.connectto_all(self.index.update_iter.tags, ( 376 ('tag-row-inserted', self._update), 377 ('tag-row-deleted', self._update), 378 )) 379 self._update() 380 381 def disconnect_index(self): 382 '''Stop the model from listening to the index. Used to unhook 383 the model before reloading the index. 384 ''' 385 if self.index is not None: 386 self.disconnect_from(self.index.update_iter.tags) 387 self._clear() 388 389 def get_tag_filter(self): 390 '''Returns a tuple with two lists of tags; the first gives all 391 tags that are selected, the second gives all tags shown in the 392 cloud. By definition the first list is a subset of the second. 393 If no tags are selected returns None instead. 394 ''' 395 return [ 396 b.indextag for b in self.get_children() if b.get_active() 397 ] 398 399 def _clear(self): 400 '''Clears the cloud''' 401 self.foreach(lambda b: self.remove(b)) 402 buffer = self.get_buffer() 403 buffer.delete(*buffer.get_bounds()) 404 405 def _update(self, *a): 406 '''Update the cloud to show only tags that share a set of pages 407 with the selected tags.''' 408 tagview = TagsView.new_from_index(self.index) 409 selected = [] 410 for button in self.get_children(): 411 if button.get_active(): 412 try: 413 selected.append(tagview.lookup_by_tagname(button.indextag)) 414 except IndexNotFoundError: 415 pass 416 # Need the lookup here in case the tag went missing in the 417 # mean time e.g. due to editing of the page 418 self._clear() 419 420 if selected: 421 tags = tagview.list_intersecting_tags(selected) 422 else: 423 tags = tagview.list_all_tags_by_n_pages() 424 425 if self._alphabetically: 426 tags = sorted(tags, key=lambda t: natural_sort_key(t.name)) 427 # else leave sorted by score 428 429 buffer = self.get_buffer() 430 for tag in tags: 431 iter = buffer.get_end_iter() 432 anchor = buffer.create_child_anchor(iter) 433 button = TagCloudItem(tag) 434 button.set_active(tag in selected) 435 button.connect("toggled", lambda b: self._update()) 436 self.add_child_at_anchor(button, anchor) 437 438 self.show_all() 439 self.emit('selection-changed') 440 441 def do_populate_popup(self, menu): 442 populate_popup_add_separator(menu, prepend=True) 443 444 item = Gtk.CheckMenuItem(_('Sort alphabetically')) # T: Context menu item for tag cloud 445 item.set_active(self._alphabetically) 446 item.connect('toggled', self._switch_sorting) 447 item.show_all() 448 menu.prepend(item) 449 450 def _switch_sorting(self, widget, *a): 451 self._alphabetically = widget.get_active() 452 self._update() 453 if self._alphabetically: 454 self.emit('sorting-changed', 'alpha') 455 else: 456 self.emit('sorting-changed', 'score') 457