aboutsummaryrefslogtreecommitdiffstats
path: root/lvc/widgets/gtk
diff options
context:
space:
mode:
Diffstat (limited to 'lvc/widgets/gtk')
-rw-r--r--lvc/widgets/gtk/__init__.py65
-rw-r--r--lvc/widgets/gtk/base.py300
-rw-r--r--lvc/widgets/gtk/const.py44
-rw-r--r--lvc/widgets/gtk/contextmenu.py31
-rw-r--r--lvc/widgets/gtk/controls.py337
-rw-r--r--lvc/widgets/gtk/customcontrols.py517
-rw-r--r--lvc/widgets/gtk/drawing.py268
-rw-r--r--lvc/widgets/gtk/gtkmenus.py404
-rw-r--r--lvc/widgets/gtk/keymap.py94
-rw-r--r--lvc/widgets/gtk/layout.py227
-rw-r--r--lvc/widgets/gtk/layoutmanager.py550
-rw-r--r--lvc/widgets/gtk/simple.py313
-rw-r--r--lvc/widgets/gtk/tableview.py1557
-rw-r--r--lvc/widgets/gtk/tableviewcells.py249
-rw-r--r--lvc/widgets/gtk/weakconnect.py56
-rw-r--r--lvc/widgets/gtk/widgets.py47
-rw-r--r--lvc/widgets/gtk/widgetset.py63
-rw-r--r--lvc/widgets/gtk/window.py708
-rw-r--r--lvc/widgets/gtk/wrappermap.py50
19 files changed, 5880 insertions, 0 deletions
diff --git a/lvc/widgets/gtk/__init__.py b/lvc/widgets/gtk/__init__.py
new file mode 100644
index 0000000..e3d666b
--- /dev/null
+++ b/lvc/widgets/gtk/__init__.py
@@ -0,0 +1,65 @@
+import os
+import sys
+import gtk
+import gobject
+
+def initialize(app):
+ from gtkmenus import MainWindowMenuBar
+ app.menubar = MainWindowMenuBar()
+ app.startup()
+ app.run()
+
+def attach_menubar():
+ from lvc.widgets import app
+ app.widgetapp.vbox.pack_start(app.widgetapp.menubar)
+
+def mainloop_start():
+ gobject.threads_init()
+ gtk.main()
+
+def mainloop_stop():
+ gtk.main_quit()
+
+def idle_add(callback, periodic=None):
+ if periodic is not None and periodic < 0:
+ raise ValueError('periodic cannot be negative')
+ def wrapper():
+ callback()
+ return periodic is not None
+ delay = periodic
+ if delay is not None:
+ delay *= 1000 # milliseconds
+ else:
+ delay = 0
+ return gobject.timeout_add(delay, wrapper)
+
+def idle_remove(id_):
+ gobject.source_remove(id_)
+
+def check_kde():
+ return os.environ.get("KDE_FULL_SESSION", None) != None
+
+def open_file_linux(filename):
+ if check_kde():
+ os.spawnlp(os.P_NOWAIT, "kfmclient", "kfmclient", # kfmclient is part of konqueror
+ "exec", "file://" + filename)
+ else:
+ os.spawnlp(os.P_NOWAIT, "gnome-open", "gnome-open", filename)
+
+def reveal_file(filename):
+ if hasattr(os, 'startfile'): # Windows
+ os.startfile(os.path.dirname(filename))
+ else:
+ open_file_linux(filename)
+
+def get_conversion_directory_windows():
+ from lvc.windows import specialfolders
+ return specialfolders.base_movies_directory
+
+def get_conversion_directory_linux():
+ return os.path.expanduser('~')
+
+if sys.platform == 'win32':
+ get_conversion_directory = get_conversion_directory_windows
+else:
+ get_conversion_directory = get_conversion_directory_linux
diff --git a/lvc/widgets/gtk/base.py b/lvc/widgets/gtk/base.py
new file mode 100644
index 0000000..ed6129f
--- /dev/null
+++ b/lvc/widgets/gtk/base.py
@@ -0,0 +1,300 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".base -- Base classes for GTK Widgets."""
+
+import gtk
+
+from lvc import signals
+import wrappermap
+from .weakconnect import weak_connect
+import keymap
+
+def make_gdk_color(miro_color):
+ def convert_value(value):
+ return int(round(value * 65535))
+
+ values = tuple(convert_value(c) for c in miro_color)
+ return gtk.gdk.Color(*values)
+
+class Widget(signals.SignalEmitter):
+ """Base class for GTK widgets.
+
+ The actual GTK Widget is stored in '_widget'.
+
+ signals:
+
+ 'size-allocated' (widget, width, height): The widget had it's size
+ allocated.
+ """
+ def __init__(self, *signal_names):
+ signals.SignalEmitter.__init__(self, *signal_names)
+ self.create_signal('size-allocated')
+ self.create_signal('key-press')
+ self.create_signal('focus-out')
+ self.style_mods = {}
+ self.use_custom_style = False
+ self._disabled = False
+
+ def wrapped_widget_connect(self, signal, method, *user_args):
+ """Connect to a signal of the widget we're wrapping.
+
+ We use a weak reference to ensures that we don't have circular
+ references between the wrapped widget and the wrapper widget.
+ """
+ return weak_connect(self._widget, signal, method, *user_args)
+
+ def set_widget(self, widget):
+ self._widget = widget
+ wrappermap.add(self._widget, self)
+ if self.should_connect_to_hierarchy_changed():
+ self.wrapped_widget_connect('hierarchy_changed',
+ self.on_hierarchy_changed)
+ self.wrapped_widget_connect('size-allocate', self.on_size_allocate)
+ self.wrapped_widget_connect('key-press-event', self.on_key_press)
+ self.wrapped_widget_connect('focus-out-event', self.on_focus_out)
+ self.use_custom_style_callback = None
+
+ def should_connect_to_hierarchy_changed(self):
+ # GTK creates windows to handle submenus, which messes with our
+ # on_hierarchy_changed callback. We don't care about custom styles
+ # for menus anyways, so just ignore the signal.
+ return not isinstance(self._widget, gtk.MenuItem)
+
+ def set_can_focus(self, allow):
+ """Set if we allow the widget to hold keyboard focus.
+ """
+ if allow:
+ self._widget.set_flags(gtk.CAN_FOCUS)
+ else:
+ self._widget.unset_flags(gtk.CAN_FOCUS)
+
+ def on_hierarchy_changed(self, widget, previous_toplevel):
+ toplevel = widget.get_toplevel()
+ if not (toplevel.flags() & gtk.TOPLEVEL):
+ toplevel = None
+ if previous_toplevel != toplevel:
+ if self.use_custom_style_callback:
+ old_window = wrappermap.wrapper(previous_toplevel)
+ old_window.disconnect(self.use_custom_style_callback)
+ if toplevel is not None:
+ window = wrappermap.wrapper(toplevel)
+ callback_id = window.connect('use-custom-style-changed',
+ self.on_use_custom_style_changed)
+ self.use_custom_style_callback = callback_id
+ else:
+ self.use_custom_style_callback = None
+ if previous_toplevel is None:
+ # Setup our initial state
+ self.on_use_custom_style_changed(window)
+
+ def on_size_allocate(self, widget, allocation):
+ self.emit('size-allocated', allocation.width, allocation.height)
+
+ def on_key_press(self, widget, event):
+ key_modifiers = keymap.translate_gtk_event(event)
+ if key_modifiers:
+ key, modifiers = key_modifiers
+ return self.emit('key-press', key, modifiers)
+
+ def on_focus_out(self, widget, event):
+ self.emit('focus-out')
+
+ def on_use_custom_style_changed(self, window):
+ self.use_custom_style = window.use_custom_style
+ if not self.style_mods:
+ return # no need to do any work here
+ if self.use_custom_style:
+ for (what, state), color in self.style_mods.items():
+ self.do_modify_style(what, state, color)
+ else:
+ # This should reset the style changes we've made
+ self._widget.modify_style(gtk.RcStyle())
+ self.handle_custom_style_change()
+
+ def handle_custom_style_change(self):
+ """Called when the user changes a from a theme where we don't want to
+ use our custom style to one where we do, or vice-versa. The Widget
+ class handles changes that used modify_style(), but subclasses might
+ want to do additional work.
+ """
+ pass
+
+ def modify_style(self, what, state, color):
+ """Change the style of our widget. This method checks to see if we
+ think the user's theme is compatible with our stylings, and doesn't
+ change things if not. what is either 'base', 'text', 'bg' or 'fg'
+ depending on which color is to be changed.
+ """
+ if self.use_custom_style:
+ self.do_modify_style(what, state, color)
+ self.style_mods[(what, state)] = color
+
+ def unmodify_style(self, what, state):
+ if (what, state) in self.style_mods:
+ del self.style_mods[(what, state)]
+ default_color = getattr(self.style, what)[state]
+ self.do_modify_style(what, state, default_color)
+
+ def do_modify_style(self, what, state, color):
+ if what == 'base':
+ self._widget.modify_base(state, color)
+ elif what == 'text':
+ self._widget.modify_text(state, color)
+ elif what == 'bg':
+ self._widget.modify_bg(state, color)
+ elif what == 'fg':
+ self._widget.modify_fg(state, color)
+ else:
+ raise ValueError("Unknown what in do_modify_style: %s" % what)
+
+ def get_window(self):
+ gtk_window = self._widget.get_toplevel()
+ return wrappermap.wrapper(gtk_window)
+
+ def clear_size_request_cache(self):
+ # This is just an OS X hack
+ pass
+
+ def get_size_request(self):
+ return self._widget.size_request()
+
+ def invalidate_size_request(self):
+ self._widget.queue_resize()
+
+ def set_size_request(self, width, height):
+ if not width >= -1 and height >= -1:
+ raise ValueError("invalid dimensions in set_size_request: %s" %
+ repr((width, height)))
+ self._widget.set_size_request(width, height)
+
+ def relative_position(self, other_widget):
+ return other_widget._widget.translate_coordinates(self._widget, 0, 0)
+
+ def convert_gtk_color(self, color):
+ return (color.red / 65535.0, color.green / 65535.0,
+ color.blue / 65535.0)
+
+ def get_width(self):
+ try:
+ return self._widget.allocation.width
+ except AttributeError:
+ return -1
+ width = property(get_width)
+
+ def get_height(self):
+ try:
+ return self._widget.allocation.height
+ except AttributeError:
+ return -1
+ height = property(get_height)
+
+ def queue_redraw(self):
+ if self._widget:
+ self._widget.queue_draw()
+
+ def redraw_now(self):
+ if self._widget:
+ self._widget.queue_draw()
+ self._widget.window.process_updates(True)
+
+ def forward_signal(self, signal_name, forwarded_signal_name=None):
+ """Add a callback so that when the GTK widget emits a signal, we emit
+ signal from the wrapper widget.
+ """
+ if forwarded_signal_name is None:
+ forwarded_signal_name = signal_name
+ self.wrapped_widget_connect(signal_name, self.do_forward_signal,
+ forwarded_signal_name)
+
+ def do_forward_signal(self, widget, *args):
+ forwarded_signal_name = args[-1]
+ args = args[:-1]
+ self.emit(forwarded_signal_name, *args)
+
+ def make_color(self, miro_color):
+ color = make_gdk_color(miro_color)
+ self._widget.get_colormap().alloc_color(color)
+ return color
+
+ def enable(self):
+ self._disabled = False
+ self._widget.set_sensitive(True)
+
+ def disable(self):
+ self._disabled = True
+ self._widget.set_sensitive(False)
+
+ def set_disabled(self, disabled):
+ if disabled:
+ self.disable()
+ else:
+ self.enable()
+
+ def get_disabled(self):
+ return self._disabled
+
+class Bin(Widget):
+ def __init__(self):
+ Widget.__init__(self)
+ self.child = None
+
+ def add(self, child):
+ if self.child is not None:
+ raise ValueError("Already have a child: %s" % self.child)
+ if child._widget.parent is not None:
+ raise ValueError("%s already has a parent" % child)
+ self.child = child
+ self.add_child_to_widget()
+ child._widget.show()
+
+ def add_child_to_widget(self):
+ self._widget.add(self.child._widget)
+
+ def remove_child_from_widget(self):
+ if self._widget.get_child() is not None:
+ # otherwise gtkmozembed gets confused
+ self._widget.get_child().hide()
+ self._widget.remove(self._widget.get_child())
+
+
+ def remove(self):
+ if self.child is not None:
+ self.child = None
+ self.remove_child_from_widget()
+
+ def set_child(self, new_child):
+ self.remove()
+ self.add(new_child)
+
+ def enable(self):
+ self.child.enable()
+
+ def disable(self):
+ self.child.disable()
diff --git a/lvc/widgets/gtk/const.py b/lvc/widgets/gtk/const.py
new file mode 100644
index 0000000..5e9ec05
--- /dev/null
+++ b/lvc/widgets/gtk/const.py
@@ -0,0 +1,44 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".const -- Constants."""
+
+import gtk
+
+DRAG_ACTION_NONE = 0
+DRAG_ACTION_COPY = gtk.gdk.ACTION_COPY
+DRAG_ACTION_MOVE = gtk.gdk.ACTION_MOVE
+DRAG_ACTION_LINK = gtk.gdk.ACTION_LINK
+DRAG_ACTION_ALL = DRAG_ACTION_COPY | DRAG_ACTION_MOVE | DRAG_ACTION_LINK
+
+ITEM_TITLE_FONT = "Helvetica"
+ITEM_DESC_FONT = "Helvetica"
+ITEM_INFO_FONT = "Lucida Grande"
+
+TOOLBAR_GRAY = (0.2, 0.2, 0.2)
diff --git a/lvc/widgets/gtk/contextmenu.py b/lvc/widgets/gtk/contextmenu.py
new file mode 100644
index 0000000..cd5b6ba
--- /dev/null
+++ b/lvc/widgets/gtk/contextmenu.py
@@ -0,0 +1,31 @@
+import gtk
+
+from .base import Widget
+
+class ContextMenu(Widget):
+
+ def __init__(self, options):
+ super(ContextMenu, self).__init__()
+ self.set_widget(gtk.Menu())
+ for i, item_info in enumerate(options):
+ if item_info is None:
+ # separator
+ item = gtk.SeparatorMenuItem()
+ else:
+ label, callback = item_info
+ item = gtk.MenuItem(label)
+ if isinstance(callback, list):
+ submenu = ContextMenu(callback)
+ item.set_submenu(submenu._widget)
+ elif callback is not None:
+ item.connect('activate', self.on_activate, callback, i)
+ else:
+ item.set_sensitive(False)
+ self._widget.append(item)
+ item.show()
+
+ def popup(self):
+ self._widget.popup(None, None, None, 0, 0)
+
+ def on_activate(self, widget, callback, i):
+ callback(self, i)
diff --git a/lvc/widgets/gtk/controls.py b/lvc/widgets/gtk/controls.py
new file mode 100644
index 0000000..26ce6d6
--- /dev/null
+++ b/lvc/widgets/gtk/controls.py
@@ -0,0 +1,337 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".controls -- Control Widgets."""
+
+import gtk
+import pango
+
+from lvc.widgets import widgetconst
+import layout
+from .base import Widget
+from .simple import Label
+
+class BinBaselineCalculator(object):
+ """Mixin class that defines the baseline method for gtk.Bin subclasses,
+ where the child is the label that we are trying to get the baseline for.
+ """
+
+ def baseline(self):
+ my_size = self._widget.size_request()
+ child_size = self._widget.child.size_request()
+ ypad = (my_size[1] - child_size[1]) / 2
+
+ pango_context = self._widget.get_pango_context()
+ metrics = pango_context.get_metrics(self._widget.style.font_desc)
+ return pango.PIXELS(metrics.get_descent()) + ypad
+
+class TextEntry(Widget):
+ entry_class = gtk.Entry
+ def __init__(self, initial_text=None):
+ Widget.__init__(self)
+ self.create_signal('activate')
+ self.create_signal('changed')
+ self.create_signal('validate')
+ self.set_widget(self.entry_class())
+ self.forward_signal('activate')
+ self.forward_signal('changed')
+ if initial_text is not None:
+ self.set_text(initial_text)
+
+ def focus(self):
+ self._widget.grab_focus()
+
+ def start_editing(self, text):
+ self.set_text(text)
+ self.focus()
+ self._widget.emit('move-cursor', gtk.MOVEMENT_BUFFER_ENDS, 1, False)
+
+ def set_text(self, text):
+ self._widget.set_text(text)
+
+ def get_text(self):
+ return self._widget.get_text().decode('utf-8')
+
+ def set_max_length(self, chars):
+ self._widget.set_max_length(chars)
+
+ def set_width(self, chars):
+ self._widget.set_width_chars(chars)
+
+ def set_invisible(self, setting):
+ self._widget.props.visibility = not setting
+
+ def set_activates_default(self, setting):
+ self._widget.set_activates_default(setting)
+
+ def baseline(self):
+ layout_height = pango.PIXELS(self._widget.get_layout().get_size()[1])
+ ypad = (self._widget.size_request()[1] - layout_height) / 2
+ pango_context = self._widget.get_pango_context()
+ metrics = pango_context.get_metrics(self._widget.style.font_desc)
+ return pango.PIXELS(metrics.get_descent()) + ypad
+
+
+class NumberEntry(TextEntry):
+ def __init__(self, initial_text=None):
+ TextEntry.__init__(self, initial_text)
+ self._widget.connect('changed', self.validate)
+ self.previous_text = initial_text or ""
+
+ def validate(self, entry):
+ text = self.get_text()
+ if text.isdigit() or not text:
+ self.previous_text = text
+ else:
+ self._widget.set_text(self.previous_text)
+
+class SecureTextEntry(TextEntry):
+ def __init__(self, initial_text=None):
+ TextEntry.__init__(self, initial_text)
+ self.set_invisible(True)
+
+class MultilineTextEntry(Widget):
+ entry_class = gtk.TextView
+ def __init__(self, initial_text=None, border=False):
+ Widget.__init__(self)
+ self.set_widget(self.entry_class())
+ if initial_text is not None:
+ self.set_text(initial_text)
+ self._widget.set_wrap_mode(gtk.WRAP_WORD)
+ self._widget.set_accepts_tab(False)
+ self.border = border
+
+ def focus(self):
+ self._widget.grab_focus()
+
+ def set_text(self, text):
+ self._widget.get_buffer().set_text(text)
+
+ def get_text(self):
+ buffer_ = self._widget.get_buffer()
+ return buffer_.get_text(*(buffer_.get_bounds())).decode('utf-8')
+
+ def baseline(self):
+ # FIXME
+ layout_height = pango.PIXELS(self._widget.get_layout().get_size()[1])
+ ypad = (self._widget.size_request()[1] - layout_height) / 2
+ pango_context = self._widget.get_pango_context()
+ metrics = pango_context.get_metrics(self._widget.style.font_desc)
+ return pango.PIXELS(metrics.get_descent()) + ypad
+
+ def set_editable(self, editable):
+ self._widget.set_editable(editable)
+
+class Checkbox(Widget, BinBaselineCalculator):
+ """Widget that the user can toggle on or off."""
+
+ def __init__(self, text=None, bold=False, color=None):
+ Widget.__init__(self)
+ BinBaselineCalculator.__init__(self)
+ if text is None:
+ text = ''
+ self.set_widget(gtk.CheckButton())
+ self.label = Label(text, color=color)
+ self._widget.add(self.label._widget)
+ self.label._widget.show()
+ self.create_signal('toggled')
+ self.forward_signal('toggled')
+ if bold:
+ self.label.set_bold(True)
+
+ def get_checked(self):
+ return self._widget.get_active()
+
+ def set_checked(self, value):
+ self._widget.set_active(value)
+
+ def set_size(self, scale_factor):
+ self.label.set_size(scale_factor)
+
+ def get_text_padding(self):
+ """
+ Returns the amount of space the checkbox takes up before the label.
+ """
+ indicator_size = self._widget.style_get_property('indicator-size')
+ indicator_spacing = self._widget.style_get_property(
+ 'indicator-spacing')
+ focus_width = self._widget.style_get_property('focus-line-width')
+ focus_padding = self._widget.style_get_property('focus-padding')
+ return (indicator_size + 3 * indicator_spacing + 2 * (focus_width +
+ focus_padding))
+
+class RadioButtonGroup(Widget, BinBaselineCalculator):
+ """RadioButtonGroup.
+
+ Create the group, then create a bunch of RadioButtons passing in the group.
+
+ NB: GTK has built-in radio button grouping functionality, and we should
+ be using that but we need this widget for portable code. We create
+ a dummy GTK radio button and make this the "root" button which gets
+ inherited by all buttons in this radio button group.
+ """
+ def __init__(self):
+ Widget.__init__(self)
+ BinBaselineCalculator.__init__(self)
+ self.set_widget(gtk.RadioButton(label=""))
+ self._widget.set_active(False)
+ self._buttons = []
+
+ def add_button(self, button):
+ self._buttons.append(button)
+
+ def get_buttons(self):
+ return self._buttons
+
+ def get_selected(self):
+ for mem in self._buttons:
+ if mem.get_selected():
+ return mem
+
+ def set_selected(self, button):
+ for mem in self._buttons:
+ if mem is button:
+ mem._widget.set_active(True)
+ else:
+ mem._widget.set_active(False)
+
+class RadioButton(Widget, BinBaselineCalculator):
+ """RadioButton."""
+ def __init__(self, label, group=None, color=None):
+ Widget.__init__(self)
+ BinBaselineCalculator.__init__(self)
+ if group:
+ self.group = group
+ else:
+ self.group = RadioButtonGroup()
+ self.set_widget(gtk.RadioButton(group=self.group._widget))
+ self.label = Label(label, color=color)
+ self._widget.add(self.label._widget)
+ self.label._widget.show()
+ self.create_signal('clicked')
+ self.forward_signal('clicked')
+
+ group.add_button(self)
+
+ def set_size(self, size):
+ self.label.set_size(size)
+
+ def get_group(self):
+ return self.group
+
+ def get_selected(self):
+ return self._widget.get_active()
+
+ def set_selected(self):
+ self.group.set_selected(self)
+
+class Button(Widget, BinBaselineCalculator):
+ def __init__(self, text, style='normal', width=None):
+ Widget.__init__(self)
+ BinBaselineCalculator.__init__(self)
+ # We just ignore style here, GTK users expect their own buttons.
+ self.set_widget(gtk.Button())
+ self.create_signal('clicked')
+ self.forward_signal('clicked')
+ self.label = Label(text)
+ # only honor width if its bigger than the width we need to display the
+ # label (#18994)
+ if width and width > self.label.get_width():
+ alignment = layout.Alignment(0.5, 0.5, 0, 0)
+ alignment.set_size_request(width, -1)
+ alignment.add(self.label)
+ self._widget.add(alignment._widget)
+ else:
+ self._widget.add(self.label._widget)
+ self.label._widget.show()
+
+ def set_text(self, title):
+ self.label.set_text(title)
+
+ def set_bold(self, bold):
+ self.label.set_bold(bold)
+
+ def set_size(self, scale_factor):
+ self.label.set_size(scale_factor)
+
+ def set_color(self, color):
+ self.label.set_color(color)
+
+class OptionMenu(Widget):
+ def __init__(self, options):
+ Widget.__init__(self)
+ self.create_signal('changed')
+
+ self.set_widget(gtk.ComboBox(gtk.ListStore(str, str)))
+ self.cell = gtk.CellRendererText()
+ self._widget.pack_start(self.cell, True)
+ self._widget.add_attribute(self.cell, 'text', 0)
+ if options:
+ for option, value in options:
+ self._widget.get_model().append((option, value))
+ self._widget.set_active(0)
+ self.options = options
+ self.wrapped_widget_connect('changed', self.on_changed)
+
+ def baseline(self):
+ my_size = self._widget.size_request()
+ child_size = self._widget.child.size_request()
+ ypad = self.cell.props.ypad + (my_size[1] - child_size[1]) / 2
+
+ pango_context = self._widget.get_pango_context()
+ metrics = pango_context.get_metrics(self._widget.style.font_desc)
+ return pango.PIXELS(metrics.get_descent()) + ypad
+
+ def set_bold(self, bold):
+ if bold:
+ self.cell.props.weight = pango.WEIGHT_BOLD
+ else:
+ self.cell.props.weight = pango.WEIGHT_NORMAL
+
+ def set_size(self, size):
+ if size == widgetconst.SIZE_NORMAL:
+ self.cell.props.scale = 1
+ else:
+ self.cell.props.scale = 0.75
+
+ def set_color(self, color):
+ self.cell.props.foreground_gdk = self.make_color(color)
+
+ def set_selected(self, index):
+ self._widget.set_active(index)
+
+ def get_selected(self):
+ return self._widget.get_active()
+
+ def on_changed(self, widget):
+ index = widget.get_active()
+ self.emit('changed', index)
+
+ def set_width(self, width):
+ self._widget.set_property('width-request', width)
diff --git a/lvc/widgets/gtk/customcontrols.py b/lvc/widgets/gtk/customcontrols.py
new file mode 100644
index 0000000..ff5b068
--- /dev/null
+++ b/lvc/widgets/gtk/customcontrols.py
@@ -0,0 +1,517 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".controls -- Contains the ControlBox and
+CustomControl classes. These handle the custom buttons/sliders used during
+playback.
+"""
+
+from __future__ import division
+import math
+
+import gtk
+import gobject
+
+import wrappermap
+from .base import Widget
+from .simple import Label, Image
+from .drawing import (CustomDrawingMixin, Drawable,
+ ImageSurface)
+from lvc.widgets import widgetconst
+
+class CustomControlMixin(CustomDrawingMixin):
+ def do_expose_event(self, event):
+ CustomDrawingMixin.do_expose_event(self, event)
+ if self.is_focus():
+ style = self.get_style()
+ style.paint_focus(self.window, self.state,
+ event.area, self, None, self.allocation.x,
+ self.allocation.y, self.allocation.width,
+ self.allocation.height)
+
+class CustomButtonWidget(CustomControlMixin, gtk.Button):
+ def draw(self, wrapper, context):
+ if self.is_active():
+ wrapper.state = 'pressed'
+ elif self.state == gtk.STATE_PRELIGHT:
+ wrapper.state = 'hover'
+ else:
+ wrapper.state = 'normal'
+ wrapper.draw(context, wrapper.layout_manager)
+ self.set_focus_on_click(False)
+
+ def is_active(self):
+ return self.state == gtk.STATE_ACTIVE
+
+class ContinuousCustomButtonWidget(CustomButtonWidget):
+ def is_active(self):
+ return (self.state == gtk.STATE_ACTIVE or
+ wrappermap.wrapper(self).button_down)
+
+class DragableCustomButtonWidget(CustomButtonWidget):
+ def __init__(self):
+ CustomButtonWidget.__init__(self)
+ self.button_press_x = None
+ self.set_events(self.get_events() | gtk.gdk.POINTER_MOTION_MASK)
+
+ def do_button_press_event(self, event):
+ self.button_press_x = event.x
+ self.last_drag_event = None
+ gtk.Button.do_button_press_event(self, event)
+
+ def do_button_release_event(self, event):
+ self.button_press_x = None
+ gtk.Button.do_button_release_event(self, event)
+
+ def do_motion_notify_event(self, event):
+ DRAG_THRESHOLD = 15
+ if self.button_press_x is None:
+ # button not down
+ return
+ if (self.last_drag_event != 'right' and
+ event.x > self.button_press_x + DRAG_THRESHOLD):
+ wrappermap.wrapper(self).emit('dragged-right')
+ self.last_drag_event = 'right'
+ elif (self.last_drag_event != 'left' and
+ event.x < self.button_press_x - DRAG_THRESHOLD):
+ wrappermap.wrapper(self).emit('dragged-left')
+ self.last_drag_event = 'left'
+
+ def do_clicked(self):
+ # only emit clicked if we didn't emit dragged-left or dragged-right
+ if self.last_drag_event is None:
+ wrappermap.wrapper(self).emit('clicked')
+
+class _DragInfo(object):
+ """Info about the start of a drag.
+
+ Attributes:
+
+ - button: button that started the drag
+ - start_pos: position of the slider
+ - click_pos: position of the click
+
+ Note that start_pos and click_pos will be different if the user clicks
+ inside the slider.
+ """
+
+ def __init__(self, button, start_pos, click_pos):
+ self.button = button
+ self.start_pos = start_pos
+ self.click_pos = click_pos
+
+class CustomScaleMixin(CustomControlMixin):
+ def __init__(self):
+ CustomControlMixin.__init__(self)
+ self.drag_info = None
+ self.min = self.max = 0.0
+
+ def get_range(self):
+ return self.min, self.max
+
+ def set_range(self, min, max):
+ self.min = float(min)
+ self.max = float(max)
+ gtk.Range.set_range(self, min, max)
+
+ def is_continuous(self):
+ return wrappermap.wrapper(self).is_continuous()
+
+ def is_horizontal(self):
+ # this comes from a mixin
+ pass
+
+ def gtk_scale_class(self):
+ if self.is_horizontal():
+ return gtk.HScale
+ else:
+ return gtk.VScale
+
+ def get_slider_pos(self, value=None):
+ if value is None:
+ value = self.get_value()
+ if self.is_horizontal():
+ size = self.allocation.width
+ else:
+ size = self.allocation.height
+ ratio = (float(value) - self.min) / (self.max - self.min)
+ start_pos = self.slider_size() / 2.0
+ return start_pos + ratio * (size - self.slider_size())
+
+ def slider_size(self):
+ return wrappermap.wrapper(self).slider_size()
+
+ def _event_pos(self, event):
+ """Get the position of an event.
+
+ If we are horizontal, this will be the x coordinate. If we are
+ vertical, the y.
+ """
+ if self.is_horizontal():
+ return event.x
+ else:
+ return event.y
+
+ def do_button_press_event(self, event):
+ if self.drag_info is not None:
+ return
+ current_pos = self.get_slider_pos()
+ event_pos = self._event_pos(event)
+ pos_difference = abs(current_pos - event_pos)
+ # only move the slider if the click was outside its boundaries
+ # (#18840)
+ if pos_difference > self.slider_size() / 2.0:
+ self.move_slider(event_pos)
+ current_pos = event_pos
+ self.drag_info = _DragInfo(event.button, current_pos, event_pos)
+ self.grab_focus()
+ wrappermap.wrapper(self).emit('pressed')
+
+ def do_motion_notify_event(self, event):
+ if self.drag_info is not None:
+ event_pos = self._event_pos(event)
+ delta = event_pos - self.drag_info.click_pos
+ self.move_slider(self.drag_info.start_pos + delta)
+
+ def move_slider(self, new_pos):
+ """Move the slider so that it's centered on new_pos."""
+ if self.is_horizontal():
+ size = self.allocation.width
+ else:
+ size = self.allocation.height
+
+ slider_size = self.slider_size()
+ new_pos -= slider_size / 2
+ size -= slider_size
+ ratio = max(0, min(1, float(new_pos) / size))
+ self.set_value(ratio * (self.max - self.min))
+
+ wrappermap.wrapper(self).emit('moved', self.get_value())
+ if self.is_continuous():
+ wrappermap.wrapper(self).emit('changed', self.get_value())
+
+ def handle_drag_out_of_bounds(self):
+ if not self.is_continuous():
+ self.set_value(self.start_value)
+
+ def do_button_release_event(self, event):
+ if self.drag_info is None or event.button != self.drag_info.button:
+ return
+ self.drag_info = None
+ if (self.is_continuous and
+ (0 <= event.x < self.allocation.width) and
+ (0 <= event.y < self.allocation.height)):
+ wrappermap.wrapper(self).emit('changed', self.get_value())
+ wrappermap.wrapper(self).emit('released')
+
+ def do_scroll_event(self, event):
+ wrapper = wrappermap.wrapper(self)
+ if self.is_horizontal():
+ if event.direction == gtk.gdk.SCROLL_UP:
+ event.direction = gtk.gdk.SCROLL_DOWN
+ elif event.direction == gtk.gdk.SCROLL_DOWN:
+ event.direction = gtk.gdk.SCROLL_UP
+ if (wrapper._scroll_step is not None and
+ event.direction in (gtk.gdk.SCROLL_UP, gtk.gdk.SCROLL_DOWN)):
+ # handle the scroll ourself
+ if event.direction == gtk.gdk.SCROLL_DOWN:
+ delta = wrapper._scroll_step
+ else:
+ delta = -wrapper._scroll_step
+ self.set_value(self.get_value() + delta)
+ else:
+ # let GTK handle the scroll
+ self.gtk_scale_class().do_scroll_event(self, event)
+ # Treat mouse scrolls as if the user clicked on the new position
+ wrapper.emit('pressed')
+ wrapper.emit('changed', self.get_value())
+ wrapper.emit('released')
+
+ def do_move_slider(self, scroll):
+ if self.is_horizontal():
+ if scroll == gtk.SCROLL_STEP_UP:
+ scroll = gtk.SCROLL_STEP_DOWN
+ elif scroll == gtk.SCROLL_STEP_DOWN:
+ scroll = gtk.SCROLL_STEP_UP
+ elif scroll == gtk.SCROLL_PAGE_UP:
+ scroll = gtk.SCROLL_PAGE_DOWN
+ elif scroll == gtk.SCROLL_PAGE_DOWN:
+ scroll = gtk.SCROLL_PAGE_UP
+ elif scroll == gtk.SCROLL_START:
+ scroll = gtk.SCROLL_END
+ elif scroll == gtk.SCROLL_END:
+ scroll = gtk.SCROLL_START
+ return self.gtk_scale_class().do_move_slider(self, scroll)
+
+class CustomHScaleWidget(CustomScaleMixin, gtk.HScale):
+ def __init__(self):
+ CustomScaleMixin.__init__(self)
+ gtk.HScale.__init__(self)
+
+ def is_horizontal(self):
+ return True
+
+class CustomVScaleWidget(CustomScaleMixin, gtk.VScale):
+ def __init__(self):
+ CustomScaleMixin.__init__(self)
+ gtk.VScale.__init__(self)
+
+ def is_horizontal(self):
+ return False
+
+gobject.type_register(CustomButtonWidget)
+gobject.type_register(ContinuousCustomButtonWidget)
+gobject.type_register(DragableCustomButtonWidget)
+gobject.type_register(CustomHScaleWidget)
+gobject.type_register(CustomVScaleWidget)
+
+class CustomControlBase(Drawable, Widget):
+ def __init__(self):
+ Widget.__init__(self)
+ Drawable.__init__(self)
+ self._gtk_cursor = None
+ self._entry_handlers = None
+
+ def _connect_enter_notify_handlers(self):
+ if self._entry_handlers is None:
+ self._entry_handlers = [
+ self.wrapped_widget_connect('enter-notify-event',
+ self.on_enter_notify),
+ self.wrapped_widget_connect('leave-notify-event',
+ self.on_leave_notify),
+ self.wrapped_widget_connect('button-release-event',
+ self.on_click)
+ ]
+
+ def _disconnect_enter_notify_handlers(self):
+ if self._entry_handlers is not None:
+ for handle in self._entry_handlers:
+ self._widget.disconnect(handle)
+ self._entry_handlers = None
+
+ def set_cursor(self, cursor):
+ if cursor == widgetconst.CURSOR_NORMAL:
+ self._gtk_cursor = None
+ self._disconnect_enter_notify_handlers()
+ elif cursor == widgetconst.CURSOR_POINTING_HAND:
+ self._gtk_cursor = gtk.gdk.Cursor(gtk.gdk.HAND2)
+ self._connect_enter_notify_handlers()
+ else:
+ raise ValueError("Unknown cursor: %s" % cursor)
+
+ def on_enter_notify(self, widget, event):
+ self._widget.window.set_cursor(self._gtk_cursor)
+
+ def on_leave_notify(self, widget, event):
+ if self._widget.window:
+ self._widget.window.set_cursor(None)
+
+ def on_click(self, widget, event):
+ self.emit('clicked')
+ return True
+
+class CustomButton(CustomControlBase):
+ def __init__(self):
+ """Create a new CustomButton. active_image will be displayed while
+ the button is pressed. The image must have the same size.
+ """
+ CustomControlBase.__init__(self)
+ self.set_widget(CustomButtonWidget())
+ self.create_signal('clicked')
+ self.forward_signal('clicked')
+
+class DragableCustomButton(CustomControlBase):
+ def __init__(self):
+ CustomControlBase.__init__(self)
+ self.set_widget(DragableCustomButtonWidget())
+ self.create_signal('clicked')
+ self.create_signal('dragged-left')
+ self.create_signal('dragged-right')
+
+class CustomSlider(CustomControlBase):
+ def __init__(self):
+ CustomControlBase.__init__(self)
+ self.create_signal('pressed')
+ self.create_signal('released')
+ self.create_signal('changed')
+ self.create_signal('moved')
+ self._scroll_step = None
+ if self.is_horizontal():
+ self.set_widget(CustomHScaleWidget())
+ else:
+ self.set_widget(CustomVScaleWidget())
+ self.wrapped_widget_connect('move-slider', self.on_slider_move)
+
+ def on_slider_move(self, widget, scrolltype):
+ self.emit('changed', widget.get_value())
+ self.emit('moved', widget.get_value())
+
+ def get_value(self):
+ return self._widget.get_value()
+
+ def set_value(self, value):
+ self._widget.set_value(value)
+
+ def get_range(self):
+ return self._widget.get_range()
+
+ def get_slider_pos(self, value=None):
+ """Get the position for the slider for our current value.
+
+ This will return position that the slider should be centered on to
+ display the value. It will be the x coordinate if is_horizontal() is
+ True and the y coordinate otherwise.
+
+ This method takes into acount the size of the slider when calculating
+ the position. The slider position will start at (slider_size / 2) and
+ will end (slider_size / 2) px before the end of the widget.
+
+ :param value: value to get the position for. Defaults to the current
+ value
+ """
+ return self._widget.get_slider_pos(value)
+
+ def set_range(self, min_value, max_value):
+ self._widget.set_range(min_value, max_value)
+ # set_digits controls the precision of the scale by limiting changes
+ # to a certain number of digits. If the range is [0, 1], this code
+ # will give us 4 digits of precision, which seems reasonable.
+ range = max_value - min_value
+ self._widget.set_digits(int(round(math.log10(10000.0 / range))))
+
+ def set_increments(self, small_step, big_step, scroll_step=None):
+ """Set the increments to scroll.
+
+ :param small_step: scroll amount for up/down
+ :param big_step: scroll amount for page up/page down.
+ :param scroll_step: scroll amount for mouse wheel, or None to make
+ this 2 times the small step
+ """
+ self._widget.set_increments(small_step, big_step)
+ self._scroll_step = scroll_step
+
+def to_miro_volume(value):
+ """Convert from 0 to 1.0 to 0.0 to MAX_VOLUME.
+ """
+ if value == 0:
+ return 0.0
+ return value * widgetconst.MAX_VOLUME
+
+def to_gtk_volume(value):
+ """Convert from 0.0 to MAX_VOLUME to 0 to 1.0.
+ """
+ if value > 0.0:
+ value = (value / widgetconst.MAX_VOLUME)
+ return value
+
+if hasattr(gtk.VolumeButton, "get_popup"):
+ # FIXME - Miro on Windows has an old version of gtk (2.16) and
+ # doesn't have the get_popup method. Once we upgrade and
+ # fix that, we can take out the hasattr check.
+
+ class VolumeMuter(Label):
+ """Empty space that has a clicked signal so it can be dropped
+ in place of the VolumeMuter.
+ """
+ def __init__(self):
+ Label.__init__(self)
+ self.create_signal("clicked")
+
+ class VolumeSlider(Widget):
+ """VolumeSlider that uses the gtk.VolumeButton().
+ """
+ def __init__(self):
+ Widget.__init__(self)
+ self.set_widget(gtk.VolumeButton())
+ self.wrapped_widget_connect('value-changed', self.on_value_changed)
+ self._widget.get_popup().connect("hide", self.on_hide)
+ self.create_signal('changed')
+ self.create_signal('released')
+
+ def on_value_changed(self, *args):
+ value = self.get_value()
+ self.emit('changed', value)
+
+ def on_hide(self, *args):
+ self.emit('released')
+
+ def get_value(self):
+ value = self._widget.get_property('value')
+ return to_miro_volume(value)
+
+ def set_value(self, value):
+ value = to_gtk_volume(value)
+ self._widget.set_property('value', value)
+
+class ClickableImageButton(CustomButton):
+ """Image that can send clicked events. If max_width and/or max_height are
+ specified, resizes the image proportionally such that all constraints are
+ met.
+ """
+ def __init__(self, image_path, max_width=None, max_height=None):
+ CustomButton.__init__(self)
+ self.max_width = max_width
+ self.max_height = max_height
+ self.image = None
+ self._width, self._height = None, None
+ if image_path:
+ self.set_path(image_path)
+ self.set_cursor(widgetconst.CURSOR_POINTING_HAND)
+
+ def set_path(self, path):
+ image = Image(path)
+ if self.max_width:
+ image = image.resize_for_space(self.max_width, self.max_height)
+ self.image = ImageSurface(image)
+ self._width, self._height = image.width, image.height
+
+ def size_request(self, layout):
+ w = self._width
+ h = self._height
+ if not w:
+ w = self.max_width
+ if not h:
+ h = self.max_height
+ return w, h
+
+ def draw(self, context, layout):
+ if self.image:
+ self.image.draw(context, 0, 0, self._width, self._height)
+ w = self._width
+ h = self._height
+ if not w:
+ w = self.max_width
+ if not h:
+ h = self.max_height
+ w = min(context.width, w)
+ h = min(context.height, h)
+ context.rectangle(0, 0, w, h)
+ context.set_color((0, 0, 0)) # black
+ context.set_line_width(1)
+ context.stroke()
diff --git a/lvc/widgets/gtk/drawing.py b/lvc/widgets/gtk/drawing.py
new file mode 100644
index 0000000..5888851
--- /dev/null
+++ b/lvc/widgets/gtk/drawing.py
@@ -0,0 +1,268 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".drawing -- Contains classes used to draw on
+widgets.
+"""
+
+import cairo
+import gobject
+import gtk
+
+import wrappermap
+from .base import Widget, Bin
+from .layoutmanager import LayoutManager
+
+def css_to_color(css_string):
+ parts = (css_string[1:3], css_string[3:5], css_string[5:7])
+ return tuple((int(value, 16) / 255.0) for value in parts)
+
+class ImageSurface:
+ def __init__(self, image):
+ format = cairo.FORMAT_RGB24
+ if image.pixbuf.get_has_alpha():
+ format = cairo.FORMAT_ARGB32
+ self.image = cairo.ImageSurface(
+ format, int(image.width), int(image.height))
+ context = cairo.Context(self.image)
+ gdkcontext = gtk.gdk.CairoContext(context)
+ gdkcontext.set_source_pixbuf(image.pixbuf, 0, 0)
+ gdkcontext.paint()
+ self.pattern = cairo.SurfacePattern(self.image)
+ self.pattern.set_extend(cairo.EXTEND_REPEAT)
+ self.width = image.width
+ self.height = image.height
+
+ def get_size(self):
+ return self.width, self.height
+
+ def _align_pattern(self, x, y):
+ """Line up our image pattern so that it's top-left corner is x, y."""
+ m = cairo.Matrix()
+ m.translate(-x, -y)
+ self.pattern.set_matrix(m)
+
+ def draw(self, context, x, y, width, height, fraction=1.0):
+ self._align_pattern(x, y)
+ cairo_context = context.context
+ cairo_context.save()
+ cairo_context.set_source(self.pattern)
+ cairo_context.new_path()
+ cairo_context.rectangle(x, y, width, height)
+ if fraction >= 1.0:
+ cairo_context.fill()
+ else:
+ cairo_context.clip()
+ cairo_context.paint_with_alpha(fraction)
+ cairo_context.restore()
+
+ def draw_rect(self, context, dest_x, dest_y, source_x, source_y,
+ width, height, fraction=1.0):
+
+ self._align_pattern(dest_x-source_x, dest_y-source_y)
+ cairo_context = context.context
+ cairo_context.save()
+ cairo_context.set_source(self.pattern)
+ cairo_context.new_path()
+ cairo_context.rectangle(dest_x, dest_y, width, height)
+ if fraction >= 1.0:
+ cairo_context.fill()
+ else:
+ cairo_context.clip()
+ cairo_context.paint_with_alpha(fraction)
+ cairo_context.restore()
+
+class DrawingStyle(object):
+ def __init__(self, widget, use_base_color=False, state=None):
+ if state is None:
+ state = widget._widget.state
+ self.use_custom_style = widget.use_custom_style
+ self.style = widget._widget.style
+ self.text_color = widget.convert_gtk_color(self.style.text[state])
+ if use_base_color:
+ self.bg_color = widget.convert_gtk_color(self.style.base[state])
+ else:
+ self.bg_color = widget.convert_gtk_color(self.style.bg[state])
+
+class DrawingContext(object):
+ """DrawingContext. This basically just wraps a Cairo context and adds a
+ couple convenience methods.
+ """
+
+ def __init__(self, window, drawing_area, expose_area):
+ self.window = window
+ self.context = window.cairo_create()
+ self.context.rectangle(expose_area.x, expose_area.y,
+ expose_area.width, expose_area.height)
+ self.context.clip()
+ self.width = drawing_area.width
+ self.height = drawing_area.height
+ self.context.translate(drawing_area.x, drawing_area.y)
+
+ def __getattr__(self, name):
+ return getattr(self.context, name)
+
+ def set_color(self, (red, green, blue), alpha=1.0):
+ self.context.set_source_rgba(red, green, blue, alpha)
+
+ def set_shadow(self, color, opacity, offset, blur_radius):
+ pass
+
+ def gradient_fill(self, gradient):
+ old_source = self.context.get_source()
+ self.context.set_source(gradient.pattern)
+ self.context.fill()
+ self.context.set_source(old_source)
+
+ def gradient_fill_preserve(self, gradient):
+ old_source = self.context.get_source()
+ self.context.set_source(gradient.pattern)
+ self.context.fill_preserve()
+ self.context.set_source(old_source)
+
+class Gradient(object):
+ def __init__(self, x1, y1, x2, y2):
+ self.pattern = cairo.LinearGradient(x1, y1, x2, y2)
+
+ def set_start_color(self, (red, green, blue)):
+ self.pattern.add_color_stop_rgb(0, red, green, blue)
+
+ def set_end_color(self, (red, green, blue)):
+ self.pattern.add_color_stop_rgb(1, red, green, blue)
+
+class CustomDrawingMixin(object):
+ def do_expose_event(self, event):
+ wrapper = wrappermap.wrapper(self)
+ if self.flags() & gtk.NO_WINDOW:
+ drawing_area = self.allocation
+ else:
+ drawing_area = gtk.gdk.Rectangle(0, 0,
+ self.allocation.width, self.allocation.height)
+ context = DrawingContext(event.window, drawing_area, event.area)
+ context.style = DrawingStyle(wrapper)
+ if self.flags() & gtk.CAN_FOCUS:
+ focus_space = (self.style_get_property('focus-padding') +
+ self.style_get_property('focus-line-width'))
+ if not wrapper.squish_width:
+ context.width -= focus_space * 2
+ translate_x = focus_space
+ else:
+ translate_x = 0
+ if not wrapper.squish_height:
+ context.height -= focus_space * 2
+ translate_y = focus_space
+ else:
+ translate_y = 0
+ context.translate(translate_x, translate_y)
+ wrapper.layout_manager.update_cairo_context(context.context)
+ self.draw(wrapper, context)
+
+ def draw(self, wrapper, context):
+ wrapper.layout_manager.reset()
+ wrapper.draw(context, wrapper.layout_manager)
+
+ def do_size_request(self, requesition):
+ wrapper = wrappermap.wrapper(self)
+ width, height = wrapper.size_request(wrapper.layout_manager)
+ requesition.width = width
+ requesition.height = height
+ if self.flags() & gtk.CAN_FOCUS:
+ focus_space = (self.style_get_property('focus-padding') +
+ self.style_get_property('focus-line-width'))
+ if not wrapper.squish_width:
+ requesition.width += focus_space * 2
+ if not wrapper.squish_height:
+ requesition.height += focus_space * 2
+
+class MiroDrawingArea(CustomDrawingMixin, gtk.Widget):
+ def __init__(self):
+ gtk.Widget.__init__(self)
+ CustomDrawingMixin.__init__(self)
+ self.set_flags(gtk.NO_WINDOW)
+
+class BackgroundWidget(CustomDrawingMixin, gtk.Bin):
+ def do_size_request(self, requesition):
+ CustomDrawingMixin.do_size_request(self, requesition)
+ if self.get_child():
+ child_width, child_height = self.get_child().size_request()
+ requesition.width = max(child_width, requesition.width)
+ requesition.height = max(child_height, requesition.height)
+
+ def do_expose_event(self, event):
+ CustomDrawingMixin.do_expose_event(self, event)
+ if self.get_child():
+ self.propagate_expose(self.get_child(), event)
+
+ def do_size_allocate(self, allocation):
+ gtk.Bin.do_size_allocate(self, allocation)
+ if self.get_child():
+ self.get_child().size_allocate(allocation)
+
+gobject.type_register(MiroDrawingArea)
+gobject.type_register(BackgroundWidget)
+
+class Drawable:
+ def __init__(self):
+ self.squish_width = self.squish_height = False
+
+ def set_squish_width(self, setting):
+ self.squish_width = setting
+
+ def set_squish_height(self, setting):
+ self.squish_height = setting
+
+ def set_widget(self, drawing_widget):
+ if self.is_opaque() and 0:
+ box = gtk.EventBox()
+ box.add(drawing_widget)
+ Widget.set_widget(self, box)
+ else:
+ Widget.set_widget(self, drawing_widget)
+ self.layout_manager = LayoutManager(self._widget)
+
+ def size_request(self, layout_manager):
+ return 0, 0
+
+ def draw(self, context, layout_manager):
+ pass
+
+ def is_opaque(self):
+ return False
+
+class DrawingArea(Drawable, Widget):
+ def __init__(self):
+ Widget.__init__(self)
+ Drawable.__init__(self)
+ self.set_widget(MiroDrawingArea())
+
+class Background(Drawable, Bin):
+ def __init__(self):
+ Bin.__init__(self)
+ Drawable.__init__(self)
+ self.set_widget(BackgroundWidget())
diff --git a/lvc/widgets/gtk/gtkmenus.py b/lvc/widgets/gtk/gtkmenus.py
new file mode 100644
index 0000000..0e89fa8
--- /dev/null
+++ b/lvc/widgets/gtk/gtkmenus.py
@@ -0,0 +1,404 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+"""gtkmenus.py -- Manage menu layout."""
+
+import gtk
+
+from lvc.widgets import app
+
+import base
+import keymap
+import wrappermap
+
+def _setup_accel(widget, name, shortcut=None):
+ """Setup accelerators for a menu item.
+
+ This method sets an accel path for the widget and optionally connects a
+ shortcut to that accel path.
+ """
+ # The GTK docs say that we should set the path using this form:
+ # <Window-Name>/Menu/Submenu/MenuItem
+ # ...but this is hard to do because we don't yet know what window/menu
+ # this menu item is going to be added to. gtk.Action and gtk.ActionGroup
+ # don't follow the above suggestion, so we don't need to either.
+ path = "<MiroActions>/MenuBar/%s" % name
+ widget.set_accel_path(path)
+ if shortcut is not None:
+ accel_string = keymap.get_accel_string(shortcut)
+ key, mods = gtk.accelerator_parse(accel_string)
+ if gtk.accel_map_lookup_entry(path) is None:
+ gtk.accel_map_add_entry(path, key, mods)
+ else:
+ gtk.accel_map_change_entry(path, key, mods, True)
+
+# map menu names to GTK stock ids.
+_STOCK_IDS = {
+ "SaveItem": gtk.STOCK_SAVE,
+ "CopyItemURL": gtk.STOCK_COPY,
+ "RemoveItems": gtk.STOCK_REMOVE,
+ "StopItem": gtk.STOCK_MEDIA_STOP,
+ "NextItem": gtk.STOCK_MEDIA_NEXT,
+ "PreviousItem": gtk.STOCK_MEDIA_PREVIOUS,
+ "PlayPauseItem": gtk.STOCK_MEDIA_PLAY,
+ "Open": gtk.STOCK_OPEN,
+ "EditPreferences": gtk.STOCK_PREFERENCES,
+ "Quit": gtk.STOCK_QUIT,
+ "Help": gtk.STOCK_HELP,
+ "About": gtk.STOCK_ABOUT,
+ "Translate": gtk.STOCK_EDIT
+}
+try:
+ _STOCK_IDS['Fullscreen'] = gtk.STOCK_FULLSCREEN
+except AttributeError:
+ # fullscreen not available on all GTK versions
+ pass
+
+class MenuItemBase(base.Widget):
+ """Base class for MenuItem and Separator."""
+
+ def show(self):
+ """Show this menu item."""
+ self._widget.show()
+
+ def hide(self):
+ """Hide and disable this menu item."""
+ self._widget.hide()
+
+ def remove_from_parent(self):
+ """Remove this menu item from it's parent Menu."""
+ parent_menu = self._widget.get_parent()
+ if parent_menu is None:
+ return
+ parent_menu_item = parent_menu.get_attach_widget()
+ if parent_menu_item is None:
+ return
+ parent_menu_item.remove(self._widget)
+
+ def _set_accel_group(self, accel_group):
+ # menu items don't care about the accel group, their parent Menu
+ # handles it for them
+ pass
+
+class MenuItem(MenuItemBase):
+ """Single item in the menu that can be clicked
+
+ :param label: The label it has (must be internationalized)
+ :param name: String identifier for this item
+ :param shortcut: Shortcut object to use
+
+ Signals:
+ - activate: menu item was clicked
+
+ Example:
+
+ >>> MenuItem(_("Preferences"), "EditPreferences")
+ >>> MenuItem(_("Cu_t"), "ClipboardCut", Shortcut("x", MOD))
+ >>> MenuItem(_("_Update Podcasts and Library"), "UpdatePodcasts",
+ ... (Shortcut("r", MOD), Shortcut(F5)))
+ >>> MenuItem(_("_Play"), "PlayPauseItem",
+ ... play=_("_Play"), pause=_("_Pause"))
+ """
+
+ def __init__(self, label, name, shortcut=None):
+ MenuItemBase.__init__(self)
+ self.name = name
+ self.set_widget(self.make_widget(label))
+ self.activate_id = self.wrapped_widget_connect('activate',
+ self._on_activate)
+ self._widget.show()
+ self.create_signal('activate')
+ _setup_accel(self._widget, self.name, shortcut)
+
+ def _on_activate(self, menu_item):
+ self.emit('activate')
+ gtk_menubar = self._find_menubar()
+ if gtk_menubar is not None:
+ try:
+ menubar = wrappermap.wrapper(gtk_menubar)
+ except KeyError:
+ logging.exception('menubar activate: '
+ 'no wrapper for gtbbk.MenuBar')
+ else:
+ menubar.emit('activate', self.name)
+
+ def _find_menubar(self):
+ """Find the MenuBar that this menu item is attached to."""
+ menu_item = self._widget
+ while True:
+ parent_menu = menu_item.get_parent()
+ if isinstance(parent_menu, gtk.MenuBar):
+ return parent_menu
+ elif parent_menu is None:
+ return None
+ menu_item = parent_menu.get_attach_widget()
+ if menu_item is None:
+ return None
+
+ def make_widget(self, label):
+ """Create the menu item to use for this widget.
+
+ Subclasses will probably want to override this.
+ """
+ if self.name in _STOCK_IDS:
+ mi = gtk.ImageMenuItem(stock_id=_STOCK_IDS[self.name])
+ mi.set_label(label)
+ return mi
+ else:
+ return gtk.MenuItem(label)
+
+ def set_label(self, new_label):
+ self._widget.set_label(new_label)
+
+ def get_label(self):
+ self._widget.get_label()
+
+class CheckMenuItem(MenuItem):
+ """MenuItem that toggles on/off"""
+
+ def make_widget(self, label):
+ return gtk.CheckMenuItem(label)
+
+ def set_state(self, active):
+ # prevent the activate signal from fireing in response to us manually
+ # changing a value
+ self._widget.handler_block(self.activate_id)
+ if active is not None:
+ self._widget.set_inconsistent(False)
+ self._widget.set_active(active)
+ else:
+ self._widget.set_inconsistent(True)
+ self._widget.set_active(False)
+ self._widget.handler_unblock(self.activate_id)
+
+ def get_state(self):
+ return self._widget.get_active()
+
+class RadioMenuItem(CheckMenuItem):
+ """MenuItem that toggles on/off and is grouped with other RadioMenuItems.
+ """
+
+ def make_widget(self, label):
+ widget = gtk.RadioMenuItem()
+ widget.set_label(label)
+ return widget
+
+ def set_group(self, group_item):
+ self._widget.set_group(group_item._widget)
+
+ def remove_from_group(self):
+ """Remove this RadioMenuItem from its current group."""
+ self._widget.set_group(None)
+
+ def _on_activate(self, menu_item):
+ # GTK sends the activate signal for both the radio button that's
+ # toggled on and the one that gets turned off. Just emit our signal
+ # for the active radio button.
+ if self.get_state():
+ MenuItem._on_activate(self, menu_item)
+
+class Separator(MenuItemBase):
+ """Separator item for menus"""
+
+ def __init__(self):
+ MenuItemBase.__init__(self)
+ self.set_widget(gtk.SeparatorMenuItem())
+ self._widget.show()
+ # Set name to be None just so that it has a similar API to other menu
+ # items.
+ self.name = None
+
+class MenuShell(base.Widget):
+ """Common code shared between Menu and MenuBar.
+
+ Subclasses must define a _menu attribute that's a gtk.MenuShell subclass.
+ """
+
+ def __init__(self):
+ base.Widget.__init__(self)
+ self._accel_group = None
+ self.children = []
+
+ def append(self, menu_item):
+ """Add a menu item to the end of this menu."""
+ self.children.append(menu_item)
+ menu_item._set_accel_group(self._accel_group)
+ self._menu.append(menu_item._widget)
+
+ def insert(self, index, menu_item):
+ """Insert a menu item in the middle of this menu."""
+ self.children.insert(index, menu_item)
+ menu_item._set_accel_group(self._accel_group)
+ self._menu.insert(menu_item._widget, index)
+
+ def remove(self, menu_item):
+ """Remove a child menu item.
+
+ :raises ValueError: menu_item is not a child of this menu
+ """
+ self.children.remove(menu_item)
+ self._menu.remove(menu_item._widget)
+ menu_item._set_accel_group(None)
+
+ def index(self, name):
+ """Get the position of a menu item in this list.
+
+ :param name: name of the menu
+ :returns: index of the menu item, or -1 if not found.
+ """
+ for i, menu_item in enumerate(self.children):
+ if menu_item.name == name:
+ return i
+ return -1
+
+ def get_children(self):
+ """Get the child menu items in order."""
+ return list(self.children)
+
+ def find(self, name):
+ """Search for a menu or menu item
+
+ This method recursively searches the entire menu structure for a Menu
+ or MenuItem object with a given name.
+
+ :raises KeyError: name not found
+ """
+ found = self._find(name)
+ if found is None:
+ raise KeyError(name)
+ else:
+ return found
+
+ def _find(self, name):
+ """Low-level helper-method for find().
+
+ :returns: found menu item or None.
+ """
+ for menu_item in self.get_children():
+ if menu_item.name == name:
+ return menu_item
+ if isinstance(menu_item, MenuShell):
+ submenu_find = menu_item._find(name)
+ if submenu_find is not None:
+ return submenu_find
+ return None
+
+class Menu(MenuShell):
+ """A Menu holds a list of MenuItems and Menus.
+
+ Example:
+ >>> Menu(_("P_layback"), "Playback", [
+ ... MenuItem(_("_Foo"), "Foo"),
+ ... MenuItem(_("_Bar"), "Bar")
+ ... ])
+ >>> Menu("", "toplevel", [
+ ... Menu(_("_File"), "File", [ ... ])
+ ... ])
+ """
+
+ def __init__(self, label, name, child_items):
+ MenuShell.__init__(self)
+ self.set_widget(gtk.MenuItem(label))
+ self._widget.show()
+ self.name = name
+ # set up _menu for the MenuShell code
+ self._menu = gtk.Menu()
+ _setup_accel(self._menu, self.name)
+ self._widget.set_submenu(self._menu)
+ for item in child_items:
+ self.append(item)
+
+ def show(self):
+ """Show this menu."""
+ self._widget.show()
+
+ def hide(self):
+ """Hide this menu."""
+ self._widget.hide()
+
+ def _set_accel_group(self, accel_group):
+ """Set the accel group for this widget.
+
+ Accel groups get created by the MenuBar. Whenever a menu or menu item
+ is added to that menu bar, the parent calls _set_accel_group() to give
+ the accel group to the child.
+ """
+ if accel_group == self._accel_group:
+ return
+ self._menu.set_accel_group(accel_group)
+ self._accel_group = accel_group
+ for child in self.children:
+ child._set_accel_group(accel_group)
+
+class MenuBar(MenuShell):
+ """Displays a list of Menu items.
+
+ Signals:
+
+ - activate(menu_bar, name): a menu item was activated
+ """
+
+ def __init__(self):
+ """Create a new MenuBar
+
+ :param name: string id to use for our action group
+ """
+ MenuShell.__init__(self)
+ self.create_signal('activate')
+ self.set_widget(gtk.MenuBar())
+ self._widget.show()
+ self._accel_group = gtk.AccelGroup()
+ # set up _menu for the MenuShell code
+ self._menu = self._widget
+
+ def get_accel_group(self):
+ return self._accel_group
+
+class MainWindowMenuBar(MenuBar):
+ """MenuBar for the main window.
+
+ This gets installed into app.widgetapp.menubar on GTK.
+ """
+ def add_initial_menus(self, menus):
+ """Add the initial set of menus.
+
+ We modify the menu structure slightly for GTK.
+ """
+ for menu in menus:
+ self.append(menu)
+ self._modify_initial_menus()
+
+ def _modify_initial_menus(self):
+ """Update the portable root menu with GTK-specific stuff."""
+ # on linux, we don't have a CheckVersion option because
+ # we update with the package system.
+ #this_platform = app.config.get(prefs.APP_PLATFORM)
+ #if this_platform == 'linux':
+ # self.find("CheckVersion").remove_from_parent()
+ #app.video_renderer.setup_subtitle_encoding_menu()
diff --git a/lvc/widgets/gtk/keymap.py b/lvc/widgets/gtk/keymap.py
new file mode 100644
index 0000000..537525a
--- /dev/null
+++ b/lvc/widgets/gtk/keymap.py
@@ -0,0 +1,94 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+"""keymap.py -- Map portable key values to GTK ones.
+"""
+
+import gtk
+
+from lvc.widgets import keyboard
+
+menubar_mod_map = {
+ keyboard.MOD: '<Ctrl>',
+ keyboard.CTRL: '<Ctrl>',
+ keyboard.ALT: '<Alt>',
+ keyboard.SHIFT: '<Shift>',
+}
+
+menubar_key_map = {
+ keyboard.RIGHT_ARROW: 'Right',
+ keyboard.LEFT_ARROW: 'Left',
+ keyboard.UP_ARROW: 'Up',
+ keyboard.DOWN_ARROW: 'Down',
+ keyboard.SPACE: 'space',
+ keyboard.ENTER: 'Return',
+ keyboard.DELETE: 'Delete',
+ keyboard.BKSPACE: 'BackSpace',
+ keyboard.ESCAPE: 'Escape',
+ '>': 'greater',
+ '<': 'less'
+}
+for i in range(1, 13):
+ name = 'F%d' % i
+ menubar_key_map[getattr(keyboard, name)] = name
+
+# These are reversed versions of menubar_key_map and menubar_mod_map
+gtk_key_map = dict((i[1], i[0]) for i in menubar_key_map.items())
+
+def get_accel_string(shortcut):
+ mod_str = ''.join(menubar_mod_map[mod] for mod in shortcut.modifiers)
+ key_str = menubar_key_map.get(shortcut.shortcut, shortcut.shortcut)
+ return mod_str + key_str
+
+def translate_gtk_modifiers(event):
+ """Convert a keypress event to a set of modifiers from the shortcut
+ module.
+ """
+ modifiers = set()
+ if event.state & gtk.gdk.CONTROL_MASK:
+ modifiers.add(keyboard.CTRL)
+ if event.state & gtk.gdk.MOD1_MASK:
+ modifiers.add(keyboard.ALT)
+ if event.state & gtk.gdk.SHIFT_MASK:
+ modifiers.add(keyboard.SHIFT)
+ return modifiers
+
+def translate_gtk_event(event):
+ """Convert a GTK key event into the tuple (key, modifiers) where
+ key and modifiers are from the shortcut module.
+ """
+ gtk_keyval = gtk.gdk.keyval_name(event.keyval)
+ if gtk_keyval == None:
+ return None
+ if len(gtk_keyval) == 1:
+ key = gtk_keyval
+ else:
+ key = gtk_key_map.get(gtk_keyval)
+ modifiers = translate_gtk_modifiers(event)
+ return key, modifiers
diff --git a/lvc/widgets/gtk/layout.py b/lvc/widgets/gtk/layout.py
new file mode 100644
index 0000000..549311c
--- /dev/null
+++ b/lvc/widgets/gtk/layout.py
@@ -0,0 +1,227 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".layout -- Layout widgets. """
+
+import gtk
+
+from lvc.utils import Matrix
+from .base import Widget, Bin
+
+class Box(Widget):
+ def __init__(self, spacing=0):
+ Widget.__init__(self)
+ self.children = set()
+ self.set_widget(self.WIDGET_CLASS(spacing=spacing))
+
+ def pack_start(self, widget, expand=False, padding=0):
+ self._widget.pack_start(widget._widget, expand, fill=True,
+ padding=padding)
+ widget._widget.show()
+ self.children.add(widget)
+
+ def pack_end(self, widget, expand=False, padding=0):
+ self._widget.pack_end(widget._widget, expand, fill=True,
+ padding=padding)
+ widget._widget.show()
+ self.children.add(widget)
+
+ def remove(self, widget):
+ widget._widget.hide() # otherwise gtkmozembed gets confused
+ self._widget.remove(widget._widget)
+ self.children.remove(widget)
+
+ def enable(self):
+ for mem in self.children:
+ mem.enable()
+
+ def disable(self):
+ for mem in self.children:
+ mem.disable()
+
+class HBox(Box):
+ WIDGET_CLASS = gtk.HBox
+
+class VBox(Box):
+ WIDGET_CLASS = gtk.VBox
+
+class Alignment(Bin):
+ def __init__(self, xalign=0, yalign=0, xscale=0, yscale=0,
+ top_pad=0, bottom_pad=0, left_pad=0, right_pad=0):
+ Bin.__init__(self)
+ self.set_widget(gtk.Alignment(xalign, yalign, xscale, yscale))
+ self.set_padding(top_pad, bottom_pad, left_pad, right_pad)
+
+ def set(self, xalign=0, yalign=0, xscale=0, yscale=0):
+ self._widget.set(xalign, yalign, xscale, yscale)
+
+ def set_padding(self, top_pad=0, bottom_pad=0, left_pad=0, right_pad=0):
+ self._widget.set_padding(top_pad, bottom_pad, left_pad, right_pad)
+
+class DetachedWindowHolder(Alignment):
+ def __init__(self):
+ Alignment.__init__(self, xscale=1, yscale=1)
+
+class Splitter(Widget):
+ def __init__(self):
+ """Create a new splitter."""
+ Widget.__init__(self)
+ self.set_widget(gtk.HPaned())
+
+ def set_left(self, widget):
+ """Set the left child widget."""
+ self.left = widget
+ self._widget.pack1(widget._widget, resize=False, shrink=False)
+ widget._widget.show()
+
+ def set_right(self, widget):
+ """Set the right child widget. """
+ self.right = widget
+ self._widget.pack2(widget._widget, resize=True, shrink=False)
+ widget._widget.show()
+
+ def remove_left(self):
+ """Remove the left child widget."""
+ if self.left is not None:
+ self.left._widget.hide() # otherwise gtkmozembed gets confused
+ self._widget.remove(self.left._widget)
+ self.left = None
+
+ def remove_right(self):
+ """Remove the right child widget."""
+ if self.right is not None:
+ self.right._widget.hide() # otherwise gtkmozembed gets confused
+ self._widget.remove(self.right._widget)
+ self.right = None
+
+ def set_left_width(self, width):
+ self._widget.set_position(width)
+
+ def get_left_width(self):
+ return self._widget.get_position()
+
+ def set_right_width(self, width):
+ self._widget.set_position(self.width - width)
+ # We should take into account the width of the bar, but this seems
+ # good enough.
+
+class Table(Widget):
+ """Lays out widgets in a table. It works very similar to the GTK Table
+ widget, or an HTML table.
+ """
+ def __init__(self, columns, rows):
+ Widget.__init__(self)
+ self.set_widget(gtk.Table(rows, columns, homogeneous=False))
+ self.children = Matrix(columns, rows)
+
+ def pack(self, widget, column, row, column_span=1, row_span=1):
+ """Add a widget to the table.
+ """
+ self.children[column, row] = widget
+ self._widget.attach(widget._widget, column, column + column_span,
+ row, row + row_span)
+ widget._widget.show()
+
+ def remove(self, widget):
+ widget._widget.hide() # otherwise gtkmozembed gets confused
+ self.children.remove(widget)
+ self._widget.remove(widget._widget)
+
+ def set_column_spacing(self, spacing):
+ self._widget.set_col_spacings(spacing)
+
+ def set_row_spacing(self, spacing):
+ self._widget.set_row_spacings(spacing)
+
+ def enable(self, row=None, column=None):
+ if row != None and column != None:
+ if self.children[column, row]:
+ self.children[column, row].enable()
+ elif row != None:
+ for mem in self.children.row(row):
+ if mem: mem.enable()
+ elif column != None:
+ for mem in self.children.column(column):
+ if mem: mem.enable()
+ else:
+ for mem in self.children:
+ if mem: mem.enable()
+
+ def disable(self, row=None, column=None):
+ if row != None and column != None:
+ if self.children[column, row]:
+ self.children[column, row].disable()
+ elif row != None:
+ for mem in self.children.row(row):
+ if mem: mem.disable()
+ elif column != None:
+ for mem in self.children.column(column):
+ if mem: mem.disable()
+ else:
+ for mem in self.children:
+ if mem: mem.disable()
+
+class TabContainer(Widget):
+ def __init__(self, xalign=0, yalign=0, xscale=0, yscale=0,
+ top_pad=0, bottom_pad=0, left_pad=0, right_pad=0):
+ Widget.__init__(self)
+ self.set_widget(gtk.Notebook())
+ self._widget.set_tab_pos(gtk.POS_TOP)
+ self.children = []
+ self._page_to_select = None
+ self.wrapped_widget_connect('realize', self._on_realize)
+
+ def _on_realize(self, widget):
+ if self._page_to_select is not None:
+ self._widget.set_current_page(self._page_to_select)
+ self._page_to_select = None
+
+ def append_tab(self, child_widget, text, image=None):
+ if image is not None:
+ label_widget = gtk.VBox(spacing=2)
+ image_widget = gtk.Image()
+ image_widget.set_from_pixbuf(image.pixbuf)
+ label_widget.pack_start(image_widget)
+ label_widget.pack_start(gtk.Label(text))
+ label_widget.show_all()
+ else:
+ label_widget = gtk.Label(text)
+
+ # switch from a center align to a top align
+ child_widget.set(0, 0, 1, 0)
+ child_widget.set_padding(10, 10, 10, 10)
+
+ self._widget.append_page(child_widget._widget, label_widget)
+ self.children.append(child_widget)
+
+ def select_tab(self, index):
+ if self._widget.flags() & gtk.REALIZED:
+ self._widget.set_current_page(index)
+ else:
+ self._page_to_select = index
diff --git a/lvc/widgets/gtk/layoutmanager.py b/lvc/widgets/gtk/layoutmanager.py
new file mode 100644
index 0000000..8097b2e
--- /dev/null
+++ b/lvc/widgets/gtk/layoutmanager.py
@@ -0,0 +1,550 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+"""drawing.py -- Contains the LayoutManager class. LayoutManager is
+handles laying out complex objects for the custom drawing code like
+text blocks and buttons.
+"""
+
+import math
+
+import cairo
+import gtk
+import pango
+
+from lvc import utils
+
+use_native_buttons = False # not implemented in MVC
+
+class FontCache(utils.Cache):
+ def get(self, context, description, scale_factor, bold, italic):
+ key = (context, description, scale_factor, bold, italic)
+ return utils.Cache.get(self, key)
+
+ def create_new_value(self, key, invalidator=None):
+ (context, description, scale_factor, bold, italic) = key
+ return Font(context, description, scale_factor, bold, italic)
+
+_font_cache = FontCache(512)
+
+class LayoutManager(object):
+ def __init__(self, widget):
+ self.pango_context = widget.get_pango_context()
+ self.update_style(widget.style)
+ self.update_direction(widget.get_direction())
+ widget.connect('style-set', self.on_style_set)
+ widget.connect('direction-changed', self.on_direction_changed)
+ self.widget = widget
+ self.reset()
+
+ def reset(self):
+ self.current_font = self.font(1.0)
+ self.text_color = (0, 0, 0)
+ self.text_shadow = None
+
+ def on_style_set(self, widget, previous_style):
+ old_font_desc = self.style_font_desc
+ self.update_style(widget.style)
+ if self.style_font_desc != old_font_desc:
+ # bug #17423 font changed, so the widget's width might have changed
+ widget.queue_resize()
+
+ def on_direction_changed(self, widget, previous_direction):
+ self.update_direction(widget.get_direction())
+
+ def update_style(self, style):
+ self.style_font_desc = style.font_desc
+ self.style = style
+
+ def update_direction(self, direction):
+ if direction == gtk.TEXT_DIR_RTL:
+ self.pango_context.set_base_dir(pango.DIRECTION_RTL)
+ else:
+ self.pango_context.set_base_dir(pango.DIRECTION_LTR)
+
+ def font(self, scale_factor, bold=False, italic=False, family=None):
+ return _font_cache.get(self.pango_context, self.style_font_desc,
+ scale_factor, bold, italic)
+
+ def set_font(self, scale_factor, bold=False, italic=False, family=None):
+ self.current_font = self.font(scale_factor, bold, italic)
+
+ def set_text_color(self, color):
+ self.text_color = color
+
+ def set_text_shadow(self, shadow):
+ self.text_shadow = shadow
+
+ def textbox(self, text, underline=False):
+ textbox = TextBox(self.pango_context, self.current_font,
+ self.text_color, self.text_shadow)
+ textbox.set_text(text, underline=underline)
+ return textbox
+
+ def button(self, text, pressed=False, disabled=False, style='normal'):
+ if style == 'webby':
+ return StyledButton(text, self.pango_context, self.current_font,
+ pressed, disabled)
+ elif use_native_buttons:
+ return NativeButton(text, self.pango_context, self.current_font,
+ pressed, self.style, self.widget)
+ else:
+ return StyledButton(text, self.pango_context, self.current_font,
+ pressed)
+
+ def update_cairo_context(self, cairo_context):
+ cairo_context.update_context(self.pango_context)
+
+class Font(object):
+ def __init__(self, context, style_font_desc, scale, bold, italic):
+ self.context = context
+ self.description = style_font_desc.copy()
+ self.description.set_size(int(scale * style_font_desc.get_size()))
+ if bold:
+ self.description.set_weight(pango.WEIGHT_BOLD)
+ if italic:
+ self.description.set_style(pango.STYLE_ITALIC)
+ self.font_metrics = None
+
+ def get_font_metrics(self):
+ if self.font_metrics is None:
+ self.font_metrics = self.context.get_metrics(self.description)
+ return self.font_metrics
+
+ def ascent(self):
+ return pango.PIXELS(self.get_font_metrics().get_ascent())
+
+ def descent(self):
+ return pango.PIXELS(self.get_font_metrics().get_descent())
+
+ def line_height(self):
+ metrics = self.get_font_metrics()
+ # the +1: some glyphs can be slightly taller than ascent+descent
+ # (#17329)
+ return (pango.PIXELS(metrics.get_ascent()) +
+ pango.PIXELS(metrics.get_descent()) + 1)
+
+class TextBox(object):
+ def __init__(self, context, font, color, shadow):
+ self.layout = pango.Layout(context)
+ self.layout.set_wrap(pango.WRAP_WORD_CHAR)
+ self.font = font
+ self.color = color
+ self.layout.set_font_description(font.description.copy())
+ self.width = self.height = None
+ self.shadow = shadow
+
+ def set_text(self, text, font=None, color=None, underline=False):
+ self.text_chunks = []
+ self.attributes = []
+ self.text_length = 0
+ self.underlines = []
+ self.append_text(text, font, color, underline)
+
+ def append_text(self, text, font=None, color=None, underline=False):
+ if text == None:
+ text = u""
+ startpos = self.text_length
+ self.text_chunks.append(text)
+ endpos = self.text_length = self.text_length + len(text)
+ if font is not None:
+ attr = pango.AttrFontDesc(font.description, startpos, endpos)
+ self.attributes.append(attr)
+ if underline:
+ self.underlines.append((startpos, endpos))
+ if color:
+ def convert(value):
+ return int(round(value * 65535))
+ attr = pango.AttrForeground(convert(color[0]), convert(color[1]),
+ convert(color[2]), startpos, endpos)
+ self.attributes.append(attr)
+ self.text_set = False
+
+ def set_width(self, width):
+ if width is not None:
+ self.layout.set_width(int(width * pango.SCALE))
+ else:
+ self.layout.set_width(-1)
+ self.width = width
+
+ def set_height(self, height):
+ # if height is not None:
+ # # not sure why set_height isn't in the python bindings, but it
+ # # isn't
+ # pygtkhacks.set_pango_layout_height(self.layout,
+ # int(height * pango.SCALE))
+ self.height = height
+
+ def set_wrap_style(self, wrap):
+ if wrap == 'word':
+ self.layout.set_wrap(pango.WRAP_WORD_CHAR)
+ elif wrap == 'char' or wrap == 'truncated-char':
+ self.layout.set_wrap(pango.WRAP_CHAR)
+ else:
+ raise ValueError("Unknown wrap value: %s" % wrap)
+ if wrap == 'truncated-char':
+ self.layout.set_ellipsize(pango.ELLIPSIZE_END)
+ else:
+ self.layout.set_ellipsize(pango.ELLIPSIZE_NONE)
+
+ def set_alignment(self, align):
+ if align == 'left':
+ self.layout.set_alignment(pango.ALIGN_LEFT)
+ elif align == 'right':
+ self.layout.set_alignment(pango.ALIGN_RIGHT)
+ elif align == 'center':
+ self.layout.set_alignment(pango.ALIGN_CENTER)
+ else:
+ raise ValueError("Unknown align value: %s" % align)
+
+ def ensure_layout(self):
+ if not self.text_set:
+ text = ''.join(self.text_chunks)
+ if len(text) > 100:
+ text = text[:self._calc_text_cutoff()]
+ self.layout.set_text(text)
+ attr_list = pango.AttrList()
+ for attr in self.attributes:
+ attr_list.insert(attr)
+ self.layout.set_attributes(attr_list)
+ self.text_set = True
+
+ def _calc_text_cutoff(self):
+ """This method is a bit of a hack... GTK slows down if we pass too
+ much text to the layout. Even text that falls below our height has a
+ performance penalty. Try not to have too much more than is necessary.
+ """
+ if None in (self.width, self.height):
+ return -1
+
+ chars_per_line = (self.width * pango.SCALE //
+ self.font.get_font_metrics().get_approximate_char_width())
+ lines_available = self.height // self.font.line_height()
+ # overestimate these because it's better to have too many characters
+ # than too little.
+ return int(chars_per_line * lines_available * 1.2)
+
+ def line_count(self):
+ self.ensure_layout()
+ return self.layout.get_line_count()
+
+ def get_size(self):
+ self.ensure_layout()
+ return self.layout.get_pixel_size()
+
+ def char_at(self, x, y):
+ self.ensure_layout()
+ x *= pango.SCALE
+ y *= pango.SCALE
+ width, height = self.layout.get_size()
+ if 0 <= x < width and 0 <= y < height:
+ index, leading = self.layout.xy_to_index(x, y)
+ # xy_to_index returns the nearest character, but that
+ # doesn't mean the user actually clicked on it. Double
+ # check that (x, y) is actually inside that char's
+ # bounding box
+ char_x, char_y, char_w, char_h = self.layout.index_to_pos(index)
+ if char_w > 0: # the glyph is LTR
+ left = char_x
+ right = char_x + char_w
+ else: # the glyph is RTL
+ left = char_x + char_w
+ right = char_x
+ if left <= x < right:
+ return index
+ return None
+
+
+ def draw(self, context, x, y, width, height):
+ self.set_width(width)
+ self.set_height(height)
+ self.ensure_layout()
+ cairo_context = context.context
+ cairo_context.save()
+ underline_drawer = UnderlineDrawer(self.underlines)
+ if self.shadow:
+ # draw shadow first so that it's underneath the regular text
+ # FIXME: we don't use the blur_radius setting
+ cairo_context.set_source_rgba(self.shadow.color[0],
+ self.shadow.color[1], self.shadow.color[2],
+ self.shadow.opacity)
+ self._draw_layout(context, x + self.shadow.offset[0],
+ y + self.shadow.offset[1], width, height,
+ underline_drawer)
+ cairo_context.set_source_rgb(*self.color)
+ self._draw_layout(context, x, y, width, height, underline_drawer)
+ cairo_context.restore()
+ cairo_context.new_path()
+
+ def _draw_layout(self, context, x, y, width, height, underline_drawer):
+ line_height = 0
+ alignment = self.layout.get_alignment()
+ for i in xrange(self.layout.get_line_count()):
+ line = self.layout.get_line_readonly(i)
+ extents = line.get_pixel_extents()[1]
+ next_line_height = line_height + extents[3]
+ if next_line_height > height:
+ break
+ if alignment == pango.ALIGN_CENTER:
+ line_x = max(x, x + (width - extents[2]) / 2.0)
+ elif alignment == pango.ALIGN_RIGHT:
+ line_x = max(x, x + width - extents[2])
+ else:
+ line_x = x
+ baseline = y + line_height + pango.ASCENT(extents)
+ context.move_to(line_x, baseline)
+ context.context.show_layout_line(line)
+ underline_drawer.draw(context, line_x, baseline, line)
+ line_height = next_line_height
+
+class UnderlineDrawer(object):
+ """Class to draw our own underlines because cairo's don't look
+ that great at small fonts. We make sure that the underline is
+ always drawn at a pixel boundary and that there always is space
+ between the text and the baseline.
+
+ This class makes a couple assumptions that might not be that
+ great. It assumes that the correct underline size is 1 pixel and
+ that the text color doesn't change in the middle of an underline.
+ """
+ def __init__(self, underlines):
+ self.underline_iter = iter(underlines)
+ self.finished = False
+ self.next_underline()
+
+ def next_underline(self):
+ try:
+ self.startpos, self.endpos = self.underline_iter.next()
+ except StopIteration:
+ self.finished = True
+ else:
+ # endpos is the char to stop underlining at
+ self.endpos -= 1
+
+ def draw(self, context, x, baseline, line):
+ baseline = round(baseline) + 0.5
+ context.set_line_width(1)
+ while not self.finished and line.start_index <= self.startpos:
+ startpos = max(line.start_index, self.startpos)
+ endpos = min(self.endpos, line.start_index + line.length)
+ x1 = x + pango.PIXELS(line.index_to_x(startpos, 0))
+ x2 = x + pango.PIXELS(line.index_to_x(endpos, 1))
+ context.move_to(x1, baseline + 1)
+ context.line_to(x2, baseline + 1)
+ context.stroke()
+ if endpos < self.endpos:
+ break
+ else:
+ self.next_underline()
+
+class NativeButton(object):
+ ICON_PAD = 4
+
+ def __init__(self, text, context, font, pressed, style, widget):
+ self.layout = pango.Layout(context)
+ self.font = font
+ self.pressed = pressed
+ self.layout.set_font_description(font.description.copy())
+ self.layout.set_text(text)
+ self.pad_x = style.xthickness + 11
+ self.pad_y = style.ythickness + 1
+ self.style = style
+ self.widget = widget
+ # The above code assumes an "inner-border" style property of
+ # 1. PyGTK doesn't seem to support Border objects very well,
+ # so can't get it from the widget style.
+ self.min_width = 0
+ self.icon = None
+
+ def set_min_width(self, width):
+ self.min_width = width
+
+ def set_icon(self, icon):
+ self.icon = icon
+
+ def get_size(self):
+ width, height = self.layout.get_pixel_size()
+ if self.icon:
+ width += self.icon.width + self.ICON_PAD
+ height = max(height, self.icon.height)
+ width += self.pad_x * 2
+ height += self.pad_y * 2
+ return max(self.min_width, width), height
+
+ def draw(self, context, x, y, width, height):
+ text_width, text_height = self.layout.get_pixel_size()
+ if self.icon:
+ inner_width = text_width + self.icon.width + self.ICON_PAD
+ # calculate the icon position x and y are still in cairo
+ # coordinates
+ icon_x = x + (width - inner_width) / 2.0
+ icon_y = y + (height - self.icon.height) / 2.0
+ text_x = icon_x + self.icon.width + self.ICON_PAD
+ else:
+ text_x = x + (width - text_width) / 2.0
+ text_y = y + (height - text_height) / 2.0
+
+ x, y = context.context.user_to_device(x, y)
+ text_x, text_y = context.context.user_to_device(text_x, text_y)
+ # Hmm, maybe we should somehow support floating point numbers
+ # here, but I don't know how to.
+ x, y, width, height = (int(f) for f in (x, y, width, height))
+ context.context.get_target().flush()
+ self.draw_box(context.window, x, y, width, height)
+ self.draw_text(context.window, text_x, text_y)
+ if self.icon:
+ self.icon.draw(context, icon_x, icon_y, self.icon.width,
+ self.icon.height)
+
+ def draw_box(self, window, x, y, width, height):
+ if self.pressed:
+ shadow = gtk.SHADOW_IN
+ state = gtk.STATE_ACTIVE
+ else:
+ shadow = gtk.SHADOW_OUT
+ state = gtk.STATE_NORMAL
+ if 'QtCurveStyle' in str(self.style):
+ # This is a horrible hack for the libqtcurve library. See
+ # http://bugzilla.pculture.org/show_bug.cgi?id=10380 for
+ # details
+ widget = window.get_user_data()
+ else:
+ widget = self.widget
+
+ self.style.paint_box(window, state, shadow, None, widget, "button",
+ int(x), int(y), int(width), int(height))
+
+ def draw_text(self, window, x, y):
+ if self.pressed:
+ state = gtk.STATE_ACTIVE
+ else:
+ state = gtk.STATE_NORMAL
+ self.style.paint_layout(window, state, True, None, None, None,
+ int(x), int(y), self.layout)
+
+class StyledButton(object):
+ PAD_HORIZONTAL = 4
+ PAD_VERTICAL = 3
+ TOP_COLOR = (1, 1, 1)
+ BOTTOM_COLOR = (0.86, 0.86, 0.86)
+ LINE_COLOR_TOP = (0.71, 0.71, 0.71)
+ LINE_COLOR_BOTTOM = (0.45, 0.45, 0.45)
+ TEXT_COLOR = (0.184, 0.184, 0.184)
+ DISABLED_COLOR = (0.86, 0.86, 0.86)
+ DISABLED_TEXT_COLOR = (0.5, 0.5, 0.5)
+ ICON_PAD = 8
+
+ def __init__(self, text, context, font, pressed, disabled=False):
+ self.layout = pango.Layout(context)
+ self.font = font
+ self.layout.set_font_description(font.description.copy())
+ self.layout.set_text(text)
+ self.min_width = 0
+ self.pressed = pressed
+ self.disabled = disabled
+ self.icon = None
+
+ def set_icon(self, icon):
+ self.icon = icon
+
+ def set_min_width(self, width):
+ self.min_width = width
+
+ def get_size(self):
+ width, height = self.layout.get_pixel_size()
+ if self.icon:
+ width += self.icon.width + self.ICON_PAD
+ height = max(height, self.icon.height)
+ height += self.PAD_VERTICAL * 2
+ if height % 2 == 1:
+ # make height even so that the radius of our circle is
+ # whole
+ height += 1
+ width += self.PAD_HORIZONTAL * 2 + height
+ return max(self.min_width, width), height
+
+ def draw_path(self, context, x, y, width, height, radius):
+ inner_width = width - radius * 2
+ context.move_to(x + radius, y)
+ context.rel_line_to(inner_width, 0)
+ context.arc(x + width - radius, y+radius, radius, -math.pi/2,
+ math.pi/2)
+ context.rel_line_to(-inner_width, 0)
+ context.arc(x + radius, y+radius, radius, math.pi/2, -math.pi/2)
+
+ def draw_button(self, context, x, y, width, height, radius):
+ context.context.save()
+ self.draw_path(context, x, y, width, height, radius)
+ if self.disabled:
+ end_color = self.DISABLED_COLOR
+ start_color = self.DISABLED_COLOR
+ elif self.pressed:
+ end_color = self.TOP_COLOR
+ start_color = self.BOTTOM_COLOR
+ else:
+ context.set_line_width(1)
+ start_color = self.TOP_COLOR
+ end_color = self.BOTTOM_COLOR
+ gradient = cairo.LinearGradient(x, y, x, y + height)
+ gradient.add_color_stop_rgb(0, *start_color)
+ gradient.add_color_stop_rgb(1, *end_color)
+ context.context.set_source(gradient)
+ context.fill()
+ context.set_line_width(1)
+ self.draw_path(context, x+0.5, y+0.5, width, height, radius)
+ gradient = cairo.LinearGradient(x, y, x, y + height)
+ gradient.add_color_stop_rgb(0, *self.LINE_COLOR_TOP)
+ gradient.add_color_stop_rgb(1, *self.LINE_COLOR_BOTTOM)
+ context.context.set_source(gradient)
+ context.stroke()
+ context.context.restore()
+
+ def draw(self, context, x, y, width, height):
+ radius = height / 2
+ self.draw_button(context, x, y, width, height, radius)
+
+ text_width, text_height = self.layout.get_pixel_size()
+ # draw the text in the center of the button
+ text_x = x + (width - text_width) / 2
+ text_y = y + (height - text_height) / 2
+ if self.icon:
+ icon_x = text_x - (self.icon.width + self.ICON_PAD) / 2
+ text_x += (self.icon.width + self.ICON_PAD) / 2
+ icon_y = y + (height - self.icon.height) / 2
+ self.icon.draw(context, icon_x, icon_y, self.icon.width,
+ self.icon.height)
+ self.draw_text(context, text_x, text_y, width, height, radius)
+
+ def draw_text(self, context, x, y, width, height, radius):
+ if self.disabled:
+ context.set_color(self.DISABLED_TEXT_COLOR)
+ else:
+ context.set_color(self.TEXT_COLOR)
+ context.move_to(x, y)
+ context.context.show_layout(self.layout)
diff --git a/lvc/widgets/gtk/simple.py b/lvc/widgets/gtk/simple.py
new file mode 100644
index 0000000..102fcd4
--- /dev/null
+++ b/lvc/widgets/gtk/simple.py
@@ -0,0 +1,313 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+"""simple.py -- Collection of simple widgets."""
+
+import gtk
+import gobject
+import pango
+
+from lvc.widgets import widgetconst
+from .base import Widget, Bin
+
+class Image(object):
+ def __init__(self, path):
+ try:
+ self._set_pixbuf(gtk.gdk.pixbuf_new_from_file(path))
+ except gobject.GError, ge:
+ raise ValueError("%s" % ge)
+ self.width = self.pixbuf.get_width()
+ self.height = self.pixbuf.get_height()
+
+ def _set_pixbuf(self, pixbuf):
+ self.pixbuf = pixbuf
+ self.width = self.pixbuf.get_width()
+ self.height = self.pixbuf.get_height()
+
+ def resize(self, width, height):
+ width = int(round(width))
+ height = int(round(height))
+ resized_pixbuf = self.pixbuf.scale_simple(width, height,
+ gtk.gdk.INTERP_BILINEAR)
+ return TransformedImage(resized_pixbuf)
+
+ def resize_for_space(self, width, height):
+ """Returns an image scaled to fit into the specified space at the
+ correct height/width ratio.
+ """
+ ratio = min(1.0 * width / self.width, 1.0 * height / self.height)
+ return self.resize(ratio * self.width, ratio * self.height)
+
+ def crop_and_scale(self, src_x, src_y, src_width, src_height, dest_width,
+ dest_height):
+ """Crop an image then scale it.
+
+ The image will be cropped to the rectangle (src_x, src_y, src_width,
+ src_height), that rectangle will be scaled to a new Image with tisez
+ (dest_width, dest_height)
+ """
+ dest = gtk.gdk.Pixbuf(self.pixbuf.get_colorspace(),
+ self.pixbuf.get_has_alpha(),
+ self.pixbuf.get_bits_per_sample(), dest_width, dest_height)
+
+ scale_x = dest_width / float(src_width)
+ scale_y = dest_height / float(src_height)
+
+ self.pixbuf.scale(dest, 0, 0, dest_width, dest_height,
+ -src_x * scale_x, -src_y * scale_y, scale_x, scale_y,
+ gtk.gdk.INTERP_BILINEAR)
+ return TransformedImage(dest)
+
+class TransformedImage(Image):
+ def __init__(self, pixbuf):
+ # XXX intentionally not calling direct super's __init__; we should do
+ # this differently
+ self._set_pixbuf(pixbuf)
+
+class ImageDisplay(Widget):
+ def __init__(self, image=None):
+ Widget.__init__(self)
+ self.set_widget(gtk.Image())
+ self.set_image(image)
+
+ def set_image(self, image):
+ self.image = image
+ if image is not None:
+ self._widget.set_from_pixbuf(image.pixbuf)
+ else:
+ self._widget.clear()
+
+class AnimatedImageDisplay(Widget):
+ def __init__(self, path):
+ Widget.__init__(self)
+ self.set_widget(gtk.Image())
+ self._animation = gtk.gdk.PixbufAnimation(path)
+ # Set to animate before we are shown and stop animating after
+ # we disappear.
+ self._widget.connect('map', lambda w: self._set_animate(True))
+ self._widget.connect('unmap-event',
+ lambda w, a: self._set_animate(False))
+
+ def _set_animate(self, enabled):
+ if enabled:
+ self._widget.set_from_animation(self._animation)
+ else:
+ self._widget.clear()
+
+class Label(Widget):
+ """Widget that displays simple text."""
+ def __init__(self, text="", color=None):
+ Widget.__init__(self)
+ self.set_widget(gtk.Label())
+ if text:
+ self.set_text(text)
+ self.attr_list = pango.AttrList()
+ self.font_description = self._widget.style.font_desc.copy()
+ self.scale_factor = 1.0
+ if color is not None:
+ self.set_color(color)
+ self.wrapped_widget_connect('style-set', self.on_style_set)
+
+ def set_bold(self, bold):
+ if bold:
+ weight = pango.WEIGHT_BOLD
+ else:
+ weight = pango.WEIGHT_NORMAL
+ self.font_description.set_weight(weight)
+ self.set_attr(pango.AttrFontDesc(self.font_description))
+
+ def set_size(self, size):
+ if size == widgetconst.SIZE_NORMAL:
+ self.scale_factor = 1
+ elif size == widgetconst.SIZE_SMALL:
+ self.scale_factor = 0.75
+ else:
+ self.scale_factor = size
+ baseline = self._widget.style.font_desc.get_size()
+ self.font_description.set_size(int(baseline * self.scale_factor))
+ self.set_attr(pango.AttrFontDesc(self.font_description))
+
+ def get_preferred_width(self):
+ return self._widget.size_request()[0]
+
+ def on_style_set(self, widget, old_style):
+ self.set_size(self.scale_factor)
+
+ def set_wrap(self, wrap):
+ self._widget.set_line_wrap(wrap)
+
+ def set_alignment(self, alignment):
+ # default to left.
+ gtkalignment = gtk.JUSTIFY_LEFT
+ if alignment == widgetconst.TEXT_JUSTIFY_LEFT:
+ gtkalignment = gtk.JUSTIFY_LEFT
+ elif alignment == widgetconst.TEXT_JUSTIFY_RIGHT:
+ gtkalignment = gtk.JUSTIFY_RIGHT
+ elif alignment == widgetconst.TEXT_JUSTIFY_CENTER:
+ gtkalignment = gtk.JUSTIFY_CENTER
+ self._widget.set_justify(gtkalignment)
+
+ def get_alignment(self):
+ return self._widget.get_justify()
+
+ def get_width(self):
+ return self._widget.get_layout().get_pixel_size()[0]
+
+ def set_text(self, text):
+ self._widget.set_text(text)
+
+ def get_text(self):
+ return self._widget.get_text().decode('utf-8')
+
+ def set_selectable(self, val):
+ self._widget.set_selectable(val)
+
+ def set_attr(self, attr):
+ attr.end_index = 65535
+ self.attr_list.change(attr)
+ self._widget.set_attributes(self.attr_list)
+
+ def set_color(self, color):
+ color_as_int = (int(65535 * c) for c in color)
+ self.set_attr(pango.AttrForeground(*color_as_int))
+
+ def baseline(self):
+ pango_context = self._widget.get_pango_context()
+ metrics = pango_context.get_metrics(self.font_description)
+ return pango.PIXELS(metrics.get_descent())
+
+ def hide(self):
+ self._widget.hide()
+
+ def show(self):
+ self._widget.show()
+
+class Scroller(Bin):
+ def __init__(self, horizontal, vertical):
+ Bin.__init__(self)
+ self.set_widget(gtk.ScrolledWindow())
+ if horizontal:
+ h_policy = gtk.POLICY_AUTOMATIC
+ else:
+ h_policy = gtk.POLICY_NEVER
+ if vertical:
+ v_policy = gtk.POLICY_AUTOMATIC
+ else:
+ v_policy = gtk.POLICY_NEVER
+ self._widget.set_policy(h_policy, v_policy)
+
+ def set_has_borders(self, has_border):
+ pass
+
+ def set_background_color(self, color):
+ pass
+
+ def add_child_to_widget(self):
+ if (isinstance(self.child._widget, gtk.TreeView) or
+ isinstance(self.child._widget, gtk.TextView)):
+ # child has native scroller
+ self._widget.add(self.child._widget)
+ else:
+ self._widget.add_with_viewport(self.child._widget)
+ self._widget.get_child().set_shadow_type(gtk.SHADOW_NONE)
+ if isinstance(self.child._widget, gtk.TextView):
+ self._widget.set_shadow_type(gtk.SHADOW_IN)
+ else:
+ self._widget.set_shadow_type(gtk.SHADOW_NONE)
+
+ def prepare_for_dark_content(self):
+ # this is just a hack for cocoa
+ pass
+
+
+class SolidBackground(Bin):
+ def __init__(self, color=None):
+ Bin.__init__(self)
+ self.set_widget(gtk.EventBox())
+ if color is not None:
+ self.set_background_color(color)
+
+ def set_background_color(self, color):
+ self.modify_style('base', gtk.STATE_NORMAL, self.make_color(color))
+ self.modify_style('bg', gtk.STATE_NORMAL, self.make_color(color))
+
+class Expander(Bin):
+ def __init__(self, child=None):
+ Bin.__init__(self)
+ self.set_widget(gtk.Expander())
+ if child is not None:
+ self.add(child)
+ self.label = None
+ # This is a complete hack. GTK expanders have a transparent
+ # background most of the time, except when they are prelighted. So we
+ # just set the background to white there because that's what should
+ # happen in the item list.
+ self.modify_style('bg', gtk.STATE_PRELIGHT,
+ gtk.gdk.color_parse('white'))
+
+ def set_spacing(self, spacing):
+ self._widget.set_spacing(spacing)
+
+ def set_label(self, widget):
+ self.label = widget
+ self._widget.set_label_widget(widget._widget)
+ widget._widget.show()
+
+ def set_expanded(self, expanded):
+ self._widget.set_expanded(expanded)
+
+class ProgressBar(Widget):
+ def __init__(self):
+ Widget.__init__(self)
+ self.set_widget(gtk.ProgressBar())
+ self._timer = None
+
+ def set_progress(self, fraction):
+ self._widget.set_fraction(fraction)
+
+ def start_pulsing(self):
+ if self._timer is None:
+ self._timer = gobject.timeout_add(100, self._do_pulse)
+
+ def stop_pulsing(self):
+ if self._timer:
+ gobject.source_remove(self._timer)
+ self._timer = None
+
+ def _do_pulse(self):
+ self._widget.pulse()
+ return True
+
+class HLine(Widget):
+ """A horizontal separator. Not to be confused with HSeparator, which is is
+ a DrawingArea, not a Widget.
+ """
+ def __init__(self):
+ Widget.__init__(self)
+ self.set_widget(gtk.HSeparator())
diff --git a/lvc/widgets/gtk/tableview.py b/lvc/widgets/gtk/tableview.py
new file mode 100644
index 0000000..df66990
--- /dev/null
+++ b/lvc/widgets/gtk/tableview.py
@@ -0,0 +1,1557 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+"""tableview.py -- Wrapper for the GTKTreeView widget. It's used for the tab
+list and the item list (AKA almost all of the miro).
+"""
+
+import logging
+
+import itertools
+import gobject
+import gtk
+from collections import namedtuple
+
+# These are probably wrong, and are placeholders for now, until custom headers
+# are also implemented for GTK.
+CUSTOM_HEADER_HEIGHT = 25
+HEADER_HEIGHT = 25
+
+from lvc import signals
+from lvc.errors import (WidgetActionError, WidgetDomainError,
+ WidgetRangeError, WidgetNotReadyError)
+from lvc.widgets.tableselection import SelectionOwnerMixin
+from lvc.widgets.tablescroll import ScrollbarOwnerMixin
+import drawing
+import wrappermap
+from .base import Widget
+from .simple import Image
+from .layoutmanager import LayoutManager
+from .weakconnect import weak_connect
+from .tableviewcells import GTKCustomCellRenderer
+
+
+PathInfo = namedtuple('PathInfo', 'path column x y')
+Rect = namedtuple('Rect', 'x y width height')
+_album_view_gtkrc_installed = False
+
+def _install_album_view_gtkrc():
+ """Hack for styling GTKTreeView for the album view widget.
+
+ We do a couple things:
+ - Remove the focus ring
+ - Remove any separator space.
+
+ We do this so that we don't draw a box through the album view column for
+ selected rows.
+ """
+ global _album_view_gtkrc_installed
+ if _album_view_gtkrc_installed:
+ return
+ rc_string = ('style "album-view-style"\n'
+ '{ \n'
+ ' GtkTreeView::vertical-separator = 0\n'
+ ' GtkTreeView::horizontal-separator = 0\n'
+ ' GtkWidget::focus-line-width = 0 \n'
+ '}\n'
+ 'widget "*.miro-album-view" style "album-view-style"\n')
+ gtk.rc_parse_string(rc_string)
+ _album_view_gtkrc_installed = True
+
+def rect_contains_point(rect, x, y):
+ return ((rect.x <= x < rect.x + rect.width) and
+ (rect.y <= y < rect.y + rect.height))
+
+class TreeViewScrolling(object):
+ def __init__(self):
+ self.scrollbars = []
+ self.scroll_positions = None, None
+ self.restoring_scroll = None
+ self.connect('parent-set', self.on_parent_set)
+ self.scroller = None
+ # hack necessary because of our weird widget hierarchy (GTK doesn't deal
+ # well with the Scroller's widget not being the direct parent of the
+ # TableView's widget.)
+ self._coords_working = False
+
+ def scroll_range_changed(self):
+ """Faux-signal; this should all be integrated into
+ GTKScrollbarOwnerMixin, making this unnecessary.
+ """
+
+ @property
+ def manually_scrolled(self):
+ """Return whether the view has been scrolled explicitly by the user
+ since the last time it was set automatically.
+ """
+ auto_pos = self.scroll_positions[1]
+ if auto_pos is None:
+ # if we don't have any position yet, user can't have manually
+ # scrolled
+ return False
+ real_pos = self.scrollbars[1].get_value()
+ return abs(auto_pos - real_pos) > 5 # allowing some fuzziness
+
+ @property
+ def position_set(self):
+ """Return whether the scroll position has been set in any way."""
+ return any(x is not None for x in self.scroll_positions)
+
+ def on_parent_set(self, widget, old_parent):
+ """We have parent window now; we need to control its scrollbars."""
+ self.set_scroller(widget.get_parent())
+
+ def set_scroller(self, window):
+ """Take control of the scrollbars of window."""
+ if not isinstance(window, gtk.ScrolledWindow):
+ return
+ self.scroller = window
+ scrollbars = tuple(bar.get_adjustment()
+ for bar in (window.get_hscrollbar(), window.get_vscrollbar()))
+ self.scrollbars = scrollbars
+ for i, bar in enumerate(scrollbars):
+ weak_connect(bar, 'changed', self.on_scroll_range_changed, i)
+ if self.restoring_scroll:
+ self.set_scroll_position(self.restoring_scroll)
+
+ def on_scroll_range_changed(self, adjustment, bar):
+ """The scrollbar might have a range now. Set its initial position if
+ we haven't already.
+ """
+ self._coords_working = True
+ if self.restoring_scroll:
+ self.set_scroll_position(self.restoring_scroll)
+ # our wrapper handles the same thing for iters
+ self.scroll_range_changed()
+
+ def set_scroll_position(self, scroll_position):
+ """Restore the scrollbars to a remembered state."""
+ try:
+ self.scroll_positions = tuple(self._clip_pos(adj, x)
+ for adj, x in zip(self.scrollbars, scroll_position))
+ except WidgetActionError, error:
+ logging.debug("can't scroll yet: %s", error.reason)
+ # try again later
+ self.restoring_scroll = scroll_position
+ else:
+ for adj, pos in zip(self.scrollbars, self.scroll_positions):
+ adj.set_value(pos)
+ self.restoring_scroll = None
+
+ def _clip_pos(self, adj, pos):
+ lower = adj.get_lower()
+ upper = adj.get_upper() - adj.get_page_size()
+ # currently, StandardView gets an upper of 2.0 when it's not ready
+ # FIXME: don't count on that
+ if pos > upper and upper < 5:
+ raise WidgetRangeError("scrollable area", pos, lower, upper)
+ return min(max(pos, lower), upper)
+
+ def get_path_rect(self, path):
+ """Return the Rect for the given item, in tree coords."""
+ if not self._coords_working:
+ # part of solution to #17405; widget_to_tree_coords tends to return
+ # y=8 before the first scroll-range-changed signal. ugh.
+ raise WidgetNotReadyError('_coords_working')
+ rect = self.get_background_area(path, self.get_columns()[0])
+ x, y = self.widget_to_tree_coords(rect.x, rect.y)
+ return Rect(x, y, rect.width, rect.height)
+
+ @property
+ def _scrollbars(self):
+ if not self.scrollbars:
+ raise WidgetNotReadyError
+ return self.scrollbars
+
+ def scroll_ancestor(self, newly_selected, down):
+ # Try to figure out what just became selected. If multiple things
+ # somehow became selected, select the outermost one
+ if len(newly_selected) == 0:
+ raise WidgetActionError("need at an item to scroll to")
+ if down:
+ path_to_show = max(newly_selected)
+ else:
+ path_to_show = min(newly_selected)
+
+ if not self.scrollbars:
+ return
+ vadjustment = self.scrollbars[1]
+
+ rect = self.get_background_area(path_to_show, self.get_columns()[0])
+ _, top = self.translate_coordinates(self.scroller, 0, rect.y)
+ top += vadjustment.value
+ bottom = top + rect.height
+ if down:
+ if bottom > vadjustment.value + vadjustment.page_size:
+ bottom_value = min(bottom, vadjustment.upper)
+ vadjustment.set_value(bottom_value - vadjustment.page_size)
+ else:
+ if top < vadjustment.value:
+ vadjustment.set_value(max(vadjustment.lower, top))
+
+class MiroTreeView(gtk.TreeView, TreeViewScrolling):
+ """Extends the GTK TreeView widget to help implement TableView
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ # Add a tiny bit of padding so that the user can drag feeds below
+ # the table, i.e. to the bottom row, as a top-level
+ PAD_BOTTOM = 3
+ def __init__(self):
+ gtk.TreeView.__init__(self)
+ TreeViewScrolling.__init__(self)
+ self.height_without_pad_bottom = -1
+ self.set_enable_search(False)
+ self.horizontal_separator = self.style_get_property("horizontal-separator")
+ self.expander_size = self.style_get_property("expander-size")
+ self.group_lines_enabled = False
+ self.group_line_color = (0, 0, 0)
+ self.group_line_width = 1
+
+ def do_size_request(self, req):
+ gtk.TreeView.do_size_request(self, req)
+ self.height_without_pad_bottom = req.height
+ req.height += self.PAD_BOTTOM
+
+ def do_move_cursor(self, step, count):
+ if step == gtk.MOVEMENT_VISUAL_POSITIONS:
+ # GTK is asking us to move left/right. Since our TableViews don't
+ # support this, return False to let the key press propagate. See
+ # #15646 for more info.
+ return False
+ if isinstance(self.get_parent(), gtk.ScrolledWindow):
+ # If our parent is a ScrolledWindow, let GTK take care of this
+ handled = gtk.TreeView.do_move_cursor(self, step, count)
+ return handled
+ else:
+ # Otherwise, we have to search up the widget tree for a
+ # ScrolledWindow to take care of it
+ selection = self.get_selection()
+ model, start_selection = selection.get_selected_rows()
+ gtk.TreeView.do_move_cursor(self, step, count)
+
+ model, end_selection = selection.get_selected_rows()
+ newly_selected = set(end_selection) - set(start_selection)
+ down = (count > 0)
+
+ try:
+ self.scroll_ancestor(newly_selected, down)
+ except WidgetActionError:
+ # not possible
+ return False
+ return True
+
+ def get_position_info(self, x, y):
+ """Wrapper for get_path_at_pos that converts the path_info to a named
+ tuple and handles rounding the coordinates.
+ """
+ path_info = self.get_path_at_pos(int(round(x)), int(round(y)))
+ if path_info:
+ return PathInfo(*path_info)
+
+gobject.type_register(MiroTreeView)
+
+class HotspotTracker(object):
+ """Handles tracking hotspots.
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+
+ def __init__(self, treeview, event):
+ self.treeview = treeview
+ self.treeview_wrapper = wrappermap.wrapper(treeview)
+ self.hit = False
+ self.button = event.button
+ path_info = treeview.get_position_info(event.x, event.y)
+ if path_info is None:
+ return
+ self.path, self.column, background_x, background_y = path_info
+ # We always pack 1 renderer for each column
+ gtk_renderer = self.column.get_cell_renderers()[0]
+ if not isinstance(gtk_renderer, GTKCustomCellRenderer):
+ return
+ self.renderer = wrappermap.wrapper(gtk_renderer)
+ self.attr_map = self.treeview_wrapper.attr_map_for_column[self.column]
+ if not rect_contains_point(self.calc_cell_area(), event.x, event.y):
+ # Mouse is in the padding around the actual cell area
+ return
+ self.update_position(event)
+ self.iter = treeview.get_model().get_iter(self.path)
+ self.name = self.calc_hotspot()
+ if self.name is not None:
+ self.hit = True
+
+ def is_for_context_menu(self):
+ return self.name == "#show-context-menu"
+
+ def calc_cell_area(self):
+ cell_area = self.treeview.get_cell_area(self.path, self.column)
+ xpad = self.renderer._renderer.props.xpad
+ ypad = self.renderer._renderer.props.ypad
+ cell_area.x += xpad
+ cell_area.y += ypad
+ cell_area.width -= xpad * 2
+ cell_area.height -= ypad * 2
+ return cell_area
+
+ def update_position(self, event):
+ self.x, self.y = int(event.x), int(event.y)
+
+ def calc_cell_state(self):
+ if self.treeview.get_selection().path_is_selected(self.path):
+ if self.treeview.flags() & gtk.HAS_FOCUS:
+ return gtk.STATE_SELECTED
+ else:
+ return gtk.STATE_ACTIVE
+ else:
+ return gtk.STATE_NORMAL
+
+ def calc_hotspot(self):
+ cell_area = self.calc_cell_area()
+ if rect_contains_point(cell_area, self.x, self.y):
+ model = self.treeview.get_model()
+ self.renderer.cell_data_func(self.column, self.renderer._renderer,
+ model, self.iter, self.attr_map)
+ style = drawing.DrawingStyle(self.treeview_wrapper,
+ use_base_color=True, state=self.calc_cell_state())
+ x = self.x - cell_area.x
+ y = self.y - cell_area.y
+ return self.renderer.hotspot_test(style,
+ self.treeview_wrapper.layout_manager,
+ x, y, cell_area.width, cell_area.height)
+ else:
+ return None
+
+ def update_hit(self):
+ if self.is_for_context_menu():
+ return # we always keep hit = True for this one
+ old_hit = self.hit
+ self.hit = (self.calc_hotspot() == self.name)
+ if self.hit != old_hit:
+ self.redraw_cell()
+
+ def redraw_cell(self):
+ # Check that the treeview is still around. We might have switched
+ # views in response to a hotspot being clicked.
+ if self.treeview.flags() & gtk.REALIZED:
+ cell_area = self.treeview.get_cell_area(self.path, self.column)
+ x, y = self.treeview.tree_to_widget_coords(cell_area.x,
+ cell_area.y)
+ self.treeview.queue_draw_area(x, y,
+ cell_area.width, cell_area.height)
+
+class TableColumn(signals.SignalEmitter):
+ """A single column of a TableView.
+
+ Signals:
+
+ clicked (table_column) -- The header for this column was clicked.
+ """
+ # GTK hard-codes 4px of padding for each column
+ FIXED_PADDING = 4
+ def __init__(self, title, renderer, header=None, **attrs):
+ # header widget not used yet in GTK (#15800)
+ signals.SignalEmitter.__init__(self)
+ self.create_signal('clicked')
+ self._column = gtk.TreeViewColumn(title, renderer._renderer)
+ self._column.set_sizing(gtk.TREE_VIEW_COLUMN_FIXED)
+ self._column.set_clickable(True)
+ self.attrs = attrs
+ renderer.setup_attributes(self._column, attrs)
+ self.renderer = renderer
+ weak_connect(self._column, 'clicked', self._header_clicked)
+ self.do_horizontal_padding = True
+
+ def set_right_aligned(self, right_aligned):
+ """Horizontal alignment of the header label."""
+ if right_aligned:
+ self._column.set_alignment(1.0)
+ else:
+ self._column.set_alignment(0.0)
+
+ def set_min_width(self, width):
+ self._column.props.min_width = width + TableColumn.FIXED_PADDING
+
+ def set_max_width(self, width):
+ self._column.props.max_width = width
+
+ def set_width(self, width):
+ self._column.set_fixed_width(width + TableColumn.FIXED_PADDING)
+
+ def get_width(self):
+ return self._column.get_width()
+
+ def _header_clicked(self, tablecolumn):
+ self.emit('clicked')
+
+ def set_resizable(self, resizable):
+ """Set if the user can resize the column."""
+ self._column.set_resizable(resizable)
+
+ def set_do_horizontal_padding(self, horizontal_padding):
+ self.do_horizontal_padding = False
+
+ def set_sort_indicator_visible(self, visible):
+ """Show/Hide the sort indicator for this column."""
+ self._column.set_sort_indicator(visible)
+
+ def get_sort_indicator_visible(self):
+ return self._column.get_sort_indicator()
+
+ def set_sort_order(self, ascending):
+ """Display a sort indicator on the column header. Ascending can be
+ either True or False which affects the direction of the indicator.
+ """
+ if ascending:
+ self._column.set_sort_order(gtk.SORT_ASCENDING)
+ else:
+ self._column.set_sort_order(gtk.SORT_DESCENDING)
+
+ def get_sort_order_ascending(self):
+ """Returns if the sort indicator is displaying that the sort is
+ ascending.
+ """
+ return self._column.get_sort_order() == gtk.SORT_ASCENDING
+
+class GTKSelectionOwnerMixin(SelectionOwnerMixin):
+ """GTK-specific methods for selection management.
+
+ This subclass should not define any behavior. Methods that cannot be
+ completed in this widget state should raise WidgetActionError.
+ """
+ def __init__(self):
+ SelectionOwnerMixin.__init__(self)
+ self.selection = self._widget.get_selection()
+ weak_connect(self.selection, 'changed', self.on_selection_changed)
+
+ def _set_allow_multiple_select(self, allow):
+ if allow:
+ mode = gtk.SELECTION_MULTIPLE
+ else:
+ mode = gtk.SELECTION_SINGLE
+ self.selection.set_mode(mode)
+
+ def _get_allow_multiple_select(self):
+ return self.selection.get_mode() == gtk.SELECTION_MULTIPLE
+
+ def _get_selected_iters(self):
+ iters = []
+ def collect(treemodel, path, iter_):
+ iters.append(iter_)
+ self.selection.selected_foreach(collect)
+ return iters
+
+ def _get_selected_iter(self):
+ model, iter_ = self.selection.get_selected()
+ return iter_
+
+ @property
+ def num_rows_selected(self):
+ return self.selection.count_selected_rows()
+
+ def _is_selected(self, iter_):
+ return self.selection.iter_is_selected(iter_)
+
+ def _select(self, iter_):
+ self.selection.select_iter(iter_)
+
+ def _unselect(self, iter_):
+ self.selection.unselect_iter(iter_)
+
+ def _unselect_all(self):
+ self.selection.unselect_all()
+
+ def _iter_to_string(self, iter_):
+ return self._model.get_string_from_iter(iter_)
+
+ def _iter_from_string(self, string):
+ try:
+ return self._model.get_iter_from_string(string)
+ except ValueError:
+ raise WidgetDomainError(
+ "model iters", string, "%s other iters" % len(self.model))
+
+ def select_path(self, path):
+ self.selection.select_path(path)
+
+ def _validate_iter(self, iter_):
+ if self.get_path(iter_) is None:
+ raise WidgetDomainError(
+ "model iters", iter_, "%s other iters" % len(self.model))
+ real_model = self._widget.get_model()
+ if not real_model:
+ raise WidgetActionError("no model")
+ elif real_model != self._model:
+ raise WidgetActionError("wrong model?")
+
+ def get_cursor(self):
+ """Return the path of the 'focused' item."""
+ path, column = self._widget.get_cursor()
+ return path
+
+ def set_cursor(self, path):
+ """Set the path of the 'focused' item."""
+ if path is None:
+ # XXX: is there a way to clear the cursor?
+ return
+ path_as_string = ':'.join(str(component) for component in path)
+ with self.preserving_selection(): # set_cursor() messes up the selection
+ self._widget.set_cursor(path_as_string)
+
+class DNDHandlerMixin(object):
+ """TableView row DnD.
+
+ Depends on arbitrary TableView methods; otherwise self-contained except:
+ on_button_press: may call start_drag
+ on_button_release: may unset drag_button_down
+ on_motion_notify: may call potential_drag_motion
+ """
+ def __init__(self):
+ self.drag_button_down = False
+ self.drag_data = {}
+ self.drag_source = self.drag_dest = None
+ self.drag_start_x, self.drag_start_y = None, None
+ self.wrapped_widget_connect('drag-data-get', self.on_drag_data_get)
+ self.wrapped_widget_connect('drag-end', self.on_drag_end)
+ self.wrapped_widget_connect('drag-motion', self.on_drag_motion)
+ self.wrapped_widget_connect('drag-leave', self.on_drag_leave)
+ self.wrapped_widget_connect('drag-drop', self.on_drag_drop)
+ self.wrapped_widget_connect('drag-data-received',
+ self.on_drag_data_received)
+ self.wrapped_widget_connect('unrealize', self.on_drag_unrealize)
+
+ def set_drag_source(self, drag_source):
+ self.drag_source = drag_source
+ # XXX: the following note no longer seems accurate:
+ # No need to call enable_model_drag_source() here, we handle it
+ # ourselves in on_motion_notify()
+
+ def set_drag_dest(self, drag_dest):
+ """Set the drop handler."""
+ self.drag_dest = drag_dest
+ if drag_dest is not None:
+ targets = self._gtk_target_list(drag_dest.allowed_types())
+ self._widget.enable_model_drag_dest(targets,
+ drag_dest.allowed_actions())
+ self._widget.drag_dest_set(0, targets,
+ drag_dest.allowed_actions())
+ else:
+ self._widget.unset_rows_drag_dest()
+ self._widget.drag_dest_unset()
+
+ def start_drag(self, treeview, event, path_info):
+ """Check whether the event is a drag event; return whether handled
+ here.
+ """
+ if event.state & (gtk.gdk.CONTROL_MASK | gtk.gdk.SHIFT_MASK):
+ return False
+ model, row_paths = treeview.get_selection().get_selected_rows()
+
+ if path_info.path not in row_paths:
+ # something outside the selection is being dragged.
+ # make it the new selection.
+ self.unselect_all(signal=False)
+ self.select_path(path_info.path)
+ row_paths = [path_info.path]
+ rows = self.model.get_rows(row_paths)
+ self.drag_data = rows and self.drag_source.begin_drag(self, rows)
+ self.drag_button_down = bool(self.drag_data)
+ if self.drag_button_down:
+ self.drag_start_x = int(event.x)
+ self.drag_start_y = int(event.y)
+
+ if len(row_paths) > 1 and path_info.path in row_paths:
+ # handle multiple selection. If the current row is already
+ # selected, stop propagating the signal. We will only change
+ # the selection if the user doesn't start a DnD operation.
+ # This makes it more natural for the user to drag a block of
+ # selected items.
+ renderer = path_info.column.get_cell_renderers()[0]
+ if (not self._x_coord_in_expander(treeview, path_info)
+ and not isinstance(renderer, GTKCheckboxCellRenderer)):
+ self.delaying_press = True
+ # grab keyboard focus since we handled the event
+ self.focus()
+ return True
+
+ def on_drag_data_get(self, treeview, context, selection, info, timestamp):
+ for typ, data in self.drag_data.items():
+ selection.set(typ, 8, repr(data))
+
+ def on_drag_end(self, treeview, context):
+ self.drag_data = {}
+
+ def find_type(self, drag_context):
+ return self._widget.drag_dest_find_target(drag_context,
+ self._widget.drag_dest_get_target_list())
+
+ def calc_positions(self, x, y):
+ """Given x and y coordinates, generate a list of drop positions to
+ try. The values are tuples in the form of (parent_path, position,
+ gtk_path, gtk_position), where parent_path and position is the
+ position to send to the Miro code, and gtk_path and gtk_position is an
+ equivalent position to send to the GTK code if the drag_dest validates
+ the drop.
+ """
+ model = self._model
+ try:
+ gtk_path, gtk_position = self._widget.get_dest_row_at_pos(x, y)
+ except TypeError:
+ # Below the last row
+ yield (None, len(model), None, None)
+ return
+
+ iter_ = model.get_iter(gtk_path)
+ if gtk_position in (gtk.TREE_VIEW_DROP_INTO_OR_BEFORE,
+ gtk.TREE_VIEW_DROP_INTO_OR_AFTER):
+ yield (iter_, -1, gtk_path, gtk_position)
+
+ if hasattr(model, 'iter_is_valid'):
+ # tablist has this; item list does not
+ assert model.iter_is_valid(iter_)
+ parent_iter = model.iter_parent(iter_)
+ position = gtk_path[-1]
+ if gtk_position in (gtk.TREE_VIEW_DROP_BEFORE,
+ gtk.TREE_VIEW_DROP_INTO_OR_BEFORE):
+ # gtk gave us a "before" position, no need to change it
+ yield (parent_iter, position, gtk_path, gtk.TREE_VIEW_DROP_BEFORE)
+ else:
+ # gtk gave us an "after" position, translate that to before the
+ # next row for miro.
+ if (self._widget.row_expanded(gtk_path) and
+ model.iter_has_child(iter_)):
+ child_path = gtk_path + (0,)
+ yield (iter_, 0, child_path, gtk.TREE_VIEW_DROP_BEFORE)
+ else:
+ yield (parent_iter, position+1, gtk_path,
+ gtk.TREE_VIEW_DROP_AFTER)
+
+ def on_drag_motion(self, treeview, drag_context, x, y, timestamp):
+ if not self.drag_dest:
+ return True
+ type = self.find_type(drag_context)
+ if type == "NONE":
+ drag_context.drag_status(0, timestamp)
+ return True
+ drop_action = 0
+ for pos_info in self.calc_positions(x, y):
+ drop_action = self.drag_dest.validate_drop(self, self.model, type,
+ drag_context.actions, pos_info[0], pos_info[1])
+ if isinstance(drop_action, (list, tuple)):
+ drop_action, iter = drop_action
+ path = self.model.get_path(iter)
+ pos = gtk.TREE_VIEW_DROP_INTO_OR_BEFORE
+ else:
+ path, pos = pos_info[2:4]
+
+ if drop_action:
+ self.set_drag_dest_row(path, pos)
+ break
+ else:
+ self.unset_drag_dest_row()
+ drag_context.drag_status(drop_action, timestamp)
+ return True
+
+ def set_drag_dest_row(self, path, position):
+ self._widget.set_drag_dest_row(path, position)
+
+ def unset_drag_dest_row(self):
+ self._widget.unset_drag_dest_row()
+
+ def on_drag_leave(self, treeview, drag_context, timestamp):
+ treeview.unset_drag_dest_row()
+
+ def on_drag_drop(self, treeview, drag_context, x, y, timestamp):
+ # prevent the default handler
+ treeview.emit_stop_by_name('drag-drop')
+ target = self.find_type(drag_context)
+ if target == "NONE":
+ return False
+ treeview.drag_get_data(drag_context, target, timestamp)
+ treeview.unset_drag_dest_row()
+
+ def on_drag_data_received(self,
+ treeview, drag_context, x, y, selection, info, timestamp):
+ # prevent the default handler
+ treeview.emit_stop_by_name('drag-data-received')
+ if not self.drag_dest:
+ return
+ type = self.find_type(drag_context)
+ if type == "NONE":
+ return
+ if selection.data is None:
+ return
+ drop_action = 0
+ for pos_info in self.calc_positions(x, y):
+ drop_action = self.drag_dest.validate_drop(self, self.model, type,
+ drag_context.actions, pos_info[0], pos_info[1])
+ if drop_action:
+ self.drag_dest.accept_drop(self, self.model, type,
+ drag_context.actions, pos_info[0], pos_info[1],
+ eval(selection.data))
+ return True
+ return False
+
+ def on_drag_unrealize(self, treeview):
+ self.drag_button_down = False
+
+ def potential_drag_motion(self, treeview, event):
+ """A motion event has occurred and did not hit a hotspot; start a drag
+ if applicable.
+ """
+ if (self.drag_data and self.drag_button_down and
+ treeview.drag_check_threshold(self.drag_start_x,
+ self.drag_start_y, int(event.x), int(event.y))):
+ self.delaying_press = False
+ treeview.drag_begin(self._gtk_target_list(self.drag_data.keys()),
+ self.drag_source.allowed_actions(), 1, event)
+
+ @staticmethod
+ def _gtk_target_list(types):
+ count = itertools.count()
+ return [(type, gtk.TARGET_SAME_APP, count.next()) for type in types]
+
+class HotspotTrackingMixin(object):
+ def __init__(self):
+ self.hotspot_tracker = None
+ self.create_signal('hotspot-clicked')
+ self._hotspot_callback_handles = []
+ self._connect_hotspot_signals()
+ self.wrapped_widget_connect('unrealize', self.on_hotspot_unrealize)
+
+ def _connect_hotspot_signals(self):
+ SIGNALS = {
+ 'row-inserted': self.on_row_inserted,
+ 'row-deleted': self.on_row_deleted,
+ 'row-changed': self.on_row_changed,
+ }
+ self._hotspot_callback_handles.extend(
+ weak_connect(self._model, signal, handler)
+ for signal, handler in SIGNALS.iteritems())
+
+ def _disconnect_hotspot_signals(self):
+ for handle in self._hotspot_callback_handles:
+ self._model.disconnect(handle)
+ self._hotspot_callback_handles = []
+
+ def on_row_inserted(self, model, path, iter_):
+ if self.hotspot_tracker:
+ self.hotspot_tracker.redraw_cell()
+ self.hotspot_tracker = None
+
+ def on_row_deleted(self, model, path):
+ if self.hotspot_tracker:
+ self.hotspot_tracker.redraw_cell()
+ self.hotspot_tracker = None
+
+ def on_row_changed(self, model, path, iter_):
+ if self.hotspot_tracker:
+ self.hotspot_tracker.update_hit()
+
+ def handle_hotspot_hit(self, treeview, event):
+ """Check whether the event is a hotspot event; return whether handled
+ here.
+ """
+ if self.hotspot_tracker:
+ return
+ hotspot_tracker = HotspotTracker(treeview, event)
+ if hotspot_tracker.hit:
+ self.hotspot_tracker = hotspot_tracker
+ hotspot_tracker.redraw_cell()
+ if hotspot_tracker.is_for_context_menu():
+ menu = self._popup_context_menu(self.hotspot_tracker.path, event)
+ if menu:
+ menu.connect('selection-done',
+ self._on_hotspot_context_menu_selection_done)
+ # grab keyboard focus since we handled the event
+ self.focus()
+ return True
+
+ def _on_hotspot_context_menu_selection_done(self, menu):
+ # context menu is closed, we won't get the button-release-event in
+ # this case, but we can unset hotspot tracker here.
+ if self.hotspot_tracker:
+ self.hotspot_tracker.redraw_cell()
+ self.hotspot_tracker = None
+
+ def on_hotspot_unrealize(self, treeview):
+ self.hotspot_tracker = None
+
+ def release_on_hotspot(self, event):
+ """A button_release occurred; return whether it has been handled as a
+ hotspot hit.
+ """
+ hotspot_tracker = self.hotspot_tracker
+ if hotspot_tracker and event.button == hotspot_tracker.button:
+ hotspot_tracker.update_position(event)
+ hotspot_tracker.update_hit()
+ if (hotspot_tracker.hit and
+ not hotspot_tracker.is_for_context_menu()):
+ self.emit('hotspot-clicked', hotspot_tracker.name,
+ hotspot_tracker.iter)
+ hotspot_tracker.redraw_cell()
+ self.hotspot_tracker = None
+ return True
+
+ def hotspot_model_changed(self):
+ """A bulk change has ended; reconnect signals and update hotspots."""
+ self._connect_hotspot_signals()
+ if self.hotspot_tracker:
+ self.hotspot_tracker.redraw_cell()
+ self.hotspot_tracker.update_hit()
+
+class ColumnOwnerMixin(object):
+ """Keeps track of the table's columns - including the list of columns, and
+ properties that we set for a table but need to apply to each column.
+
+ This manages:
+ columns
+ attr_map_for_column
+ gtk_column_to_wrapper
+ for use throughout tableview.
+ """
+ def __init__(self):
+ self._columns_draggable = False
+ self._renderer_xpad = self._renderer_ypad = 0
+ self.columns = []
+ self.attr_map_for_column = {}
+ self.gtk_column_to_wrapper = {}
+ self.create_signal('reallocate-columns') # not emitted on GTK
+
+ def remove_column(self, index):
+ """Remove a column from the display and forget it from the column lists.
+ """
+ column = self.columns.pop(index)
+ del self.attr_map_for_column[column._column]
+ del self.gtk_column_to_wrapper[column._column]
+ self._widget.remove_column(column._column)
+
+ def get_columns(self):
+ """Returns the current columns, in order, by title."""
+ # FIXME: this should probably return column objects, and really should
+ # not be keeping track of columns by title at all
+ titles = [column.get_title().decode('utf-8')
+ for column in self._widget.get_columns()]
+ return titles
+
+ def add_column(self, column):
+ """Append a column to this table; setup all necessary mappings, and
+ setup the new column's properties to match the table's settings.
+ """
+ self.model.check_new_column(column)
+ self._widget.append_column(column._column)
+ self.columns.append(column)
+ self.attr_map_for_column[column._column] = column.attrs
+ self.gtk_column_to_wrapper[column._column] = column
+ self.setup_new_column(column)
+
+ def setup_new_column(self, column):
+ """Apply properties that we keep track of at the table level to a
+ newly-created column.
+ """
+ if self.background_color:
+ column.renderer._renderer.set_property('cell-background-gdk',
+ self.background_color)
+ column._column.set_reorderable(self._columns_draggable)
+ if column.do_horizontal_padding:
+ column.renderer._renderer.set_property('xpad', self._renderer_xpad)
+ column.renderer._renderer.set_property('ypad', self._renderer_ypad)
+
+ def set_column_spacing(self, space):
+ """Set the amount of space between columns."""
+ self._renderer_xpad = space / 2
+ for column in self.columns:
+ if column.do_horizontal_padding:
+ column.renderer._renderer.set_property('xpad',
+ self._renderer_xpad)
+
+ def set_row_spacing(self, space):
+ """Set the amount of space between columns."""
+ self._renderer_ypad = space / 2
+ for column in self.columns:
+ column.renderer._renderer.set_property('ypad', self._renderer_ypad)
+
+ def set_columns_draggable(self, setting):
+ """Set the draggability of existing and future columns."""
+ self._columns_draggable = setting
+ for column in self.columns:
+ column._column.set_reorderable(setting)
+
+ def set_column_background_color(self):
+ """Set the background color of existing columns to the table's
+ background_color.
+ """
+ for column in self.columns:
+ column.renderer._renderer.set_property('cell-background-gdk',
+ self.background_color)
+
+ def set_auto_resizes(self, setting):
+ # FIXME: to be implemented.
+ # At this point, GTK somehow does the right thing anyway in terms of
+ # auto-resizing. I'm not sure exactly what's happening, but I believe
+ # that if the column widths don't add up to the total width,
+ # gtk.TreeView allocates extra width for the last column. This works
+ # well enough for the tab list and item list, since there's only one
+ # column.
+ pass
+
+class HoverTrackingMixin(object):
+ """Handle mouse hover events - tooltips for some cells and hover events for
+ renderers which support them.
+ """
+ def __init__(self):
+ self.hover_info = None
+ self.hover_pos = None
+ if hasattr(self, 'get_tooltip'):
+ # this should probably be something like self.set_tooltip_source
+ self._widget.set_property('has-tooltip', True)
+ self.wrapped_widget_connect('query-tooltip', self.on_tooltip)
+ self._last_tooltip_place = None
+
+ def on_tooltip(self, treeview, x, y, keyboard_mode, tooltip):
+ # x, y are relative to the entire widget, but we want them to be
+ # relative to our bin window. The bin window doesn't include things
+ # like the column headers.
+ origin = treeview.window.get_origin()
+ bin_origin = treeview.get_bin_window().get_origin()
+ x += origin[0] - bin_origin[0]
+ y += origin[1] - bin_origin[1]
+ path_info = treeview.get_position_info(x, y)
+ if path_info is None:
+ self._last_tooltip_place = None
+ return False
+ if (self._last_tooltip_place is not None and
+ path_info[:2] != self._last_tooltip_place):
+ # the default GTK behavior is to keep the tooltip in the same
+ # position, but this is looks bad when we move to a different row.
+ # So return False once to stop this.
+ self._last_tooltip_place = None
+ return False
+ self._last_tooltip_place = path_info[:2]
+ iter_ = treeview.get_model().get_iter(path_info.path)
+ column = self.gtk_column_to_wrapper[path_info.column]
+ text = self.get_tooltip(iter_, column)
+ if text is None:
+ return False
+ pygtkhacks.set_tooltip_text(tooltip, text)
+ return True
+
+ def _update_hover(self, treeview, event):
+ old_hover_info, old_hover_pos = self.hover_info, self.hover_pos
+ path_info = treeview.get_position_info(event.x, event.y)
+ if (path_info and
+ self.gtk_column_to_wrapper[path_info.column].renderer.want_hover):
+ self.hover_info = path_info.path, path_info.column
+ self.hover_pos = path_info.x, path_info.y
+ else:
+ self.hover_info = None
+ self.hover_pos = None
+ if (old_hover_info != self.hover_info or
+ old_hover_pos != self.hover_pos):
+ if (old_hover_info != self.hover_info and
+ old_hover_info is not None):
+ self._redraw_cell(treeview, *old_hover_info)
+ if self.hover_info is not None:
+ self._redraw_cell(treeview, *self.hover_info)
+
+class GTKScrollbarOwnerMixin(ScrollbarOwnerMixin):
+ # XXX this is half a wrapper for TreeViewScrolling. A lot of things will
+ # become much simpler when we integrate TVS into this
+ def __init__(self):
+ ScrollbarOwnerMixin.__init__(self)
+ # super uses this for postponed scroll_to_iter
+ # it's a faux-signal from our _widget; this hack is only necessary until
+ # we integrate TVS
+ self._widget.scroll_range_changed = (lambda *a:
+ self.emit('scroll-range-changed'))
+
+ def set_scroller(self, scroller):
+ """Set the Scroller object for this widget, if its ScrolledWindow is
+ not a direct ancestor of the object. Standard View needs this.
+ """
+ self._widget.set_scroller(scroller._widget)
+
+ def _set_scroll_position(self, scroll_pos):
+ self._widget.set_scroll_position(scroll_pos)
+
+ def _get_item_area(self, iter_):
+ return self._widget.get_path_rect(self.get_path(iter_))
+
+ @property
+ def _manually_scrolled(self):
+ return self._widget.manually_scrolled
+
+ @property
+ def _position_set(self):
+ return self._widget.position_set
+
+ def _get_visible_area(self):
+ """Return the Rect of the visible area, in tree coords.
+
+ get_visible_rect gets this wrong for StandardView, always returning an
+ origin of (0, 0) - this is because our ScrolledWindow is not our direct
+ parent.
+ """
+ bars = self._widget._scrollbars
+ x, y = (int(adj.get_value()) for adj in bars)
+ width, height = (int(adj.get_page_size()) for adj in bars)
+ if height == 0:
+ # this happens even after _widget._coords_working
+ raise WidgetNotReadyError('visible height')
+ return Rect(x, y, width, height)
+
+ def _get_scroll_position(self):
+ """Get the current position of both scrollbars, to restore later."""
+ try:
+ return tuple(int(bar.get_value()) for bar in self._widget._scrollbars)
+ except WidgetNotReadyError:
+ return None
+
+class TableView(Widget, GTKSelectionOwnerMixin, DNDHandlerMixin,
+ HotspotTrackingMixin, ColumnOwnerMixin, HoverTrackingMixin,
+ GTKScrollbarOwnerMixin):
+ """https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+
+ draws_selection = True
+
+ def __init__(self, model, custom_headers=False):
+ Widget.__init__(self)
+ self.set_widget(MiroTreeView())
+ self.model = model
+ self.model.add_to_tableview(self._widget)
+ self._model = self._widget.get_model()
+ wrappermap.add(self._model, model)
+ self._setup_colors()
+ self.background_color = None
+ self.context_menu_callback = None
+ self.in_bulk_change = False
+ self.delaying_press = False
+ self._use_custom_headers = False
+ self.layout_manager = LayoutManager(self._widget)
+ self.height_changed = None # 17178 hack
+ self._connect_signals()
+ # setting up mixins after general TableView init
+ GTKSelectionOwnerMixin.__init__(self)
+ DNDHandlerMixin.__init__(self)
+ HotspotTrackingMixin.__init__(self)
+ ColumnOwnerMixin.__init__(self)
+ HoverTrackingMixin.__init__(self)
+ GTKScrollbarOwnerMixin.__init__(self)
+ if custom_headers:
+ self._enable_custom_headers()
+
+ # FIXME: should implement set_model() and make None a special case.
+ def unset_model(self):
+ """Disconnect our model from this table view.
+
+ This should be called when you want to destroy a TableView and
+ there's a new TableView sharing its model.
+ """
+ self._widget.set_model(None)
+ self.model = None
+
+ def _connect_signals(self):
+ self.create_signal('row-expanded')
+ self.create_signal('row-collapsed')
+ self.create_signal('row-clicked')
+ self.create_signal('row-activated')
+ self.wrapped_widget_connect('row-activated', self.on_row_activated)
+ self.wrapped_widget_connect('row-expanded', self.on_row_expanded)
+ self.wrapped_widget_connect('row-collapsed', self.on_row_collapsed)
+ self.wrapped_widget_connect('button-press-event', self.on_button_press)
+ self.wrapped_widget_connect('button-release-event',
+ self.on_button_release)
+ self.wrapped_widget_connect('motion-notify-event',
+ self.on_motion_notify)
+
+ def set_gradient_highlight(self, gradient):
+ # This is just an OS X thing.
+ pass
+
+ def set_background_color(self, color):
+ self.background_color = self.make_color(color)
+ self.modify_style('base', gtk.STATE_NORMAL, self.background_color)
+ if not self.draws_selection:
+ self.modify_style('base', gtk.STATE_SELECTED,
+ self.background_color)
+ self.modify_style('base', gtk.STATE_ACTIVE, self.background_color)
+ if self.use_custom_style:
+ self.set_column_background_color()
+
+ def set_group_lines_enabled(self, enabled):
+ """Enable/Disable group lines.
+
+ This only has an effect if our model is an InfoListModel and it has a
+ grouping set.
+
+ If group lines are enabled, we will draw a line below the last item in
+ the group. Use set_group_line_style() to change the look of the line.
+ """
+ self._widget.group_lines_enabled = enabled
+ self.queue_redraw()
+
+ def set_group_line_style(self, color, width):
+ self._widget.group_line_color = color
+ self._widget.group_line_width = width
+ self.queue_redraw()
+
+ def handle_custom_style_change(self):
+ if self.background_color is not None:
+ if self.use_custom_style:
+ self.set_column_background_color()
+ else:
+ for column in self.columns:
+ column.renderer._renderer.set_property(
+ 'cell-background-set', False)
+
+ def set_alternate_row_backgrounds(self, setting):
+ self._widget.set_rules_hint(setting)
+
+ def set_grid_lines(self, horizontal, vertical):
+ if horizontal and vertical:
+ setting = gtk.TREE_VIEW_GRID_LINES_BOTH
+ elif horizontal:
+ setting = gtk.TREE_VIEW_GRID_LINES_HORIZONTAL
+ elif vertical:
+ setting = gtk.TREE_VIEW_GRID_LINES_VERTICAL
+ else:
+ setting = gtk.TREE_VIEW_GRID_LINES_NONE
+ self._widget.set_grid_lines(setting)
+
+ def width_for_columns(self, total_width):
+ """Given the width allocated for the TableView, return how much of that
+ is available to column contents. Note that this depends on the number of
+ columns.
+ """
+ column_spacing = TableColumn.FIXED_PADDING * len(self.columns)
+ return total_width - column_spacing
+
+ def enable_album_view_focus_hack(self):
+ _install_album_view_gtkrc()
+ self._widget.set_name("miro-album-view")
+
+ def focus(self):
+ self._widget.grab_focus()
+
+ def _enable_custom_headers(self):
+ # NB: this is currently not used because the GTK tableview does not
+ # support custom headers.
+ self._use_custom_headers = True
+
+ def set_show_headers(self, show):
+ self._widget.set_headers_visible(show)
+ self._widget.set_headers_clickable(show)
+
+ def _setup_colors(self):
+ style = self._widget.style
+ if not self.draws_selection:
+ # if we don't want to draw selection, make the selected/active
+ # colors the same as the normal ones
+ self.modify_style('base', gtk.STATE_SELECTED,
+ style.base[gtk.STATE_NORMAL])
+ self.modify_style('base', gtk.STATE_ACTIVE,
+ style.base[gtk.STATE_NORMAL])
+
+ def set_search_column(self, model_index):
+ self._widget.set_search_column(model_index)
+
+ def set_fixed_height(self, fixed_height):
+ self._widget.set_fixed_height_mode(fixed_height)
+
+ def set_row_expanded(self, iter_, expanded):
+ """Expand or collapse the row specified by iter_. Succeeds or raises
+ WidgetActionError. Causes row-expanded or row-collapsed to be emitted
+ when successful.
+ """
+ path = self.get_path(iter_)
+ if expanded:
+ self._widget.expand_row(path, False)
+ else:
+ self._widget.collapse_row(path)
+ if bool(self._widget.row_expanded(path)) != bool(expanded):
+ raise WidgetActionError("cannot expand the given item - it "
+ "probably has no children.")
+
+ def is_row_expanded(self, iter_):
+ path = self.get_path(iter_)
+ return self._widget.row_expanded(path)
+
+ def set_context_menu_callback(self, callback):
+ self.context_menu_callback = callback
+
+ # GTK is really good and it is safe to operate on table even when
+ # cells may be constantly changing in flux.
+ def set_volatile(self, volatile):
+ return
+
+ def on_row_expanded(self, _widget, iter_, path):
+ self.emit('row-expanded', iter_, path)
+
+ def on_row_collapsed(self, _widget, iter_, path):
+ self.emit('row-collapsed', iter_, path)
+
+ def on_button_press(self, treeview, event):
+ """Handle a mouse button press"""
+ if event.type == gtk.gdk._2BUTTON_PRESS:
+ # already handled as row-activated
+ return False
+
+ path_info = treeview.get_position_info(event.x, event.y)
+ if not path_info:
+ # no item was clicked, so it's not going to be a hotspot, drag, or
+ # context menu
+ return False
+ if event.type == gtk.gdk.BUTTON_PRESS:
+ # single click; emit the event but keep on running so we can handle
+ # stuff like drag and drop.
+ if not self._x_coord_in_expander(treeview, path_info):
+ iter_ = treeview.get_model().get_iter(path_info.path)
+ self.emit('row-clicked', iter_)
+
+ if (event.button == 1 and self.handle_hotspot_hit(treeview, event)):
+ return True
+ if event.window != treeview.get_bin_window():
+ # click is outside the content area, don't try to handle this.
+ # In particular, our DnD code messes up resizing table columns.
+ return False
+ if (event.button == 1 and self.drag_source and
+ not self._x_coord_in_expander(treeview, path_info)):
+ return self.start_drag(treeview, event, path_info)
+ elif event.button == 3 and self.context_menu_callback:
+ self.show_context_menu(treeview, event, path_info)
+ return True
+
+ # FALLTHROUGH
+ return False
+
+ def show_context_menu(self, treeview, event, path_info):
+ """Pop up a context menu for the given click event (which is a
+ right-click on a row).
+ """
+ # hack for album view
+ if (treeview.group_lines_enabled and
+ path_info.column == treeview.get_columns()[0]):
+ self._select_all_rows_in_group(treeview, path_info.path)
+ self._popup_context_menu(path_info.path, event)
+ # grab keyboard focus since we handled the event
+ self.focus()
+
+ def _select_all_rows_in_group(self, treeview, path):
+ """Select all items in the group """
+
+ # FIXME: this is very tightly coupled with the portable code.
+
+ infolist = self.model
+ gtk_model = treeview.get_model()
+ if (not isinstance(infolist, InfoListModel) or
+ infolist.get_grouping() is None):
+ return
+ it = gtk_model.get_iter(path)
+ info, attrs, group_info = infolist.row_for_iter(it)
+ start_row = path[0] - group_info[0]
+ total_rows = group_info[1]
+
+ with self._ignoring_changes():
+ self.unselect_all()
+ for row in xrange(start_row, start_row + total_rows):
+ self.select_path((row,))
+ self.emit('selection-changed')
+
+ def _popup_context_menu(self, path, event):
+ if not self.selection.path_is_selected(path):
+ self.unselect_all(signal=False)
+ self.select_path(path)
+ menu = self.make_context_menu()
+ if menu:
+ menu.popup(None, None, None, event.button, event.time)
+ return menu
+ else:
+ return None
+
+ # XXX treeview.get_cell_area handles what we're trying to use this for
+ def _x_coord_in_expander(self, treeview, path_info):
+ """Calculate if an x coordinate is over the expander triangle
+
+ :param treeview: Gtk.TreeView
+ :param path_info: PathInfo(
+ tree path for the cell,
+ Gtk.TreeColumn,
+ x coordinate relative to column's cell area,
+ y coordinate relative to column's cell area (ignored),
+ )
+ """
+ if path_info.column != treeview.get_expander_column():
+ return False
+ model = treeview.get_model()
+ if not model.iter_has_child(model.get_iter(path_info.path)):
+ return False
+ # GTK allocateds an extra 4px to the right of the expanders. This
+ # seems to be hardcoded as EXPANDER_EXTRA_PADDING in the source code.
+ total_exander_size = treeview.expander_size + 4
+ # include horizontal_separator
+ # XXX: should this value be included in total_exander_size ?
+ offset = treeview.horizontal_separator / 2
+ # allocate space for expanders for parent nodes
+ expander_start = total_exander_size * (len(path_info.path) - 1) + offset
+ expander_end = expander_start + total_exander_size + offset
+ return expander_start <= path_info.x < expander_end
+
+ def on_row_activated(self, treeview, path, view_column):
+ iter_ = treeview.get_model().get_iter(path)
+ self.emit('row-activated', iter_)
+
+ def make_context_menu(self):
+ def gen_menu(menu_items):
+ menu = gtk.Menu()
+ for menu_item_info in menu_items:
+ if menu_item_info is None:
+ item = gtk.SeparatorMenuItem()
+ else:
+ label, callback = menu_item_info
+
+ if isinstance(label, tuple) and len(label) == 2:
+ text_label, icon_path = label
+ pixbuf = gtk.gdk.pixbuf_new_from_file(icon_path)
+ image = gtk.Image()
+ image.set_from_pixbuf(pixbuf)
+ item = gtk.ImageMenuItem(text_label)
+ item.set_image(image)
+ else:
+ item = gtk.MenuItem(label)
+
+ if callback is None:
+ item.set_sensitive(False)
+ elif isinstance(callback, list):
+ item.set_submenu(gen_menu(callback))
+ else:
+ item.connect('activate', self.on_context_menu_activate,
+ callback)
+ menu.append(item)
+ item.show()
+ return menu
+
+ items = self.context_menu_callback(self)
+ if items:
+ return gen_menu(items)
+ else:
+ return None
+
+ def on_context_menu_activate(self, item, callback):
+ callback()
+
+ def on_button_release(self, treeview, event):
+ if self.release_on_hotspot(event):
+ return True
+ if event.button == 1:
+ self.drag_button_down = False
+
+ if self.delaying_press:
+ # if dragging did not happen, unselect other rows and
+ # select current row
+ path_info = treeview.get_position_info(event.x, event.y)
+ if path_info is not None:
+ self.unselect_all(signal=False)
+ self.select_path(path_info.path)
+ self.delaying_press = False
+
+ def _redraw_cell(self, treeview, path, column):
+ cell_area = treeview.get_cell_area(path, column)
+ x, y = treeview.convert_bin_window_to_widget_coords(cell_area.x,
+ cell_area.y)
+ treeview.queue_draw_area(x, y, cell_area.width, cell_area.height)
+
+ def on_motion_notify(self, treeview, event):
+ self._update_hover(treeview, event)
+
+ if self.hotspot_tracker:
+ self.hotspot_tracker.update_position(event)
+ self.hotspot_tracker.update_hit()
+ return True
+
+ self.potential_drag_motion(treeview, event)
+ return None # XXX: used to fall through; not sure what retval does here
+
+ def start_bulk_change(self):
+ self._widget.freeze_child_notify()
+ self._widget.set_model(None)
+ self._disconnect_hotspot_signals()
+ self.in_bulk_change = True
+
+ def model_changed(self):
+ if self.in_bulk_change:
+ self._widget.set_model(self._model)
+ self._widget.thaw_child_notify()
+ self.hotspot_model_changed()
+ self.in_bulk_change = False
+
+ def get_path(self, iter_):
+ """Always use this rather than the model's get_path directly -
+ if the iter isn't valid, a GTK assertion causes us to exit
+ without warning; this wrapper changes that to a much more useful
+ AssertionError. Example related bug: #17362.
+ """
+ assert self.model.iter_is_valid(iter_)
+ return self._model.get_path(iter_)
+
+class TableModel(object):
+ """https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ MODEL_CLASS = gtk.ListStore
+
+ def __init__(self, *column_types):
+ self._model = self.MODEL_CLASS(*self.map_types(column_types))
+ self._column_types = column_types
+ if 'image' in self._column_types:
+ self.convert_row_for_gtk = self.convert_row_for_gtk_slow
+ self.convert_value_for_gtk = self.convert_value_for_gtk_slow
+ else:
+ self.convert_row_for_gtk = self.convert_row_for_gtk_fast
+ self.convert_value_for_gtk = self.convert_value_for_gtk_fast
+
+ def add_to_tableview(self, widget):
+ widget.set_model(self._model)
+
+ def map_types(self, miro_column_types):
+ type_map = {
+ 'boolean': bool,
+ 'numeric': float,
+ 'integer': int,
+ 'text': str,
+ 'image': gtk.gdk.Pixbuf,
+ 'datetime': object,
+ 'object': object,
+ }
+ try:
+ return [type_map[type] for type in miro_column_types]
+ except KeyError, e:
+ raise ValueError("Unknown column type: %s" % e[0])
+
+ # If we store image data, we need to do some work to convert row data to
+ # send to GTK
+ def convert_value_for_gtk_slow(self, column_value):
+ if isinstance(column_value, Image):
+ return column_value.pixbuf
+ else:
+ return column_value
+
+ def convert_row_for_gtk_slow(self, column_values):
+ return tuple(self.convert_value_for_gtk(c) for c in column_values)
+
+ def check_new_column(self, column):
+ for value in column.attrs.values():
+ if not isinstance(value, int):
+ msg = "Attribute values must be integers, not %r" % value
+ raise TypeError(msg)
+ if value < 0 or value >= len(self._column_types):
+ raise ValueError("Attribute index out of range: %s" % value)
+
+ # If we don't store image data, we can don't need to do any work to
+ # convert row data to gtk
+ def convert_value_for_gtk_fast(self, value):
+ return value
+
+ def convert_row_for_gtk_fast(self, column_values):
+ return column_values
+
+ def append(self, *column_values):
+ return self._model.append(self.convert_row_for_gtk(column_values))
+
+ def update_value(self, iter_, index, value):
+ assert self._model.iter_is_valid(iter_)
+ self._model.set(iter_, index, self.convert_value_for_gtk(value))
+
+ def update(self, iter_, *column_values):
+ self._model[iter_] = self.convert_value_for_gtk(column_values)
+
+ def remove(self, iter_):
+ if self._model.remove(iter_):
+ return iter_
+ else:
+ return None
+
+ def insert_before(self, iter_, *column_values):
+ row = self.convert_row_for_gtk(column_values)
+ return self._model.insert_before(iter_, row)
+
+ def first_iter(self):
+ return self._model.get_iter_first()
+
+ def next_iter(self, iter_):
+ return self._model.iter_next(iter_)
+
+ def nth_iter(self, index):
+ assert index >= 0
+ return self._model.iter_nth_child(None, index)
+
+ def __iter__(self):
+ return iter(self._model)
+
+ def __len__(self):
+ return len(self._model)
+
+ def __getitem__(self, iter_):
+ return self._model[iter_]
+
+ def get_rows(self, row_paths):
+ return [self._model[path] for path in row_paths]
+
+ def get_path(self, iter_):
+ return self._model.get_path(iter_)
+
+ def iter_is_valid(self, iter_):
+ return self._model.iter_is_valid(iter_)
+
+class TreeTableModel(TableModel):
+ """https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ MODEL_CLASS = gtk.TreeStore
+
+ def append(self, *column_values):
+ return self._model.append(None, self.convert_row_for_gtk(
+ column_values))
+
+ def insert_before(self, iter_, *column_values):
+ parent = self.parent_iter(iter_)
+ row = self.convert_row_for_gtk(column_values)
+ return self._model.insert_before(parent, iter_, row)
+
+ def append_child(self, iter_, *column_values):
+ return self._model.append(iter_, self.convert_row_for_gtk(
+ column_values))
+
+ def child_iter(self, iter_):
+ return self._model.iter_children(iter_)
+
+ def nth_child_iter(self, iter_, index):
+ assert index >= 0
+ return self._model.iter_nth_child(iter_, index)
+
+ def has_child(self, iter_):
+ return self._model.iter_has_child(iter_)
+
+ def children_count(self, iter_):
+ return self._model.iter_n_children(iter_)
+
+ def parent_iter(self, iter_):
+ assert self._model.iter_is_valid(iter_)
+ return self._model.iter_parent(iter_)
diff --git a/lvc/widgets/gtk/tableviewcells.py b/lvc/widgets/gtk/tableviewcells.py
new file mode 100644
index 0000000..6511970
--- /dev/null
+++ b/lvc/widgets/gtk/tableviewcells.py
@@ -0,0 +1,249 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+"""tableviewcells.py - Cell renderers for TableView."""
+
+import gobject
+import gtk
+import pango
+
+from lvc import signals
+from lvc.widgets import widgetconst
+import drawing
+import wrappermap
+from .base import make_gdk_color
+
+class CellRenderer(object):
+ """Simple Cell Renderer
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ def __init__(self):
+ self._renderer = gtk.CellRendererText()
+ self.want_hover = False
+
+ def setup_attributes(self, column, attr_map):
+ column.add_attribute(self._renderer, 'text', attr_map['value'])
+
+ def set_align(self, align):
+ if align == 'left':
+ self._renderer.props.xalign = 0.0
+ elif align == 'center':
+ self._renderer.props.xalign = 0.5
+ elif align == 'right':
+ self._renderer.props.xalign = 1.0
+ else:
+ raise ValueError("unknown alignment: %s" % align)
+
+ def set_color(self, color):
+ self._renderer.props.foreground_gdk = make_gdk_color(color)
+
+ def set_bold(self, bold):
+ font_desc = self._renderer.props.font_desc
+ if bold:
+ font_desc.set_weight(pango.WEIGHT_BOLD)
+ else:
+ font_desc.set_weight(pango.WEIGHT_NORMAL)
+ self._renderer.props.font_desc = font_desc
+
+ def set_text_size(self, size):
+ if size == widgetconst.SIZE_NORMAL:
+ self._renderer.props.scale = 1.0
+ elif size == widgetconst.SIZE_SMALL:
+ # FIXME: on 3.5 we just ignored the call. Always setting scale to
+ # 1.0 basically replicates that behavior, but should we actually
+ # try to implement the semantics of SIZE_SMALL?
+ self._renderer.props.scale = 1.0
+ else:
+ raise ValueError("unknown size: %s" % size)
+
+ def set_font_scale(self, scale_factor):
+ self._renderer.props.scale = scale_factor
+
+class ImageCellRenderer(object):
+ """Cell Renderer for images
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ def __init__(self):
+ self._renderer = gtk.CellRendererPixbuf()
+ self.want_hover = False
+
+ def setup_attributes(self, column, attr_map):
+ column.add_attribute(self._renderer, 'pixbuf', attr_map['image'])
+
+class GTKCheckboxCellRenderer(gtk.CellRendererToggle):
+ def do_activate(self, event, treeview, path, background_area, cell_area,
+ flags):
+ iter = treeview.get_model().get_iter(path)
+ self.set_active(not self.get_active())
+ wrappermap.wrapper(self).emit('clicked', iter)
+
+gobject.type_register(GTKCheckboxCellRenderer)
+
+class CheckboxCellRenderer(signals.SignalEmitter):
+ """Cell Renderer for booleans
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+ def __init__(self):
+ signals.SignalEmitter.__init__(self)
+ self.create_signal("clicked")
+ self._renderer = GTKCheckboxCellRenderer()
+ wrappermap.add(self._renderer, self)
+ self.want_hover = False
+
+ def set_control_size(self, size):
+ pass
+
+ def setup_attributes(self, column, attr_map):
+ column.add_attribute(self._renderer, 'active', attr_map['value'])
+
+class GTKCustomCellRenderer(gtk.GenericCellRenderer):
+ """Handles the GTK hide of CustomCellRenderer
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+
+ def on_get_size(self, widget, cell_area=None):
+ wrapper = wrappermap.wrapper(self)
+ widget_wrapper = wrappermap.wrapper(widget)
+ style = drawing.DrawingStyle(widget_wrapper, use_base_color=True)
+ # NOTE: CustomCellRenderer.cell_data_func() sets up its attributes
+ # from the model itself, so we don't have to worry about setting them
+ # here.
+ width, height = wrapper.get_size(style, widget_wrapper.layout_manager)
+ x_offset = self.props.xpad
+ y_offset = self.props.ypad
+ width += self.props.xpad * 2
+ height += self.props.ypad * 2
+ if cell_area:
+ x_offset += cell_area.x
+ y_offset += cell_area.x
+ extra_width = max(0, cell_area.width - width)
+ extra_height = max(0, cell_area.height - height)
+ x_offset += int(round(self.props.xalign * extra_width))
+ y_offset += int(round(self.props.yalign * extra_height))
+ return x_offset, y_offset, width, height
+
+ def on_render(self, window, widget, background_area, cell_area, expose_area,
+ flags):
+ widget_wrapper = wrappermap.wrapper(widget)
+ cell_wrapper = wrappermap.wrapper(self)
+
+ selected = (flags & gtk.CELL_RENDERER_SELECTED)
+ if selected:
+ if widget.flags() & gtk.HAS_FOCUS:
+ state = gtk.STATE_SELECTED
+ else:
+ state = gtk.STATE_ACTIVE
+ else:
+ state = gtk.STATE_NORMAL
+ if cell_wrapper.IGNORE_PADDING:
+ area = background_area
+ else:
+ xpad = self.props.xpad
+ ypad = self.props.ypad
+ area = gtk.gdk.Rectangle(cell_area.x + xpad, cell_area.y + ypad,
+ cell_area.width - xpad * 2, cell_area.height - ypad * 2)
+ context = drawing.DrawingContext(window, area, expose_area)
+ if (selected and not widget_wrapper.draws_selection and
+ widget_wrapper.use_custom_style):
+ # Draw the base color as our background. This erases the gradient
+ # that GTK draws for selected items.
+ window.draw_rectangle(widget.style.base_gc[state], True,
+ background_area.x, background_area.y,
+ background_area.width, background_area.height)
+ context.style = drawing.DrawingStyle(widget_wrapper,
+ use_base_color=True, state=state)
+ widget_wrapper.layout_manager.update_cairo_context(context.context)
+ hotspot_tracker = widget_wrapper.hotspot_tracker
+ if (hotspot_tracker and hotspot_tracker.hit and
+ hotspot_tracker.column == self.column and
+ hotspot_tracker.path == self.path):
+ hotspot = hotspot_tracker.name
+ else:
+ hotspot = None
+ if (self.path, self.column) == widget_wrapper.hover_info:
+ hover = widget_wrapper.hover_pos
+ hover = (hover[0] - xpad, hover[1] - ypad)
+ else:
+ hover = None
+ # NOTE: CustomCellRenderer.cell_data_func() sets up its attributes
+ # from the model itself, so we don't have to worry about setting them
+ # here.
+ widget_wrapper.layout_manager.reset()
+ cell_wrapper.render(context, widget_wrapper.layout_manager, selected,
+ hotspot, hover)
+
+ def on_activate(self, event, widget, path, background_area, cell_area,
+ flags):
+ pass
+
+ def on_start_editing(self, event, widget, path, background_area,
+ cell_area, flags):
+ pass
+gobject.type_register(GTKCustomCellRenderer)
+
+class CustomCellRenderer(object):
+ """Customizable Cell Renderer
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+
+ IGNORE_PADDING = False
+
+ def __init__(self):
+ self._renderer = GTKCustomCellRenderer()
+ self.want_hover = False
+ wrappermap.add(self._renderer, self)
+
+ def setup_attributes(self, column, attr_map):
+ column.set_cell_data_func(self._renderer, self.cell_data_func,
+ attr_map)
+
+ def cell_data_func(self, column, cell, model, iter, attr_map):
+ cell.column = column
+ cell.path = model.get_path(iter)
+ row = model[iter]
+ # Set attributes on self instead cell This works because cell is just
+ # going to turn around and call our methods to do the rendering.
+ for name, index in attr_map.items():
+ setattr(self, name, row[index])
+
+ def hotspot_test(self, style, layout, x, y, width, height):
+ return None
+
+class InfoListRenderer(CustomCellRenderer):
+ """Custom Renderer for InfoListModels
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+
+ def cell_data_func(self, column, cell, model, iter, attr_map):
+ self.info, self.attrs, self.group_info = \
+ wrappermap.wrapper(model).row_for_iter(iter)
+ cell.column = column
+ cell.path = model.get_path(iter)
+
+class InfoListRendererText(CellRenderer):
+ """Renderer for InfoListModels that only display text
+ https://develop.participatoryculture.org/index.php/WidgetAPITableView"""
+
+ def setup_attributes(self, column, attr_map):
+ infolist.gtk.setup_text_cell_data_func(column, self._renderer,
+ self.get_value)
diff --git a/lvc/widgets/gtk/weakconnect.py b/lvc/widgets/gtk/weakconnect.py
new file mode 100644
index 0000000..204a855
--- /dev/null
+++ b/lvc/widgets/gtk/weakconnect.py
@@ -0,0 +1,56 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+"""weakconnect.py -- Connect to a signal of a GObject using a weak method
+reference. This means that this connection will not keep the object alive.
+This is a good thing because it prevents circular references between wrapper
+widgets and the wrapped GTK widget.
+"""
+
+from lvc import signals
+
+class WeakSignalHandler(object):
+ def __init__(self, method):
+ self.method = signals.WeakMethodReference(method)
+
+ def connect(self, obj, signal, *user_args):
+ self.user_args = user_args
+ self.signal_handle = obj.connect(signal, self.handle_callback)
+ return self.signal_handle
+
+ def handle_callback(self, obj, *args):
+ real_method = self.method()
+ if real_method is not None:
+ return real_method(obj, *(args + self.user_args))
+ else:
+ obj.disconnect(self.signal_handle)
+
+def weak_connect(gobject, signal, method, *user_args):
+ handler = WeakSignalHandler(method)
+ return handler.connect(gobject, signal, *user_args)
diff --git a/lvc/widgets/gtk/widgets.py b/lvc/widgets/gtk/widgets.py
new file mode 100644
index 0000000..6c4280d
--- /dev/null
+++ b/lvc/widgets/gtk/widgets.py
@@ -0,0 +1,47 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".widgets -- Contains portable implementations of
+the GTK Widgets. These are shared between the windows port and the x11 port.
+"""
+
+import gtk
+
+# Just use the GDK Rectangle class
+class Rect(gtk.gdk.Rectangle):
+ @classmethod
+ def from_string(cls, rect_string):
+ x, y, width, height = [int(i) for i in rect_string.split(',')]
+ return Rect(x, y, width, height)
+
+ def __str__(self):
+ return "%d,%d,%d,%d" % (self.x, self.y, self.width, self.height)
+
+ def get_width(self):
+ return self.width
diff --git a/lvc/widgets/gtk/widgetset.py b/lvc/widgets/gtk/widgetset.py
new file mode 100644
index 0000000..c63855c
--- /dev/null
+++ b/lvc/widgets/gtk/widgetset.py
@@ -0,0 +1,63 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+from .base import Widget, Bin
+from .const import *
+from .controls import TextEntry, NumberEntry, \
+ SecureTextEntry, MultilineTextEntry, Checkbox, RadioButton, \
+ RadioButtonGroup, OptionMenu, Button
+from .customcontrols import (
+ CustomButton, DragableCustomButton, CustomSlider,
+ ClickableImageButton)
+# VolumeSlider and VolumeMuter aren't defined if gtk.VolumeButton
+# doesn't have get_popup.
+try:
+ from .customcontrols import (
+ VolumeSlider, VolumeMuter)
+except ImportError:
+ pass
+from .contextmenu import ContextMenu
+from .drawing import ImageSurface, DrawingContext, \
+ DrawingArea, Background, Gradient
+from .layout import HBox, VBox, Alignment, \
+ Splitter, Table, TabContainer, DetachedWindowHolder
+from .window import Window, MainWindow, Dialog, \
+ FileOpenDialog, FileSaveDialog, DirectorySelectDialog, AboutDialog, \
+ AlertDialog, DialogWindow
+from .tableview import (TableView, TableModel,
+ TableColumn, TreeTableModel, CUSTOM_HEADER_HEIGHT)
+from .tableviewcells import (CellRenderer,
+ ImageCellRenderer, CheckboxCellRenderer, CustomCellRenderer,
+ InfoListRenderer, InfoListRendererText)
+from .simple import (Image, ImageDisplay,
+ AnimatedImageDisplay, Label, Scroller, Expander, SolidBackground,
+ ProgressBar, HLine)
+from .widgets import Rect
+from .gtkmenus import (MenuItem, RadioMenuItem, CheckMenuItem, Separator,
+ Menu, MenuBar, MainWindowMenuBar)
diff --git a/lvc/widgets/gtk/window.py b/lvc/widgets/gtk/window.py
new file mode 100644
index 0000000..de912cc
--- /dev/null
+++ b/lvc/widgets/gtk/window.py
@@ -0,0 +1,708 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Jesus Eduardo (Heckyel) | 2017
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".window -- GTK Window widget."""
+
+import gobject
+import gtk
+import os
+
+from lvc import resources
+from lvc import signals
+
+import keymap
+import layout
+import widgets
+import wrappermap
+
+# keeps the objects alive until destroy() is called
+alive_windows = set()
+running_dialogs = set()
+
+class WrappedWindow(gtk.Window):
+ def do_map(self):
+ gtk.Window.do_map(self)
+ wrappermap.wrapper(self).emit('show')
+
+ def do_unmap(self):
+ gtk.Window.do_unmap(self)
+ wrappermap.wrapper(self).emit('hide')
+ def do_focus_in_event(self, event):
+ gtk.Window.do_focus_in_event(self, event)
+ wrappermap.wrapper(self).emit('active-change')
+ def do_focus_out_event(self, event):
+ gtk.Window.do_focus_out_event(self, event)
+ wrappermap.wrapper(self).emit('active-change')
+
+ def do_key_press_event(self, event):
+ if self.activate_key(event): # event activated a menu item
+ return
+
+ if self.propagate_key_event(event): # event handled by widget
+ return
+
+ ret = keymap.translate_gtk_event(event)
+ if ret is not None:
+ key, modifiers = ret
+ rv = wrappermap.wrapper(self).emit('key-press', key, modifiers)
+ if not rv:
+ gtk.Window.do_key_press_event(self, event)
+
+ def _get_focused_wrapper(self):
+ """Get the wrapper of the widget with keyboard focus"""
+ focused = self.get_focus()
+ # some of our widgets created children for their use
+ # (GtkSearchTextEntry). If we don't find a wrapper for
+ # focused, try it's parents
+ while focused is not None:
+ try:
+ wrapper = wrappermap.wrapper(focused)
+ except KeyError:
+ focused = focused.get_parent()
+ else:
+ return wrapper
+ return None
+
+ def change_focus_using_wrapper(self, direction):
+ my_wrapper = wrappermap.wrapper(self)
+ focused_wrapper = self._get_focused_wrapper()
+ if direction == gtk.DIR_TAB_FORWARD:
+ to_focus = my_wrapper.get_next_tab_focus(focused_wrapper, True)
+ elif direction == gtk.DIR_TAB_BACKWARD:
+ to_focus = my_wrapper.get_next_tab_focus(focused_wrapper, False)
+ else:
+ return False
+ if to_focus is not None:
+ to_focus.focus()
+ return True
+ return False
+
+ def do_focus(self, direction):
+ if not self.change_focus_using_wrapper(direction):
+ gtk.Window.do_focus(self, direction)
+
+gobject.type_register(WrappedWindow)
+
+class WindowBase(signals.SignalEmitter):
+ def __init__(self):
+ signals.SignalEmitter.__init__(self)
+ self.create_signal('use-custom-style-changed')
+ self.create_signal('key-press')
+ self.create_signal('show')
+ self.create_signal('hide')
+
+ def set_window(self, window):
+ self._window = window
+ window.connect('style-set', self.on_style_set)
+ wrappermap.add(window, self)
+ self.calc_use_custom_style()
+
+ def on_style_set(self, widget, old_style):
+ old_use_custom_style = self.use_custom_style
+ self.calc_use_custom_style()
+ if old_use_custom_style != self.use_custom_style:
+ self.emit('use-custom-style-changed')
+
+ def calc_use_custom_style(self):
+ if self._window is not None:
+ base = self._window.style.base[gtk.STATE_NORMAL]
+ # Decide if we should use a custom style. Right now the
+ # formula is the base color is a very light shade of
+ # gray/white (lighter than #f0f0f0).
+ self.use_custom_style = ((base.red == base.green == base.blue) and
+ base.red >= 61680)
+
+
+class Window(WindowBase):
+ """The main Libre window. """
+
+ def __init__(self, title, rect=None):
+ """Create the Libre Main Window. Title is the name to give the
+ window, rect specifies the position it should have on screen.
+ """
+ WindowBase.__init__(self)
+ self.set_window(self._make_gtk_window())
+ self._window.set_title(title)
+ self.setup_icon()
+ if rect:
+ self._window.set_default_size(rect.width, rect.height)
+ self._window.set_default_size(rect.width, rect.height)
+ self._window.set_gravity(gtk.gdk.GRAVITY_CENTER)
+ self._window.move(rect.x, rect.y)
+
+ self.create_signal('active-change')
+ self.create_signal('will-close')
+ self.create_signal('did-move')
+ self.create_signal('file-drag-motion')
+ self.create_signal('file-drag-received')
+ self.create_signal('file-drag-leave')
+ self.create_signal('on-shown')
+ self.drag_signals = []
+ alive_windows.add(self)
+
+ self._window.connect('delete-event', self.on_delete_window)
+ self._window.connect('map-event', lambda w, a: self.emit('on-shown'))
+ # XXX: Define MVCWindow/MiroWindow style not hard code this
+ self._window.set_resizable(False)
+
+ def setup_icon(self):
+ icon_pixbuf = gtk.gdk.pixbuf_new_from_file(
+ resources.image_path("lvc-logo.png"))
+ self._window.set_icon(icon_pixbuf)
+
+
+ def accept_file_drag(self, val):
+ if not val:
+ self._window.drag_dest_set(0, [], 0)
+ for handle in self.drag_signals:
+ self.disconnect(handle)
+ self.drag_signals = []
+ else:
+ self._window.drag_dest_set(
+ gtk.DEST_DEFAULT_MOTION | gtk.DEST_DEFAULT_DROP,
+ [('text/uri-list', 0, 0)],
+ gtk.gdk.ACTION_COPY)
+ for signal, callback in (
+ ('drag-motion', self.on_drag_motion),
+ ('drag-data-received', self.on_drag_data_received),
+ ('drag-leave', self.on_drag_leave)):
+ self.drag_signals.append(
+ self._window.connect(signal, callback))
+
+ def on_drag_motion(self, widget, context, x, y, time):
+ self.emit('file-drag-motion')
+
+ def on_drag_data_received(self, widget, context, x, y, selection_data,
+ info, time):
+ self.emit('file-drag-received', selection_data.get_uris())
+
+ def on_drag_leave(self, widget, context, time):
+ self.emit('file-drag-leave')
+
+ def on_delete_window(self, widget, event):
+ # when the user clicks on the X in the corner of the window we
+ # want that to close the window, but also trigger our
+ # will-close signal and all that machinery unless the window
+ # is currently hidden--then we don't do anything.
+ if not self._window.window.is_visible():
+ return
+ self.close()
+ return True
+
+ def _make_gtk_window(self):
+ return WrappedWindow()
+
+ def set_title(self, title):
+ self._window.set_title(title)
+
+ def get_title(self):
+ self._window.get_title()
+
+ def center(self):
+ self._window.set_position(gtk.WIN_POS_CENTER)
+
+ def show(self):
+ if self not in alive_windows:
+ raise ValueError("Window destroyed")
+ self._window.show()
+
+ def close(self):
+ if hasattr(self, "_closing"):
+ return
+ self._closing = True
+ # Keep a reference to the widget in case will-close signal handler
+ # calls destroy()
+ old_window = self._window
+ self.emit('will-close')
+ old_window.hide()
+ del self._closing
+
+ def destroy(self):
+ self.close()
+ self._window = None
+ alive_windows.discard(self)
+
+ def is_active(self):
+ return self._window.is_active()
+
+ def is_visible(self):
+ return self._window.props.visible
+
+ def get_next_tab_focus(self, current, is_forward):
+ return None
+
+ def set_content_widget(self, widget):
+ """Set the widget that will be drawn in the content area for this
+ window.
+
+ It will be allocated the entire area of the widget, except the
+ space needed for the titlebar, frame and other decorations.
+ When the window is resized, content should also be resized.
+ """
+ self._add_content_widget(widget)
+ widget._widget.show()
+ self.content_widget = widget
+
+ def _add_content_widget(self, widget):
+ self._window.add(widget._widget)
+
+ def get_content_widget(self, widget):
+ """Get the current content widget."""
+ return self.content_widget
+
+ def get_frame(self):
+ pos = self._window.get_position()
+ size = self._window.get_size()
+ return widgets.Rect(pos[0], pos[1], size[0], size[1])
+
+ def set_frame(self, x=None, y=None, width=None, height=None):
+ if x is not None or y is not None:
+ pos = self._window.get_position()
+ x = x if x is not None else pos[0]
+ y = y if y is not None else pos[1]
+ self._window.move(x, y)
+
+ if width is not None or height is not None:
+ size = self._window.get_size()
+ width = width if width is not None else size[0]
+ height = height if height is not None else size[1]
+ self._window.resize(width, height)
+
+ def get_monitor_geometry(self):
+ """Returns a Rect of the geometry of the monitor that this
+ window is currently on.
+
+ :returns: Rect
+ """
+ gtkwindow = self._window
+ gdkwindow = gtkwindow.window
+ screen = gtkwindow.get_screen()
+
+ monitor = screen.get_monitor_at_window(gdkwindow)
+ return screen.get_monitor_geometry(monitor)
+
+ def check_position_and_fix(self):
+ """This pulls the geometry of the monitor of the screen this
+ window is on as well as the position of the window.
+
+ It then makes sure that the position y is greater than the
+ monitor geometry y. This makes sure that the titlebar of
+ the window is showing.
+ """
+ gtkwindow = self._window
+ gdkwindow = gtkwindow.window
+ monitor_geom = self.get_monitor_geometry()
+
+ frame_extents = gdkwindow.get_frame_extents()
+ position = gtkwindow.get_position()
+
+ # if the frame is not visible, then we move the window so that
+ # it is
+ if frame_extents.y < monitor_geom.y:
+ gtkwindow.move(position[0],
+ monitor_geom.y + (position[1] - frame_extents.y))
+
+
+
+class DialogWindow(Window):
+ def __init__(self, title, rect=None):
+ Window.__init__(self, title, rect)
+ self._window.set_resizable(False)
+
+class MainWindow(Window):
+ def __init__(self, title, rect):
+ Window.__init__(self, title, rect)
+ self.vbox = gtk.VBox()
+ self._window.add(self.vbox)
+ self.vbox.show()
+ self._add_app_menubar()
+ self.create_signal('save-dimensions')
+ self.create_signal('save-maximized')
+ self._window.connect('key-release-event', self.on_key_release)
+ self._window.connect('window-state-event', self.on_window_state_event)
+ self._window.connect('configure-event', self.on_configure_event)
+
+ def _make_gtk_window(self):
+ return WrappedWindow()
+
+ def on_delete_window(self, widget, event):
+ return True
+
+ def on_configure_event(self, widget, event):
+ (x, y) = self._window.get_position()
+ (width, height) = self._window.get_size()
+ self.emit('save-dimensions', x, y, width, height)
+
+ def on_window_state_event(self, widget, event):
+ maximized = bool(
+ event.new_window_state & gtk.gdk.WINDOW_STATE_MAXIMIZED)
+ self.emit('save-maximized', maximized)
+
+ def on_key_release(self, widget, event):
+ if app.playback_manager.is_playing:
+ if gtk.gdk.keyval_name(event.keyval) in ('Right', 'Left',
+ 'Up', 'Down'):
+ return True
+
+ def _add_app_menubar(self):
+ self.menubar = app.widgetapp.menubar
+ self.vbox.pack_start(self.menubar._widget, expand=False)
+ self.connect_menu_keyboard_shortcuts()
+
+ def _add_content_widget(self, widget):
+ self.vbox.pack_start(widget._widget, expand=True)
+
+
+class DialogBase(WindowBase):
+ def set_transient_for(self, window):
+ self._window.set_transient_for(window._window)
+
+ def run(self):
+ running_dialogs.add(self)
+ try:
+ return self._run()
+ finally:
+ running_dialogs.remove(self)
+ self._window = None
+
+ def _run(self):
+ """Run the dialog. Must be implemented by subclasses."""
+ raise NotImplementedError()
+
+ def destroy(self):
+ if self._window is not None:
+ self._window.response(gtk.RESPONSE_NONE)
+ # don't set self._window to None yet. We will unset it when we
+ # return from the _run() method
+
+class Dialog(DialogBase):
+ def __init__(self, title, description=None):
+ """Create a dialog."""
+ DialogBase.__init__(self)
+ self.create_signal('open')
+ self.create_signal('close')
+ self.set_window(gtk.Dialog(title))
+ self._window.set_default_size(425, -1)
+ self.extra_widget = None
+ self.buttons_to_add = []
+ wrappermap.add(self._window, self)
+ self.description = description
+
+ def build_content(self):
+ packing_vbox = layout.VBox(spacing=20)
+ packing_vbox._widget.set_border_width(6)
+ if self.description is not None:
+ label = gtk.Label(self.description)
+ label.set_line_wrap(True)
+ label.set_size_request(390, -1)
+ label.set_selectable(True)
+ packing_vbox._widget.pack_start(label)
+ if self.extra_widget:
+ packing_vbox._widget.pack_start(self.extra_widget._widget)
+ return packing_vbox
+
+ def add_button(self, text):
+ from lvc.widgets import dialogs
+ _stock = {
+ dialogs.BUTTON_OK.text: gtk.STOCK_OK,
+ dialogs.BUTTON_CANCEL.text: gtk.STOCK_CANCEL,
+ dialogs.BUTTON_YES.text: gtk.STOCK_YES,
+ dialogs.BUTTON_NO.text: gtk.STOCK_NO,
+ dialogs.BUTTON_QUIT.text: gtk.STOCK_QUIT,
+ dialogs.BUTTON_REMOVE.text: gtk.STOCK_REMOVE,
+ dialogs.BUTTON_DELETE.text: gtk.STOCK_DELETE,
+ }
+ if text in _stock:
+ # store both the text and the stock ID
+ text = _stock[text], text
+ self.buttons_to_add.append(text)
+
+ def pack_buttons(self):
+ # There's a couple tricky things here:
+ # 1) We need to add them in the reversed order we got them, since GTK
+ # lays them out left-to-right
+ #
+ # 2) We can't use 0 as a response-id. GTK only reserves positive
+ # response_ids for the user.
+ response_id = len(self.buttons_to_add)
+ for text in reversed(self.buttons_to_add):
+ label = None
+ if isinstance(text, tuple): # stock ID, text
+ text, label = text
+ button = self._window.add_button(text, response_id)
+ if label is not None:
+ button.set_label(label)
+ response_id -= 1
+ self.buttons_to_add = []
+ self._window.set_default_response(1)
+
+ def _run(self):
+ self.pack_buttons()
+ packing_vbox = self.build_content()
+ self._window.vbox.pack_start(packing_vbox._widget, True, True)
+ self._window.show_all()
+ response = self._window.run()
+ self._window.hide()
+ if response == gtk.RESPONSE_DELETE_EVENT:
+ return -1
+ else:
+ return response - 1 # response IDs started at 1
+
+ def set_extra_widget(self, widget):
+ self.extra_widget = widget
+
+ def get_extra_widget(self):
+ return self.extra_widget
+
+class FileDialogBase(DialogBase):
+ def _run(self):
+ ret = self._window.run()
+ self._window.hide()
+ if ret == gtk.RESPONSE_OK:
+ self._files = self._window.get_filenames()
+ return 0
+
+class FileOpenDialog(FileDialogBase):
+ def __init__(self, title):
+ FileDialogBase.__init__(self)
+ self._files = None
+ fcd = gtk.FileChooserDialog(title,
+ action=gtk.FILE_CHOOSER_ACTION_OPEN,
+ buttons=(gtk.STOCK_CANCEL,
+ gtk.RESPONSE_CANCEL,
+ gtk.STOCK_OPEN,
+ gtk.RESPONSE_OK))
+
+ self.set_window(fcd)
+
+ def set_filename(self, text):
+ self._window.set_filename(text)
+
+ def set_select_multiple(self, value):
+ self._window.set_select_multiple(value)
+
+ def add_filters(self, filters):
+ for name, ext_list in filters:
+ f = gtk.FileFilter()
+ f.set_name(name)
+ for mem in ext_list:
+ f.add_pattern('*.%s' % mem)
+ self._window.add_filter(f)
+
+ f = gtk.FileFilter()
+ f.set_name(_('All files'))
+ f.add_pattern('*')
+ self._window.add_filter(f)
+
+ def get_filenames(self):
+ return [unicode(f) for f in self._files]
+
+ def get_filename(self):
+ if self._files is None:
+ # clicked Cancel
+ return None
+ else:
+ return unicode(self._files[0])
+
+ # provide a common interface for file chooser dialogs
+ get_path = get_filename
+ def set_path(self, path):
+ # set_filename puts the whole path in the filename field
+ self._window.set_current_folder(os.path.dirname(path))
+ self._window.set_current_name(os.path.basename(path))
+
+class FileSaveDialog(FileDialogBase):
+ def __init__(self, title):
+ FileDialogBase.__init__(self)
+ self._files = None
+ fcd = gtk.FileChooserDialog(title,
+ action=gtk.FILE_CHOOSER_ACTION_SAVE,
+ buttons=(gtk.STOCK_CANCEL,
+ gtk.RESPONSE_CANCEL,
+ gtk.STOCK_SAVE,
+ gtk.RESPONSE_OK))
+ self.set_window(fcd)
+
+ def set_filename(self, text):
+ self._window.set_current_name(text)
+
+ def get_filename(self):
+ if self._files is None:
+ # clicked Cancel
+ return None
+ else:
+ return unicode(self._files[0])
+
+ # provide a common interface for file chooser dialogs
+ get_path = get_filename
+ def set_path(self, path):
+ # set_filename puts the whole path in the filename field
+ self._window.set_current_folder(os.path.dirname(path))
+ self._window.set_current_name(os.path.basename(path))
+
+class DirectorySelectDialog(FileDialogBase):
+ def __init__(self, title):
+ FileDialogBase.__init__(self)
+ self._files = None
+ choose_str = 'Choose'
+ fcd = gtk.FileChooserDialog(
+ title,
+ action=gtk.FILE_CHOOSER_ACTION_SELECT_FOLDER,
+ buttons=(gtk.STOCK_CANCEL,
+ gtk.RESPONSE_CANCEL,
+ choose_str, gtk.RESPONSE_OK))
+ self.set_window(fcd)
+
+ def set_directory(self, text):
+ self._window.set_filename(text)
+
+ def get_directory(self):
+ if self._files is None:
+ # clicked Cancel
+ return None
+ else:
+ return unicode(self._files[0])
+
+ # provide a common interface for file chooser dialogs
+ get_path = get_directory
+ set_path = set_directory
+
+class AboutDialog(Dialog):
+ def __init__(self):
+ Dialog.__init__(self, "Libre Video Converter")
+# _("About %(appname)s",
+# {'appname': app.config.get(prefs.SHORT_APP_NAME)}))
+# self.add_button(_("Close"))
+ self.add_button("Close")
+ self._window.set_has_separator(False)
+
+ def build_content(self):
+ packing_vbox = layout.VBox(spacing=20)
+ #icon_pixbuf = gtk.gdk.pixbuf_new_from_file_at_size(
+ # resources.share_path('icons/hicolor/128x128/apps/miro.png'),
+ # 48, 48)
+ #packing_vbox._widget.pack_start(gtk.image_new_from_pixbuf(icon_pixbuf))
+ #if app.config.get(prefs.APP_REVISION_NUM):
+ # version = "%s (%s)" % (
+ # app.config.get(prefs.APP_VERSION),
+ # app.config.get(prefs.APP_REVISION_NUM))
+ #else:
+ # version = "%s" % app.config.get(prefs.APP_VERSION)
+ version = '1.0.1'
+ #name_label = gtk.Label(
+ # '<span size="xx-large" weight="bold">%s %s</span>' % (
+ # app.config.get(prefs.SHORT_APP_NAME), version))
+ name_label = gtk.Label(
+ '<span size="xx-large" weight="bold">%s %s</span>' % (
+ 'Libre Video Converter', version))
+ name_label.set_use_markup(True)
+ packing_vbox._widget.pack_start(name_label)
+ copyright_text = 'Copyright (c) Jesus Eduardo (Heckyel) | 2017'
+ copyright_label = gtk.Label('<small>%s</small>' % copyright_text)
+ copyright_label.set_use_markup(True)
+ copyright_label.set_justify(gtk.JUSTIFY_CENTER)
+ packing_vbox._widget.pack_start(copyright_label)
+
+ # FIXME - make the project url clickable
+ #packing_vbox._widget.pack_start(
+ # gtk.Label(app.config.get(prefs.PROJECT_URL)))
+
+ #contributor_label = gtk.Label(
+ # _("Thank you to all the people who contributed to %(appname)s "
+ # "%(version)s:",
+ # {"appname": app.config.get(prefs.SHORT_APP_NAME),
+ # "version": app.config.get(prefs.APP_VERSION)}))
+ #contributor_label.set_justify(gtk.JUSTIFY_CENTER)
+ #packing_vbox._widget.pack_start(contributor_label)
+
+ # get contributors, remove newlines and wrap it
+ #contributors = open(resources.path('CREDITS'), 'r').readlines()
+ #contributors = [c[2:].strip()
+ # for c in contributors if c.startswith("* ")]
+ #contributors = ", ".join(contributors)
+
+ # show contributors
+ #contrib_buffer = gtk.TextBuffer()
+ #contrib_buffer.set_text(contributors)
+
+ #contrib_view = gtk.TextView(contrib_buffer)
+ #contrib_view.set_editable(False)
+ #contrib_view.set_cursor_visible(False)
+ #contrib_view.set_wrap_mode(gtk.WRAP_WORD)
+ #contrib_window = gtk.ScrolledWindow()
+ #contrib_window.set_policy(gtk.POLICY_NEVER, gtk.POLICY_ALWAYS)
+ #contrib_window.add(contrib_view)
+ #contrib_window.set_size_request(-1, 100)
+ #packing_vbox._widget.pack_start(contrib_window)
+
+ # FIXME - make the project url clickable
+ #donate_label = gtk.Label(
+ # _("To help fund continued %(appname)s development, visit the "
+ # "donation page at:",
+ # {"appname": app.config.get(prefs.SHORT_APP_NAME)}))
+ #donate_label.set_justify(gtk.JUSTIFY_CENTER)
+ #packing_vbox._widget.pack_start(donate_label)
+
+ #packing_vbox._widget.pack_start(
+ # gtk.Label(app.config.get(prefs.DONATE_URL)))
+ return packing_vbox
+
+ def on_contrib_link_event(self, texttag, widget, event, iter_):
+ if event.type == gtk.gdk.BUTTON_PRESS:
+ resources.open_url('https://notabug.org/heckyel/librevideoconverter')
+
+type_map = {
+ 0: gtk.MESSAGE_WARNING,
+ 1: gtk.MESSAGE_INFO,
+ 2: gtk.MESSAGE_ERROR
+}
+
+class AlertDialog(DialogBase):
+ def __init__(self, title, description, alert_type):
+ DialogBase.__init__(self)
+ message_type = type_map.get(alert_type, gtk.MESSAGE_INFO)
+ self.set_window(gtk.MessageDialog(type=message_type,
+ message_format=description))
+ self._window.set_title(title)
+ self.description = description
+
+ def add_button(self, text):
+ self._window.add_button(_stock.get(text, text), 1)
+ self._window.set_default_response(1)
+
+ def _run(self):
+ self._window.set_modal(False)
+ self._window.show_all()
+ response = self._window.run()
+ self._window.hide()
+ if response == gtk.RESPONSE_DELETE_EVENT:
+ return -1
+ else:
+ # response IDs start at 1
+ return response - 1
diff --git a/lvc/widgets/gtk/wrappermap.py b/lvc/widgets/gtk/wrappermap.py
new file mode 100644
index 0000000..c2b2aad
--- /dev/null
+++ b/lvc/widgets/gtk/wrappermap.py
@@ -0,0 +1,50 @@
+# @Base: Miro - an RSS based video player application
+# Copyright (C) 2005, 2006, 2007, 2008, 2009, 2010, 2011
+# Participatory Culture Foundation
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation; either version 2 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA
+#
+# In addition, as a special exception, the copyright holders give
+# permission to link the code of portions of this program with the OpenSSL
+# library.
+#
+# You must obey the GNU General Public License in all respects for all of
+# the code used other than OpenSSL. If you modify file(s) with this
+# exception, you may extend this exception to your version of the file(s),
+# but you are not obligated to do so. If you do not wish to do so, delete
+# this exception statement from your version. If you delete this exception
+# statement from all source files in the program, then also delete it here.
+
+""".wrappermap -- Map GTK Widgets to the Libre Widget
+that wraps them.
+"""
+
+import weakref
+
+# Maps gtk windows -> wrapper objects. We use a weak references to prevent
+# circular references between the GTK widget and it's wrapper. (Keeping a
+# reference to the GTK widget is fine, since if the wrapper is alive, the GTK
+# widget should be).
+widget_mapping = weakref.WeakValueDictionary()
+
+def wrapper(gtk_widget):
+ """Find the wrapper widget for a GTK widget."""
+ try:
+ return widget_mapping[gtk_widget]
+ except KeyError:
+ raise KeyError("Widget wrapper no longer exists")
+
+def add(gtk_widget, wrapper):
+ widget_mapping[gtk_widget] = wrapper