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