048764e97f6a1c204c24a0948fe6b7ee3bc0aa9f
[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.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 #            ('results', panel.results.Results(self), 'bottom'),
200             ]:
201             self._add_panel(p, style)
202         self.execute_command(  # setup already loaded playlists
203             command=self._command_by_name('playlists'))
204         self.execute_command(  # setup already loaded curve
205             command=self._command_by_name('get curve'))
206
207     def _add_panel(self, panel, style):
208         self._c[panel.name] = panel
209         m_name = panel.managed_name
210         info = aui.AuiPaneInfo().Name(m_name).Caption(m_name)
211         info.PaneBorder(False).CloseButton(True).MaximizeButton(False)
212         if style == 'top':
213             info.Top()
214         elif style == 'center':
215             info.CenterPane()
216         elif style == 'left':
217             info.Left()
218         elif style == 'right':
219             info.Right()
220         else:
221             assert style == 'bottom', style
222             info.Bottom()
223         self._c['manager'].AddPane(panel, info)
224
225     def _setup_toolbars(self):
226         self._c['navigation bar'] = navbar.NavBar(
227             callbacks={
228                 'next': self._next_curve,
229                 'previous': self._previous_curve,
230                 },
231             parent=self,
232             style=wx.TB_FLAT | wx.TB_NODIVIDER)
233         self._c['manager'].AddPane(
234             self._c['navigation bar'],
235             aui.AuiPaneInfo().Name('Navigation').Caption('Navigation'
236                 ).ToolbarPane().Top().Layer(1).Row(1).LeftDockable(False
237                 ).RightDockable(False))
238
239     def _bind_events(self):
240         # TODO: figure out if we can use the eventManager for menu
241         # ranges and events of 'self' without raising an assertion
242         # fail error.
243         self.Bind(wx.EVT_ERASE_BACKGROUND, self._on_erase_background)
244         self.Bind(wx.EVT_SIZE, self._on_size)
245         self.Bind(wx.EVT_CLOSE, self._on_close)
246         self.Bind(aui.EVT_AUI_PANE_CLOSE, self._on_pane_close)
247         self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self._on_notebook_page_close)
248
249         return # TODO: cleanup
250         treeCtrl = self._c['folders'].GetTreeCtrl()
251         treeCtrl.Bind(wx.EVT_LEFT_DCLICK, self._on_dir_ctrl_left_double_click)
252
253     def _on_about(self, *args):
254         dialog = wx.MessageDialog(
255             parent=self,
256             message=self.gui._splash_text(extra_info={
257                     'get-details':'click "Help -> License"'},
258                                           wrap=False),
259             caption='About Hooke',
260             style=wx.OK|wx.ICON_INFORMATION)
261         dialog.ShowModal()
262         dialog.Destroy()
263
264     def _on_size(self, event):
265         event.Skip()
266
267     def _on_close(self, *args):
268         self.log.info('closing GUI framework')
269         # apply changes
270         self._set_config('main height', self.GetSize().GetHeight())
271         self._set_config('main left', self.GetPosition()[0])
272         self._set_config('main top', self.GetPosition()[1])
273         self._set_config('main width', self.GetSize().GetWidth())
274         self._c['manager'].UnInit()
275         del self._c['manager']
276         self.Destroy()
277
278     def _on_erase_background(self, event):
279         event.Skip()
280
281
282
283     # Panel utility functions
284
285     def _file_name(self, name):
286         """Cleanup names according to configured preferences.
287         """
288         if self.gui.config['hide extensions'] == True:
289             name,ext = os.path.splitext(name)
290         return name
291
292
293
294     # Command handling
295
296     def _command_by_name(self, name):
297         cs = [c for c in self.commands if c.name == name]
298         if len(cs) == 0:
299             raise KeyError(name)
300         elif len(cs) > 1:
301             raise Exception('Multiple commands named "%s"' % name)
302         return cs[0]
303
304     def execute_command(self, _class=None, method=None,
305                         command=None, args=None):
306         if args == None:
307             args = {}
308         if ('property editor' in self._c
309             and self.gui.config['selected command'] == command.name):
310             for name,value in self._c['property editor'].get_values().items():
311                 arg = self._c['property editor']._argument_from_label.get(
312                     name, None)
313                 if arg == None:
314                     continue
315                 elif arg.count == 1:
316                     args[arg.name] = value
317                     continue
318                 # deal with counted arguments
319                 if arg.name not in args:
320                     args[arg.name] = {}
321                 index = int(name[len(arg.name):])
322                 args[arg.name][index] = value
323             for arg in command.arguments:
324                 if arg.name not in args:
325                     continue  # undisplayed argument, e.g. 'driver' types.
326                 count = arg.count
327                 if hasattr(arg, '_display_count'):  # support HACK in props_from_argument()
328                     count = arg._display_count
329                 if count != 1 and arg.name in args:
330                     keys = sorted(args[arg.name].keys())
331                     assert keys == range(count), keys
332                     args[arg.name] = [args[arg.name][i]
333                                       for i in range(count)]
334                 if arg.count == -1:
335                     while (len(args[arg.name]) > 0
336                            and args[arg.name][-1] == None):
337                         args[arg.name].pop()
338                     if len(args[arg.name]) == 0:
339                         args[arg.name] = arg.default
340         cm = CommandMessage(command.name, args)
341         self.gui._submit_command(cm, self.inqueue)
342         return self._handle_response(command_message=cm)
343
344     def _handle_response(self, command_message):
345         results = []
346         while True:
347             msg = self.outqueue.get()
348             results.append(msg)
349             if isinstance(msg, Exit):
350                 self._on_close()
351                 break
352             elif isinstance(msg, CommandExit):
353                 # TODO: display command complete
354                 break
355             elif isinstance(msg, ReloadUserInterfaceConfig):
356                 self.gui.reload_config(msg.config)
357                 continue
358             elif isinstance(msg, Request):
359                 h = handler.HANDLERS[msg.type]
360                 h.run(self, msg)  # TODO: pause for response?
361                 continue
362         pp = getattr(
363            self, '_postprocess_%s' % command_message.command.replace(' ', '_'),
364            self._postprocess_text)
365         pp(command=command_message.command,
366            args=command_message.arguments,
367            results=results)
368         return results
369
370     def _handle_request(self, msg):
371         """Repeatedly try to get a response to `msg`.
372         """
373         if prompt == None:
374             raise NotImplementedError('_%s_request_prompt' % msg.type)
375         prompt_string = prompt(msg)
376         parser = getattr(self, '_%s_request_parser' % msg.type, None)
377         if parser == None:
378             raise NotImplementedError('_%s_request_parser' % msg.type)
379         error = None
380         while True:
381             if error != None:
382                 self.cmd.stdout.write(''.join([
383                         error.__class__.__name__, ': ', str(error), '\n']))
384             self.cmd.stdout.write(prompt_string)
385             value = parser(msg, self.cmd.stdin.readline())
386             try:
387                 response = msg.response(value)
388                 break
389             except ValueError, error:
390                 continue
391         self.inqueue.put(response)
392
393     def _set_config(self, option, value, section=None):
394         self.gui._set_config(section=section, option=option, value=value,
395                              ui_to_command_queue=self.inqueue,
396                              response_handler=self._handle_response)
397
398
399     # Command-specific postprocessing
400
401     def _postprocess_text(self, command, args={}, results=[]):
402         """Print the string representation of the results to the Results window.
403
404         This is similar to :class:`~hooke.ui.commandline.DoCommand`'s
405         approach, except that :class:`~hooke.ui.commandline.DoCommand`
406         doesn't print some internally handled messages
407         (e.g. :class:`~hooke.interaction.ReloadUserInterfaceConfig`).
408         """
409         for result in results:
410             if isinstance(result, CommandExit):
411                 self._c['output'].write(result.__class__.__name__+'\n')
412             self._c['output'].write(str(result).rstrip()+'\n')
413
414     def _postprocess_playlists(self, command, args={}, results=None):
415         """Update `self` to show the playlists.
416         """
417         if not isinstance(results[-1], Success):
418             self._postprocess_text(command, results=results)
419             return
420         assert len(results) == 2, results
421         playlists = results[0]
422         if 'playlist' in self._c:
423             for playlist in playlists:
424                 if self._c['playlist'].is_playlist_loaded(playlist):
425                     self._c['playlist'].update_playlist(playlist)
426                 else:
427                     self._c['playlist'].add_playlist(playlist)
428
429     def _postprocess_new_playlist(self, command, args={}, results=None):
430         """Update `self` to show the new playlist.
431         """
432         if not isinstance(results[-1], Success):
433             self._postprocess_text(command, results=results)
434             return
435         assert len(results) == 2, results
436         playlist = results[0]
437         if 'playlist' in self._c:
438             loaded = self._c['playlist'].is_playlist_loaded(playlist)
439             assert loaded == False, loaded
440             self._c['playlist'].add_playlist(playlist)
441
442     def _postprocess_load_playlist(self, command, args={}, results=None):
443         """Update `self` to show the playlist.
444         """
445         if not isinstance(results[-1], Success):
446             self._postprocess_text(command, results=results)
447             return
448         assert len(results) == 2, results
449         playlist = results[0]
450         self._c['playlist'].add_playlist(playlist)
451
452     def _postprocess_get_playlist(self, command, args={}, results=[]):
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         if 'playlist' in self._c:
459             loaded = self._c['playlist'].is_playlist_loaded(playlist)
460             assert loaded == True, loaded
461             self._c['playlist'].update_playlist(playlist)
462
463     def _postprocess_get_curve(self, command, args={}, results=[]):
464         """Update `self` to show the curve.
465         """
466         if not isinstance(results[-1], Success):
467             self._postprocess_text(command, results=results)
468             return
469         assert len(results) == 2, results
470         curve = results[0]
471         if args.get('curve', None) == None:
472             # the command defaults to the current curve of the current playlist
473             results = self.execute_command(
474                 command=self._command_by_name('get playlist'))
475             playlist = results[0]
476         else:
477             raise NotImplementedError()
478         if 'note' in self._c:
479             self._c['note'].set_text(curve.info.get('note', ''))
480         if 'playlist' in self._c:
481             self._c['playlist'].set_selected_curve(
482                 playlist, curve)
483         if 'plot' in self._c:
484             self._c['plot'].set_curve(curve, config=self.gui.config)
485
486     def _postprocess_next_curve(self, command, args={}, results=[]):
487         """No-op.  Only call 'next curve' via `self._next_curve()`.
488         """
489         pass
490
491     def _postprocess_previous_curve(self, command, args={}, results=[]):
492         """No-op.  Only call 'previous curve' via `self._previous_curve()`.
493         """
494         pass
495
496     def _postprocess_glob_curves_to_playlist(
497         self, command, args={}, results=[]):
498         """Update `self` to show new curves.
499         """
500         if not isinstance(results[-1], Success):
501             self._postprocess_text(command, results=results)
502             return
503         if 'playlist' in self._c:
504             if args.get('playlist', None) != None:
505                 playlist = args['playlist']
506                 pname = playlist.name
507                 loaded = self._c['playlist'].is_playlist_name_loaded(pname)
508                 assert loaded == True, loaded
509                 for curve in results[:-1]:
510                     self._c['playlist']._add_curve(pname, curve)
511             else:
512                 self.execute_command(
513                     command=self._command_by_name('get playlist'))
514
515     def _postprocess_zero_block_surface_contact_point(
516         self, command, args={}, results=[]):
517         """Update the curve, since the available columns may have changed.
518         """
519         if isinstance(results[-1], Success):
520             self.execute_command(
521                 command=self._command_by_name('get curve'))
522  
523     def _postprocess_add_block_force_array(
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
532
533     # Command panel interface
534
535     def select_command(self, _class, method, command):
536         #self.select_plugin(plugin=command.plugin)
537         self._c['property editor'].clear()
538         self._c['property editor']._argument_from_label = {}
539         for argument in command.arguments:
540             if argument.name == 'help':
541                 continue
542
543             results = self.execute_command(
544                 command=self._command_by_name('playlists'))
545             if not isinstance(results[-1], Success):
546                 self._postprocess_text(command, results=results)
547                 playlists = []
548             else:
549                 playlists = results[0]
550
551             results = self.execute_command(
552                 command=self._command_by_name('playlist curves'))
553             if not isinstance(results[-1], Success):
554                 self._postprocess_text(command, results=results)
555                 curves = []
556             else:
557                 curves = results[0]
558
559             ret = props_from_argument(
560                 argument, curves=curves, playlists=playlists)
561             if ret == None:
562                 continue  # property intentionally not handled (yet)
563             for label,p in ret:
564                 self._c['property editor'].append_property(p)
565                 self._c['property editor']._argument_from_label[label] = (
566                     argument)
567
568         self._set_config('selected command', command.name)
569
570
571
572     # Folders panel interface
573
574     def _on_dir_ctrl_left_double_click(self, event):
575         file_path = self.panelFolders.GetPath()
576         if os.path.isfile(file_path):
577             if file_path.endswith('.hkp'):
578                 self.do_loadlist(file_path)
579         event.Skip()
580
581
582
583     # Note panel interface
584
585     def _on_update_note(self, _class, method, text):
586         """Sets the note for the active curve.
587         """
588         self.execute_command(
589             command=self._command_by_name('set note'),
590             args={'note':text})
591
592
593
594     # Playlist panel interface
595
596     def _on_user_delete_playlist(self, _class, method, playlist):
597         pass
598
599     def _on_delete_playlist(self, _class, method, playlist):
600         if hasattr(playlist, 'path') and playlist.path != None:
601             os.remove(playlist.path)
602
603     def _on_user_delete_curve(self, _class, method, playlist, curve):
604         pass
605
606     def _on_delete_curve(self, _class, method, playlist, curve):
607         # TODO: execute_command 'remove curve from playlist'
608         os.remove(curve.path)
609
610     def _on_set_selected_playlist(self, _class, method, playlist):
611         """Call the `jump to playlist` command.
612         """
613         results = self.execute_command(
614             command=self._command_by_name('playlists'))
615         if not isinstance(results[-1], Success):
616             return
617         assert len(results) == 2, results
618         playlists = results[0]
619         matching = [p for p in playlists if p.name == playlist.name]
620         assert len(matching) == 1, matching
621         index = playlists.index(matching[0])
622         results = self.execute_command(
623             command=self._command_by_name('jump to playlist'),
624             args={'index':index})
625
626     def _on_set_selected_curve(self, _class, method, playlist, curve):
627         """Call the `jump to curve` command.
628         """
629         self._on_set_selected_playlist(_class, method, playlist)
630         index = playlist.index(curve)
631         results = self.execute_command(
632             command=self._command_by_name('jump to curve'),
633             args={'index':index})
634         if not isinstance(results[-1], Success):
635             return
636         #results = self.execute_command(
637         #    command=self._command_by_name('get playlist'))
638         #if not isinstance(results[-1], Success):
639         #    return
640         self.execute_command(
641             command=self._command_by_name('get curve'))
642
643
644
645     # Plot panel interface
646
647     def _on_plot_status_text(self, _class, method, text):
648         if 'status bar' in self._c:
649             self._c['status bar'].set_plot_text(text)
650
651
652
653     # Navbar interface
654
655     def _next_curve(self, *args):
656         """Call the `next curve` command.
657         """
658         results = self.execute_command(
659             command=self._command_by_name('next curve'))
660         if isinstance(results[-1], Success):
661             self.execute_command(
662                 command=self._command_by_name('get curve'))
663
664     def _previous_curve(self, *args):
665         """Call the `previous curve` command.
666         """
667         results = self.execute_command(
668             command=self._command_by_name('previous curve'))
669         if isinstance(results[-1], Success):
670             self.execute_command(
671                 command=self._command_by_name('get curve'))
672
673
674
675     # Panel display handling
676
677     def _on_pane_close(self, event):
678         pane = event.pane
679         view = self._c['menu bar']._c['view']
680         if pane.name in  view._c.keys():
681             view._c[pane.name].Check(False)
682         event.Skip()
683
684     def _on_panel_visibility(self, _class, method, panel_name, visible):
685         pane = self._c['manager'].GetPane(panel_name)
686         pane.Show(visible)
687         #if we don't do the following, the Folders pane does not resize properly on hide/show
688         if pane.caption == 'Folders' and pane.IsShown() and pane.IsDocked():
689             #folders_size = pane.GetSize()
690             self.panelFolders.Fit()
691         self._c['manager'].Update()
692
693     def _setup_perspectives(self):
694         """Add perspectives to menubar and _perspectives.
695         """
696         self._perspectives = {
697             'Default': self._c['manager'].SavePerspective(),
698             }
699         path = self.gui.config['perspective path']
700         if os.path.isdir(path):
701             files = sorted(os.listdir(path))
702             for fname in files:
703                 name, extension = os.path.splitext(fname)
704                 if extension != self.gui.config['perspective extension']:
705                     continue
706                 fpath = os.path.join(path, fname)
707                 if not os.path.isfile(fpath):
708                     continue
709                 perspective = None
710                 with open(fpath, 'rU') as f:
711                     perspective = f.readline()
712                 if perspective:
713                     self._perspectives[name] = perspective
714
715         selected_perspective = self.gui.config['active perspective']
716         if not self._perspectives.has_key(selected_perspective):
717             self._set_config('active perspective', 'Default')
718
719         self._restore_perspective(selected_perspective, force=True)
720         self._update_perspective_menu()
721
722     def _update_perspective_menu(self):
723         self._c['menu bar']._c['perspective'].update(
724             sorted(self._perspectives.keys()),
725             self.gui.config['active perspective'])
726
727     def _save_perspective(self, perspective, perspective_dir, name,
728                           extension=None):
729         path = os.path.join(perspective_dir, name)
730         if extension != None:
731             path += extension
732         if not os.path.isdir(perspective_dir):
733             os.makedirs(perspective_dir)
734         with open(path, 'w') as f:
735             f.write(perspective)
736         self._perspectives[name] = perspective
737         self._restore_perspective(name)
738         self._update_perspective_menu()
739
740     def _delete_perspectives(self, perspective_dir, names,
741                              extension=None):
742         self.log.debug('remove perspectives %s from %s'
743                        % (names, perspective_dir))
744         for name in names:
745             path = os.path.join(perspective_dir, name)
746             if extension != None:
747                 path += extension
748             os.remove(path)
749             del(self._perspectives[name])
750         self._update_perspective_menu()
751         if self.gui.config['active perspective'] in names:
752             self._restore_perspective('Default')
753         # TODO: does this bug still apply?
754         # Unfortunately, there is a bug in wxWidgets for win32 (Ticket #3258
755         #   http://trac.wxwidgets.org/ticket/3258 
756         # ) that makes the radio item indicator in the menu disappear.
757         # The code should be fine once this issue is fixed.
758
759     def _restore_perspective(self, name, force=False):
760         if name != self.gui.config['active perspective'] or force == True:
761             self.log.debug('restore perspective %s' % name)
762             self._set_config('active perspective', name)
763             self._c['manager'].LoadPerspective(self._perspectives[name])
764             self._c['manager'].Update()
765             for pane in self._c['manager'].GetAllPanes():
766                 view = self._c['menu bar']._c['view']
767                 if pane.name in view._c.keys():
768                     view._c[pane.name].Check(pane.window.IsShown())
769
770     def _on_save_perspective(self, *args):
771         perspective = self._c['manager'].SavePerspective()
772         name = self.gui.config['active perspective']
773         if name == 'Default':
774             name = 'New perspective'
775         name = select_save_file(
776             directory=self.gui.config['perspective path'],
777             name=name,
778             extension=self.gui.config['perspective extension'],
779             parent=self,
780             message='Enter a name for the new perspective:',
781             caption='Save perspective')
782         if name == None:
783             return
784         self._save_perspective(
785             perspective, self.gui.config['perspective path'], name=name,
786             extension=self.gui.config['perspective extension'])
787
788     def _on_delete_perspective(self, *args, **kwargs):
789         options = sorted([p for p in self._perspectives.keys()
790                           if p != 'Default'])
791         dialog = SelectionDialog(
792             options=options,
793             message="\nPlease check the perspectives\n\nyou want to delete and click 'Delete'.\n",
794             button_id=wx.ID_DELETE,
795             selection_style='multiple',
796             parent=self,
797             title='Delete perspective(s)',
798             style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
799         dialog.CenterOnScreen()
800         dialog.ShowModal()
801         if dialog.canceled == True:
802             return
803         names = [options[i] for i in dialog.selected]
804         dialog.Destroy()
805         self._delete_perspectives(
806             self.gui.config['perspective path'], names=names,
807             extension=self.gui.config['perspective extension'])
808
809     def _on_select_perspective(self, _class, method, name):
810         self._restore_perspective(name)
811
812
813
814 class HookeApp (wx.App):
815     """A :class:`wx.App` wrapper around :class:`HookeFrame`.
816
817     Tosses up a splash screen and then loads :class:`HookeFrame` in
818     its own window.
819     """
820     def __init__(self, gui, commands, inqueue, outqueue, *args, **kwargs):
821         self.gui = gui
822         self.commands = commands
823         self.inqueue = inqueue
824         self.outqueue = outqueue
825         super(HookeApp, self).__init__(*args, **kwargs)
826
827     def OnInit(self):
828         self.SetAppName('Hooke')
829         self.SetVendorName('')
830         self._setup_splash_screen()
831
832         height = self.gui.config['main height']
833         width = self.gui.config['main width']
834         top = self.gui.config['main top']
835         left = self.gui.config['main left']
836
837         # Sometimes, the ini file gets confused and sets 'left' and
838         # 'top' to large negative numbers.  Here we catch and fix
839         # this.  Keep small negative numbers, the user might want
840         # those.
841         if left < -width:
842             left = 0
843         if top < -height:
844             top = 0
845
846         self._c = {
847             'frame': HookeFrame(
848                 self.gui, self.commands, self.inqueue, self.outqueue,
849                 parent=None, title='Hooke',
850                 pos=(left, top), size=(width, height),
851                 style=wx.DEFAULT_FRAME_STYLE|wx.SUNKEN_BORDER|wx.CLIP_CHILDREN),
852             }
853         self._c['frame'].Show(True)
854         self.SetTopWindow(self._c['frame'])
855         return True
856
857     def _setup_splash_screen(self):
858         if self.gui.config['show splash screen'] == True:
859             path = self.gui.config['splash screen image']
860             if os.path.isfile(path):
861                 duration = self.gui.config['splash screen duration']
862                 wx.SplashScreen(
863                     bitmap=wx.Image(path).ConvertToBitmap(),
864                     splashStyle=wx.SPLASH_CENTRE_ON_SCREEN|wx.SPLASH_TIMEOUT,
865                     milliseconds=duration,
866                     parent=None)
867                 wx.Yield()
868                 # For some reason splashDuration and sleep do not
869                 # correspond to each other at least not on Windows.
870                 # Maybe it's because duration is in milliseconds and
871                 # sleep in seconds.  Thus we need to increase the
872                 # sleep time a bit. A factor of 1.2 seems to work.
873                 sleepFactor = 1.2
874                 time.sleep(sleepFactor * duration / 1000)
875
876
877 class GUI (UserInterface):
878     """wxWindows graphical user interface.
879     """
880     def __init__(self):
881         super(GUI, self).__init__(name='gui')
882
883     def default_settings(self):
884         """Return a list of :class:`hooke.config.Setting`\s for any
885         configurable UI settings.
886
887         The suggested section setting is::
888
889             Setting(section=self.setting_section, help=self.__doc__)
890         """
891         return [
892             Setting(section=self.setting_section, help=self.__doc__),
893             Setting(section=self.setting_section, option='icon image',
894                     value=os.path.join('doc', 'img', 'microscope.ico'),
895                     type='file',
896                     help='Path to the hooke icon image.'),
897             Setting(section=self.setting_section, option='show splash screen',
898                     value=True, type='bool',
899                     help='Enable/disable the splash screen'),
900             Setting(section=self.setting_section, option='splash screen image',
901                     value=os.path.join('doc', 'img', 'hooke.jpg'),
902                     type='file',
903                     help='Path to the Hooke splash screen image.'),
904             Setting(section=self.setting_section,
905                     option='splash screen duration',
906                     value=1000, type='int',
907                     help='Duration of the splash screen in milliseconds.'),
908             Setting(section=self.setting_section, option='perspective path',
909                     value=os.path.join('resources', 'gui', 'perspective'),
910                     help='Directory containing perspective files.'), # TODO: allow colon separated list, like $PATH.
911             Setting(section=self.setting_section, option='perspective extension',
912                     value='.txt',
913                     help='Extension for perspective files.'),
914             Setting(section=self.setting_section, option='hide extensions',
915                     value=False, type='bool',
916                     help='Hide file extensions when displaying names.'),
917             Setting(section=self.setting_section, option='plot legend',
918                     value=True, type='bool',
919                     help='Enable/disable the plot legend.'),
920             Setting(section=self.setting_section, option='plot SI format',
921                     value='True', type='bool',
922                     help='Enable/disable SI plot axes numbering.'),
923             Setting(section=self.setting_section, option='plot decimals',
924                     value=2, type='int',
925                     help='Number of decimal places to show if "plot SI format" is enabled.'),
926             Setting(section=self.setting_section, option='folders-workdir',
927                     value='.', type='path',
928                     help='This should probably go...'),
929             Setting(section=self.setting_section, option='folders-filters',
930                     value='.', type='path',
931                     help='This should probably go...'),
932             Setting(section=self.setting_section, option='active perspective',
933                     value='Default',
934                     help='Name of active perspective file (or "Default").'),
935             Setting(section=self.setting_section,
936                     option='folders-filter-index',
937                     value=0, type='int',
938                     help='This should probably go...'),
939             Setting(section=self.setting_section, option='main height',
940                     value=450, type='int',
941                     help='Height of main window in pixels.'),
942             Setting(section=self.setting_section, option='main width',
943                     value=800, type='int',
944                     help='Width of main window in pixels.'),
945             Setting(section=self.setting_section, option='main top',
946                     value=0, type='int',
947                     help='Pixels from screen top to top of main window.'),
948             Setting(section=self.setting_section, option='main left',
949                     value=0, type='int',
950                     help='Pixels from screen left to left of main window.'),
951             Setting(section=self.setting_section, option='selected command',
952                     value='load playlist',
953                     help='Name of the initially selected command.'),
954             ]
955
956     def _app(self, commands, ui_to_command_queue, command_to_ui_queue):
957         redirect = True
958         if __debug__:
959             redirect=False
960         app = HookeApp(gui=self,
961                        commands=commands,
962                        inqueue=ui_to_command_queue,
963                        outqueue=command_to_ui_queue,
964                        redirect=redirect)
965         return app
966
967     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
968         app = self._app(commands, ui_to_command_queue, command_to_ui_queue)
969         app.MainLoop()