1 2# Copyright 2015 Tobias Haupenthal 3# Copyright 2016-2018 Jaap Karssenberg <jaap.karssenberg@gmail.com> 4 5 6from gi.repository import GObject 7from gi.repository import Gtk 8from gi.repository import Gdk 9from gi.repository import Pango 10 11import re 12import weakref 13import logging 14 15 16logger = logging.getLogger('zim.plugin.tableeditor') 17 18from zim.plugins import PluginClass, InsertedObjectTypeExtension 19from zim.actions import action 20from zim.signals import SignalEmitter, ConnectorMixin, SIGNAL_RUN_LAST 21from zim.utils import natural_sort_key 22from zim.config import String 23from zim.main import ZIM_APPLICATION 24from zim.formats import ElementTreeModule as ElementTree 25from zim.formats import TABLE, HEADROW, HEADDATA, TABLEROW, TABLEDATA 26from zim.formats.wiki import Parser as WikiParser 27 28from zim.gui.pageview import PageViewExtension 29from zim.gui.widgets import Dialog, ScrolledWindow, IconButton, InputEntry, gtk_popup_at_pointer 30from zim.gui.insertedobjects import InsertedObjectWidget 31 32 33SYNTAX_CELL_INPUT = [ 34 ('&', '&'), ('>', '>'), ('<', '<'), ('"', '"'), (''', "'") 35] 36 37# Regex replacement strings: Wiki-Parsetree -> Pango (Table cell) -> Input (Table cell editing) 38# the target pattern is easier to read, the source pattern is generated out of it 39# With this syntax text can be format within a table-cell 40SYNTAX_WIKI_PANGO2 = [ 41 (r'<strong>\1</strong>', r'<b>\1</b>', r'**\1**'), 42 (r'<mark>\1</mark>', r'<span background="yellow">\1</span>', r'__\1__'), 43 (r'<code>\1</code>', r'<tt>\1</tt>', r"''\1''"), 44 (r'<strike>\1</strike>', r'<s>\1</s>', r'~~\1~~'), 45 # Link url without link text - Link url has always size = 0 46 (r'<link href="\1">\1</link>', r'<span foreground="blue">\1<span size="0">\1</span></span>', r'[[\1]]'), 47 # Link url with link text - Link url has always size = 0 48 (r'<link href="\1">\2</link>', r'<span foreground="blue">\2<span size="0">\1</span></span>', r'[[\2|\1]]'), 49 (r'<emphasis>\1</emphasis>', r'<i>\1</i>', r'//\1//') 50] 51 52# Possible alignments in edit-table-dialog 53COLUMNS_ALIGNMENTS = {'left': ['left', Gtk.STOCK_JUSTIFY_LEFT, _('Left')], # T: alignment option 54 'center': ['center', Gtk.STOCK_JUSTIFY_CENTER, _('Center')], # T: alignment option 55 'right': ['right', Gtk.STOCK_JUSTIFY_RIGHT, _('Right')], # T: alignment option 56 'normal': ['normal', None, _('Unspecified')], } # T: alignment option 57 58 59def reg_replace(string): 60 ''' 61 Target pattern is translated into source regex pattern 62 :param string: target pattern 63 :return:source pattern 64 ''' 65 string = string.replace('*', '\*').replace('[', '\[').replace(']', '\]') \ 66 .replace(r'\1', '(.+?)', 1).replace(r'\2', '(.+?)', 1).replace('|', '\|') 67 return re.compile(string) 68 69# Regex compiled search patterns 70SYNTAX_WIKI_PANGO = [tuple(map(reg_replace, expr_list)) for expr_list in SYNTAX_WIKI_PANGO2] 71 72 73class TableEditorPlugin(PluginClass): 74 ''' 75 This is the plugin for displaying tables within the wiki. 76 A table consists always of a header with at least one header-cell and at least one or several rows. 77 The number of cells in a row must be equal to the header. 78 Currently there are two attributes, which have a tuple format, so they can describe all columns: 79 - aligns: left, center, right 80 - wraps: 0 / display text in a row 1 / long text will be broken and wrapped 81 ''' 82 plugin_info = { 83 'name': _('Table Editor'), # T: plugin name 84 'description': _('''\ 85With this plugin you can embed a 'Table' into the wiki page. Tables will be shown as GTK TreeView widgets. 86Exporting them to various formats (i.e. HTML/LaTeX) completes the feature set. 87'''), # T: plugin description 88 'help': 'Plugins:Table Editor', 89 'author': 'Tobias Haupenthal', 90 } 91 92 global LINES_NONE, LINES_HORIZONTAL, LINES_VERTICAL, LINES_BOTH # Hack - to make sure translation is loaded 93 LINES_BOTH = _('with lines') # T: option value 94 LINES_NONE = _('no grid lines') # T: option value 95 LINES_HORIZONTAL = _('horizontal lines') # T: option value 96 LINES_VERTICAL = _('vertical lines') # T: option value 97 98 99 100 plugin_preferences = ( 101 # key, type, label, default 102 ('show_helper_toolbar', 'bool', _('Show helper toolbar'), True), # T: preference description 103 104 # option for displaying grid-lines within the table 105 ('grid_lines', 'choice', _('Grid lines'), LINES_BOTH, (LINES_BOTH, LINES_NONE, LINES_HORIZONTAL, LINES_VERTICAL)), 106 # T: preference description 107 ) 108 109 110class CellFormatReplacer: 111 ''' 112 Static class for converting formated text from one into the other format: 113 - cell: in a wiki pageview the table-cell must be of this format 114 - input: if a user is editing the cell, this format is used 115 - zimtree: Format for zimtree xml structure 116 ''' 117 @staticmethod 118 def cell_to_input(text, with_pango=True): 119 ''' Displayed table-cell will converted to gtk-entry input text ''' 120 text = text or '' 121 if with_pango: 122 for pattern, replace in zip(SYNTAX_WIKI_PANGO, SYNTAX_WIKI_PANGO2): 123 text = pattern[1].sub(replace[2], text) 124 for k, v in SYNTAX_CELL_INPUT: 125 text = text.replace(k, v) 126 return text 127 128 @staticmethod 129 def input_to_cell(text, with_pango=True): 130 for k, v in SYNTAX_CELL_INPUT: 131 text = text.replace(v, k) 132 if with_pango: 133 # Links without text are handled as [[link]] and not as [[link|text]], therefore reverse order of replacements 134 for pattern, replace in zip(reversed(SYNTAX_WIKI_PANGO), reversed(SYNTAX_WIKI_PANGO2)): 135 text = pattern[2].sub(replace[1], text) 136 return text 137 138 @staticmethod 139 def zim_to_cell(text): 140 for pattern, replace in zip(SYNTAX_WIKI_PANGO, SYNTAX_WIKI_PANGO2): 141 text = pattern[0].sub(replace[1], text) 142 return text 143 144 @staticmethod 145 def cell_to_zim(text): 146 for pattern, replace in zip(SYNTAX_WIKI_PANGO, SYNTAX_WIKI_PANGO2): 147 text = pattern[1].sub(replace[0], text) 148 return text 149 150 151class TableViewObjectType(InsertedObjectTypeExtension): 152 153 name = 'table' 154 155 label = _('Table') # T: menu item 156 verb_icon = 'zim-insert-table' 157 158 object_attr = { 159 'aligns': String(''), # i.e. String(left,right,center) 160 'wraps': String('') # i.e. String(0,1,0) 161 } 162 163 def __init__(self, plugin, objmap): 164 self._widgets = weakref.WeakSet() 165 self.preferences = plugin.preferences 166 InsertedObjectTypeExtension.__init__(self, plugin, objmap) 167 self.connectto(self.preferences, 'changed', self.on_preferences_changed) 168 169 def new_model_interactive(self, parent, notebook, page): 170 definition = EditTableDialog(parent).run() 171 if definition is None: 172 raise ValueError # dialog cancelled 173 174 ids, headers, wraps, aligns = definition 175 attrib = self.parse_attrib({ 176 'aligns': ','.join(map(str, aligns)), 177 'wraps': ','.join(map(str, wraps)) 178 }) 179 rows = [''] * len(headers) 180 return TableModel(attrib, headers, rows) 181 182 def model_from_data(self, notebook, page, attrib, data): 183 tree = WikiParser().parse(data) 184 element = tree._etree.getroot().find('table') # XXX - should use token interface instead 185 if element is not None: 186 return self.model_from_element(element.attrib, element) 187 else: 188 return TableModel(attrib, [data.strip()], ['']) 189 190 def model_from_element(self, attrib, element): 191 assert ElementTree.iselement(element) 192 attrib = self.parse_attrib(attrib) 193 headers, rows = self._tabledom_to_list(element) 194 return TableModel(attrib, headers, rows) 195 196 def _tabledom_to_list(self, tabledata): 197 ''' 198 Extracts necessary data out of a xml-table into a list structure 199 200 :param tabledata: XML - formated as a zim-tree table-object 201 :return: tuple of header-list and list of row lists - ([h1,h2],[[r11,r12],[r21,r22]) 202 ''' 203 headers = [head.text for head in tabledata.findall('thead/th')] 204 headers = list(map(CellFormatReplacer.zim_to_cell, headers)) 205 206 rows = [] 207 for trow in tabledata.findall('trow'): 208 row = trow.findall('td') 209 row = [ElementTree.tostring(r, 'unicode').replace('<td>', '').replace('</td>', '') for r in row] 210 row = list(map(CellFormatReplacer.zim_to_cell, row)) 211 rows.append(row) 212 return headers, rows 213 214 def create_widget(self, model): 215 widget = TableViewWidget(model) 216 widget.set_preferences(self.preferences) 217 self._widgets.add(widget) 218 return widget 219 220 def on_preferences_changed(self, preferences): 221 for widget in self._widgets: 222 widget.set_preferences(preferences) 223 224 def dump(self, builder, model): 225 headers, attrib, rows = model.get_object_data() 226 def append(tag, text): 227 builder.start(tag, {}) 228 builder.data(text) 229 builder.end(tag) 230 231 builder.start(TABLE, dict(attrib)) 232 builder.start(HEADROW, {}) 233 for header in headers: 234 append(HEADDATA, header) 235 builder.end(HEADROW) 236 for row in rows: 237 builder.start(TABLEROW, {}) 238 for cell in row: 239 append(TABLEDATA, cell) 240 builder.end(TABLEROW) 241 builder.end(TABLE) 242 243 244class TableModel(ConnectorMixin, SignalEmitter): 245 '''Thin object that contains a C{Gtk.ListStore} 246 Key purpose of this wrapper is to allow replacing the store 247 ''' 248 249 __signals__ = { 250 'changed': (SIGNAL_RUN_LAST, None, ()), 251 'model-changed': (SIGNAL_RUN_LAST, None, ()), 252 } 253 254 def __init__(self, attrib, headers, rows): 255 self._attrib = attrib 256 self.headers = headers 257 self.liststore = self._create_liststore(headers) 258 for row in rows: 259 self.liststore.append(row) 260 261 def _create_liststore(self, headers): 262 cols = [str] * len(headers) 263 self.liststore = Gtk.ListStore(*cols) 264 self.connectto_all( 265 self.liststore, 266 ('row-changed', 'row-deleted', 'row-inserted', 'rows-reordered'), 267 handler=lambda *a: self.emit('changed') 268 ) 269 return self.liststore 270 271 def get_object_data(self): 272 rows = [ 273 map(CellFormatReplacer.cell_to_input, row) 274 for row in self.liststore 275 ] 276 return self.headers, self._attrib, rows 277 278 def get_aligns(self): 279 return self._attrib['aligns'].split(',') 280 281 def set_aligns(self, data): 282 self._attrib['aligns'] = ','.join(map(str, data)) 283 284 def get_wraps(self): 285 return list(map(int, self._attrib['wraps'].split(','))) 286 287 def set_wraps(self, data): 288 self._attrib['wraps'] = ','.join(map(str, data)) 289 290 def change_model(self, newdefinition): 291 '''Creates a new C{Gtk.ListStore} based on C{newdefinition} 292 and notifies all widgets to replace the current one by the 293 "model-changed" signal 294 ''' 295 ids, headers, wraps, aligns = newdefinition 296 297 self.disconnect_from(self.liststore) 298 oldliststore = self.liststore 299 300 self.liststore = self._create_liststore(headers) 301 self.headers = headers 302 self.set_aligns(aligns) 303 self.set_wraps(wraps) 304 305 for row in oldliststore: 306 newrow = [ 307 (row[i] if i >= 0 else '') for i in ids 308 ] 309 self.liststore.append(newrow) 310 311 self.emit('model-changed') 312 self.emit('changed') 313 314 315GTK_GRIDLINES = { 316 LINES_BOTH: Gtk.TreeViewGridLines.BOTH, 317 LINES_NONE: Gtk.TreeViewGridLines.NONE, 318 LINES_HORIZONTAL: Gtk.TreeViewGridLines.HORIZONTAL, 319 LINES_VERTICAL: Gtk.TreeViewGridLines.VERTICAL, 320} 321 322 323class TableViewWidget(InsertedObjectWidget): 324 325 def __init__(self, model): 326 InsertedObjectWidget.__init__(self) 327 self.expand = False 328 self.textarea_width = 0 329 self.model = model 330 331 # used in pageview 332 self._has_cursor = False # Skip table object, if someone moves cursor around in textview 333 334 # used here 335 self._timer = None # NONE or number of current GObject.timer, which is running 336 self._keep_toolbar_open = False # a cell is currently edited, toolbar should not be hidden 337 self._cellinput_canceled = None # cell changes should be skipped 338 self._toolbar_enabled = True # sets if toolbar should be shown beneath a selected table 339 340 # Toolbar for table actions 341 self.toolbar = self.create_toolbar() 342 self.toolbar.show_all() 343 self.toolbar.set_no_show_all(True) 344 self.toolbar.hide() 345 346 # Create treeview 347 self._init_treeview(model) 348 349 # package gui elements 350 self.vbox = Gtk.VBox() 351 self.add(self.vbox) 352 self.vbox.pack_end(self.toolbar, True, True, 0) 353 self.scroll_win = ScrolledWindow(self.treeview, Gtk.PolicyType.NEVER, Gtk.PolicyType.NEVER, Gtk.ShadowType.NONE) 354 self.vbox.pack_start(self.scroll_win, True, True, 0) 355 356 # signals 357 model.connect('model-changed', self.on_model_changed) 358 359 def _init_treeview(self, model): 360 # Actual gtk table object 361 self.treeview = self.create_treeview(model) 362 363 # Hook up signals & set options 364 self.treeview.connect('button-press-event', self.on_button_press_event) 365 self.treeview.connect('focus-in-event', self.on_focus_in, self.toolbar) 366 self.treeview.connect('focus-out-event', self.on_focus_out, self.toolbar) 367 self.treeview.connect('move-cursor', self.on_move_cursor) 368 369 # Set options 370 self.treeview.set_grid_lines(Gtk.TreeViewGridLines.BOTH) 371 self.treeview.set_receives_default(True) 372 self.treeview.set_size_request(-1, -1) 373 self.treeview.set_border_width(2) 374 375 # disable interactive column search 376 self.treeview.set_enable_search(False) 377 #Gtk.binding_entry_remove(Gtk.TreeView, Gdk.KEY_f, Gdk.ModifierType.CONTROL_MASK) 378 self.treeview.set_search_column(-1) 379 380 def on_model_changed(self, model): 381 self.scroll_win.remove(self.treeview) 382 self.treeview.destroy() 383 self._init_treeview(model) 384 self.scroll_win.add(self.treeview) 385 self.scroll_win.show_all() 386 387 def old_do_size_request(self, requisition): # TODO - FIX this behavior 388 model = self.get_model() 389 wraps = model.get_wraps() 390 if not any(wraps): 391 return InsertedObjectWidget.do_size_request(self, requisition) 392 393 # Negotiate how to wrap .. 394 for col in self.treeview.get_columns(): 395 cr = col.get_cell_renderers()[0] 396 cr.set_property('wrap-width', -1) # reset size 397 398 #~ col.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) # allow column shrinks 399 #~ col.set_max_width(0) # shrink column 400 #~ col.set_max_width(-1) # reset value 401 #~ col.set_sizing(Gtk.TreeViewColumnSizing.GROW_ONLY) # reset value 402 403 InsertedObjectWidget.do_size_request(self, requisition) 404 405 #~ print("Widget requests: %i textview: %i" % (requisition.width, self._textview_width)) 406 if requisition.width > self._textview_width: 407 # Figure out width of fixed cols 408 fixed = 0 409 for col, wrap in zip(self.treeview.get_columns(), wraps): 410 if not wrap: 411 fixed += col.get_width() 412 413 nwrap = sum(wraps) 414 wrap_size = (self._textview_width - fixed) // nwrap 415 416 # Set width for wrappable cols 417 #~ print("Fixed, nwrap, wrap_size", (fixed, nwrap, wrap_size)) 418 for col, wrap in zip(self.treeview.get_columns(), wraps): 419 if wrap: 420 cr = col.get_cell_renderers()[0] 421 cr.set_property('wrap-width', wrap_size) # reset size 422 423 # Update request 424 InsertedObjectWidget.do_size_request(self, requisition) 425 else: 426 pass 427 428 def on_focus_in(self, treeview, event, toolbar): 429 '''After a table is selected, this function will be triggered''' 430 431 self._keep_toolbar_open = False 432 if self._timer: 433 GObject.source_remove(self._timer) 434 if self._toolbar_enabled: 435 toolbar.show() 436 437 def on_focus_out(self, treeview, event, toolbar): 438 '''After a table is deselected, this function will be triggered''' 439 def receive_alarm(): 440 if self._keep_toolbar_open: 441 self._timer = None 442 if self._timer: 443 self._timer = None 444 treeview.get_selection().unselect_all() 445 if self._toolbar_enabled: 446 toolbar.hide() 447 return False 448 449 self._timer = GObject.timeout_add(500, receive_alarm) 450 451 def create_toolbar(self): 452 '''This function creates a toolbar which is displayed next to the table''' 453 toolbar = Gtk.Toolbar() 454 toolbar.set_orientation(Gtk.Orientation.HORIZONTAL) 455 toolbar.set_style(Gtk.ToolbarStyle.ICONS) 456 toolbar.set_border_width(1) 457 458 for pos, stock, handler, data, tooltip in ( 459 (0, Gtk.STOCK_ADD, self.on_add_row, None, _('Add row')), # T: tooltip on mouse hover 460 (1, Gtk.STOCK_DELETE, self.on_delete_row, None, _('Remove row')), # T: tooltip on mouse hover 461 (2, Gtk.STOCK_COPY, self.on_clone_row, None, _('Clone row')), # T: tooltip on mouse hover 462 (3, None, None, None, None), 463 (4, Gtk.STOCK_GO_UP, self.on_move_row, -1, _('Row up')), # T: tooltip on mouse hover 464 (5, Gtk.STOCK_GO_DOWN, self.on_move_row, 1, _('Row down')), # T: tooltip on mouse hover 465 (6, None, None, None, None), 466 (7, Gtk.STOCK_PREFERENCES, self.on_change_columns, None, _('Change columns')), # T: tooltip on mouse hover 467 (8, None, None, None, None), 468 (9, Gtk.STOCK_HELP, self.on_open_help, None, _('Open help')), # T: tooltip on mouse hover 469 ): 470 if stock is None: 471 toolbar.insert(Gtk.SeparatorToolItem(), pos) 472 else: 473 button = Gtk.ToolButton(stock) 474 if data: 475 button.connect('clicked', handler, data) 476 else: 477 button.connect('clicked', handler) 478 button.set_tooltip_text(tooltip) 479 toolbar.insert(button, pos) 480 481 toolbar.set_size_request(-1, -1) 482 toolbar.set_icon_size(Gtk.IconSize.MENU) 483 484 return toolbar 485 486 def _column_alignment(self, aligntext): 487 ''' The column alignment must be converted from numeric to keywords ''' 488 if aligntext == 'left': 489 align = 0.0 490 elif aligntext == 'center': 491 align = 0.5 492 elif aligntext == 'right': 493 align = 1.0 494 else: 495 align = None 496 return align 497 498 def create_treeview(self, model): 499 '''Initializes a treeview with its model (liststore) and all its columns''' 500 treeview = Gtk.TreeView(model.liststore) 501 502 # Set default sorting function. 503 model.liststore.set_default_sort_func(lambda *a: 0) 504 505 aligns = model.get_aligns() 506 wraps = model.get_wraps() 507 for i, headcol in enumerate(model.headers): 508 cell = Gtk.CellRendererText() 509 tview_column = Gtk.TreeViewColumn(headcol, cell) 510 tview_column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) # allow column shrinks 511 treeview.append_column(tview_column) 512 513 # set title as label 514 header_label = self.create_headerlabel(headcol) 515 tview_column.set_widget(header_label) 516 517 # set properties of column 518 tview_column.set_attributes(cell, markup=i) 519 cell.set_property('editable', True) 520 cell.set_property('yalign', 0.0) # no vertical alignment, text starts on the top 521 tview_column.set_sort_column_id(i) 522 # set sort function 523 model.liststore.set_sort_func(i, self.sort_by_number_or_string, i) 524 # set alignment - left center right 525 align = self._column_alignment(aligns[i]) 526 if align: 527 tview_column.set_alignment(align) 528 cell.set_alignment(align, 0.0) 529 530 # set wrap mode, wrap-size is set elsewhere 531 if wraps[i]: 532 cell.set_property('wrap-mode', Pango.WrapMode.WORD) 533 534 # callbacks after an action 535 cell.connect('edited', self.on_cell_changed, treeview.get_model(), i) 536 cell.connect('editing-started', self.on_cell_editing_started, treeview.get_model(), i) 537 cell.connect('editing-canceled', self.on_cell_editing_canceled) 538 539 return treeview 540 541 def create_headerlabel(self, title): 542 return TableViewWidget.create_headerlabel(title) 543 544 @staticmethod 545 def create_headerlabel(title): 546 ''' Sets options for the treeview header''' 547 col_widget = Gtk.VBox() 548 col_widget.show() 549 550 551 col_label = Gtk.Label(label='<u>' + title + '</u>') 552 col_label.set_use_markup(True) 553 col_label.show() 554 col_widget.pack_start(col_label, True, True, 0) 555 #col_align.add(col_label) 556 557 '''col_entry = InputEntry() 558 col_entry.set_name('treeview-header-entry') 559 col_entry.show() 560 col_widget.pack_start(col_entry, True, True, 0)''' 561 562 return col_widget 563 564 def get_treeview(self): 565 # treeview of current table 566 return self.treeview 567 568 def set_preferences(self, preferences): 569 self._toolbar_enabled = preferences.get('show_helper_toolbar', True) 570 self.treeview.set_grid_lines(GTK_GRIDLINES[preferences.get('grid_lines', LINES_BOTH)]) 571 572 def on_move_cursor(self, view, step_size, count): 573 ''' If you try to move the cursor out of the tableditor release the cursor to the parent textview ''' 574 return None # let parent handle this signal 575 576 def fetch_cell_by_event(self, event, treeview): 577 ''' Looks for the cell where the mouse clicked on it ''' 578 liststore = treeview.get_model() 579 (xpos, ypos) = event.get_coords() 580 (treepath, treecol, xrel, yrel) = treeview.get_path_at_pos(int(xpos), int(ypos)) 581 treeiter = liststore.get_iter(treepath) 582 cellvalue = liststore.get_value(treeiter, treeview.get_columns().index(treecol)) 583 return cellvalue 584 585 def get_linkurl(self, celltext): 586 ''' Checks a cellvalue if it contains a link and returns only the link value ''' 587 linkregex = r'<span foreground="blue">.*?<span.*?>(.*?)</span></span>' 588 matches = re.match(linkregex, celltext) 589 linkvalue = matches.group(1) if matches else None 590 return linkvalue 591 592 def on_button_press_event(self, treeview, event): 593 ''' 594 Displays a context-menu on right button click 595 Opens the link of a tablecell on CTRL pressed and left button click 596 ''' 597 if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 1 and event.get_state() & Gdk.ModifierType.CONTROL_MASK: 598 # With CTRL + LEFT-Mouse-Click link of cell is opened 599 cellvalue = self.fetch_cell_by_event(event, treeview) 600 linkvalue = self.get_linkurl(cellvalue) 601 if linkvalue: 602 self.emit('link-clicked', {'href': str(linkvalue)}) 603 return 604 605 if event.type == Gdk.EventType.BUTTON_PRESS and event.button == 3: 606 # Right button opens context menu 607 self._keep_toolbar_open = True 608 cellvalue = self.fetch_cell_by_event(event, treeview) 609 linkvalue = self.get_linkurl(cellvalue) 610 linkitem_is_activated = (linkvalue is not None) 611 612 menu = Gtk.Menu() 613 614 for stock, handler, data, tooltip in ( 615 (Gtk.STOCK_ADD, self.on_add_row, None, _('Add row')), # T: menu item 616 (Gtk.STOCK_DELETE, self.on_delete_row, None, _('Delete row')), # T: menu item 617 (Gtk.STOCK_COPY, self.on_clone_row, None, _('Clone row')), # T: menu item 618 (None, None, None, None), # T: menu item 619 (Gtk.STOCK_JUMP_TO, self.on_open_link, linkvalue, _('Open cell content link')), # T: menu item 620 (None, None, None, None), 621 (Gtk.STOCK_GO_UP, self.on_move_row, -1, _('Row up')), # T: menu item 622 (Gtk.STOCK_GO_DOWN, self.on_move_row, 1, _('Row down')), # T: menu item 623 (None, None, None, None), 624 (Gtk.STOCK_PREFERENCES, self.on_change_columns, None, _('Change columns')) # T: menu item 625 ): 626 627 if stock is None: 628 menu.append(Gtk.SeparatorMenuItem()) 629 else: 630 item = Gtk.ImageMenuItem(stock) 631 item.set_always_show_image(True) 632 item.set_label(_(tooltip)) 633 if data: 634 item.connect_after('activate', handler, data) 635 else: 636 item.connect_after('activate', handler) 637 if handler == self.on_open_link: 638 item.set_sensitive(linkitem_is_activated) 639 menu.append(item) 640 641 menu.show_all() 642 gtk_popup_at_pointer(menu, event) 643 644 def on_add_row(self, action): 645 ''' Context menu: Add a row ''' 646 selection = self.treeview.get_selection() 647 model, treeiter = selection.get_selected() 648 if not treeiter: # no selected item 649 self.selection_info() 650 return 651 652 # Set default sorting. 653 model.set_sort_column_id(-1, Gtk.SortType.ASCENDING) 654 655 row = len(self.treeview.get_columns()) * [''] 656 path = model.insert_after(treeiter, row) 657 658 def on_clone_row(self, action): 659 ''' Context menu: Clone a row ''' 660 selection = self.treeview.get_selection() 661 model, treeiter = selection.get_selected() 662 if not treeiter: # no selected item 663 self.selection_info() 664 return 665 666 path = model.get_path(treeiter) 667 row = list(model[path[0]]) # copy 668 model.insert_after(treeiter, row) 669 670 def on_delete_row(self, action): 671 ''' Context menu: Delete a row ''' 672 selection = self.treeview.get_selection() 673 model, treeiter = selection.get_selected() 674 if not treeiter: # no selected item 675 self.selection_info() 676 return 677 678 if len(model) > 1: 679 model.remove(treeiter) 680 else: 681 md = Gtk.MessageDialog(None, Gtk.DialogFlags.DESTROY_WITH_PARENT, Gtk.MessageType.WARNING, Gtk.ButtonsType.CLOSE, 682 _("The table must consist of at least on row!\n No deletion done.")) 683 # T: Popup dialog 684 md.run() 685 md.destroy() 686 687 def on_move_row(self, action, direction): 688 ''' Trigger for moving a row one position up/down ''' 689 selection = self.treeview.get_selection() 690 model, treeiter = selection.get_selected() 691 if not treeiter: # no selected item 692 self.selection_info() 693 return 694 695 path = model.get_path(treeiter) 696 newpos = path[0] + direction 697 if 0 > newpos or newpos >= len(model): # first item cannot be pushed forward, last not backwards 698 return 699 newiter = model.get_iter((newpos,)) 700 701 # Set default sorting. 702 model.set_sort_column_id(-1, Gtk.SortType.ASCENDING) 703 704 # Change values of two rows. 705 for col in range(model.get_n_columns()): 706 value = model.get_value(treeiter, col) 707 newvalue = model.get_value(newiter, col) 708 model.set_value(newiter, col, value) 709 model.set_value(treeiter, col, newvalue) 710 711 def on_open_link(self, action, link): 712 ''' Context menu: Open a link, which is written in a cell ''' 713 self.emit('link-clicked', {'href': str(link)}) 714 715 def on_open_help(self, action): 716 ''' Context menu: Open help ''' 717 ZIM_APPLICATION.run('--manual', 'Plugins:Table Editor') 718 719 def on_change_columns(self, action): 720 ''' Context menu: Edit table, run the EditTableDialog ''' 721 aligns = self.model.get_aligns() 722 wraps = self.model.get_wraps() 723 headers = [col.get_title() for col in self.treeview.get_columns()] 724 ids = [i for i in range(len(headers))] 725 definition = ids, headers, wraps, aligns 726 newdefinition = EditTableDialog(self.get_toplevel(), definition).run() 727 if newdefinition: 728 self.model.change_model(newdefinition) # Will call back to change our treeview 729 730 def on_cell_changed(self, cellrenderer, path, text, liststore, colid): 731 ''' Trigger after cell-editing, to transform displayed table cell into right format ''' 732 self._keep_toolbar_open = False 733 markup = CellFormatReplacer.input_to_cell(text) 734 liststore[path][colid] = markup 735 self._cellinput_canceled = False 736 737 def on_cell_editing_started(self, cellrenderer, editable, path, liststore, colid): 738 ''' Trigger before cell-editing, to transform text-field data into right format ''' 739 self._keep_toolbar_open = True 740 741 editable.connect('focus-out-event', self.on_cell_focus_out, cellrenderer, path, liststore, colid) 742 markup = liststore[path][colid] 743 markup = CellFormatReplacer.cell_to_input(markup) 744 editable.set_text(markup) 745 self._cellinput_canceled = False 746 747 def on_cell_focus_out(self, editable, event, cellrenderer, path, liststore, colid): 748 if not self._cellinput_canceled: 749 self.on_cell_changed(cellrenderer, path, editable.get_text(), liststore, colid) 750 751 def on_cell_editing_canceled(self, renderer): 752 ''' Trigger after a cell is edited but any change is skipped ''' 753 self._cellinput_canceled = True 754 755 756 def sort_by_number_or_string(self, liststore, treeiter1, treeiter2, colid): 757 ''' 758 Sort algorithm for sorting numbers correctly and putting 10 after 3. 759 This part can be improved in future to support also currencies, dates, floats, etc. 760 :param liststore: model of treeview 761 :param treeiter1: treeiter 1 762 :param treeiter2: treeiter 2 763 :param colid: a column number 764 :return: -1 / first data is smaller than second, 0 / equality, 1 / else 765 ''' 766 data1 = natural_sort_key(liststore.get_value(treeiter1, colid)) 767 data2 = natural_sort_key(liststore.get_value(treeiter2, colid)) 768 return (data1 > data2) - (data1 < data2) # python3 jargon for "cmp()" 769 770 def selection_info(self): 771 ''' Info-Popup for selecting a cell before this action can be done ''' 772 md = Gtk.MessageDialog(None, Gtk.DialogFlags.DESTROY_WITH_PARENT, Gtk.MessageType.WARNING, Gtk.ButtonsType.CLOSE, 773 _("Please select a row, before you push the button.")) 774 # T: 775 md.run() 776 md.destroy() 777 778 #~ def _search_in_widget(self, start, step): 779 #~ ''' 780 #~ Search within a widget 781 #~ :param start: position-of-widget 782 #~ :param step: search direction (up / down): -1 / 1 783 #~ :return: tuple (startiter, enditer, match) 784 #~ ''' 785 #~ if start.get_child_anchor() is None or len(start.get_child_anchor().get_widgets()) < 1: 786 #~ return 787 #~ widgets = start.get_child_anchor().get_widgets() 788 #~ # TODO TODO TODO - generalize interface so all widgets can integrate find 789 #~ if isinstance(widgets[0], zim.plugins.tableeditor.TableViewWidget): 790 #~ table = widgets[0] 791 #~ # get treeview first 792 #~ treeview = table.get_treeview() 793 #~ liststore = treeview.get_model() 794 #~ iter = liststore.get_iter_root() 795 #~ while iter is not None: 796 #~ for col in range(liststore.get_n_columns()): 797 #~ text = liststore.get_value(iter, col) 798 #~ matches = self.regex.finditer(text) 799 #~ if step == -1: 800 #~ matches = list(matches) 801 #~ matches.reverse() 802 #~ for match in matches: 803 #~ startiter = iter 804 #~ enditer = iter 805 #~ return startiter, enditer, match 806 #~ iter = liststore.iter_next(iter) 807 808 #~ def _replace_in_widget(self, start, regex, string, replaceall=False): 809 #~ ''' 810 #~ Replace within a widget 811 #~ :param start: position-of-widget 812 #~ :param regex: regular expression pattern 813 #~ :param text: substituation text 814 #~ :param replaceall: boolean if all matches should be replaced 815 #~ :return: True / False - a replacement was done / no replaces 816 #~ ''' 817 #~ if start.get_child_anchor() is None or len(start.get_child_anchor().get_widgets()) < 1: 818 #~ return 819 #~ widgets = start.get_child_anchor().get_widgets() 820 #~ if isinstance(widgets[0], zim.plugins.tableeditor.TableViewWidget): 821 #~ table = widgets[0] 822 #~ liststore = table.get_liststore() 823 #~ iter = liststore.get_iter_root() 824 #~ has_replaced = False 825 #~ while iter is not None: 826 #~ for col in range(liststore.get_n_columns()): 827 #~ text = liststore.get_value(iter, col) 828 #~ if(regex.search(text)): 829 #~ newtext = regex.sub(string, text) 830 #~ liststore.set_value(iter, col, newtext) 831 #~ if(not replaceall): 832 #~ return True 833 #~ else: 834 #~ has_replaced = True 835 #~ iter = liststore.iter_next(iter) 836 #~ return has_replaced 837 838 839class EditTableDialog(Dialog): 840 ''' 841 Graphical dialog for the user, where a new table can be created or an existing one can be modified 842 Here columns can be added / modified and titles be managed. 843 ''' 844 class Col(): 845 ''' 846 Format of the treeview in which columns of the table can be managed: 847 - id: -1 or position of original column 848 - wrapped: 0/1 should text be wrapped over multiple lines 849 - align, alignicon, aligntext: english-keyword, GTK-ICON, translated-keyword for alignments 850 ''' 851 id, title, wrapped, align, alignicon, aligntext = list(range(6)) 852 853 def __init__(self, parent, definition=None): 854 ''' 855 Constructor, which intializes the dialog window 856 :param parent: 857 :param definition: tuple of C{(ids, headers, wraps, aligns)} 858 :return: 859 ''' 860 title = _('Insert Table') if definition is None else _('Edit Table') # T: Dialog title 861 Dialog.__init__(self, parent, title) 862 863 # Prepare treeview in which all columns of the table are listed 864 self.default_column_item = [-1, "", 0, "left", Gtk.STOCK_JUSTIFY_LEFT, _("Left")] 865 # currently edited cell - tuple (editable, path, colid) save it on exit 866 self.currently_edited = None 867 868 # Set layout of Window 869 self.add_help_text(_('Managing table columns')) # T: Description of "Table-Insert" Dialog 870 self.set_default_size(380, 400) 871 872 liststore = self._prepare_liststore(definition) 873 self.treeview = self._prepare_treeview_with_headcolumn_list(liststore) 874 hbox = Gtk.HBox(spacing=5) 875 hbox.set_size_request(300, 300) 876 self.vbox.pack_start(hbox, False, True, 0) 877 header_scrolled_area = ScrolledWindow(self.treeview) 878 header_scrolled_area.set_size_request(200, -1) 879 hbox.pack_start(header_scrolled_area, True, True, 0) 880 hbox.pack_start(self._button_box(), False, False, 0) 881 882 self.show_all() 883 if definition is None: # preselect first entry 884 path = self.treeview.get_model().get_path(self.treeview.get_model().get_iter_first()) 885 self.treeview.set_cursor_on_cell(path, self.treeview.get_column(0), None, True) 886 887 888 def _prepare_liststore(self, definition): 889 ''' 890 Preparation of liststore to show a treeview, that displays the columns of the table 891 :param definition: tuple of C{(ids, headers, wraps, aligns)} 892 :return:liststore 893 ''' 894 liststore = Gtk.ListStore(int, str, int, str, str, str) 895 896 # each table column is displayed in a new row 897 if definition is None: 898 first_column_item = list(self.default_column_item) 899 first_column_item[1] = _("Column 1") # T: Initial data for column title in table 900 liststore.append(first_column_item) 901 else: 902 ids, headers, wraps, aligns = definition 903 default_align = COLUMNS_ALIGNMENTS['normal'] 904 for row in map(list, zip(ids, headers, wraps, aligns)): 905 align = row.pop() 906 align_fields = COLUMNS_ALIGNMENTS.get(align, default_align) 907 row.extend(align_fields) 908 liststore.append(row) 909 910 return liststore 911 912 def _prepare_treeview_with_headcolumn_list(self, liststore): 913 ''' 914 Preparation of the treeview element, that displays the columns of the table 915 :param liststore: model for current treeview 916 :return: the treeview 917 ''' 918 treeview = Gtk.TreeView(liststore) 919 920 # 1. Column - Title 921 cell = Gtk.CellRendererText() 922 cell.set_property('editable', True) 923 column = Gtk.TreeViewColumn(_('Title'), cell, text=self.Col.title) 924 column.set_min_width(120) 925 treeview.append_column(column) 926 cell.connect('edited', self.on_cell_changed, liststore, self.Col.title) 927 cell.connect('editing-started', self.on_cell_editing_started, liststore, self.Col.title) 928 929 # 2. Column - Wrap Line 930 cell = Gtk.CellRendererToggle() 931 cell.connect('toggled', self.on_wrap_toggled, liststore, self.Col.wrapped) 932 column = Gtk.TreeViewColumn(_('Auto\nWrap'), cell) # T: table header 933 treeview.append_column(column) 934 column.add_attribute(cell, 'active', self.Col.wrapped) 935 936 # 3. Column - Alignment 937 store = Gtk.ListStore(str, str, str) 938 store.append(COLUMNS_ALIGNMENTS['left']) 939 store.append(COLUMNS_ALIGNMENTS['center']) 940 store.append(COLUMNS_ALIGNMENTS['right']) 941 942 column = Gtk.TreeViewColumn(_('Align')) # T: table header 943 cellicon = Gtk.CellRendererPixbuf() 944 column.pack_start(cellicon, True) 945 column.add_attribute(cellicon, 'stock-id', self.Col.alignicon) 946 947 cell = Gtk.CellRendererCombo() 948 cell.set_property('model', store) 949 cell.set_property('has-entry', False) 950 cell.set_property('text-column', 2) 951 cell.set_property('width', 50) 952 cell.set_property('editable', True) 953 column.pack_start(cell, True) 954 column.add_attribute(cell, 'text', self.Col.aligntext) 955 cell.connect('changed', self.on_alignment_changed, liststore) 956 treeview.append_column(column) 957 958 return treeview 959 960 def _button_box(self): 961 ''' 962 Panel which includes buttons for manipulating the current treeview: 963 - add / delete 964 - move up / move down row 965 :return: vbox-panel 966 ''' 967 vbox = Gtk.VBox(spacing=5) 968 for stock, handler, data, tooltip in ( 969 (Gtk.STOCK_ADD, self.on_add_new_column, None, _('Add column')), # T: hoover tooltip 970 (Gtk.STOCK_DELETE, self.on_delete_column, None, _('Remove column')), # T: hoover tooltip 971 (Gtk.STOCK_GO_UP, self.on_move_column, -1, _('Move column ahead')), # T: hoover tooltip 972 (Gtk.STOCK_GO_DOWN, self.on_move_column, 1, _('Move column backward')), # T: hoover tooltip 973 ): 974 button = IconButton(stock) 975 if data: 976 button.connect('clicked', handler, data) 977 else: 978 button.connect('clicked', handler) 979 button.set_tooltip_text(tooltip) 980 vbox.pack_start(button, False, True, 0) 981 982 vbox.show_all() 983 return vbox 984 985 def do_response_ok(self): 986 ''' Dialog Window is closed with "OK" ''' 987 self.autosave_title_cell() 988 m = [r[0:4] for r in self.treeview.get_model()] 989 ids, headers, aligns, wraps = list(zip(*m)) 990 self.result = ids, headers, aligns, wraps 991 return True 992 993 def do_response_cancel(self): 994 ''' Dialog Window is closed with "Cancel" ''' 995 self.result = None 996 return True 997 998 def on_cell_editing_started(self, renderer, editable, path, model, colid): 999 ''' Trigger before cell-editing, to transform text-field data into right format ''' 1000 text = model[path][colid] 1001 text = CellFormatReplacer.cell_to_input(text, with_pango=False) 1002 editable.set_text(text) 1003 self.currently_edited = (editable, model, path, colid) 1004 1005 def on_cell_changed(self, renderer, path, text, model, colid): 1006 ''' Trigger after cell-editing, to transform text-field data into right format ''' 1007 model[path][colid] = CellFormatReplacer.input_to_cell(text, with_pango=False) 1008 self.currently_edited = None 1009 1010 def on_wrap_toggled(self, renderer, path, model, colid): 1011 ''' Trigger for wrap-option (enable/disable)''' 1012 treeiter = model.get_iter(path) 1013 val = model.get_value(treeiter, colid) 1014 model.set_value(treeiter, colid, not val) 1015 1016 def on_alignment_changed(self, renderer, path, comboiter, model): 1017 ''' Trigger for align-option (selectionbox with icon and alignment as text)''' 1018 combomodel = renderer.get_property('model') 1019 align = combomodel.get_value(comboiter, 0) 1020 alignimg = combomodel.get_value(comboiter, 1) 1021 aligntext = combomodel.get_value(comboiter, 2) 1022 1023 treeiter = model.get_iter(path) 1024 model.set_value(treeiter, self.Col.align, align) 1025 model.set_value(treeiter, self.Col.alignicon, alignimg) 1026 model.set_value(treeiter, self.Col.aligntext, aligntext) 1027 1028 def autosave_title_cell(self): 1029 ''' Saving cell, in case of editing it and then do not close it, but do another action, like closing window ''' 1030 if self.currently_edited: 1031 editable, model, path, colid = self.currently_edited 1032 text = editable.get_text() 1033 model[path][colid] = CellFormatReplacer.input_to_cell(text, with_pango=False) 1034 self.currently_edited = None 1035 1036 def on_add_new_column(self, btn): 1037 ''' Trigger for adding a new column into the table / it is a new row in the treeview ''' 1038 self.autosave_title_cell() 1039 (model, treeiter) = self.treeview.get_selection().get_selected() 1040 if not treeiter: # preselect first entry 1041 path = model.iter_n_children(None) - 1 1042 treeiter = model.get_iter(path) 1043 newiter = model.insert_after(treeiter, self.default_column_item) 1044 self.treeview.set_cursor_on_cell(model.get_path(newiter), self.treeview.get_column(0), None, True) 1045 1046 def on_delete_column(self, btn): 1047 ''' Trigger for deleting a column out of the table / it is a deleted row in the treeview ''' 1048 self.autosave_title_cell() 1049 (model, treeiter) = self.treeview.get_selection().get_selected() 1050 1051 if treeiter: 1052 if len(model) > 1: 1053 model.remove(treeiter) 1054 else: 1055 md = Gtk.MessageDialog(None, Gtk.DialogFlags.DESTROY_WITH_PARENT, Gtk.MessageType.WARNING, Gtk.ButtonsType.CLOSE, 1056 _("A table needs to have at least one column.")) # T: popup dialog 1057 md.run() 1058 md.destroy() 1059 else: 1060 self.selection_info() 1061 1062 def on_move_column(self, btn, direction): 1063 ''' Trigger for moving a column one position left/right) - it is a movement up/down in the treeview ''' 1064 self.autosave_title_cell() 1065 (model, treeiter) = self.treeview.get_selection().get_selected() 1066 1067 if not treeiter: # no selected item 1068 self.selection_info() 1069 return 1070 1071 path = model.get_path(treeiter) 1072 newpos = path[0] + direction 1073 if 0 > newpos or newpos >= len(model): # first item cannot be pushed forward, last not backwards 1074 return 1075 newiter = model.get_iter((newpos,)) 1076 1077 model.swap(treeiter, newiter) 1078 1079 def selection_info(self): 1080 ''' Info-Popup for selecting a cell before this action can be done ''' 1081 md = Gtk.MessageDialog(None, Gtk.DialogFlags.DESTROY_WITH_PARENT, Gtk.MessageType.WARNING, Gtk.ButtonsType.CLOSE, 1082 _("Please select a row, before you push the button.")) # T: Popup dialog 1083 md.run() 1084 md.destroy() 1085