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