Skip to content

Instantly share code, notes, and snippets.

@achadwick
Created January 3, 2013 13:36
Show Gist options
  • Save achadwick/4443517 to your computer and use it in GitHub Desktop.
Save achadwick/4443517 to your computer and use it in GitHub Desktop.
Tabbed sidebar interface idea.
#!/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