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