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