diff options
Diffstat (limited to 'lvc/widgets/gtk')
-rw-r--r-- | lvc/widgets/gtk/__init__.py | 65 | ||||
-rw-r--r-- | lvc/widgets/gtk/base.py | 300 | ||||
-rw-r--r-- | lvc/widgets/gtk/const.py | 44 | ||||
-rw-r--r-- | lvc/widgets/gtk/contextmenu.py | 31 | ||||
-rw-r--r-- | lvc/widgets/gtk/controls.py | 337 | ||||
-rw-r--r-- | lvc/widgets/gtk/customcontrols.py | 517 | ||||
-rw-r--r-- | lvc/widgets/gtk/drawing.py | 268 | ||||
-rw-r--r-- | lvc/widgets/gtk/gtkmenus.py | 404 | ||||
-rw-r--r-- | lvc/widgets/gtk/keymap.py | 94 | ||||
-rw-r--r-- | lvc/widgets/gtk/layout.py | 227 | ||||
-rw-r--r-- | lvc/widgets/gtk/layoutmanager.py | 550 | ||||
-rw-r--r-- | lvc/widgets/gtk/simple.py | 313 | ||||
-rw-r--r-- | lvc/widgets/gtk/tableview.py | 1557 | ||||
-rw-r--r-- | lvc/widgets/gtk/tableviewcells.py | 249 | ||||
-rw-r--r-- | lvc/widgets/gtk/weakconnect.py | 56 | ||||
-rw-r--r-- | lvc/widgets/gtk/widgets.py | 47 | ||||
-rw-r--r-- | lvc/widgets/gtk/widgetset.py | 63 | ||||
-rw-r--r-- | lvc/widgets/gtk/window.py | 708 | ||||
-rw-r--r-- | lvc/widgets/gtk/wrappermap.py | 50 |
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 |