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	('&amp;', '&'), ('&gt;', '>'), ('&lt;', '<'), ('&quot;', '"'), ('&apos;', "'")
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