6f1a02161493309df7840357b347ce499e390304
[hooke.git] / hooke / ui / gui / __init__.py
1 # Copyright (C) 2008-2010 Fabrizio Benedetti
2 #                         Massimo Sandal <devicerandom@gmail.com>
3 #                         Rolf Schmidt <rschmidt@alcor.concordia.ca>
4 #                         W. Trevor King <wking@drexel.edu>
5 #
6 # This file is part of Hooke.
7 #
8 # Hooke is free software: you can redistribute it and/or modify it
9 # under the terms of the GNU Lesser General Public License as
10 # published by the Free Software Foundation, either version 3 of the
11 # License, or (at your option) any later version.
12 #
13 # Hooke is distributed in the hope that it will be useful, but WITHOUT
14 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
15 # or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU Lesser General
16 # Public License for more details.
17 #
18 # You should have received a copy of the GNU Lesser General Public
19 # License along with Hooke.  If not, see
20 # <http://www.gnu.org/licenses/>.
21
22 """Defines :class:`GUI` providing a wxWidgets interface to Hooke.
23
24 """
25
26 WX_GOOD=['2.8']
27
28 import wxversion
29 wxversion.select(WX_GOOD)
30
31 import copy
32 import logging
33 import os
34 import os.path
35 import platform
36 import shutil
37 import time
38
39 import wx.html
40 import wx.aui as aui
41 import wx.lib.evtmgr as evtmgr
42 # wxPropertyGrid is included in wxPython >= 2.9.1, see
43 #   http://wxpropgrid.sourceforge.net/cgi-bin/index?page=download
44 # until then, we'll avoid it because of the *nix build problems.
45 #import wx.propgrid as wxpg
46
47 from ...command import CommandExit, Exit, Success, Failure, Command, Argument
48 from ...config import Setting
49 from ...engine import CommandMessage
50 from ...interaction import Request, BooleanRequest, ReloadUserInterfaceConfig
51 from ...ui import UserInterface
52 from .dialog.selection import Selection as SelectionDialog
53 from .dialog.save_file import select_save_file
54 from . import menu as menu
55 from . import navbar as navbar
56 from . import panel as panel
57 from .panel.propertyeditor import props_from_argument, props_from_setting
58 from . import statusbar as statusbar
59
60
61 class HookeFrame (wx.Frame):
62     """The main Hooke-interface window.    
63     """
64     def __init__(self, gui, commands, inqueue, outqueue, *args, **kwargs):
65         super(HookeFrame, self).__init__(*args, **kwargs)
66         self.log = logging.getLogger('hooke')
67         self.gui = gui
68         self.commands = commands
69         self.inqueue = inqueue
70         self.outqueue = outqueue
71         self._perspectives = {}  # {name: perspective_str}
72         self._c = {}
73
74         self.SetIcon(wx.Icon(self.gui.config['icon image'], wx.BITMAP_TYPE_ICO))
75
76         # setup frame manager
77         self._c['manager'] = aui.AuiManager()
78         self._c['manager'].SetManagedWindow(self)
79
80         # set the gradient and drag styles
81         self._c['manager'].GetArtProvider().SetMetric(
82             aui.AUI_DOCKART_GRADIENT_TYPE, aui.AUI_GRADIENT_NONE)
83         self._c['manager'].SetFlags(
84             self._c['manager'].GetFlags() ^ aui.AUI_MGR_TRANSPARENT_DRAG)
85
86         # Min size for the frame itself isn't completely done.  See
87         # the end of FrameManager::Update() for the test code. For
88         # now, just hard code a frame minimum size.
89         #self.SetMinSize(wx.Size(500, 500))
90
91         self._setup_panels()
92         self._setup_toolbars()
93         self._c['manager'].Update()  # commit pending changes
94
95         # Create the menubar after the panes so that the default
96         # perspective is created with all panes open
97         panels = [p for p in self._c.values() if isinstance(p, panel.Panel)]
98         self._c['menu bar'] = menu.HookeMenuBar(
99             parent=self,
100             panels=panels,
101             callbacks={
102                 'close': self._on_close,
103                 'about': self._on_about,
104                 'view_panel': self._on_panel_visibility,
105                 'save_perspective': self._on_save_perspective,
106                 'delete_perspective': self._on_delete_perspective,
107                 'select_perspective': self._on_select_perspective,
108                 })
109         self.SetMenuBar(self._c['menu bar'])
110
111         self._c['status bar'] = statusbar.StatusBar(
112             parent=self,
113             style=wx.ST_SIZEGRIP)
114         self.SetStatusBar(self._c['status bar'])
115
116         self._setup_perspectives()
117         self._bind_events()
118         return # TODO: cleanup
119         self._displayed_plot = None
120         #load default list, if possible
121         self.do_loadlist(self.GetStringFromConfig('core', 'preferences', 'playlists'))
122
123
124     # GUI maintenance
125
126     def _setup_panels(self):
127         client_size = self.GetClientSize()
128         for p,style in [
129 #            ('folders', wx.GenericDirCtrl(
130 #                    parent=self,
131 #                    dir=self.gui.config['folders-workdir'],
132 #                    size=(200, 250),
133 #                    style=wx.DIRCTRL_SHOW_FILTERS,
134 #                    filter=self.gui.config['folders-filters'],
135 #                    defaultFilter=self.gui.config['folders-filter-index']), 'left'),
136             (panel.PANELS['playlist'](
137                     callbacks={
138                         'delete_playlist':self._on_user_delete_playlist,
139                         '_delete_playlist':self._on_delete_playlist,
140                         'delete_curve':self._on_user_delete_curve,
141                         '_delete_curve':self._on_delete_curve,
142                         '_on_set_selected_playlist':self._on_set_selected_playlist,
143                         '_on_set_selected_curve':self._on_set_selected_curve,
144                         },
145                     parent=self,
146                     style=wx.WANTS_CHARS|wx.NO_BORDER,
147                     # WANTS_CHARS so the panel doesn't eat the Return key.
148 #                    size=(160, 200),
149                     ), 'left'),
150             (panel.PANELS['note'](
151                     callbacks = {
152                         '_on_update':self._on_update_note,
153                         },
154                     parent=self,
155                     style=wx.WANTS_CHARS|wx.NO_BORDER,
156 #                    size=(160, 200),
157                     ), 'left'),
158 #            ('notebook', Notebook(
159 #                    parent=self,
160 #                    pos=wx.Point(client_size.x, client_size.y),
161 #                    size=wx.Size(430, 200),
162 #                    style=aui.AUI_NB_DEFAULT_STYLE
163 #                    | aui.AUI_NB_TAB_EXTERNAL_MOVE | wx.NO_BORDER), 'center'),
164             (panel.PANELS['commands'](
165                     commands=self.commands,
166                     selected=self.gui.config['selected command'],
167                     callbacks={
168                         'execute': self.explicit_execute_command,
169                         'select_plugin': self.select_plugin,
170                         'select_command': self.select_command,
171 #                        'selection_changed': self.panelProperties.select(self, method, command),  #SelectedTreeItem = selected_item,
172                         },
173                     parent=self,
174                     style=wx.WANTS_CHARS|wx.NO_BORDER,
175                     # WANTS_CHARS so the panel doesn't eat the Return key.
176 #                    size=(160, 200),
177                     ), 'right'),
178             (panel.PANELS['propertyeditor'](
179                     callbacks={},
180                     parent=self,
181                     style=wx.WANTS_CHARS,
182                     # WANTS_CHARS so the panel doesn't eat the Return key.
183                     ), 'center'),
184             (panel.PANELS['plot'](
185                     callbacks={
186                         '_set_status_text': self._on_plot_status_text,
187                         },
188                     parent=self,
189                     style=wx.WANTS_CHARS|wx.NO_BORDER,
190                     # WANTS_CHARS so the panel doesn't eat the Return key.
191 #                    size=(160, 200),
192                     ), 'center'),
193             (panel.PANELS['output'](
194                     parent=self,
195                     pos=wx.Point(0, 0),
196                     size=wx.Size(150, 90),
197                     style=wx.TE_READONLY|wx.NO_BORDER|wx.TE_MULTILINE),
198              'bottom'),
199             ]:
200             self._add_panel(p, style)
201         self.execute_command(  # setup already loaded playlists
202             command=self._command_by_name('playlists'))
203         self.execute_command(  # setup already loaded curve
204             command=self._command_by_name('get curve'))
205
206     def _add_panel(self, panel, style):
207         self._c[panel.name] = panel
208         m_name = panel.managed_name
209         info = aui.AuiPaneInfo().Name(m_name).Caption(m_name)
210         info.PaneBorder(False).CloseButton(True).MaximizeButton(False)
211         if style == 'top':
212             info.Top()
213         elif style == 'center':
214             info.CenterPane()
215         elif style == 'left':
216             info.Left()
217         elif style == 'right':
218             info.Right()
219         else:
220             assert style == 'bottom', style
221             info.Bottom()
222         self._c['manager'].AddPane(panel, info)
223
224     def _setup_toolbars(self):
225         self._c['navigation bar'] = navbar.NavBar(
226             callbacks={
227                 'next': self._next_curve,
228                 'previous': self._previous_curve,
229                 },
230             parent=self,
231             style=wx.TB_FLAT | wx.TB_NODIVIDER)
232         self._c['manager'].AddPane(
233             self._c['navigation bar'],
234             aui.AuiPaneInfo().Name('Navigation').Caption('Navigation'
235                 ).ToolbarPane().Top().Layer(1).Row(1).LeftDockable(False
236                 ).RightDockable(False))
237
238     def _bind_events(self):
239         # TODO: figure out if we can use the eventManager for menu
240         # ranges and events of 'self' without raising an assertion
241         # fail error.
242         self.Bind(wx.EVT_ERASE_BACKGROUND, self._on_erase_background)
243         self.Bind(wx.EVT_SIZE, self._on_size)
244         self.Bind(wx.EVT_CLOSE, self._on_close)
245         self.Bind(aui.EVT_AUI_PANE_CLOSE, self._on_pane_close)
246
247         return # TODO: cleanup
248         treeCtrl = self._c['folders'].GetTreeCtrl()
249         treeCtrl.Bind(wx.EVT_LEFT_DCLICK, self._on_dir_ctrl_left_double_click)
250
251     def _on_about(self, *args):
252         dialog = wx.MessageDialog(
253             parent=self,
254             message=self.gui._splash_text(extra_info={
255                     'get-details':'click "Help -> License"'},
256                                           wrap=False),
257             caption='About Hooke',
258             style=wx.OK|wx.ICON_INFORMATION)
259         dialog.ShowModal()
260         dialog.Destroy()
261
262     def _on_size(self, event):
263         event.Skip()
264
265     def _on_close(self, *args):
266         self.log.info('closing GUI framework')
267         # apply changes
268         self._set_config('main height', self.GetSize().GetHeight())
269         self._set_config('main left', self.GetPosition()[0])
270         self._set_config('main top', self.GetPosition()[1])
271         self._set_config('main width', self.GetSize().GetWidth())
272         self._c['manager'].UnInit()
273         del self._c['manager']
274         self.Destroy()
275
276     def _on_erase_background(self, event):
277         event.Skip()
278
279
280
281     # Panel utility functions
282
283     def _file_name(self, name):
284         """Cleanup names according to configured preferences.
285         """
286         if self.gui.config['hide extensions'] == True:
287             name,ext = os.path.splitext(name)
288         return name
289
290
291
292     # Command handling
293
294     def _command_by_name(self, name):
295         cs = [c for c in self.commands if c.name == name]
296         if len(cs) == 0:
297             raise KeyError(name)
298         elif len(cs) > 1:
299             raise Exception('Multiple commands named "%s"' % name)
300         return cs[0]
301
302     def explicit_execute_command(self, _class=None, method=None,
303                                  command=None, args=None):
304         return self.execute_command(
305             _class=_class, method=method, command=command, args=args,
306             explicit_user_call=True)
307
308     def execute_command(self, _class=None, method=None,
309                         command=None, args=None, explicit_user_call=False):
310         if args == None:
311             args = {}
312         if ('property editor' in self._c
313             and self.gui.config['selected command'] == command.name):
314             for name,value in self._c['property editor'].get_values().items():
315                 arg = self._c['property editor']._argument_from_label.get(
316                     name, None)
317                 if arg == None:
318                     continue
319                 elif arg.count == 1:
320                     args[arg.name] = value
321                     continue
322                 # deal with counted arguments
323                 if arg.name not in args:
324                     args[arg.name] = {}
325                 index = int(name[len(arg.name):])
326                 args[arg.name][index] = value
327             for arg in command.arguments:
328                 if arg.name not in args:
329                     continue  # undisplayed argument, e.g. 'driver' types.
330                 count = arg.count
331                 if hasattr(arg, '_display_count'):  # support HACK in props_from_argument()
332                     count = arg._display_count
333                 if count != 1 and arg.name in args:
334                     keys = sorted(args[arg.name].keys())
335                     assert keys == range(count), keys
336                     args[arg.name] = [args[arg.name][i]
337                                       for i in range(count)]
338                 if arg.count == -1:
339                     while (len(args[arg.name]) > 0
340                            and args[arg.name][-1] == None):
341                         args[arg.name].pop()
342                     if len(args[arg.name]) == 0:
343                         args[arg.name] = arg.default
344         cm = CommandMessage(command.name, args)
345         self.gui._submit_command(
346             cm, self.inqueue, explicit_user_call=explicit_user_call)
347         # TODO: skip responses for commands that were captured by the
348         # command stack.  We'd need to poll on each request, remember
349         # capture state, or add a flag to the response...
350         return self._handle_response(command_message=cm)
351
352     def _handle_response(self, command_message):
353         results = []
354         while True:
355             msg = self.outqueue.get()
356             results.append(msg)
357             if isinstance(msg, Exit):
358                 self._on_close()
359                 break
360             elif isinstance(msg, CommandExit):
361                 # TODO: display command complete
362                 break
363             elif isinstance(msg, ReloadUserInterfaceConfig):
364                 self.gui.reload_config(msg.config)
365                 continue
366             elif isinstance(msg, Request):
367                 h = handler.HANDLERS[msg.type]
368                 h.run(self, msg)  # TODO: pause for response?
369                 continue
370         pp = getattr(
371            self, '_postprocess_%s' % command_message.command.replace(' ', '_'),
372            self._postprocess_text)
373         pp(command=command_message.command,
374            args=command_message.arguments,
375            results=results)
376         return results
377
378     def _handle_request(self, msg):
379         """Repeatedly try to get a response to `msg`.
380         """
381         if prompt == None:
382             raise NotImplementedError('_%s_request_prompt' % msg.type)
383         prompt_string = prompt(msg)
384         parser = getattr(self, '_%s_request_parser' % msg.type, None)
385         if parser == None:
386             raise NotImplementedError('_%s_request_parser' % msg.type)
387         error = None
388         while True:
389             if error != None:
390                 self.cmd.stdout.write(''.join([
391                         error.__class__.__name__, ': ', str(error), '\n']))
392             self.cmd.stdout.write(prompt_string)
393             value = parser(msg, self.cmd.stdin.readline())
394             try:
395                 response = msg.response(value)
396                 break
397             except ValueError, error:
398                 continue
399         self.inqueue.put(response)
400
401     def _set_config(self, option, value, section=None):
402         self.gui._set_config(section=section, option=option, value=value,
403                              ui_to_command_queue=self.inqueue,
404                              response_handler=self._handle_response)
405
406
407     # Command-specific postprocessing
408
409     def _postprocess_text(self, command, args={}, results=[]):
410         """Print the string representation of the results to the Results window.
411
412         This is similar to :class:`~hooke.ui.commandline.DoCommand`'s
413         approach, except that :class:`~hooke.ui.commandline.DoCommand`
414         doesn't print some internally handled messages
415         (e.g. :class:`~hooke.interaction.ReloadUserInterfaceConfig`).
416         """
417         for result in results:
418             if isinstance(result, CommandExit):
419                 self._c['output'].write(result.__class__.__name__+'\n')
420             self._c['output'].write(str(result).rstrip()+'\n')
421
422     def _postprocess_playlists(self, command, args={}, results=None):
423         """Update `self` to show the playlists.
424         """
425         if not isinstance(results[-1], Success):
426             self._postprocess_text(command, results=results)
427             return
428         assert len(results) == 2, results
429         playlists = results[0]
430         if 'playlist' in self._c:
431             for playlist in playlists:
432                 if self._c['playlist'].is_playlist_loaded(playlist):
433                     self._c['playlist'].update_playlist(playlist)
434                 else:
435                     self._c['playlist'].add_playlist(playlist)
436
437     def _postprocess_new_playlist(self, command, args={}, results=None):
438         """Update `self` to show the new playlist.
439         """
440         if not isinstance(results[-1], Success):
441             self._postprocess_text(command, results=results)
442             return
443         assert len(results) == 2, results
444         playlist = results[0]
445         if 'playlist' in self._c:
446             loaded = self._c['playlist'].is_playlist_loaded(playlist)
447             assert loaded == False, loaded
448             self._c['playlist'].add_playlist(playlist)
449
450     def _postprocess_load_playlist(self, command, args={}, results=None):
451         """Update `self` to show the playlist.
452         """
453         if not isinstance(results[-1], Success):
454             self._postprocess_text(command, results=results)
455             return
456         assert len(results) == 2, results
457         playlist = results[0]
458         self._c['playlist'].add_playlist(playlist)
459
460     def _postprocess_get_playlist(self, command, args={}, results=[]):
461         if not isinstance(results[-1], Success):
462             self._postprocess_text(command, results=results)
463             return
464         assert len(results) == 2, results
465         playlist = results[0]
466         if 'playlist' in self._c:
467             loaded = self._c['playlist'].is_playlist_loaded(playlist)
468             assert loaded == True, loaded
469             self._c['playlist'].update_playlist(playlist)
470
471     def _postprocess_get_curve(self, command, args={}, results=[]):
472         """Update `self` to show the curve.
473         """
474         if not isinstance(results[-1], Success):
475             self._postprocess_text(command, results=results)
476             return
477         assert len(results) == 2, results
478         curve = results[0]
479         if args.get('curve', None) == None:
480             # the command defaults to the current curve of the current playlist
481             results = self.execute_command(
482                 command=self._command_by_name('get playlist'))
483             playlist = results[0]
484         else:
485             raise NotImplementedError()
486         if 'note' in self._c:
487             self._c['note'].set_text(curve.info.get('note', ''))
488         if 'playlist' in self._c:
489             self._c['playlist'].set_selected_curve(
490                 playlist, curve)
491         if 'plot' in self._c:
492             self._c['plot'].set_curve(curve, config=self.gui.config)
493
494     def _postprocess_next_curve(self, command, args={}, results=[]):
495         """No-op.  Only call 'next curve' via `self._next_curve()`.
496         """
497         pass
498
499     def _postprocess_previous_curve(self, command, args={}, results=[]):
500         """No-op.  Only call 'previous curve' via `self._previous_curve()`.
501         """
502         pass
503
504     def _postprocess_glob_curves_to_playlist(
505         self, command, args={}, results=[]):
506         """Update `self` to show new curves.
507         """
508         if not isinstance(results[-1], Success):
509             self._postprocess_text(command, results=results)
510             return
511         if 'playlist' in self._c:
512             if args.get('playlist', None) != None:
513                 playlist = args['playlist']
514                 pname = playlist.name
515                 loaded = self._c['playlist'].is_playlist_name_loaded(pname)
516                 assert loaded == True, loaded
517                 for curve in results[:-1]:
518                     self._c['playlist']._add_curve(pname, curve)
519             else:
520                 self.execute_command(
521                     command=self._command_by_name('get playlist'))
522
523     def _postprocess_zero_block_surface_contact_point(
524         self, command, args={}, results=[]):
525         """Update the curve, since the available columns may have changed.
526         """
527         if isinstance(results[-1], Success):
528             self.execute_command(
529                 command=self._command_by_name('get curve'))
530  
531     def _postprocess_add_block_force_array(
532         self, command, args={}, results=[]):
533         """Update the curve, since the available columns may have changed.
534         """
535         if isinstance(results[-1], Success):
536             self.execute_command(
537                 command=self._command_by_name('get curve'))
538
539
540
541     # Command panel interface
542
543     def select_command(self, _class, method, command):
544         #self.select_plugin(plugin=command.plugin)
545         self._c['property editor'].clear()
546         self._c['property editor']._argument_from_label = {}
547         for argument in command.arguments:
548             if argument.name == 'help':
549                 continue
550
551             results = self.execute_command(
552                 command=self._command_by_name('playlists'))
553             if not isinstance(results[-1], Success):
554                 self._postprocess_text(command, results=results)
555                 playlists = []
556             else:
557                 playlists = results[0]
558
559             results = self.execute_command(
560                 command=self._command_by_name('playlist curves'))
561             if not isinstance(results[-1], Success):
562                 self._postprocess_text(command, results=results)
563                 curves = []
564             else:
565                 curves = results[0]
566
567             ret = props_from_argument(
568                 argument, curves=curves, playlists=playlists)
569             if ret == None:
570                 continue  # property intentionally not handled (yet)
571             for label,p in ret:
572                 self._c['property editor'].append_property(p)
573                 self._c['property editor']._argument_from_label[label] = (
574                     argument)
575
576         self._set_config('selected command', command.name)
577
578     def select_plugin(self, _class=None, method=None, plugin=None):
579         pass
580
581
582
583     # Folders panel interface
584
585     def _on_dir_ctrl_left_double_click(self, event):
586         file_path = self.panelFolders.GetPath()
587         if os.path.isfile(file_path):
588             if file_path.endswith('.hkp'):
589                 self.do_loadlist(file_path)
590         event.Skip()
591
592
593
594     # Note panel interface
595
596     def _on_update_note(self, _class, method, text):
597         """Sets the note for the active curve.
598         """
599         self.execute_command(
600             command=self._command_by_name('set note'),
601             args={'note':text})
602
603
604
605     # Playlist panel interface
606
607     def _on_user_delete_playlist(self, _class, method, playlist):
608         pass
609
610     def _on_delete_playlist(self, _class, method, playlist):
611         if hasattr(playlist, 'path') and playlist.path != None:
612             os.remove(playlist.path)
613
614     def _on_user_delete_curve(self, _class, method, playlist, curve):
615         pass
616
617     def _on_delete_curve(self, _class, method, playlist, curve):
618         # TODO: execute_command 'remove curve from playlist'
619         os.remove(curve.path)
620
621     def _on_set_selected_playlist(self, _class, method, playlist):
622         """Call the `jump to playlist` command.
623         """
624         results = self.execute_command(
625             command=self._command_by_name('playlists'))
626         if not isinstance(results[-1], Success):
627             return
628         assert len(results) == 2, results
629         playlists = results[0]
630         matching = [p for p in playlists if p.name == playlist.name]
631         assert len(matching) == 1, matching
632         index = playlists.index(matching[0])
633         results = self.execute_command(
634             command=self._command_by_name('jump to playlist'),
635             args={'index':index})
636
637     def _on_set_selected_curve(self, _class, method, playlist, curve):
638         """Call the `jump to curve` command.
639         """
640         self._on_set_selected_playlist(_class, method, playlist)
641         index = playlist.index(curve)
642         results = self.execute_command(
643             command=self._command_by_name('jump to curve'),
644             args={'index':index})
645         if not isinstance(results[-1], Success):
646             return
647         #results = self.execute_command(
648         #    command=self._command_by_name('get playlist'))
649         #if not isinstance(results[-1], Success):
650         #    return
651         self.execute_command(
652             command=self._command_by_name('get curve'))
653
654
655
656     # Plot panel interface
657
658     def _on_plot_status_text(self, _class, method, text):
659         if 'status bar' in self._c:
660             self._c['status bar'].set_plot_text(text)
661
662
663
664     # Navbar interface
665
666     def _next_curve(self, *args):
667         """Call the `next curve` command.
668         """
669         results = self.execute_command(
670             command=self._command_by_name('next curve'))
671         if isinstance(results[-1], Success):
672             self.execute_command(
673                 command=self._command_by_name('get curve'))
674
675     def _previous_curve(self, *args):
676         """Call the `previous curve` command.
677         """
678         results = self.execute_command(
679             command=self._command_by_name('previous curve'))
680         if isinstance(results[-1], Success):
681             self.execute_command(
682                 command=self._command_by_name('get curve'))
683
684
685
686     # Panel display handling
687
688     def _on_pane_close(self, event):
689         pane = event.pane
690         view = self._c['menu bar']._c['view']
691         if pane.name in  view._c.keys():
692             view._c[pane.name].Check(False)
693         event.Skip()
694
695     def _on_panel_visibility(self, _class, method, panel_name, visible):
696         pane = self._c['manager'].GetPane(panel_name)
697         pane.Show(visible)
698         #if we don't do the following, the Folders pane does not resize properly on hide/show
699         if pane.caption == 'Folders' and pane.IsShown() and pane.IsDocked():
700             #folders_size = pane.GetSize()
701             self.panelFolders.Fit()
702         self._c['manager'].Update()
703
704     def _setup_perspectives(self):
705         """Add perspectives to menubar and _perspectives.
706         """
707         self._perspectives = {
708             'Default': self._c['manager'].SavePerspective(),
709             }
710         path = self.gui.config['perspective path']
711         if os.path.isdir(path):
712             files = sorted(os.listdir(path))
713             for fname in files:
714                 name, extension = os.path.splitext(fname)
715                 if extension != self.gui.config['perspective extension']:
716                     continue
717                 fpath = os.path.join(path, fname)
718                 if not os.path.isfile(fpath):
719                     continue
720                 perspective = None
721                 with open(fpath, 'rU') as f:
722                     perspective = f.readline()
723                 if perspective:
724                     self._perspectives[name] = perspective
725
726         selected_perspective = self.gui.config['active perspective']
727         if not self._perspectives.has_key(selected_perspective):
728             self._set_config('active perspective', 'Default')
729
730         self._restore_perspective(selected_perspective, force=True)
731         self._update_perspective_menu()
732
733     def _update_perspective_menu(self):
734         self._c['menu bar']._c['perspective'].update(
735             sorted(self._perspectives.keys()),
736             self.gui.config['active perspective'])
737
738     def _save_perspective(self, perspective, perspective_dir, name,
739                           extension=None):
740         path = os.path.join(perspective_dir, name)
741         if extension != None:
742             path += extension
743         if not os.path.isdir(perspective_dir):
744             os.makedirs(perspective_dir)
745         with open(path, 'w') as f:
746             f.write(perspective)
747         self._perspectives[name] = perspective
748         self._restore_perspective(name)
749         self._update_perspective_menu()
750
751     def _delete_perspectives(self, perspective_dir, names,
752                              extension=None):
753         self.log.debug('remove perspectives %s from %s'
754                        % (names, perspective_dir))
755         for name in names:
756             path = os.path.join(perspective_dir, name)
757             if extension != None:
758                 path += extension
759             os.remove(path)
760             del(self._perspectives[name])
761         self._update_perspective_menu()
762         if self.gui.config['active perspective'] in names:
763             self._restore_perspective('Default')
764         # TODO: does this bug still apply?
765         # Unfortunately, there is a bug in wxWidgets for win32 (Ticket #3258
766         #   http://trac.wxwidgets.org/ticket/3258 
767         # ) that makes the radio item indicator in the menu disappear.
768         # The code should be fine once this issue is fixed.
769
770     def _restore_perspective(self, name, force=False):
771         if name != self.gui.config['active perspective'] or force == True:
772             self.log.debug('restore perspective %s' % name)
773             self._set_config('active perspective', name)
774             self._c['manager'].LoadPerspective(self._perspectives[name])
775             self._c['manager'].Update()
776             for pane in self._c['manager'].GetAllPanes():
777                 view = self._c['menu bar']._c['view']
778                 if pane.name in view._c.keys():
779                     view._c[pane.name].Check(pane.window.IsShown())
780
781     def _on_save_perspective(self, *args):
782         perspective = self._c['manager'].SavePerspective()
783         name = self.gui.config['active perspective']
784         if name == 'Default':
785             name = 'New perspective'
786         name = select_save_file(
787             directory=self.gui.config['perspective path'],
788             name=name,
789             extension=self.gui.config['perspective extension'],
790             parent=self,
791             message='Enter a name for the new perspective:',
792             caption='Save perspective')
793         if name == None:
794             return
795         self._save_perspective(
796             perspective, self.gui.config['perspective path'], name=name,
797             extension=self.gui.config['perspective extension'])
798
799     def _on_delete_perspective(self, *args, **kwargs):
800         options = sorted([p for p in self._perspectives.keys()
801                           if p != 'Default'])
802         dialog = SelectionDialog(
803             options=options,
804             message="\nPlease check the perspectives\n\nyou want to delete and click 'Delete'.\n",
805             button_id=wx.ID_DELETE,
806             selection_style='multiple',
807             parent=self,
808             title='Delete perspective(s)',
809             style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
810         dialog.CenterOnScreen()
811         dialog.ShowModal()
812         if dialog.canceled == True:
813             return
814         names = [options[i] for i in dialog.selected]
815         dialog.Destroy()
816         self._delete_perspectives(
817             self.gui.config['perspective path'], names=names,
818             extension=self.gui.config['perspective extension'])
819
820     def _on_select_perspective(self, _class, method, name):
821         self._restore_perspective(name)
822
823
824
825 class HookeApp (wx.App):
826     """A :class:`wx.App` wrapper around :class:`HookeFrame`.
827
828     Tosses up a splash screen and then loads :class:`HookeFrame` in
829     its own window.
830     """
831     def __init__(self, gui, commands, inqueue, outqueue, *args, **kwargs):
832         self.gui = gui
833         self.commands = commands
834         self.inqueue = inqueue
835         self.outqueue = outqueue
836         super(HookeApp, self).__init__(*args, **kwargs)
837
838     def OnInit(self):
839         self.SetAppName('Hooke')
840         self.SetVendorName('')
841         self._setup_splash_screen()
842
843         height = self.gui.config['main height']
844         width = self.gui.config['main width']
845         top = self.gui.config['main top']
846         left = self.gui.config['main left']
847
848         # Sometimes, the ini file gets confused and sets 'left' and
849         # 'top' to large negative numbers.  Here we catch and fix
850         # this.  Keep small negative numbers, the user might want
851         # those.
852         if left < -width:
853             left = 0
854         if top < -height:
855             top = 0
856
857         self._c = {
858             'frame': HookeFrame(
859                 self.gui, self.commands, self.inqueue, self.outqueue,
860                 parent=None, title='Hooke',
861                 pos=(left, top), size=(width, height),
862                 style=wx.DEFAULT_FRAME_STYLE|wx.SUNKEN_BORDER|wx.CLIP_CHILDREN),
863             }
864         self._c['frame'].Show(True)
865         self.SetTopWindow(self._c['frame'])
866         return True
867
868     def _setup_splash_screen(self):
869         if self.gui.config['show splash screen'] == True:
870             path = self.gui.config['splash screen image']
871             if os.path.isfile(path):
872                 duration = self.gui.config['splash screen duration']
873                 wx.SplashScreen(
874                     bitmap=wx.Image(path).ConvertToBitmap(),
875                     splashStyle=wx.SPLASH_CENTRE_ON_SCREEN|wx.SPLASH_TIMEOUT,
876                     milliseconds=duration,
877                     parent=None)
878                 wx.Yield()
879                 # For some reason splashDuration and sleep do not
880                 # correspond to each other at least not on Windows.
881                 # Maybe it's because duration is in milliseconds and
882                 # sleep in seconds.  Thus we need to increase the
883                 # sleep time a bit. A factor of 1.2 seems to work.
884                 sleepFactor = 1.2
885                 time.sleep(sleepFactor * duration / 1000)
886
887
888 class GUI (UserInterface):
889     """wxWindows graphical user interface.
890     """
891     def __init__(self):
892         super(GUI, self).__init__(name='gui')
893
894     def default_settings(self):
895         """Return a list of :class:`hooke.config.Setting`\s for any
896         configurable UI settings.
897
898         The suggested section setting is::
899
900             Setting(section=self.setting_section, help=self.__doc__)
901         """
902         return [
903             Setting(section=self.setting_section, help=self.__doc__),
904             Setting(section=self.setting_section, option='icon image',
905                     value=os.path.join('doc', 'img', 'microscope.ico'),
906                     type='file',
907                     help='Path to the hooke icon image.'),
908             Setting(section=self.setting_section, option='show splash screen',
909                     value=True, type='bool',
910                     help='Enable/disable the splash screen'),
911             Setting(section=self.setting_section, option='splash screen image',
912                     value=os.path.join('doc', 'img', 'hooke.jpg'),
913                     type='file',
914                     help='Path to the Hooke splash screen image.'),
915             Setting(section=self.setting_section,
916                     option='splash screen duration',
917                     value=1000, type='int',
918                     help='Duration of the splash screen in milliseconds.'),
919             Setting(section=self.setting_section, option='perspective path',
920                     value=os.path.join('resources', 'gui', 'perspective'),
921                     help='Directory containing perspective files.'), # TODO: allow colon separated list, like $PATH.
922             Setting(section=self.setting_section, option='perspective extension',
923                     value='.txt',
924                     help='Extension for perspective files.'),
925             Setting(section=self.setting_section, option='hide extensions',
926                     value=False, type='bool',
927                     help='Hide file extensions when displaying names.'),
928             Setting(section=self.setting_section, option='plot legend',
929                     value=True, type='bool',
930                     help='Enable/disable the plot legend.'),
931             Setting(section=self.setting_section, option='plot SI format',
932                     value='True', type='bool',
933                     help='Enable/disable SI plot axes numbering.'),
934             Setting(section=self.setting_section, option='plot decimals',
935                     value=2, type='int',
936                     help='Number of decimal places to show if "plot SI format" is enabled.'),
937             Setting(section=self.setting_section, option='folders-workdir',
938                     value='.', type='path',
939                     help='This should probably go...'),
940             Setting(section=self.setting_section, option='folders-filters',
941                     value='.', type='path',
942                     help='This should probably go...'),
943             Setting(section=self.setting_section, option='active perspective',
944                     value='Default',
945                     help='Name of active perspective file (or "Default").'),
946             Setting(section=self.setting_section,
947                     option='folders-filter-index',
948                     value=0, type='int',
949                     help='This should probably go...'),
950             Setting(section=self.setting_section, option='main height',
951                     value=450, type='int',
952                     help='Height of main window in pixels.'),
953             Setting(section=self.setting_section, option='main width',
954                     value=800, type='int',
955                     help='Width of main window in pixels.'),
956             Setting(section=self.setting_section, option='main top',
957                     value=0, type='int',
958                     help='Pixels from screen top to top of main window.'),
959             Setting(section=self.setting_section, option='main left',
960                     value=0, type='int',
961                     help='Pixels from screen left to left of main window.'),
962             Setting(section=self.setting_section, option='selected command',
963                     value='load playlist',
964                     help='Name of the initially selected command.'),
965             ]
966
967     def _app(self, commands, ui_to_command_queue, command_to_ui_queue):
968         redirect = True
969         if __debug__:
970             redirect=False
971         app = HookeApp(gui=self,
972                        commands=commands,
973                        inqueue=ui_to_command_queue,
974                        outqueue=command_to_ui_queue,
975                        redirect=redirect)
976         return app
977
978     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
979         app = self._app(commands, ui_to_command_queue, command_to_ui_queue)
980         app.MainLoop()