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