OSDN Git Service

730c720274d6892eb070d605c51a1942b69520e9
[mypaint-anime/master.git] / gui / layout.py
1 # This file is part of MyPaint.
2 # Copyright (C) 2007-2011 by Andrew Chadwick <andrewc-git@piffle.org>
3 #
4 # This program is free software; you can redistribute it and/or modify
5 # it under the terms of the GNU General Public License as published by
6 # the Free Software Foundation; either version 2 of the License, or
7 # (at your option) any later version.
8
9 """Manage a main window with a sidebar, plus a number of subwindows.
10 Tools can be snapped in and out of the sidebar.
11 Window sizes and sidebar positions are stored to user preferences.
12 """
13
14 import gtk
15 import gobject
16 from gtk import gdk
17 from math import sqrt
18 from warnings import warn
19 import pango
20 import cairo
21 from gettext import gettext as _
22
23
24 class LayoutManager:
25     """Keeps track of tool positions, and main window state.
26     """
27
28     def __init__(self, app, factory, factory_opts=[], prefs=None):
29         """Constructor.
30
31             "prefs"
32                 should be a ref to a dict-like object. Persistent prefs
33                 will be read from and written back to it.
34             
35             "factory"
36                 is a callable which has the signature
37
38                     factory(role, layout_manager, *factory_opts)
39
40                 and which will be called for each needed tool. It should
41                 return a value of the form:
42                 
43                    1. (None,)
44                    2. (window,)
45                    3. (widget,)
46                    4. (tool_widget, title_str)
47
48                 Form 1 indicates that no matching widget could be found.
49                 Form 2 should be used for floating dialog windows, popup
50                 windows and similar, or for "main-window". Form 3 is
51                 expected when the "role" parameter is "main-widget"; the
52                 returned widgets are packed into the main window.
53         """
54
55         if prefs is None:
56             prefs = {}
57         self.app = app
58         self.prefs = prefs
59         self.window_group = gtk.WindowGroup()
60         self.factory = factory
61         self.factory_opts = factory_opts
62         self.widgets = {}  # {role: <gtk.Widget>}   as returned by factory()
63         self.tools = {}   # {role: Tool}
64         self.subwindows = {}   # {role: <gtk.Window>}
65         self.main_window = None
66         self.saved_user_tools = []
67         self.tool_visibility_observers = []
68
69     def set_main_window_title(self, title):
70         """Set the title for the main window.
71         """
72         self.main_window.set_title(title)
73
74     def get_widget_by_role(self, role):
75         """Returns the raw widget for a particular role name.
76         
77         Internally cached; will invoke the factory method once for each
78         unique role name the first time it's seen.
79         """
80         if role in self.widgets:
81             return self.widgets[role]
82         else:
83             result = self.factory(role, self, *self.factory_opts)
84             if role == 'main-window':
85                 self.main_window = result[0]
86                 assert isinstance(self.main_window, MainWindow)
87                 self.drag_state = ToolDragState(self)
88                     # ^^^ Yuck. Would be nicer to init in constructor
89                 self.widgets[role] = self.main_window
90                 return self.main_window
91             elif role == 'main-widget':
92                 widget = result[0]
93                 self.widgets[role] = widget
94                 return widget
95             if result is None:
96                 return None
97             else:
98                 widget = result[0]
99                 if isinstance(widget, gtk.Window):
100                     self.subwindows[role] = widget
101                     self.widgets[role] = widget
102                     widget.set_role(role)
103                     widget.set_transient_for(self.main_window)
104                     return widget
105                 elif isinstance(widget, gtk.Widget):
106                     title = result[1]
107                     tool = Tool(widget, role, title, title, self)
108                     self.tools[role] = tool
109                     self.widgets[role] = tool
110                     return widget
111                 else:
112                     self.widgets[role] = None
113                     warn("Unknown role \"%s\"" % role, RuntimeWarning,
114                          stacklevel=2)
115                     return None
116
117     def get_subwindow_by_role(self, role):
118         """Returns the managed subwindow for a given role, or None
119         """
120         if self.get_widget_by_role(role):
121             return self.subwindows.get(role, None)
122         return None
123
124     def get_tool_by_role(self, role):
125         """Returns the tool wrapper for a given role.
126         
127         Only valid for roles for which a corresponding packable widget was
128         created by the factory method.
129         """
130         #newly_loaded = role not in self.widgets
131         _junk = self.get_widget_by_role(role)
132         tool = self.tools.get(role, None)
133         #if tool is not None and newly_loaded:
134         #    hidden = self.get_window_hidden_by_role(tool.role)
135         #    floating = self.get_window_floating_by_role(tool.role)
136         #    tool.set_floating(floating)
137         #    tool.set_hidden(hidden, reason="newly-loaded-prefs")
138         #    # XXX move the above to get_widget_by_role()?
139         return tool
140
141     def get_window_hidden_by_role(self, role, default=True):
142         return self.prefs.get(role, {}).get("hidden", default)
143
144     def get_window_floating_by_role(self, role, default=False):
145         return self.prefs.get(role, {}).get("floating", default)
146
147     def get_tools_in_sbindex_order(self):
148         """Lists all loaded tools in order of ther sbindex setting.
149         
150         The returned list contains all tools known to the LayoutManager
151         even if they aren't currently docked in a sidebar.
152         """
153         tools = []
154         sbindexes = [s.get("sbindex", 0) for r, s in self.prefs.iteritems()]
155         sbindexes.append(0)
156         index_hwm = len(self.tools) + max(sbindexes)
157         for role, tool in self.tools.iteritems():
158             index = self.prefs.get(role, {}).get("sbindex", index_hwm)
159             index_hwm += 1
160             tools.append((index, tool.role, tool))
161         tools.sort()
162         return [t for i,r,t in tools]
163
164     def toggle_user_tools(self, on=None):
165         """Temporarily toggle the user's chosen tools on or off.
166         """
167         if on is None:
168             on = False
169             off = False
170         else:
171             off = not on
172         if on or self.saved_user_tools:
173             for tool in self.saved_user_tools:
174                 tool.set_hidden(False, temporary=True, reason="toggle-user-tools")
175                 tool.set_floating(tool.floating)
176             self.saved_user_tools = []
177         elif off or not self.saved_user_tools:
178             for tool in self.get_tools_in_sbindex_order():
179                 if tool.hidden:
180                     continue
181                 tool.set_hidden(True, temporary=True, reason="toggle-user-tools")
182                 self.saved_user_tools.append(tool)
183
184     def show_all(self):
185         """Displays all initially visible tools.
186         """
187         # Ensure that everything not hidden in prefs is loaded up front
188         for role, win_pos in self.prefs.iteritems():
189             hidden = win_pos.get("hidden", False)
190             if not hidden:
191                 self.get_widget_by_role(role)
192
193         # Insert those that are tools into sidebar, or float them
194         # free as appropriate
195         floating_tools = []
196         for tool in self.get_tools_in_sbindex_order():
197             floating = self.prefs.get(tool.role, {}).get("floating", False)
198             if floating:
199                 floating_tools.append(tool)
200             else:
201                 self.main_window.sidebar.add_tool(tool)
202             tool.set_floating(floating)
203
204         self.main_window.show_all()
205
206         # Floating tools
207         for tool in floating_tools:
208             win = tool.floating_window
209             tool.set_floating(True)
210             win.show_all()
211
212         # Present the main window for consistency with the toggle action.
213         gobject.idle_add(self.main_window.present)
214
215     def notify_tool_visibility_observers(self, *args, **kwargs):
216         for func in self.tool_visibility_observers:
217             func(*args, **kwargs)
218
219
220 class ElasticContainer:
221     """Mixin for containers which mirror certain size changes of descendents
222
223     Descendents which wish to report internally-generated size changes should
224     add the ElasticContent mixin, and should be packed into a container that
225     derives from ElasticContainer. More than one ElasticContent widget can be
226     packed under an ElasticContainer, and each can report different types of
227     resizes - however the sub-hierarchies cannot overlap. You -can- nest
228     ElasticContainers though: it's the outer one that receives the resize
229     request.
230     """
231
232     def __init__(self):
233         """Mixin constructor (construct as a gtk.Widget before calling)
234         """
235         self.__last_size = None
236         self.__saved_size_request = None
237
238     def mirror_elastic_content_resize(self, dx, dy):
239         """Resize by a given amount.
240
241         This is called by some ElasticContent widget below here in the
242         hierarchy when it notices a request-size change on itself.
243         """
244         p = self.parent
245         while p is not None:
246             if isinstance(p, ElasticContainer):
247                 # propagate up and don't handle here
248                 p.mirror_elastic_content_resize(dx, dy)
249                 return
250             p = p.parent
251         self.__saved_size_request = self.get_size_request()
252         alloc = self.get_allocation()
253         w = alloc.width+dx
254         h = alloc.height+dy
255         if isinstance(self, gtk.Window):
256             self.resize(w, h)
257         self.set_size_request(w, h)
258         self.queue_resize()
259
260
261 class ElasticContent:
262     """Mixin for GTK widgets which want some parent to change size to match.
263     """
264
265     def __init__(self, mirror_vertical=True, mirror_horizontal=True):
266         """Mixin constructor (construct as a gtk.Widget before calling)
267
268         The options control which size changes are reported to the
269         ElasticContainer ancestor and how:
270
271             `mirror_vertical`:
272                 mirror vertical size changes
273
274             `mirror_horizontal`:
275                 mirror horizontal size changes
276
277         """
278         self.__vertical = mirror_vertical
279         self.__horizontal = mirror_horizontal
280         if not mirror_horizontal and not mirror_vertical:
281             return
282         self.__last_req = None
283         self.__expose_connid = self.connect_after("expose-event",
284             self.__after_expose_event)
285         self.__notify_parent = False
286         self.connect_after("size-request", self.__after_size_request)
287
288     def __after_expose_event(self, widget, event):
289         # Begin notifying changes to the ancestor after the first expose event.
290         # It doesn't matter for widgets that know their size before hand.
291         # Assume widgets which initially don't know their size *do* know their
292         # proper size after their first draw, even if they drew themselves
293         # wrongly and now have to resize and do another size-request...
294         connid = self.__expose_connid
295         if not connid:
296             return
297         self.__expose_connid = None
298         self.__notify_parent = True
299         self.disconnect(connid)
300
301     def __after_size_request(self, widget, req):
302         # Catch the final value of each size-request, calculate the
303         # difference needed and throw it back up the widget hierarchy
304         # to interested parties.
305         if not self.__last_req:
306             dx, dy = 0, 0
307         else:
308             w0, h0 = self.__last_req
309             dx, dy = req.width - w0, req.height - h0
310         self.__last_req = (req.width, req.height)
311         if not self.__notify_parent:
312             return
313         p = self.parent
314         while p is not None:
315             if isinstance(p, ElasticContainer):
316                 if not self.__vertical:
317                     dy = 0
318                 if not self.__horizontal:
319                     dx = 0
320                 if dy != 0 or dx != 0:
321                     p.mirror_elastic_content_resize(dx, dy)
322                 break
323             if isinstance(p, ElasticContent):
324                 break
325             p = p.parent
326
327
328 class ElasticVBox (gtk.VBox, ElasticContainer):
329     def __init__(self, *args, **kwargs):
330         gtk.VBox.__init__(self, *args, **kwargs)
331         ElasticContainer.__init__(self)
332
333
334 class ElasticExpander (gtk.Expander, ElasticContent):
335     def __init__(self, *args, **kwargs):
336         gtk.Expander.__init__(self, *args, **kwargs)
337         ElasticContent.__init__(self, mirror_horizontal=False,
338                                 mirror_vertical=True)
339
340
341 class WindowWithSavedPosition:
342     """Mixin for gtk.Windows which load/save their position via a LayoutManager.
343
344     Classes using this interface must provide an attribute named layout_manager
345     which exposes the LayoutManager whose prefs attr this window will read its
346     initial position from and update. As a consequence of how this mixin
347     interacts with the LayoutManager, it must have a meaningful window role at
348     realize time.
349     """
350
351     def __init__(self):
352         """Mixin constructor. Construction order does not matter.
353         """
354         self.__last_conf_pos = None
355         self.__pos_save_timer = None
356         self.__is_fullscreen = False
357         self.__is_maximized = False
358         self.__mapped_once = False
359         self.__initial_xy = None
360         self.connect("realize", self.__on_realize)
361         self.connect("map", self.__on_map)
362         self.connect("configure-event", self.__on_configure_event)
363         self.connect("window-state-event", self.__on_window_state_event)
364
365     @property
366     def __pos(self):
367         lm = self.layout_manager
368         role = self.get_role()
369         assert role in lm.prefs, 'Window %r is not mentioned in DEFAULT_CONFIG (application.py)' % role
370         return lm.prefs[role]
371
372     def __on_realize(self, widget):
373         xy = set_initial_window_position(self, self.__pos)
374         self.__initial_xy = xy
375
376     def __force_initial_xy(self):
377         """Revolting hack to force the initial x,y position after mapping.
378
379         It's cosmetically necessary to do this for tool windows at least, since
380         otherwise snapping out an initially docked window using the button can
381         look really weird if the WM forces a position on you (most *ix ones
382         do), and we really do have a better idea than the WM about what the
383         user meant. The WM is given a sporting chance to help position the
384         main-window sensibly though.
385         """
386         role = self.get_role()
387         if role == 'main-window':
388             return
389         try:
390             x, y = self.__initial_xy
391         except TypeError:
392             return
393         self.move(x, y)
394
395     def __on_map(self, widget):
396         parent = self.get_transient_for()
397         if parent is self.layout_manager.main_window:
398             self.window.set_accept_focus(False)
399             # Prevent all saved-position subwindows from taking keyboard
400             # focus from the main window (in Metacity) by presenting it
401             # again. https://gna.org/bugs/?17899
402             gobject.idle_add(parent.present)
403             ## The alternative is:
404             # gobject.idle_add(self.window.raise_)
405             # gobject.idle_add(parent.window.focus)
406             ## but that doesn't seem to be necessary.
407         if self.__mapped_once:
408             return
409         self.__mapped_once = True
410         gobject.idle_add(self.__force_initial_xy)
411
412     def __on_window_state_event(self, widget, event):
413         # Respond to changes of the fullscreen or maximized state only
414         interesting = gdk.WINDOW_STATE_MAXIMIZED | gdk.WINDOW_STATE_FULLSCREEN
415         if not event.changed_mask & interesting:
416             return
417         state = event.new_window_state
418         self.__is_maximized = state & gdk.WINDOW_STATE_MAXIMIZED
419         self.__is_fullscreen = state & gdk.WINDOW_STATE_FULLSCREEN
420
421     def __on_configure_event(self, widget, event):
422         """Store the current size of the window for future launches.
423         """
424         # Save the new position in the prefs...
425         f_ex = self.window.get_frame_extents()
426         x = max(0, f_ex.x)
427         y = max(0, f_ex.y)
428         conf_pos = dict(x=x, y=y, w=event.width, h=event.height)
429         self.__last_conf_pos = conf_pos
430         if self.get_role() == 'main-window':
431             # ... however, wait for a bit so window-state-event has a chance to
432             # fire first if the window can be meaningfully fullscreened. Compiz
433             # in particular enjoys firing up to three configure-events (at
434             # various sizes) before the window-state-event describing the
435             # fullscreen.
436             if not self.__pos_save_timer:
437                 self.__pos_save_timer \
438                  = gobject.timeout_add_seconds(2, self.__save_position_cb)
439         else:
440             # Save the position now for non-main windows
441             self.__save_position_cb()
442
443     def __save_position_cb(self):
444         if not (self.__is_maximized or self.__is_fullscreen):
445             self.__pos.update(self.__last_conf_pos)
446         self.__pos_save_timer = None
447         return False
448
449
450
451 class MainWindow (WindowWithSavedPosition):
452     """Mixin for main gtk.Windows in a layout.
453
454     Contains slots and initialisation stuff for various widget slots, which
455     can be provided by the layout manager's factory callable. These are:
456
457         main-menubar
458         main-toolbar
459         main-widget
460         main-statusbar
461
462     """
463
464     def __init__(self, layout_manager):
465         """Mixin constructor: initialise as a gtk.Window vefore calling.
466
467         This builds the sidebar and packs the main UI with pieces provided
468         either by the factory or by overriding the various init_*() methods.
469         This also sets up the window with an initial position and configures
470         it to save its position and sidebar width when reconfigured to the
471         LayoutManager's prefs.
472         """
473         assert isinstance(self, gtk.Window)
474         WindowWithSavedPosition.__init__(self)
475         self.layout_manager = layout_manager
476         self.set_role("main-window")
477         self.menubar = None; self.init_menubar()
478         self.toolbar = None; self.init_toolbar()
479         self.statusbar = None; self.init_statusbar()
480         self.main_widget = None; self.init_main_widget()
481         self.sidebar = Sidebar(layout_manager)
482         self.hpaned = gtk.HPaned()
483         self.hpaned_position_loaded = False
484         self.hpaned.pack1(self.main_widget, True, False)
485         self.hpaned.pack2(self.sidebar, False, False)
486         self.layout_vbox = gtk.VBox()
487         if self.menubar is not None:
488             self.layout_vbox.pack_start(self.menubar, False, False)
489         if self.toolbar is not None:
490             self.layout_vbox.pack_start(self.toolbar, False, False)
491         self.layout_vbox.pack_start(self.hpaned, True, True)
492         if self.statusbar is not None:
493             self.layout_vbox.pack_start(self.statusbar, False, False)
494         self.add(self.layout_vbox)
495         self.last_conf_size = None
496         self.connect("configure-event", self.__on_configure_event)
497         self.sidebar.connect("size-allocate", self.__on_sidebar_size_allocate)
498         self.connect("map-event", self.__on_map_event)
499
500     def __on_map_event(self, widget, event):
501         if self.sidebar.is_empty():
502             self.sidebar.hide()
503
504     def init_menubar(self):
505         self.menubar = self.layout_manager.get_widget_by_role("main-menubar")
506
507     def init_toolbar(self):
508         self.toolbar = self.layout_manager.get_widget_by_role("main-toolbar")
509
510     def init_statusbar(self):
511         self.statusbar = self.layout_manager.get_widget_by_role("main-statusbar")
512
513     def init_main_widget(self):
514         self.main_widget = self.layout_manager.get_widget_by_role("main-widget")
515
516     def __on_configure_event(self, widget, event):
517         self.last_conf_size = (event.width, event.height)
518
519     def __on_sidebar_size_allocate(self, sidebar, allocation):
520         lm = self.layout_manager
521         role = self.get_role()
522         if not role in lm.prefs:
523             lm.prefs[role] = {}
524         if self.hpaned_position_loaded:
525             # Save the sidebar's width each time space is allocated to it.
526             # Responds to the user adjusting the HPaned's divider position.
527             sbwidth = allocation.width
528             self.layout_manager.prefs["main-window"]["sbwidth"] = sbwidth
529             self.layout_manager.main_window.sidebar.set_tool_widths(sbwidth)
530         else:
531             # Except, ugh, the first time. If the position isn't loaded yet,
532             # load the main-window's sbwidth from the settings.
533             if self.last_conf_size:
534                 width, height = self.last_conf_size
535                 sbwidth = lm.prefs[role].get("sbwidth", None)
536                 if sbwidth is not None:
537                     handle_size = self.hpaned.style_get_property("handle-size")
538                     pos = width - handle_size - sbwidth
539                     self.hpaned.set_position(pos)
540                     self.hpaned.queue_resize()
541                 self.hpaned_position_loaded = True
542
543
544 gtk.rc_parse_string ("""
545     style "small-stock-image-button-style" {
546         GtkWidget::focus-padding = 0
547         GtkWidget::focus-line-width = 0
548         xthickness = 0
549         ythickness = 0
550     }
551     widget "*.small-stock-image-button"
552     style "small-stock-image-button-style"
553     """)
554
555
556 class SmallImageButton (gtk.Button):
557     """A small button containing an image.
558
559     Instances are used for the close button and snap in/snap out buttons in a
560     ToolDragHandle.
561     """
562
563     ICON_SIZE = gtk.ICON_SIZE_MENU
564
565     def __init__(self, stock_id=None, tooltip=None):
566         gtk.Button.__init__(self)
567         self.image = None
568         if stock_id is not None:
569             self.set_image_from_stock(stock_id)
570         else:
571             self.set_image_from_stock(gtk.STOCK_MISSING_IMAGE)
572         self.set_name("small-stock-image-button")
573         self.set_relief(gtk.RELIEF_NONE)
574         self.set_property("can-focus", False)
575         if tooltip is not None:
576             self.set_tooltip_text(tooltip)
577         self.connect("style-set", self.on_style_set)
578
579     def set_image_from_stock(self, stock_id):
580         if self.image:
581             self.remove(self.image)
582         self.image = gtk.image_new_from_stock(stock_id, self.ICON_SIZE)
583         self.add(self.image)
584         self.show_all()
585
586     # TODO: support image.set_from_icon_name maybe
587
588     def on_style_set(self, widget, prev_style):
589         settings = self.get_settings()
590         w, h = gtk.icon_size_lookup_for_settings(settings, gtk.ICON_SIZE_MENU)
591         self.set_size_request(w+4, h+4)
592
593
594 class ToolResizeGrip (gtk.DrawingArea): 
595     """A draggable bar for resizing a Tool vertically."""
596
597     AREA_LEFT = 0
598     AREA_MIDDLE = 1
599     AREA_RIGHT = 2
600
601     handle_size = gtk.HPaned().style_get_property("handle-size") + 2
602     corner_width = 4*handle_size
603
604     window_edge_map = {
605             AREA_RIGHT: gdk.WINDOW_EDGE_SOUTH_EAST,
606             AREA_MIDDLE: gdk.WINDOW_EDGE_SOUTH,
607             AREA_LEFT: gdk.WINDOW_EDGE_SOUTH_WEST,
608         }
609     floating_cursor_map = {
610             AREA_LEFT: gdk.Cursor(gdk.BOTTOM_LEFT_CORNER),
611             AREA_MIDDLE: gdk.Cursor(gdk.BOTTOM_SIDE),
612             AREA_RIGHT: gdk.Cursor(gdk.BOTTOM_RIGHT_CORNER),
613         }
614     nonfloating_cursor = gdk.Cursor(gdk.SB_V_DOUBLE_ARROW)
615
616     def __init__(self, tool):
617         gtk.DrawingArea.__init__(self)
618         self.tool = tool
619         self.set_size_request(4*self.corner_width, self.handle_size)
620         self.connect("configure-event", self.on_configure_event)
621         self.connect("expose-event", self.on_expose_event)
622         self.connect("button-press-event", self.on_button_press_event)
623         self.connect("button-release-event", self.on_button_release_event)
624         self.connect("motion-notify-event", self.on_motion_notify_event)
625         self.connect("leave-notify-event", self.on_leave_notify_event)
626         self.width = self.height = self.handle_size
627         mask = gdk.BUTTON_PRESS_MASK | gdk.BUTTON_RELEASE_MASK \
628             | gdk.LEAVE_NOTIFY_MASK | gdk.POINTER_MOTION_MASK
629         self.set_events(mask)
630         self.resize = None
631     
632     def on_configure_event(self, widget, event):
633         self.width = event.width
634         self.height = event.height
635
636     def on_expose_event(self, widget, event):
637         self.window.clear()
638         self.window.begin_paint_rect(event.area)
639         x = (self.width - self.handle_size) / 2
640         y = (self.height - self.handle_size) / 2
641         state = self.get_state()
642         self.style.paint_handle(self.window, state,
643             gtk.SHADOW_NONE, event.area, self, 'paned',
644             0, 0, self.width, self.height,
645             gtk.ORIENTATION_HORIZONTAL)
646         self.window.end_paint()
647
648     def get_area(self, x, y):
649         if self.tool.floating:
650             if x <= self.corner_width:
651                 return self.AREA_LEFT
652             elif x >= self.width - self.corner_width:
653                 return self.AREA_RIGHT
654         return self.AREA_MIDDLE
655
656     def on_button_press_event(self, widget, event):
657         if event.button != 1:
658             return
659         if self.tool.floating:
660             area = self.get_area(event.x, event.y)
661             win = self.tool.floating_window.window
662             edge = self.window_edge_map[area]
663             win.begin_resize_drag(edge, event.button,
664                                   int(event.x_root), int(event.y_root),
665                                   event.time)
666         else:
667             min_w, min_h = self.tool.get_minimum_size()
668             lm = self.tool.layout_manager
669             max_w, max_h = lm.main_window.sidebar.max_tool_size()
670             min_w = max_w
671             alloc = self.tool.allocation
672             w = alloc.width
673             h = alloc.height
674             self.resize = event.x_root, event.y_root, w, h, \
675                           min_w, min_h, max_w, max_h
676             self.grab_add()
677     
678     def on_button_release_event(self, widget, event):
679         self.resize = None
680         self.grab_remove()
681         self.window.set_cursor(None)
682     
683     def get_cursor(self, area):
684         if self.tool.floating:
685             cursor = self.floating_cursor_map.get(area)
686         else:
687             cursor = self.nonfloating_cursor
688         return cursor
689
690     def on_motion_notify_event(self, widget, event):
691         if not self.resize:
692             area = self.get_area(event.x, event.y)
693             self.window.set_cursor(self.get_cursor(area))
694             return
695         assert not self.tool.floating
696         (ptr_x_root0, ptr_y_root0, w0, h0,
697          min_w, min_h, max_w, max_h) = self.resize
698         cursor = self.nonfloating_cursor
699         self.window.set_cursor(cursor)
700         dh = event.y_root - ptr_y_root0
701         h = int(min(max(min_h, h0+dh), max_h))
702         w = -1   # constrained horizontally anyway, better to not care
703         self.tool.set_size_request(w, h)
704         self.tool.queue_resize()
705     
706     def on_leave_notify_event(self, widget, event):
707         self.window.set_cursor(None)
708
709
710 class FoldOutArrow (gtk.Button):
711     
712     TEXT_EXPANDED = _("Collapse")
713     TEXT_COLLAPSED = _("Expand")
714
715     def __init__(self, tool):
716         gtk.Button.__init__(self)
717         self.tool = tool
718         self.set_name("small-stock-image-button")
719         self.set_relief(gtk.RELIEF_NONE)
720         self.set_property("can-focus", False)
721         self.arrow = gtk.Arrow(gtk.ARROW_DOWN, gtk.SHADOW_NONE)
722         self.set_tooltip_text(self.TEXT_EXPANDED)
723         self.add(self.arrow)
724         self.connect("clicked", self.on_click)
725     
726     def on_click(self, *a):
727         self.tool.set_rolled_up(not self.tool.rolled_up)
728
729     def set_arrow(self, rolled_up):
730         if rolled_up:
731             self.arrow.set(gtk.ARROW_RIGHT, gtk.SHADOW_NONE)
732             self.set_tooltip_text(self.TEXT_COLLAPSED)
733         else:
734             self.arrow.set(gtk.ARROW_DOWN, gtk.SHADOW_NONE)
735             self.set_tooltip_text(self.TEXT_EXPANDED)
736
737
738 class ToolDragHandle (gtk.EventBox):
739     """A draggable handle for repositioning a Tool.
740     """
741
742     min_drag_distance = 10
743     spacing = 2
744
745     def __init__(self, tool, label_text):
746         gtk.EventBox.__init__(self)
747         self.tool = tool
748         self.frame = frame = gtk.Frame()
749         frame.set_shadow_type(gtk.SHADOW_OUT)
750         self.hbox = hbox = gtk.HBox(spacing=self.spacing)
751         self.hbox.set_border_width(self.spacing)
752         self.img = gtk.Image()
753         pixbuf = getattr(self.tool.layout_manager.app.pixmaps, "tool_%s" % self.tool.role)
754         self.img.set_from_pixbuf(pixbuf)
755         hbox.pack_start(self.img, False, False)
756         self.roll_up_button = FoldOutArrow(self.tool)
757         hbox.pack_start(self.roll_up_button, False, False)
758         self.label = label = gtk.Label(label_text)
759         self.label.set_alignment(0.0, 0.5)
760         self.label.set_ellipsize(pango.ELLIPSIZE_END)
761         hbox.pack_start(label, True, True)
762         self.snap_button = SmallImageButton(tooltip=_("Snap in/out"))  # TODO: update this when pressed
763         self.snap_button.connect("clicked", tool.on_snap_button_pressed)
764         self.close_button = SmallImageButton(gtk.STOCK_CLOSE, _("Close"))
765         self.close_button.connect("clicked", tool.on_close_button_pressed)
766         hbox.pack_start(self.snap_button, False, False)
767         hbox.pack_start(self.close_button, False, False)
768         frame.add(hbox)
769         self.add(frame)
770         self.connect("button-press-event", self.on_button_press_event)
771         self.connect("button-release-event", self.on_button_release_event)
772         self.connect("motion-notify-event", self.on_motion_notify_event)
773         self.connect("enter-notify-event", self.on_enter_notify_event)
774         self.connect("leave-notify-event", self.on_leave_notify_event)
775         # Drag initiation
776         self.set_events(gdk.BUTTON_PRESS_MASK | gdk.BUTTON_RELEASE_MASK)
777         self.button_press_xy = None
778         self.in_reposition_drag = False
779         # Floating status
780         self.set_floating(False)
781         self.frame.connect("expose-event", self.on_frame_expose_event)
782
783     def on_frame_expose_event(self, widget, event):
784         state = self.get_state()
785         alloc = self.get_allocation()
786         w = alloc.width
787         h = alloc.height
788
789         # Draw a subtle vertical gradient
790         def _col2rgba(col, alpha=0.3):
791             return col.red_float, col.green_float, col.blue_float, alpha
792         light_rgba = _col2rgba(self.style.light[state])
793         mid_rgba = _col2rgba(self.style.bg[state])
794         dark_rgba = _col2rgba(self.style.dark[state])
795         cr = widget.window.cairo_create()
796         lg = cairo.LinearGradient(0, 0, 0, h)
797         lg.add_color_stop_rgba(0.0, *light_rgba)
798         lg.add_color_stop_rgba(0.5, *mid_rgba)
799         lg.add_color_stop_rgba(1.0, *dark_rgba)
800         cr.set_source(lg)
801         cr.paint()
802
803         #self.style.paint_flat_box(widget.window, state,
804         #    gtk.SHADOW_OUT, event.area, widget,
805         #    'button', 0, 0, w, h)
806
807         return False   # let the normal handler draw the frame outline too
808
809     def set_floating(self, floating):
810         if floating:
811             stock_id = gtk.STOCK_GOTO_LAST
812             if self.roll_up_button in self.hbox:
813                 self.roll_up_button.hide()
814                 self.hbox.remove(self.roll_up_button)
815         else:
816             stock_id = gtk.STOCK_GOTO_FIRST
817             if self.roll_up_button not in self.hbox:
818                 self.hbox.pack_start(self.roll_up_button, False, False)
819                 self.hbox.reorder_child(self.roll_up_button, 1)
820                 self.roll_up_button.show_all()
821                 self.queue_resize()
822         self.snap_button.set_image_from_stock(stock_id)
823
824     def set_rolled_up(self, rolled_up):
825         self.roll_up_button.set_arrow(rolled_up)
826
827     def on_button_press_event(self, widget, event):
828         if event.button != 1:
829             return
830         if event.type == gdk._2BUTTON_PRESS:
831             if not self.tool.floating:
832                 self.tool.set_rolled_up(not self.tool.rolled_up)
833                 self._reset()
834         elif event.type == gdk.BUTTON_PRESS:
835             self.set_state(gtk.STATE_ACTIVE)
836             self.window.set_cursor(gdk.Cursor(gdk.FLEUR))
837             self.button_press_xy = event.x, event.y
838
839     def on_button_release_event(self, widget, event):
840         self._reset()
841
842     def _reset(self):
843         self.button_press_xy = None
844         self.set_state(gtk.STATE_NORMAL)
845         if self.window:
846             self.window.set_cursor(None)
847
848     def on_motion_notify_event(self, widget, event):
849         if not self.button_press_xy:
850             return False
851         if not event.state & gdk.BUTTON1_MASK:
852             return
853         lm = self.tool.layout_manager
854         ix, iy = self.button_press_xy
855         dx, dy = event.x - ix, event.y - iy
856         dd = sqrt(dx**2 + dy**2)
857         if dd > self.min_drag_distance:
858             self.start_reposition_drag()
859
860     def start_reposition_drag(self):  # XXX: move to Tool?
861         """Begin repositioning this tool."""
862         lm = self.tool.layout_manager
863         ix, iy = self.button_press_xy
864         self.button_press_xy = None
865         self.window.set_cursor(gdk.Cursor(gdk.FLEUR))
866         self.set_state(gtk.STATE_ACTIVE)
867         self.tool.layout_manager.drag_state.begin(self.tool, ix, iy)
868
869     def on_reposition_drag_finished(self):   # XXX: move to Tool?
870         """Called when repositioning has finished."""
871         self._reset()
872
873     def on_leave_notify_event(self, widget, event):
874         self.window.set_cursor(None)
875         self.set_state(gtk.STATE_NORMAL)
876         
877     def on_enter_notify_event(self, widget, event):
878         self.window.set_cursor(gdk.Cursor(gdk.HAND2))
879         #if not self.in_reposition_drag:
880         #    self.set_state(gtk.STATE_PRELIGHT)
881
882
883 class ToolWindow (gtk.Window, ElasticContainer, WindowWithSavedPosition):
884     """Window containing a Tool in the floating state.
885     """
886
887     def __init__(self, title, role, layout_manager):
888         gtk.Window.__init__(self)
889         ElasticContainer.__init__(self)
890         WindowWithSavedPosition.__init__(self)
891         self.layout_manager = layout_manager
892         self.set_transient_for(layout_manager.main_window)
893         self.set_type_hint(gdk.WINDOW_TYPE_HINT_UTILITY)
894         self.set_focus_on_map(False)
895         self.set_decorated(False)
896         self.role = role
897         self.set_role(role)
898         self.set_title(title)
899         self.tool = None
900         self.connect("configure-event", self.on_configure_event)
901         self.pre_hide_pos = None
902
903     def add(self, tool):
904         self.tool = tool
905         gtk.Window.add(self, tool)
906
907     def remove(self, tool):
908         self.tool = None
909         gtk.Window.remove(self, tool)
910
911     def on_configure_event(self, widget, event):
912         if self.pre_hide_pos:
913             return
914         role = self.role
915         lm = self.layout_manager
916         if not lm.prefs.get(role, False):
917             lm.prefs[role] = {}
918         lm.prefs[role]['floating'] = True
919
920     def show(self):
921         """Shows or re-shows the window.
922
923         If a previous position was set by hide(), it will be restored.
924         """
925         gtk.Window.show(self)
926         if self.pre_hide_pos:
927             self.move(*self.pre_hide_pos)
928         self.pre_hide_pos = None
929
930     def show_all(self):
931         """Shows or re-shows the window and all its descendents.
932
933         If a previous position was set by hide(), it will be restored.
934         """
935         gtk.Window.show_all(self)
936         if self.pre_hide_pos:
937             self.move(*self.pre_hide_pos)
938         self.pre_hide_pos = None
939
940     def hide(self):
941         """Hides the window, remembering its position.
942         """
943         self.pre_hide_pos = self.get_position()
944         gtk.Window.hide(self)
945
946     def place(self, x, y, w, h):
947         """Places a window at a position, either now or when mapped.
948
949         This is intended to be called before a show() or show_all() by
950         code that lets the user pick a position for a tool window before it's
951         shown: for example when snapping a tool free from the sidebar using
952         the preview window.
953
954         The pre-hide position will be forgotten and not used by the override
955         for show() or show_all().
956         """
957         self.pre_hide_pos = None
958
959         # Constrain x and y so negative positions passed in don't look like
960         # the funky right or bottom edge positioning magic (to the realize
961         # handler; move() doesn't care, but be symmetrical in behaviour).
962         x = max(x, 0)
963         y = max(y, 0)
964
965         if self.window is not None:
966             # It's mapped, so this will probably work even if the window
967             # is not viewable right now.
968             self.move(x, y)
969             self.resize(w, h)
970         else:
971             # No window yet, not mapped.
972             # Update prefs ready for the superclass's realize handler.
973             role = self.role
974             lm = self.layout_manager
975             if not lm.prefs.get(role, False):
976                 lm.prefs[role] = {}
977             new_size = dict(x=x, y=y, w=w, h=h)
978             lm.prefs[role].update(new_size)
979
980
981 class Tool (gtk.VBox, ElasticContainer):
982     """Container for a dockable tool widget.
983     
984     The widget may be packed into a Sidebar, or snapped out into its own
985     floating ToolWindow.
986     """
987
988     def __init__(self, widget, role, title, gloss, layout_manager):
989         gtk.VBox.__init__(self)
990         ElasticContainer.__init__(self)
991         self.role = role
992         self.layout_manager = layout_manager
993         self.handle = ToolDragHandle(self, gloss)
994         self.pack_start(self.handle, False, False)
995         self.widget_frame = frame = gtk.Frame()
996         frame.set_shadow_type(gtk.SHADOW_IN)
997         frame.add(widget)
998         self.widget = widget
999         self.pack_start(frame, True, True)
1000         self.resize_grip = ToolResizeGrip(self)
1001         self.resize_grip_frame = gtk.Frame()
1002         self.resize_grip_frame.set_shadow_type(gtk.SHADOW_OUT)
1003         self.resize_grip_frame.add(self.resize_grip)
1004         self.pack_start(self.resize_grip_frame, False, False)
1005         self.floating_window = ToolWindow(title, role, layout_manager)
1006         self.floating_window.connect("delete-event", self.on_floating_window_delete_event)
1007         self.layout_manager.window_group.add_window(self.floating_window)
1008         self.floating = False
1009         self.hidden = False
1010         self.rolled_up = False
1011         self.rolled_up_prev_size = None
1012         self.connect("size-allocate", self.on_size_allocate)
1013     
1014     def on_size_allocate(self, widget, allocation):
1015         if self.rolled_up:
1016             return
1017         lm = self.layout_manager
1018         if self not in lm.main_window.sidebar.tools_vbox:
1019             return
1020         if not lm.prefs.get(self.role, False):
1021             lm.prefs[self.role] = {}
1022         lm.prefs[self.role]["sbheight"] = allocation.height
1023
1024     def on_floating_window_delete_event(self, window, event):
1025         self.set_hidden(True, reason="window-deleted")
1026         return True   # Suppress ordinary deletion. We'll be wanting it again.
1027
1028     def set_show_resize_grip(self, show):
1029         widget = self.resize_grip_frame
1030         if not show:
1031             if widget in self:
1032                 widget.hide()
1033                 self.remove(widget)
1034         else:
1035             if widget not in self:
1036                 self.pack_start(widget, False, False)
1037                 self.reorder_child(widget, -1)
1038                 widget.show()
1039
1040     def set_show_widget_frame(self, show):
1041         widget = self.widget_frame
1042         if not show:
1043             if widget in self:
1044                 widget.hide()
1045                 self.remove(widget)
1046         else:
1047             if widget not in self:
1048                 self.pack_start(widget, True, True)
1049                 self.reorder_child(widget, 1)
1050                 widget.show()
1051
1052     def restore_sbheight(self):
1053         """Restore the height of the tool when docked."""
1054         lm = self.layout_manager
1055         role = self.role
1056         sbheight = lm.prefs.get(role, {}).get("sbheight", None)
1057         if sbheight is not None:
1058             self.set_size_request(-1, sbheight)
1059
1060     def set_floating(self, floating):
1061         """Flips the widget beween floating and non-floating, reparenting it.
1062         """
1063         self.handle.set_floating(floating)
1064         self.set_rolled_up(False)
1065         
1066         # Clear any explicit size requests so that the frame is able to adopt a
1067         # natural size again.
1068         for wid in (self.handle, self):
1069             if wid.get_visible():
1070                 wid.set_size_request(-1, -1)
1071                 wid.queue_resize()
1072
1073         if self.parent:
1074             self.parent.remove(self)
1075         lm = self.layout_manager
1076         if lm.prefs.get(self.role, None) is None:
1077             lm.prefs[self.role] = {}
1078         if not floating:
1079             if lm.main_window.sidebar.is_empty():
1080                 lm.main_window.sidebar.show_all()
1081             sbindex = lm.prefs[self.role].get("sbindex", None)
1082             lm.main_window.sidebar.add_tool(self, index=sbindex)
1083             self.floating_window.hide()
1084             self.floating = lm.prefs[self.role]["floating"] = False
1085             self.restore_sbheight()
1086             lm.main_window.sidebar.reassign_indices()
1087             self.resize_grip_frame.set_shadow_type(gtk.SHADOW_NONE)
1088         else:
1089             self.floating_window.add(self)
1090             # Defer the show_all(), seems to be needed when toggling on a
1091             # hidden, floating window which hasn't yet been loaded.
1092             gobject.idle_add(self.floating_window.show_all)
1093             self.floating = lm.prefs[self.role]["floating"] = True
1094             lm.main_window.sidebar.reassign_indices()
1095             if lm.main_window.sidebar.is_empty():
1096                 lm.main_window.sidebar.hide()
1097             self.resize_grip_frame.set_shadow_type(gtk.SHADOW_OUT)
1098
1099     def set_hidden(self, hidden, reason=None, temporary=False):
1100         """Sets a tool as hidden, hiding or showing it as appropriate.
1101         
1102         Note that this does not affect whether the window is floating or not
1103         when un-hidden it will restore to the same place in the UI it was
1104         hidden.
1105
1106         If the `temporary` argument is true, the new state will not be saved in
1107         the preferences.
1108         """
1109         self.set_rolled_up(False)
1110         role = self.role
1111         lm = self.layout_manager
1112         if not lm.prefs.get(role, False):
1113             lm.prefs[role] = {}
1114         if hidden:
1115             self.hide()
1116             if self.parent:
1117                 self.parent.remove(self)
1118             if self.floating:
1119                 self.floating_window.hide()
1120         else:
1121             self.set_floating(self.floating)
1122             # Which will restore it to the correct state
1123         self.hidden = hidden
1124         lm.notify_tool_visibility_observers(role=self.role, active=not hidden,
1125                                             reason=reason, temporary=temporary)
1126         if not temporary:
1127             lm.prefs[role]["hidden"] = hidden
1128         if lm.main_window.sidebar.is_empty():
1129             lm.main_window.sidebar.hide()
1130         else:
1131             lm.main_window.sidebar.show_all()
1132
1133     def set_rolled_up(self, rolled_up):
1134         resize_needed = False
1135         if rolled_up:
1136             if not self.rolled_up:
1137                 self.rolled_up = True
1138                 alloc = self.get_allocation()
1139                 self.rolled_up_prev_size = (alloc.width, alloc.height)
1140                 self.set_show_widget_frame(False)
1141                 self.set_show_resize_grip(False)
1142                 newalloc = self.handle.get_allocation()
1143                 self.set_size_request(newalloc.width, newalloc.height)
1144                 resize_needed = True
1145         else:
1146             if self.rolled_up:
1147                 self.rolled_up = False
1148                 self.set_show_widget_frame(True)
1149                 self.set_show_resize_grip(True)
1150                 self.set_size_request(*self.rolled_up_prev_size)
1151                 resize_needed = True
1152                 self.rolled_up_prev_size = None
1153         # Since other handlers call this to unroll a window before moving it
1154         # or hiding it, it's best to perform any necessary resizes now before
1155         # returning. Otherwise sizes can become screwy when the operation 
1156         # is reversed.
1157         if resize_needed:
1158             self.queue_resize()
1159             while gtk.events_pending():
1160                 gtk.main_iteration(False)
1161         # Notify the indicator arrow
1162         self.handle.set_rolled_up(rolled_up)
1163
1164
1165     def on_close_button_pressed(self, window):
1166         self.set_hidden(True, reason="close-button-pressed")
1167     
1168     def on_snap_button_pressed(self, window):
1169         # Mouse position
1170         display = gdk.display_get_default()
1171         screen, ptr_x, ptr_y, _modmask = display.get_pointer()
1172         if self.rolled_up:
1173             self.set_rolled_up(False)    # pending events will be processed
1174         alloc = self.allocation
1175         w = alloc.width
1176         h = alloc.height
1177         titlebar_h = self.handle.allocation.height
1178
1179         def handle_snap():
1180             sidebar = self.layout_manager.main_window.sidebar
1181             n = sidebar.num_tools()
1182             m = 5
1183             if not self.floating:
1184                 x = ptr_x - (w + (n*w/m))  # cascade effect
1185                 y = ptr_y + ((m - n) * (titlebar_h + 6))
1186                 self.floating_window.place(x, y, w, h)
1187                 self.set_floating(True)
1188                 self.handle.snap_button.set_state(gtk.STATE_NORMAL)
1189                 if sidebar.is_empty():
1190                     sidebar.hide()
1191             else:
1192                 if sidebar.is_empty():
1193                    sidebar.show()
1194                 self.set_floating(False)
1195                 self.handle.snap_button.set_state(gtk.STATE_NORMAL)
1196             return False
1197         gobject.idle_add(handle_snap)
1198
1199     def get_preview_size(self):
1200         alloc = self.get_allocation()
1201         return alloc.width, alloc.height
1202
1203     def get_minimum_size(self):
1204         min_w, min_h = 0, 0
1205         for child in self.handle, self.resize_grip_frame, self.widget_frame:
1206             child_min_w, child_min_h = child.size_request()
1207             min_w = max(min_w, child_min_w)
1208             min_h += child_min_h
1209         return min_w, min_h
1210
1211
1212 class ToolDragPreviewWindow (gtk.Window):
1213     """A shaped outline window showing where the current drag will move a Tool.
1214     """
1215
1216     def __init__(self, layout_manager):
1217         gtk.Window.__init__(self, gtk.WINDOW_POPUP)
1218         self.owner = layout_manager.main_window
1219         #self.set_opacity(0.5)
1220         self.set_decorated(False)
1221         self.set_position(gtk.WIN_POS_MOUSE)
1222         self.connect("map-event", self.on_map_event)
1223         self.connect("expose-event", self.on_expose_event)
1224         self.connect("configure-event", self.on_configure_event)
1225         self.bg = None
1226         self.set_default_size(1,1)
1227
1228     def show_all(self):
1229         gtk.Window.show_all(self)
1230         self.window.move_resize(0, 0, 1, 1)
1231     
1232     def on_map_event(self, window, event):
1233         owner_win = self.owner.get_toplevel()
1234         self.set_transient_for(owner_win)
1235         # The first time we're mapped, set a background pixmap.
1236         # Background pixmap, a checkerboard
1237         if self.bg is None:
1238             self.bg = gdk.Pixmap(drawable=self.window, width=2, height=2)
1239             cmap = gdk.colormap_get_system()
1240             black = cmap.alloc_color(gdk.Color(0.0, 0.0, 0.0))
1241             white = cmap.alloc_color(gdk.Color(1.0, 1.0, 1.0))
1242             self.bg.set_colormap(cmap)
1243             gc = self.bg.new_gc(black, white)
1244             self.bg.draw_rectangle(gc, True, 0, 0, 2, 2)
1245             gc = self.bg.new_gc(white, black)
1246             self.bg.draw_points(gc, [(0,0), (1,1)])
1247             self.window.set_back_pixmap(self.bg, False)
1248             self.window.clear()
1249     
1250     def on_configure_event(self, window, event):
1251         # Shape the window
1252         w = event.width
1253         h = event.height
1254         r = gdk.Region()
1255         s = 4
1256         r.union_with_rect(gdk.Rectangle(0, 0, w, s))
1257         r.union_with_rect(gdk.Rectangle(0, 0, s, h))
1258         r.union_with_rect(gdk.Rectangle(0, h-s, w, s))
1259         r.union_with_rect(gdk.Rectangle(w-s, 0, s, h))
1260         self.window.shape_combine_region(r, 0, 0)
1261
1262     def on_expose_event(self, window, event):
1263         # Clear to the backing pixmap established earlier
1264         if self.bg is not None:
1265             self.window.begin_paint_rect(event.area)
1266             self.window.set_back_pixmap(self.bg, False)
1267             self.window.clear()
1268             self.window.end_paint()
1269
1270
1271 class ToolDragState:
1272     
1273     """Manages visual state during tool repositioning.
1274
1275     The resize grip can largely take care of itself, and deploy its own grabs.
1276     However when tools are repositioned, they get reparented between floating
1277     windows and the sidebar and a grab on either tends to become confused. The
1278     DragState object establishes whatever grabs are necessary for tools to be
1279     repositioned; all they have to do is detect when the process starts and
1280     call enter().
1281     """
1282
1283     def __init__(self, layout_manager):
1284         self.layout_manager = layout_manager
1285         self.insert_pos = None
1286         self.preview = ToolDragPreviewWindow(layout_manager)
1287         self.preview_size = None
1288         self.tool = None        # the box being dragged around & previewed
1289         self.handle_pos = None
1290         self.pointer_inside_sidebar = None
1291         self.conn_ids = []
1292
1293     def connect_reposition_handlers(self):
1294         """Connect the event handlers used while repositioning tools.
1295         """
1296         win = self.layout_manager.main_window
1297         conn_id = win.connect("motion-notify-event",
1298                               self.on_reposition_motion_notify)
1299         self.conn_ids.append(conn_id)
1300         conn_id = win.connect("button-release-event",
1301                               self.on_reposition_button_release)
1302         self.conn_ids.append(conn_id)
1303
1304     def disconnect_reposition_handlers(self):
1305         """Disconnect the handlers set up by connect_reposition_handlers().
1306         """
1307         win = self.layout_manager.main_window
1308         while self.conn_ids:
1309             conn_id = self.conn_ids.pop()
1310             win.disconnect(conn_id)
1311
1312     def begin(self, tool, handle_x, handle_y):
1313         """Begin repositioning a tool using its drag handle.
1314         
1315         It's assumed the pointer button is held down at this point. The rest of
1316         the procedure happens behind a pointer grab on the main window, and
1317         ends when the button is released.
1318         """
1319         lm = self.layout_manager
1320
1321         # Show the preview window
1322         width, height = tool.get_preview_size()
1323         self.preview.show_all()
1324         self.preview_size = (width, height)
1325         self.handle_pos = (handle_x, handle_y)
1326
1327         # Establish a pointer grab on the main window (which stays mapped
1328         # throughout the drag).
1329         main_win = lm.main_window
1330         main_win_gdk = main_win.window
1331         events = gdk.BUTTON_PRESS_MASK | gdk.BUTTON_RELEASE_MASK \
1332             | gdk.BUTTON1_MOTION_MASK
1333         grab_status = gdk.pointer_grab(main_win_gdk, False, events,
1334             None, gdk.Cursor(gdk.FLEUR), 0L)
1335         if grab_status != gtk.gdk.GRAB_SUCCESS:
1336             warn("Grab failed, aborting", RuntimeWarning, 2)
1337             return
1338         try:
1339             self.tool = tool
1340             self.connect_reposition_handlers()
1341         except:
1342             gdk.pointer_ungrab()
1343             self.disconnect_reposition_handlers()
1344             raise
1345
1346     def on_reposition_button_release(self, widget, event):
1347         """Ends the current tool reposition drag.
1348         """
1349         gdk.pointer_ungrab()
1350         self.disconnect_reposition_handlers()
1351         self.end()
1352
1353     def on_reposition_motion_notify(self, widget, event):
1354         """Bulk of window shuffling within a tool reposition drag.
1355         """
1356         x_root = event.x_root
1357         y_root = event.y_root
1358         w, h = self.preview_size
1359         # Show the sidebar if it's not currently visible
1360         sidebar = self.layout_manager.main_window.sidebar
1361         if sidebar.is_empty():
1362             sidebar.show_all()
1363         # Moving into or out of the box defined by the sidebar
1364         # changes the mode of the current drag.
1365         sb_left, sb_top = sidebar.window.get_origin()
1366         sb_alloc = sidebar.allocation
1367         sb_right = sb_left + sb_alloc.width
1368         sb_bottom = sb_top + sb_alloc.height
1369         if (x_root < sb_left or x_root > sb_right
1370             or y_root < sb_top or y_root > sb_bottom):
1371             self.pointer_inside_sidebar = False
1372         else:
1373             self.pointer_inside_sidebar = True
1374         # Move the preview window
1375         if self.pointer_inside_sidebar:
1376             tool = self.tool
1377             ins = sidebar.insertion_point_at_pointer()
1378             if ins is not None and ins != self.insert_pos:
1379                 self.insert_pos = ins
1380                 if not tool in sidebar.tools_vbox:
1381                     tool.set_floating(False)
1382                     # Update preview size: the widget will be a different
1383                     # size after being shoved in the sidebar.
1384                     while gtk.events_pending():
1385                         gtk.main_iteration(False)
1386                     w, h = self.preview_size = tool.get_preview_size()
1387                 sidebar.reorder_item(tool, ins)
1388                 sidebar.reassign_indices()
1389             x, y = tool.handle.window.get_origin()
1390         else:
1391             ix, iy = self.handle_pos
1392             x = int(x_root - ix)
1393             y = int(y_root - iy)
1394         self.handle_pos_root = (x, y)
1395         self.preview.window.move_resize(x, y, w, h)
1396
1397     def end(self):
1398         """Invoked at the end of repositioning. Snapping out happens here.
1399         """
1400         sidebar = self.layout_manager.main_window.sidebar
1401         if self.pointer_inside_sidebar:
1402             pass  # Widget has already been reordered,
1403                   # or snapped in and then reordered.
1404         else:
1405             # Set window position to that of the floating window
1406             x, y = self.handle_pos_root
1407             w, h = self.preview_size
1408             self.tool.floating_window.place(x, y, w, h)
1409             # Snap out
1410             if self.tool in sidebar.tools_vbox:
1411                 self.tool.set_floating(True)
1412         self.preview.hide()
1413         self.tool.handle.on_reposition_drag_finished()
1414         self.tool = None
1415         self.handle_pos = None
1416         self.insert_pos = None
1417         self.pointer_inside_sidebar = None
1418         self.preview_pos_root = (0, 0)
1419         if sidebar.is_empty():
1420            sidebar.hide()
1421
1422
1423 class Sidebar (gtk.EventBox):
1424     """Vertical sidebar containing reorderable tools which can be snapped out.
1425     """
1426
1427     MIN_SIZE = 150
1428
1429     def __init__(self, layout_manager):
1430         gtk.EventBox.__init__(self)
1431         self.layout_manager = layout_manager
1432         self.scrolledwin = gtk.ScrolledWindow()
1433         self.scrolledwin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_ALWAYS)
1434         self.add(self.scrolledwin)
1435         self.tools_vbox = gtk.VBox()
1436         self.scrolledwin.add_with_viewport(self.tools_vbox)
1437         self.slack = gtk.EventBox()   # needs its own window
1438         self.tools_vbox.pack_start(self.slack, True, True)
1439         self.set_size_request(self.MIN_SIZE, self.MIN_SIZE)
1440
1441     def add_tool(self, widget, index=None):
1442         assert isinstance(widget, Tool)
1443         if self.is_empty():
1444             self.show()
1445         self.tools_vbox.pack_start(widget, expand=False, fill=False)
1446         if index is not None:
1447             self.tools_vbox.reorder_child(widget, index)
1448         self.reassign_indices()
1449         self.tools_vbox.reorder_child(self.slack, -1)
1450
1451     def remove_tool(self, widget):
1452         assert isinstance(widget, Tool)
1453         self.reassign_indices()
1454         self.tools_vbox.remove(widget)
1455         if self.is_empty():
1456             self.hide()
1457         else:
1458             self.reassign_indices()
1459
1460     def reorder_item(self, item, pos):
1461         assert isinstance(item, Tool)
1462         self.tools_vbox.reorder_child(item, pos)
1463         self.tools_vbox.reorder_child(self.slack, -1)
1464         self.reassign_indices()
1465
1466     def get_tools(self):
1467         tools = []
1468         for widget in self.tools_vbox:
1469             if widget is self.slack:
1470                 continue
1471             tools.append(widget)
1472         return tools
1473
1474     def num_tools(self):
1475         """Returns the number of tools in the sidebar"""
1476         return len(self.get_tools())
1477
1478     def is_empty(self):
1479         """True if there are no tools in the sidebar."""
1480         return self.num_tools() == 0
1481
1482     def insertion_point_at_pointer(self):
1483         """Returns where in the sidebar a tool would be inserted.
1484
1485         Returns an integer position for passing to reorder_item(), or None.
1486         Currently only tool drag handles are valid insertion points.
1487         """
1488         window_info = gdk.window_at_pointer()
1489         if window_info is None:
1490             return None
1491         pointer_window = window_info[0]
1492         current_tool = self.layout_manager.drag_state.tool
1493         i = 0
1494         for widget in self.tools_vbox:
1495             if widget is self.slack:
1496                 if widget.window is pointer_window:
1497                     return i
1498             if isinstance(widget, Tool):
1499                 if widget is not current_tool:
1500                     dragger = widget.handle
1501                     if dragger.window is pointer_window:
1502                         return i
1503             i += 1
1504         return None
1505
1506     def max_tool_size(self):
1507         """Returns the largest a packed tool is allowed to be.
1508         """
1509         scrwin = self.scrolledwin
1510         viewpt = scrwin.get_child()
1511         sb_pad = 2 * scrwin.style_get_property("scrollbar-spacing")
1512         vp_alloc = viewpt.allocation
1513         max_size = (vp_alloc.width-sb_pad, vp_alloc.height-sb_pad)
1514         return max_size
1515
1516     def reassign_indices(self):
1517         """Calculates and reassigns the "sbindex" prefs value.
1518         """
1519         lm = self.layout_manager
1520         i = 0
1521         for tool in [t for t in self.tools_vbox if t is not self.slack]:
1522             role = tool.role
1523             if not lm.prefs.get(role, False):
1524                 lm.prefs[role] = {}
1525             lm.prefs[role]["sbindex"] = i
1526             i += 1
1527
1528     def set_tool_widths(self, width):
1529         """Constrain all packed tools' widths to a certain size.
1530         """
1531         lm = self.layout_manager
1532         max_w, max_h = self.max_tool_size()
1533         for tool in [t for t in self.tools_vbox if t is not self.slack]:
1534             natural_w, natural_h = tool.size_request()
1535             req_w, req_h = tool.get_size_request()
1536             if req_w == -1:
1537                 # Only constrain if the natual width is larger than permitted
1538                 if natural_w > max_w:
1539                     tool.set_size_request(max_w, req_h)
1540             else:
1541                 if req_w > max_w:
1542                     if natural_w <= max_w:
1543                         tool.set_size_request(-1, req_h)
1544                         # Dubious. Could this lead to an infinite loop?
1545                     else:
1546                         tool.set_size_request(max_w, req_h)
1547
1548
1549 def set_initial_window_position(win, pos):
1550     """Set the position of a gtk.Window, used during initial positioning.
1551
1552     This is used both for restoring a saved window position, and for the
1553     application-wide defaults. The ``pos`` argument is a dict containing the
1554     following optional keys
1555
1556         "w": <int>
1557         "h": <int>
1558             If positive, the size of the window.
1559             If negative, size is calculated based on the size of the
1560             monitor with the pointer on it, and x (or y) if given, e.g.
1561
1562                 width = mouse_mon_w -  abs(x) + abs(w)   # or (if no x)
1563                 width = mouse_mon_w - (2 * abs(w))
1564
1565             The same is true of calulated heights.
1566
1567         "x": <int>
1568         "y": <int>
1569             If positive, the left/top of the window.
1570             If negative, the bottom/right of the window: you MUST provide
1571             a positive w,h if you do this!
1572
1573     If the window's calculated top-left would place it offscreen, it will be
1574     placed in its default, window manager provided position. If its calculated
1575     size is larger than the screen, the window will be given its natural size
1576     instead.
1577
1578     Returns the final, chosen (x, y) pair for forcing the window position on
1579     first map, or None if defaults are being used.
1580     """
1581
1582     MIN_USABLE_SIZE = 100
1583
1584     # Final calculated positions
1585     final_x, final_y = None, None
1586     final_w, final_h = None, None
1587
1588     # Positioning arguments
1589     x = pos.get("x", None)
1590     y = pos.get("y", None)
1591     w = pos.get("w", None)
1592     h = pos.get("h", None)
1593
1594     # Where the mouse is right now
1595     display = gdk.display_get_default()
1596     screen, ptr_x, ptr_y, _modmask = display.get_pointer()
1597     if screen is None:
1598         raise RuntimeError, "No cursor on the default screen. Eek."
1599     screen_w = screen.get_width()
1600     screen_h = screen.get_height()
1601     assert screen_w > MIN_USABLE_SIZE
1602     assert screen_h > MIN_USABLE_SIZE
1603
1604     if x is not None and y is not None:
1605         if x >= 0:
1606             final_x = x
1607         else:
1608             assert w is not None
1609             assert w > 0
1610             final_x = screen_w - w - abs(x)
1611         if y >= 0:
1612             final_y = y
1613         else:
1614             assert h is not None
1615             assert h > 0
1616             final_y = screen_h - h - abs(y)
1617         if final_x < 0 or final_x > screen_w - MIN_USABLE_SIZE: final_x = None
1618         if final_y < 0 or final_y > screen_h - MIN_USABLE_SIZE: final_h = None
1619
1620     if w is not None and h is not None:
1621         final_w = w
1622         final_h = h
1623         if w < 0 or h < 0:
1624             mon_num = screen.get_monitor_at_point(ptr_x, ptr_y)
1625             mon_geom = screen.get_monitor_geometry(mon_num)
1626             if w < 0:
1627                 if x is not None:
1628                     final_w = max(0, mon_geom.width - abs(x) - abs(w))
1629                 else:
1630                     final_w = max(0, mon_geom.width - 2*abs(w))
1631             if h < 0:
1632                 if x is not None:
1633                     final_h = max(0, mon_geom.height - abs(y) - abs(h))
1634                 else:
1635                     final_h = max(0, mon_geom.height - 2*abs(h))
1636         if final_w > screen_w or final_w < MIN_USABLE_SIZE: final_w = None
1637         if final_h > screen_h or final_h < MIN_USABLE_SIZE: final_h = None
1638
1639     # TODO: check that the final position makes sense for the current
1640     # screen (or monitor)
1641
1642     if None not in (final_w, final_h, final_x, final_y):
1643         geom_str = "%dx%d+%d+%d" % (final_w, final_h, final_x, final_y)
1644         win.parse_geometry(geom_str)
1645         return final_x, final_y
1646
1647     if final_w and final_h:
1648         win.set_default_size(final_w, final_h)
1649     if final_x and final_y:
1650         win.move(final_x, final_y)
1651         return final_x, final_y
1652
1653     return None