1# -*- coding: UTF-8 -*-
2
3# Copyright 2011 Jiří Janoušek <janousek.jiri@gmail.com>
4# Copyright 2014-2018 Jaap Karssenberg <jaap.karssenberg@gmail.com>
5
6
7from gi.repository import Gtk
8from gi.repository import GObject
9from gi.repository import Gdk
10
11import zim.errors
12
13from zim.plugins import PluginManager, InsertedObjectTypeExtension
14from zim.insertedobjects import InsertedObjectType
15
16from zim.gui.widgets import ScrolledTextView, ScrolledWindow, widget_set_css
17
18
19# Constants for grab-focus-cursor and release-focus-cursor
20POSITION_BEGIN = 1
21POSITION_END = 2
22
23
24class InsertedObjectWidget(Gtk.EventBox):
25	'''Base class & contained for custom object widget
26
27	We derive from a C{Gtk.EventBox} because we want to re-set the
28	default cursor for the area of the object widget. For this the
29	widget needs it's own window for drawing.
30
31	@signal: C{link-clicked (link)}: To be emitted when the user clicks a link
32	@signal: C{link-enter (link)}: To be emitted when the mouse pointer enters a link
33	@signal: C{link-leave (link)}: To be emitted when the mouse pointer leaves a link
34	@signal: C{grab-cursor (position)}: emitted when embedded widget
35	should grab focus, position can be either POSITION_BEGIN or POSITION_END
36	@signal:  C{release-cursor (position)}: emitted when the embedded
37	widget wants to give back focus to the embedding TextView
38	'''
39
40	# define signals we want to use - (closure type, return type and arg types)
41	__gsignals__ = {
42		'link-clicked': (GObject.SignalFlags.RUN_LAST, None, (object,)),
43		'link-enter': (GObject.SignalFlags.RUN_LAST, None, (object,)),
44		'link-leave': (GObject.SignalFlags.RUN_LAST, None, (object,)),
45
46		'grab-cursor': (GObject.SignalFlags.RUN_LAST, None, (int,)),
47		'release-cursor': (GObject.SignalFlags.RUN_LAST, None, (int,)),
48	}
49
50	expand = True
51
52	def __init__(self, widget_style=None):
53		GObject.GObject.__init__(self)
54		self._has_cursor = False
55		self._vbox = Gtk.VBox()
56		Gtk.EventBox.add(self, self._vbox)
57		if widget_style == 'inline':
58			self._vbox.set_name('zim-inserted-object-inline')
59		else:
60			self.set_border_width(3)
61			widget_set_css(self._vbox, 'zim-inserted-object', 'border: 1px solid #ccc')
62				# Choosen #ccc because it should give contract with both light and
63				# dark theme, but less than the text color itself
64				# Can be overruled in user css is really conflicts with theme
65
66	def add(self, widget):
67		'''Add a widget to the object'''
68		self._vbox.pack_start(widget, True, True, 0)
69
70	def add_header(self, widget):
71		'''Add an header widget on top of the object'''
72		widget.get_style_context().add_class(Gtk.STYLE_CLASS_BACKGROUND)
73		widget_set_css(widget, 'zim-inserted-object-head', 'border-bottom: 1px solid #ccc')
74		self._vbox.pack_start(widget, True, True, 0)
75		self._vbox.reorder_child(widget, 0)
76
77	def remove(self, widget):
78		self._vbox.remove(widget)
79
80	def do_realize(self):
81		Gtk.EventBox.do_realize(self)
82		window = self.get_parent_window()
83		window.set_cursor(Gdk.Cursor.new(Gdk.CursorType.ARROW))
84
85	def set_textview_wrap_width(self, width):
86		if self.expand:
87			self.set_size_request(width, -1)
88
89	def has_cursor(self):
90		'''Returns True if this object has an internal cursor. Will be
91		used by the TextView to determine if the cursor should go
92		"into" the object or just jump from the position before to the
93		position after the object. If True the embedded widget is
94		expected to support grab_cursor() and use release_cursor().
95		'''
96		return self._has_cursor
97
98	def set_has_cursor(self, has_cursor):
99		'''See has_cursor()'''
100		self._has_cursor = has_cursor
101
102	def grab_cursor(self, position):
103		'''Emits the grab-cursor signal'''
104		self.emit('grab-cursor', position)
105
106	def release_cursor(self, position):
107		'''Emits the release-cursor signal'''
108		self.emit('release-cursor', position)
109
110	def do_button_press_event(self, event):
111		if Gdk.Event.triggers_context_menu(event) \
112			and event.type == Gdk.EventType.BUTTON_PRESS:
113				self._do_popup_menu(event)
114		return True # Prevent propagating event to parent textview
115
116	def do_button_release_event(self, event):
117		return True # Prevent propagating event to parent textview
118
119	def do_popup_menu(self):
120		# See https://developer.gnome.org/gtk3/stable/gtk-migrating-checklist.html#checklist-popup-menu
121		self._do_popup_menu(None)
122
123	def _do_popup_menu(self, event):
124		menu = Gtk.Menu()
125		try:
126			self.populate_popup(menu)
127		except NotImplementedError:
128			return False
129		else:
130			menu.show_all()
131
132		if event is not None:
133			button = event.button
134			event_time = event.time
135		else:
136			button = 0
137			event_time = Gtk.get_current_event_time()
138
139		menu.attach_to_widget(self)
140		menu.popup(None, None, None, None, button, event_time)
141
142	def populate_popup(self, menu):
143		raise NotImplementedError
144
145	def edit_object(self):
146		raise NotImplementedError
147
148
149class TextViewWidget(InsertedObjectWidget):
150
151	def __init__(self, buffer):
152		InsertedObjectWidget.__init__(self)
153		self.set_has_cursor(True)
154		self.buffer = buffer
155		self._init_view()
156		self._init_signals()
157
158	def _init_view(self):
159		win, self.view = ScrolledTextView(monospace=True,
160			hpolicy=Gtk.PolicyType.AUTOMATIC, vpolicy=Gtk.PolicyType.NEVER, shadow=Gtk.ShadowType.NONE)
161		self.view.set_buffer(self.buffer)
162		self.view.set_editable(True)
163		self.add(win)
164
165	def _init_signals(self):
166		# Hook up integration with pageview cursor movement
167		self.view.connect('move-cursor', self.on_move_cursor)
168		self.connect('parent-set', self.on_parent_set)
169		self.parent_notify_h = None
170
171	def set_editable(self, editable):
172		self.view.set_editable(editable)
173		self.view.set_cursor_visible(editable)
174
175	def on_parent_set(self, widget, old_parent):
176		if old_parent and self.parent_notify_h:
177			old_parent.disconnect(self.parent_notify_h)
178			self.parent_notify_h = None
179		parent = self.get_parent()
180		if parent:
181			self.set_editable(parent.get_editable())
182			self.parent_notify_h = parent.connect('notify::editable', self.on_parent_notify)
183
184	def on_parent_notify(self, widget, prop, *args):
185		self.set_editable(self.get_parent().get_editable())
186
187	def do_grab_cursor(self, position):
188		# Emitted when we are requesed to capture the cursor
189		begin, end = self.buffer.get_bounds()
190		if position == POSITION_BEGIN:
191			self.buffer.place_cursor(begin)
192		else:
193			self.buffer.place_cursor(end)
194		self.view.grab_focus()
195
196	def on_move_cursor(self, view, step_size, count, extend_selection):
197		# If you try to move the cursor out of the sourceview
198		# release the cursor to the parent textview
199		buffer = view.get_buffer()
200		iter = buffer.get_iter_at_mark(buffer.get_insert())
201		if (iter.is_start() or iter.is_end()) \
202		and not extend_selection:
203			if iter.is_start() and count < 0:
204				self.release_cursor(POSITION_BEGIN)
205				return None
206			elif iter.is_end() and count > 0:
207				self.release_cursor(POSITION_END)
208				return None
209
210		return None # let parent handle this signal
211
212
213class ImageFileWidget(InsertedObjectWidget):
214
215	expand = False
216
217	def __init__(self, file, widget_style=None):
218		InsertedObjectWidget.__init__(self, widget_style=widget_style)
219		self.file = file
220		if file.exists():
221			self.image = Gtk.Image.new_from_file(file.path)
222		else:
223			self.image = Gtk.Image()
224		self.image.set_property('margin', 1) # seperate line and content
225		self.add(self.image)
226
227		# TODO: setup file monitor to reload on changed -- update it in "set_file"
228
229		# TODO: shrink image when larger than width -- have "shrink" class property
230		# implement set_textview_wrap_width() for this here
231
232	def set_file(self, file):
233		self.file = file
234		if self.file.exists():
235			self.image.set_from_file(file.path)
236		else:
237			self.image.clear()
238
239
240def _find_plugin(name):
241	plugins = PluginManager()
242	for plugin_name in plugins.list_installed_plugins():
243		try:
244			klass = plugins.get_plugin_class(plugin_name)
245			for objtype in klass.discover_classes(InsertedObjectTypeExtension):
246				if objtype.name == name:
247					activatable = klass.check_dependencies_ok()
248					return (plugin_name, klass.plugin_info['name'], activatable, klass)
249		except:
250			continue
251	return None
252
253
254class UnkownObjectWidget(TextViewWidget):
255
256	def __init__(self, buffer):
257		TextViewWidget.__init__(self, buffer)
258		#~ self.view.set_editable(False) # object knows best how to manage content
259		# TODO set background grey ?
260
261		type = buffer.object_attrib.get('type')
262		plugin_info = _find_plugin(type) if type else None
263		if plugin_info:
264			header = self._add_load_plugin_bar(plugin_info)
265			self.add_header(header)
266		else:
267			label = Gtk.Label(
268				_("No plugin available to display objects of type: %s") % type # T: Label for object manager
269			)
270			self.add_header(label)
271
272	def _add_load_plugin_bar(self, plugin_info):
273		key, name, activatable, klass = plugin_info
274
275		hbox = Gtk.HBox(False, 5)
276		label = Gtk.Label(label=_("Plugin \"%s\" is required to display this object") % name)
277			# T: Label for object manager - "%s" is the plugin name
278		hbox.pack_start(label, True, True, 0)
279
280		button = Gtk.Button(_("Enable plugin")) # T: Label for object manager
281		button.set_relief(Gtk.ReliefStyle.NONE)
282		hbox.pack_end(button, False, False, 0)
283
284		if activatable:
285			# Plugin can be enabled
286			def load_plugin(button):
287				PluginManager().load_plugin(key)
288			button.connect("clicked", load_plugin)
289		else:
290			button.set_sensitive(False)
291
292		return hbox
293
294
295class UnkownObjectBuffer(Gtk.TextBuffer):
296
297	def __init__(self, attrib, data):
298		Gtk.TextBuffer.__init__(self)
299		self.object_attrib = attrib
300		self.set_text(data)
301
302	def get_object_data(self):
303		attrib = self.object_attrib.copy()
304		start, end = self.get_bounds()
305		data = start.get_text(end)
306		return attrib, data
307
308
309class UnknownInsertedObject(InsertedObjectType):
310
311	name = "unknown"
312
313	label = _('Unkown Object')  # T: label for inserted object
314
315	def parse_attrib(self, attrib):
316		# Overrule base class checks since we don't know what this object is
317		attrib.setdefault('type', self.name)
318		return attrib
319
320	def model_from_data(self, notebook, page, attrib, data):
321		return UnkownObjectBuffer(attrib, data)
322
323	def data_from_model(self, buffer):
324		return buffer.get_object_data()
325
326	def create_widget(self, buffer):
327		return UnkownObjectWidget(buffer)
328
329
330class UnkownImage(object):
331
332	def __init__(self, file, attrib, data):
333		self.file = file
334		self.object_attrib = attrib
335		self.object_data = data
336
337	def get_object_data(self):
338		return self.object_attrib.copy(), self.object_data
339
340	def connect(self, signal, handler):
341		assert signal == 'changed'
342		pass
343
344	def __getattr__(self, name):
345		return getattr(self.file, name)
346
347
348class UnknownInsertedImageObject(InsertedObjectType):
349
350	name = "unknown-image"
351
352	label = _('Unkown Image type')  # T: label for inserted object
353
354	def parse_attrib(self, attrib):
355		# Overrule base class checks since we don't know what this object is
356		attrib.setdefault('type', self.name)
357		return attrib
358
359	def model_from_data(self, notebook, page, attrib, data):
360		file = notebook.resolve_file(attrib['src'], page)
361		return UnkownImage(file, attrib, data)
362
363	def data_from_model(self, model):
364		return model.get_object_data()
365
366	def create_widget(self, model):
367		return ImageFileWidget(model)
368
369
370
371class InsertedObjectUI(object):
372
373	def __init__(self, uimanager, pageview):
374		self.uimanager = uimanager
375		self.pageview = pageview
376		self.insertedobjects = PluginManager().insertedobjects
377		self._ui_id = None
378		self._actiongroup = None
379		self.add_ui()
380		self.insertedobjects.connect('changed', self.on_changed)
381
382	def on_changed(self, o):
383		self.uimanager.remove_ui(self._ui_id)
384		self.uimanager.remove_action_group(self._actiongroup)
385		self._ui_id = None
386		self._actiongroup = None
387		self.add_ui()
388
389	def add_ui(self):
390		assert self._ui_id is None
391		assert self._actiongroup is None
392
393		self._actiongroup = self.get_actiongroup()
394		ui_xml = self.get_ui_xml()
395
396		self.uimanager.insert_action_group(self._actiongroup, 0)
397		self._ui_id = self.uimanager.add_ui_from_string(ui_xml)
398
399	def get_actiongroup(self):
400		actions = [
401			('insert_' + obj.name, obj.verb_icon, obj.label, '', None, self._action_handler)
402				for obj in self.insertedobjects.values()
403		]
404		group = Gtk.ActionGroup('inserted_objects')
405		group.add_actions(actions)
406		return group
407
408	def get_ui_xml(self):
409		menulines = []
410		for obj in self.insertedobjects.values():
411			name = 'insert_' + obj.name
412			menulines.append("<menuitem action='%s'/>\n" % name)
413		return """\
414		<ui>
415			<menubar name='menubar'>
416				<menu action='insert_menu'>
417					<placeholder name='plugin_items'>
418					 %s
419					</placeholder>
420				</menu>
421			</menubar>
422		</ui>
423		""" % (
424			''.join(menulines),
425		)
426
427	def _action_handler(self, action):
428		try:
429			name = action.get_name()[7:] # len('insert_') = 7
430			otype = self.insertedobjects[name]
431			pageview = self.pageview
432			notebook = pageview.notebook
433			page = pageview.page
434			try:
435				model = otype.new_model_interactive(self.pageview, notebook, page)
436			except ValueError:
437				return # dialog cancelled
438			self.pageview.insert_object_model(otype, model)
439		except:
440			zim.errors.exception_handler(
441				'Exception during action: %s' % name)
442