Created
January 3, 2013 13:36
-
-
Save achadwick/4443517 to your computer and use it in GitHub Desktop.
Tabbed sidebar interface idea.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python | |
# Interface idea: dragging tabs and suchlike. | |
# Released as Creative Commons Zero: CC0 v1.0 <[email protected]> | |
import gobject | |
import gtk | |
from gtk import gdk | |
import cairo | |
from gettext import gettext as _ | |
from warnings import warn | |
def is_class(obj): | |
"""True if its argument is a class object. | |
>>> import xml.dom | |
>>> is_class(xml.dom.Node) | |
True | |
>>> is_class("a string") | |
False | |
""" | |
if type(obj).__name__ == 'classobj': # Old-style class | |
return True | |
# Potential new-style class | |
try: | |
return issubclass(obj, object) | |
except TypeError: | |
pass | |
return False | |
def get_qualified_class_name(obj): | |
"""Returns the qualified name for a class or an instance. | |
>>> import xml.dom as d | |
>>> get_qualified_class_name(d.Node) | |
'xml.dom.Node' | |
>>> get_qualified_class_name(d.Node()) | |
'xml.dom.Node' | |
The returned qualified names are strings that can be used by | |
`load_class()` for importing the relevant class. | |
""" | |
obj_class = obj | |
if not is_class(obj_class): | |
obj_class = getattr(obj, "__class__", None) | |
if not is_class(obj_class): | |
raise TypeError, "obj must be either a class or an instance" | |
module = obj_class.__module__ | |
name = obj_class.__name__ | |
assert module != "__main__" | |
sep = "." | |
return sep.join((module, name)) | |
def load_class(name): | |
"""Load a class object by qualified name. | |
Returns either a class object, or `None` in the case of any error. The | |
`name` parameter is a fully qualified name. The name is split, and used | |
in an `__import__()` invocation which does the equivalent of "from | |
namespace import classname". | |
>>> Node = load_class("xml.dom.Node") # "from xml.dom import Node" | |
>>> import xml.dom | |
>>> Node is xml.dom.Node | |
True | |
""" | |
sep = "." | |
name_parts = name.split(sep) | |
assert len(name_parts) > 1 | |
class_name = name_parts.pop(-1) | |
module_name = sep.join(name_parts) | |
try: | |
module_obj = __import__(module_name, fromlist=[class_name], level=0) | |
except ImportError, err: | |
warn(err.message, category=ImportWarning) | |
class_obj = getattr(module_obj, class_name, None) | |
if is_class(class_obj): | |
return class_obj | |
else: | |
warn('Imported "%s" from "%s", but it is not a class object' | |
% (class_name, module_name), | |
category=ImportWarning) | |
return None | |
NOTEBOOK_DRAG_ID = 4242 | |
TAB_ICON_SIZE = gtk.ICON_SIZE_SMALL_TOOLBAR | |
TAB_TOOLTIP_ICON_SIZE = gtk.ICON_SIZE_DIALOG | |
class ToolTab: | |
"""Interface for widgets which appear in ToolStacks. | |
""" | |
title = "Untitled" | |
description = "No Description" | |
icon_name = "gtk-missing-image" | |
def make_tab_label(self): | |
"""Creates and returns a new tab label widget for the page | |
""" | |
img = gtk.Image() | |
img.set_from_icon_name(self.icon_name, TAB_ICON_SIZE) | |
img.connect("query-tooltip", self.__tab_label_tooltip_query_cb, | |
self.title, self.description, self.icon_name) | |
img.set_property("has-tooltip", True) | |
return img | |
def __tab_label_tooltip_query_cb(self, widget, x, y, kbd, tooltip, | |
title, desc, icon_name): | |
tooltip.set_icon_from_icon_name(icon_name, TAB_TOOLTIP_ICON_SIZE) | |
markup = "<b>%s</b>\n%s" % (title, desc) | |
tooltip.set_markup(markup) | |
return True | |
class _PlaceholderCanvas (gtk.DrawingArea): | |
def __init__(self): | |
gtk.DrawingArea.__init__(self) | |
self.connect("expose-event", self.__expose_cb) | |
self.set_size_request(64, 64) | |
def __expose_cb(self, widget, event): | |
import math | |
cr = widget.get_window().cairo_create() | |
cr.set_source_rgb(0.2, 0.3, 0.7) | |
cr.paint() | |
x, y, w, h = widget.get_allocation() | |
r = min(w, h) * 0.4 | |
cr.arc(w/2, h/2, r, 0, 2*math.pi) | |
cr.set_source_rgb(0.80, 0.85, 0.30) | |
cr.set_line_width(5) | |
cr.stroke() | |
class Workspace (gtk.EventBox): | |
"""A central canvas widget and two sidebar ToolStacks. | |
""" | |
__lpaned = None #: HPaned holding the left stack and `__rpaned` | |
__rpaned = None #: HPaned holding the canvas, and the right stack | |
__lstack = None | |
__rstack = None | |
__floating = None | |
def __init__(self): | |
gtk.EventBox.__init__(self) | |
self.__lpaned = lpaned = gtk.HPaned() | |
self.__rpaned = rpaned = gtk.HPaned() | |
self.__lstack = lstack = ToolStack() | |
self.__rstack = rstack = ToolStack() | |
lstack.set_workspace(self) | |
rstack.set_workspace(self) | |
lstack.connect("hide", self.__stack_hide_cb, lpaned) | |
rstack.connect("hide", self.__stack_hide_cb, rpaned) | |
#lstack.set_tab_pos(gtk.POS_RIGHT) # perhaps only if the screen is wide? | |
#rstack.set_tab_pos(gtk.POS_LEFT) # it does save vertical space... | |
lpaned.pack1(lstack, resize=False, shrink=False) | |
lpaned.pack2(rpaned, resize=True, shrink=False) | |
rpaned.pack2(rstack, resize=False, shrink=False) | |
self.set_canvas(_PlaceholderCanvas()) | |
self.add(lpaned) | |
self.__floating = set() | |
def set_canvas(self, widget): | |
current = self.__rpaned.get_child1() | |
if current is not None: | |
self.__rpaned.remove(current) | |
self.__rpaned.pack1(widget, resize=True, shrink=False) | |
def build_from_layout(self, layout): | |
llayout = layout.get("left_sidebar", []) | |
rlayout = layout.get("right_sidebar", []) | |
self.__lstack.build_from_layout(llayout) | |
self.__rstack.build_from_layout(rlayout) | |
def get_layout(self): | |
llayout = self.__lstack.get_layout() | |
rlayout = self.__rstack.get_layout() | |
float_layouts = [w.get_layout() for w in self.__floating] | |
return { | |
"left_sidebar": llayout, | |
"right_sidebar": rlayout, | |
"floating": float_layouts, | |
} | |
def _tool_tab_drag_begin_cb(self): | |
for stack in (self.__lstack, self.__rstack): | |
if stack.is_empty(): | |
stack.show_all() | |
def _tool_tab_drag_end_cb(self): | |
for stack in (self.__lstack, self.__rstack): | |
if stack.is_empty(): | |
stack.hide() | |
def __stack_hide_cb(self, stack, paned): | |
# Reset any user-modified paned position if the hide is due to the | |
# sidebar stack having been emptied out. On the next show, the stack | |
# wil use the size-request of its children, which should be a single | |
# placeholder notebook, 16x8. | |
if stack.is_empty(): | |
paned.set_position(-1) | |
def register_floating_window(self, win): | |
self.__floating.add(win) | |
def unregister_floating_window(self, win): | |
self.__floating.remove(win) | |
class ToolStack (gtk.EventBox): | |
"""Vertical stack of ToolTab groups. | |
The layout has movable dividers between the groups of ToolTabs, and an | |
empty group on the end which accepts tabs dragged to it. The groups are | |
implmented as `gtk.Notebook`s, but that interface is not exposed; instead, | |
ToolStacks are constructed from layout defnitions built from simple types. | |
""" | |
# Class constants | |
PLACEHOLDER_HEIGHT = 8 | |
PLACEHOLDER_WIDTH = 16 | |
PLACEHOLDER_PACKING_RESIZE = True | |
PLACEHOLDER_PACKING_SHRINK = False | |
NORMAL_PACKING_RESIZE = False | |
NORMAL_PACKING_SHRINK = False | |
SUBPANED_PACKING_RESIZE = True | |
SUBPANED_PACKING_SHRINK = False | |
# Instance var defaults | |
__tab_pos = None #: Tab position for new notebooks; `None` means default. | |
__workspace = None #: A central workspace to notify about dragging | |
def __init__(self): | |
"""Constructs a new stack with a single placeholder group. | |
""" | |
gtk.EventBox.__init__(self) | |
self.add(self.__make_notebook()) | |
self.set_size_request(-1, -1) | |
def set_workspace(self, workspace): | |
self.__workspace = workspace | |
def get_workspace(self): | |
return self.__workspace | |
def add_page(self, page): | |
"""Adds a page to the first group in the stack. | |
""" | |
notebook = self.__get_first_notebook() | |
notebook.append_page(page, page.make_tab_label()) | |
notebook.set_tab_reorderable(page, True) | |
notebook.set_tab_detachable(page, True) | |
def build_from_layout(self, desc): | |
"""Loads groups and pages from a layout description. | |
Desc is a list of group defintions; each group definition is a list of | |
page class names as used by `load_class()`. | |
""" | |
next_nb = self.__get_first_notebook() | |
assert next_nb.get_n_pages() == 0 | |
for nb_desc in desc: | |
nb = next_nb | |
for page_class_name in nb_desc: | |
page_class = load_class(page_class_name) | |
if page_class is None: | |
continue | |
page = page_class() | |
page_label = page.make_tab_label() | |
page.__prev_size = (-1, -1) | |
if nb.get_n_pages() == 0: | |
next_nb = self.__append_new_placeholder(nb) | |
nb.append_page(page, page_label) | |
nb.set_tab_reorderable(page, True) | |
nb.set_tab_detachable(page, True) | |
def get_layout(self): | |
"""Returns a description of the current layout using simple types. | |
""" | |
layout_desc = [] | |
for nb in self.__get_notebooks(): | |
nb_desc = [] | |
for page in nb: | |
page_qname = get_qualified_class_name(page) | |
nb_desc.append(page_qname) | |
if len(nb_desc) > 0: | |
layout_desc.append(nb_desc) | |
return layout_desc | |
def set_tab_pos(self, tab_pos): | |
"""Sets the tab position for all groups (see `gtk.Notebook`). | |
""" | |
for nb in self.__get_notebooks(): | |
nb.set_tab_pos(tab_pos) | |
self.__tab_pos = tab_pos | |
def is_empty(self): | |
"""Returns true if this stack contains only a tab drop placeholder. | |
""" | |
widget = self.get_child() | |
if isinstance(widget, gtk.Paned): | |
return False | |
assert isinstance(widget, gtk.Notebook) | |
return widget.get_n_pages() == 0 | |
def __get_first_notebook(self): | |
widget = self.get_child() | |
if isinstance(widget, gtk.Paned): | |
widget = widget.get_child1() | |
assert isinstance(widget, gtk.Notebook) | |
return widget | |
def __get_notebooks(self): | |
child = self.get_child() | |
if child is None: | |
return [] | |
queue = [child] | |
notebooks = [] | |
while len(queue) > 0: | |
widget = queue.pop(0) | |
if isinstance(widget, gtk.Paned): | |
queue.append(widget.get_child1()) | |
queue.append(widget.get_child2()) | |
elif isinstance(widget, gtk.Notebook): | |
notebooks.append(widget) | |
else: | |
warn("Unknown member type: %s" % str(widget), RuntimeWarning) | |
assert len(notebooks) > 0 | |
return notebooks | |
def __make_notebook(self): | |
nb = gtk.Notebook() | |
nb.set_group_id(NOTEBOOK_DRAG_ID) | |
nb.connect("create-window", self.__nb_create_window_cb) | |
nb.connect("page-added", self.__nb_page_added_cb) | |
nb.connect("page-removed", self.__nb_page_removed_cb) | |
nb.connect("expose-event", self.__nb_expose_cb) | |
nb.connect("size-request", self.__nb_size_request_cb) | |
nb.connect_after("drag-begin", self.__nb_drag_begin_cb) | |
nb.connect_after("drag-end", self.__nb_drag_end_cb) | |
nb.set_scrollable(True) | |
if self.__tab_pos is not None: | |
nb.set_tab_pos(self.__tab_pos) | |
return nb | |
def __nb_drag_begin_cb(self, nb, *a): | |
# Record the notebook's size in the page; this will be recreated | |
# if a valid drop happens into a fresh ToolWindow or into a | |
# placeholder notebook. | |
alloc = nb.get_allocation() | |
page_num = nb.get_current_page() | |
page = nb.get_nth_page(page_num) | |
page.__prev_size = (alloc.width, alloc.height) | |
# Notify any workspace: causes empty sidebars to show. | |
if self.__workspace is not None: | |
self.__workspace._tool_tab_drag_begin_cb() | |
def __nb_drag_end_cb(self, nb, *a): | |
# Notify any workspace: causes empty sidebars to hide again. | |
if self.__workspace is not None: | |
self.__workspace._tool_tab_drag_end_cb() | |
def __nb_size_request_cb(self, notebook, req): | |
# Placeholder notebooks negotiate small sizes | |
if notebook.get_n_pages() == 0: | |
req.height = self.PLACEHOLDER_HEIGHT | |
req.width = self.PLACEHOLDER_WIDTH | |
def __nb_create_window_cb(self, notebook, page, x, y): | |
# Dragging into empty space creates a new stack in a new window, | |
# and stashes the page there. | |
win = ToolStackWindow() | |
if self.__workspace is not None: | |
win.stack.set_workspace(self.__workspace) | |
win.set_transient_for(self.__workspace.get_toplevel()) | |
notebook.remove(page) | |
w, h = page.__prev_size | |
new_nb = win.stack.__get_first_notebook() | |
new_nb.append_page(page, page.make_tab_label()) | |
new_nb.set_tab_reorderable(page, True) | |
new_nb.set_tab_detachable(page, True) | |
new_placeholder = win.stack.__append_new_placeholder(new_nb) | |
new_paned = new_placeholder.get_parent() | |
new_paned.set_position(h) | |
# Initial position. Hopefully this will work. | |
win.move(x, y) | |
win.set_default_size(w, h) | |
win.show_all() | |
def __nb_expose_cb(self, notebook, event): | |
if notebook.get_n_pages() > 0: | |
return False | |
# Override placeholder drawing | |
cr = notebook.get_window().cairo_create() | |
cr.rectangle(event.area) | |
cr.clip() | |
# Pattern | |
style = self.get_style() | |
state = self.get_state() | |
bg = style.bg[state] | |
dark = style.dark[state] | |
bg_rgb = [(bg.red_float + dark.red_float)/2.0, | |
(bg.green_float + dark.green_float)/2.0, | |
(bg.blue_float + dark.blue_float)/2.0] | |
dark_rgb = [max(0, c-0.01) for c in bg_rgb] | |
light_rgb = [min(1, c+0.01) for c in bg_rgb] | |
cr.set_source_rgb(*dark_rgb) | |
cr.paint() | |
cr.set_source_rgb(*light_rgb) | |
x, y, w, h = tuple(event.area) | |
sw = 6 | |
y = event.area.y | |
for x in range(event.area.x, event.area.x+w+h, sw*2): | |
cr.move_to(x, y) | |
cr.line_to(x-h, y+h) | |
cr.line_to(x-h+sw, y+h) | |
cr.line_to(x+sw, y) | |
cr.close_path() | |
cr.fill() | |
# Slight shadow gradient under the final divider | |
#dark_rgb = [dark.red_float, dark.green_float, dark.blue_float] | |
shadow0 = [0, dark_rgb[0], dark_rgb[1], dark_rgb[2], 1] | |
shadow1 = [1, dark_rgb[0], dark_rgb[1], dark_rgb[2], 0] | |
x, y, w, h = tuple(event.area) | |
h = self.PLACEHOLDER_HEIGHT / 2.0 | |
lg = cairo.LinearGradient(x, y, x, y+h) | |
lg.add_color_stop_rgba(*shadow0) | |
lg.add_color_stop_rgba(*shadow1) | |
cr.set_source(lg) | |
cr.rectangle(x, y, w, h) | |
cr.fill() | |
return True # All placeholder drawing was handled here | |
def __nb_page_added_cb(self, notebook, child, page_num): | |
gobject.idle_add(self.__update_structure_cb) | |
def __nb_page_removed_cb(self, notebook, child, page_num): | |
gobject.idle_add(self.__update_structure_cb) | |
def __append_new_placeholder(self, old_placeholder): | |
"""Appends a new placeholder after a current or former placeholder. | |
""" | |
old_placeholder_parent = old_placeholder.get_parent() | |
assert old_placeholder_parent is not None | |
new_paned = gtk.VPaned() | |
if isinstance(old_placeholder_parent, gtk.Paned): | |
assert old_placeholder is not old_placeholder_parent.get_child1() | |
assert old_placeholder is old_placeholder_parent.get_child2() | |
old_placeholder_parent.remove(old_placeholder) | |
old_placeholder_parent.pack2(new_paned, | |
self.SUBPANED_PACKING_RESIZE, | |
self.SUBPANED_PACKING_SHRINK) | |
else: | |
assert old_placeholder_parent is self | |
old_placeholder_parent.remove(old_placeholder) | |
old_placeholder_parent.add(new_paned) | |
new_placeholder = self.__make_notebook() | |
new_paned.pack1(old_placeholder, | |
self.NORMAL_PACKING_RESIZE, | |
self.NORMAL_PACKING_SHRINK) | |
new_paned.pack2(new_placeholder, | |
self.PLACEHOLDER_PACKING_RESIZE, | |
self.PLACEHOLDER_PACKING_SHRINK) | |
new_paned.show_all() | |
new_paned.queue_resize() | |
return new_placeholder | |
def __update_structure_cb(self): | |
"""Maintains structure after "page-added" & "page-deleted" events. | |
If a page is added to the placeholder notebook on the end by the user | |
dragging a tab there, a new placeholder must be created and the tree | |
structure repacked. Similarly emptying out a notebook by dragging tabs | |
around must result in the empty notebook being removed. | |
This callback is queued as an idle function in response to the above | |
events because moving from one paned to another invokes both remove and | |
add. If the structure doesn't need changing, calling it multiple times | |
is harmless. | |
""" | |
# The final notebook should always be an empty placeholder. If | |
# it isn't, then create a new paned with a placeholder in the | |
# second slot. | |
notebooks = self.__get_notebooks() | |
if len(notebooks) == 0: | |
return | |
placeholder_nb = notebooks.pop(-1) | |
nb_parent = placeholder_nb.get_parent() | |
if placeholder_nb.get_n_pages() > 0: | |
# Something was dropped into a former placeholder, populating it | |
# Create a new placeholder, and set the bar position for the newly | |
# populated notebook, which will be in child1 of the parent paned. | |
newpop_nb = placeholder_nb | |
assert newpop_nb.get_n_pages() == 1 | |
placeholder_nb = self.__append_new_placeholder(newpop_nb) | |
paned = newpop_nb.get_parent() | |
newpop_page = newpop_nb.get_nth_page(0) | |
newpop_w, newpop_h = newpop_page.__prev_size | |
paned.set_position(newpop_h) | |
# Detect emptied middle notebooks and remove them. There should be no | |
# notebooks in the stack whose parent is not a Paned at this point. | |
while len(notebooks) > 0: | |
nb = notebooks.pop(0) | |
nb_parent = nb.get_parent() | |
assert isinstance(nb_parent, gtk.Paned) | |
if nb.get_n_pages() > 0: | |
continue | |
nb_grandparent = nb_parent.get_parent() | |
assert nb is nb_parent.get_child1() | |
assert nb is not nb_parent.get_child2() | |
sib = nb_parent.get_child2() | |
nb_parent.remove(nb) | |
nb_parent.remove(sib) | |
if isinstance(nb_grandparent, gtk.Paned): | |
assert nb_parent is not nb_grandparent.get_child1() | |
assert nb_parent is nb_grandparent.get_child2() | |
nb_grandparent.remove(nb_parent) | |
if sib is placeholder_nb: | |
nb_grandparent.pack2(sib, | |
self.PLACEHOLDER_PACKING_RESIZE, | |
self.PLACEHOLDER_PACKING_SHRINK) | |
else: | |
nb_grandparent.pack2(sib, | |
self.NORMAL_PACKING_RESIZE, | |
self.NORMAL_PACKING_SHRINK) | |
else: | |
assert nb_grandparent is self | |
nb_grandparent.remove(nb_parent) | |
nb_grandparent.add(sib) | |
# Detect empty stacks | |
n_tabs_total = 0 | |
for nb in self.__get_notebooks(): | |
n_tabs_total += nb.get_n_pages() | |
parent = self.get_parent() | |
if n_tabs_total == 0: | |
if isinstance(parent, ToolStackWindow): | |
parent.destroy() | |
else: | |
self.hide() | |
return | |
# Update title of parent ToolStackWindows | |
if isinstance(parent, ToolStackWindow): | |
page_titles = [] | |
for nb in self.__get_notebooks(): | |
for p in nb: | |
page_titles.append(p.title) | |
parent._update_title(page_titles) | |
class ToolStackWindow (gtk.Window): | |
"""A floating window containing a single `ToolStack`. | |
""" | |
# Instance variable defaults and docs | |
stack = None #: The ToolStack child of the window | |
__pos = None | |
def __init__(self): | |
gtk.Window.__init__(self) | |
self.set_type_hint(gdk.WINDOW_TYPE_HINT_UTILITY) | |
self.set_accept_focus(False) | |
self.connect("destroy", self.__destroy_cb) | |
self.stack = ToolStack() | |
self.add(self.stack) | |
self.connect("map-event", self.__map_cb) | |
self.connect("configure-event", self.__configure_cb) | |
self._update_title([]) | |
self.__pos = {} | |
def __configure_cb(self, widget, event): | |
f_ex = self.window.get_frame_extents() | |
x = max(0, f_ex.x) | |
y = max(0, f_ex.y) | |
self.__pos = dict(x=x, y=y, w=event.width, h=event.height) | |
def get_layout(self): | |
return { | |
"position": self.__pos, | |
"contents": self.stack.get_layout(), | |
} | |
def __map_cb(self, widget, event): | |
win = widget.get_window() | |
win.set_decorations(gdk.DECOR_BORDER|gdk.DECOR_RESIZEH) | |
win.set_functions(gdk.FUNC_RESIZE|gdk.FUNC_MOVE) | |
workspace = self.stack.get_workspace() | |
if workspace is not None: | |
workspace.register_floating_window(self) | |
def __destroy_cb(self, widget): | |
workspace = self.stack.get_workspace() | |
if workspace is not None: | |
workspace.unregister_floating_window(self) | |
def _update_title(self, tool_tab_titles): | |
window_title_tmpl = _("%s - MyPaint") | |
window_title_sep = _(", ") | |
title = window_title_tmpl % (window_title_sep.join(tool_tab_titles)) | |
self.set_title(title) | |
class _TestToolTab (gtk.Label, ToolTab): | |
body_label = "Test Page" | |
icon_name = 'gtk-missing-image' | |
def __init__(self): | |
gtk.Label.__init__(self, self.body_label) | |
self.set_size_request(150, 100) | |
@property | |
def title(self): | |
return self.body_label | |
class _TestToolTab1 (_TestToolTab): | |
body_label = "Apples" | |
icon_name = 'gtk-dialog-error' | |
class _TestToolTab2 (_TestToolTab): | |
body_label = "Oranges" | |
icon_name = 'gtk-dialog-warning' | |
class _TestToolTab3 (_TestToolTab): | |
body_label = "Limes" | |
icon_name = 'gtk-dialog-info' | |
class _TestToolTab4 (_TestToolTab): | |
body_label = "Grapes" | |
icon_name = 'gtk-dialog-question' | |
if __name__ == '__main__': | |
layout_def = { | |
"left_sidebar": [['taboret_standalone._TestToolTab1', | |
'taboret_standalone._TestToolTab2', | |
'taboret_standalone._TestToolTab3'] ], | |
"right_sidebar": [['taboret_standalone._TestToolTab4'], | |
['taboret_standalone._TestToolTab1', | |
'taboret_standalone._TestToolTab2'] ], | |
} | |
win2 = gtk.Window() | |
win2.set_title("Workspace Layout Test") | |
workspace = Workspace() | |
workspace.set_size_request(640, 480) | |
workspace.build_from_layout(layout_def) | |
win2.add(workspace) | |
win2.show_all() | |
def __test_quit_cb(*a): | |
print "*** exiting, workspace layout dump follows" | |
print workspace.get_layout() | |
gtk.main_quit() | |
win2.connect("destroy", __test_quit_cb) | |
gtk.main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment