Restore HookeFrame.select_plugin (was removed in 29866287a21e).
[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         self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self._on_notebook_page_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 execute_command(self, _class=None, method=None,
305                         command=None, args=None):
306         if args == None:
307             args = {}
308         if ('property editor' in self._c
309             and self.gui.config['selected command'] == command.name):
310             for name,value in self._c['property editor'].get_values().items():
311                 arg = self._c['property editor']._argument_from_label.get(
312                     name, None)
313                 if arg == None:
314                     continue
315                 elif arg.count == 1:
316                     args[arg.name] = value
317                     continue
318                 # deal with counted arguments
319                 if arg.name not in args:
320                     args[arg.name] = {}
321                 index = int(name[len(arg.name):])
322                 args[arg.name][index] = value
323             for arg in command.arguments:
324                 if arg.name not in args:
325                     continue  # undisplayed argument, e.g. 'driver' types.
326                 count = arg.count
327                 if hasattr(arg, '_display_count'):  # support HACK in props_from_argument()
328                     count = arg._display_count
329                 if count != 1 and arg.name in args:
330                     keys = sorted(args[arg.name].keys())
331                     assert keys == range(count), keys
332                     args[arg.name] = [args[arg.name][i]
333                                       for i in range(count)]
334                 if arg.count == -1:
335                     while (len(args[arg.name]) > 0
336                            and args[arg.name][-1] == None):
337                         args[arg.name].pop()
338                     if len(args[arg.name]) == 0:
339                         args[arg.name] = arg.default
340         cm = CommandMessage(command.name, args)
341         self.gui._submit_command(cm, self.inqueue)
342         return self._handle_response(command_message=cm)
343
344     def _handle_response(self, command_message):
345         results = []
346         while True:
347             msg = self.outqueue.get()
348             results.append(msg)
349             if isinstance(msg, Exit):
350                 self._on_close()
351                 break
352             elif isinstance(msg, CommandExit):
353                 # TODO: display command complete
354                 break
355             elif isinstance(msg, ReloadUserInterfaceConfig):
356                 self.gui.reload_config(msg.config)
357                 continue
358             elif isinstance(msg, Request):
359                 h = handler.HANDLERS[msg.type]
360                 h.run(self, msg)  # TODO: pause for response?
361                 continue
362         pp = getattr(
363            self, '_postprocess_%s' % command_message.command.replace(' ', '_'),
364            self._postprocess_text)
365         pp(command=command_message.command,
366            args=command_message.arguments,
367            results=results)
368         return results
369
370     def _handle_request(self, msg):
371         """Repeatedly try to get a response to `msg`.
372         """
373         if prompt == None:
374             raise NotImplementedError('_%s_request_prompt' % msg.type)
375         prompt_string = prompt(msg)
376         parser = getattr(self, '_%s_request_parser' % msg.type, None)
377         if parser == None:
378             raise NotImplementedError('_%s_request_parser' % msg.type)
379         error = None
380         while True:
381             if error != None:
382                 self.cmd.stdout.write(''.join([
383                         error.__class__.__name__, ': ', str(error), '\n']))
384             self.cmd.stdout.write(prompt_string)
385             value = parser(msg, self.cmd.stdin.readline())
386             try:
387                 response = msg.response(value)
388                 break
389             except ValueError, error:
390                 continue
391         self.inqueue.put(response)
392
393     def _set_config(self, option, value, section=None):
394         self.gui._set_config(section=section, option=option, value=value,
395                              ui_to_command_queue=self.inqueue,
396                              response_handler=self._handle_response)
397
398
399     # Command-specific postprocessing
400
401     def _postprocess_text(self, command, args={}, results=[]):
402         """Print the string representation of the results to the Results window.
403
404         This is similar to :class:`~hooke.ui.commandline.DoCommand`'s
405         approach, except that :class:`~hooke.ui.commandline.DoCommand`
406         doesn't print some internally handled messages
407         (e.g. :class:`~hooke.interaction.ReloadUserInterfaceConfig`).
408         """
409         for result in results:
410             if isinstance(result, CommandExit):
411                 self._c['output'].write(result.__class__.__name__+'\n')
412             self._c['output'].write(str(result).rstrip()+'\n')
413
414     def _postprocess_playlists(self, command, args={}, results=None):
415         """Update `self` to show the playlists.
416         """
417         if not isinstance(results[-1], Success):
418             self._postprocess_text(command, results=results)
419             return
420         assert len(results) == 2, results
421         playlists = results[0]
422         if 'playlist' in self._c:
423             for playlist in playlists:
424                 if self._c['playlist'].is_playlist_loaded(playlist):
425                     self._c['playlist'].update_playlist(playlist)
426                 else:
427                     self._c['playlist'].add_playlist(playlist)
428
429     def _postprocess_new_playlist(self, command, args={}, results=None):
430         """Update `self` to show the new playlist.
431         """
432         if not isinstance(results[-1], Success):
433             self._postprocess_text(command, results=results)
434             return
435         assert len(results) == 2, results
436         playlist = results[0]
437         if 'playlist' in self._c:
438             loaded = self._c['playlist'].is_playlist_loaded(playlist)
439             assert loaded == False, loaded
440             self._c['playlist'].add_playlist(playlist)
441
442     def _postprocess_load_playlist(self, command, args={}, results=None):
443         """Update `self` to show the 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         self._c['playlist'].add_playlist(playlist)
451
452     def _postprocess_get_playlist(self, command, args={}, results=[]):
453         if not isinstance(results[-1], Success):
454             self._postprocess_text(command, results=results)
455             return
456         assert len(results) == 2, results
457         playlist = results[0]
458         if 'playlist' in self._c:
459             loaded = self._c['playlist'].is_playlist_loaded(playlist)
460             assert loaded == True, loaded
461             self._c['playlist'].update_playlist(playlist)
462
463     def _postprocess_get_curve(self, command, args={}, results=[]):
464         """Update `self` to show the curve.
465         """
466         if not isinstance(results[-1], Success):
467             self._postprocess_text(command, results=results)
468             return
469         assert len(results) == 2, results
470         curve = results[0]
471         if args.get('curve', None) == None:
472             # the command defaults to the current curve of the current playlist
473             results = self.execute_command(
474                 command=self._command_by_name('get playlist'))
475             playlist = results[0]
476         else:
477             raise NotImplementedError()
478         if 'note' in self._c:
479             self._c['note'].set_text(curve.info.get('note', ''))
480         if 'playlist' in self._c:
481             self._c['playlist'].set_selected_curve(
482                 playlist, curve)
483         if 'plot' in self._c:
484             self._c['plot'].set_curve(curve, config=self.gui.config)
485
486     def _postprocess_next_curve(self, command, args={}, results=[]):
487         """No-op.  Only call 'next curve' via `self._next_curve()`.
488         """
489         pass
490
491     def _postprocess_previous_curve(self, command, args={}, results=[]):
492         """No-op.  Only call 'previous curve' via `self._previous_curve()`.
493         """
494         pass
495
496     def _postprocess_glob_curves_to_playlist(
497         self, command, args={}, results=[]):
498         """Update `self` to show new curves.
499         """
500         if not isinstance(results[-1], Success):
501             self._postprocess_text(command, results=results)
502             return
503         if 'playlist' in self._c:
504             if args.get('playlist', None) != None:
505                 playlist = args['playlist']
506                 pname = playlist.name
507                 loaded = self._c['playlist'].is_playlist_name_loaded(pname)
508                 assert loaded == True, loaded
509                 for curve in results[:-1]:
510                     self._c['playlist']._add_curve(pname, curve)
511             else:
512                 self.execute_command(
513                     command=self._command_by_name('get playlist'))
514
515     def _postprocess_zero_block_surface_contact_point(
516         self, command, args={}, results=[]):
517         """Update the curve, since the available columns may have changed.
518         """
519         if isinstance(results[-1], Success):
520             self.execute_command(
521                 command=self._command_by_name('get curve'))
522  
523     def _postprocess_add_block_force_array(
524         self, command, args={}, results=[]):
525         """Update the curve, since the available columns may have changed.
526         """
527         if isinstance(results[-1], Success):
528             self.execute_command(
529                 command=self._command_by_name('get curve'))
530
531
532
533     # Command panel interface
534
535     def select_command(self, _class, method, command):
536         #self.select_plugin(plugin=command.plugin)
537         self._c['property editor'].clear()
538         self._c['property editor']._argument_from_label = {}
539         for argument in command.arguments:
540             if argument.name == 'help':
541                 continue
542
543             results = self.execute_command(
544                 command=self._command_by_name('playlists'))
545             if not isinstance(results[-1], Success):
546                 self._postprocess_text(command, results=results)
547                 playlists = []
548             else:
549                 playlists = results[0]
550
551             results = self.execute_command(
552                 command=self._command_by_name('playlist curves'))
553             if not isinstance(results[-1], Success):
554                 self._postprocess_text(command, results=results)
555                 curves = []
556             else:
557                 curves = results[0]
558
559             ret = props_from_argument(
560                 argument, curves=curves, playlists=playlists)
561             if ret == None:
562                 continue  # property intentionally not handled (yet)
563             for label,p in ret:
564                 self._c['property editor'].append_property(p)
565                 self._c['property editor']._argument_from_label[label] = (
566                     argument)
567
568         self._set_config('selected command', command.name)
569
570     def select_plugin(self, _class=None, method=None, plugin=None):
571         pass
572
573
574
575     # Folders panel interface
576
577     def _on_dir_ctrl_left_double_click(self, event):
578         file_path = self.panelFolders.GetPath()
579         if os.path.isfile(file_path):
580             if file_path.endswith('.hkp'):
581                 self.do_loadlist(file_path)
582         event.Skip()
583
584
585
586     # Note panel interface
587
588     def _on_update_note(self, _class, method, text):
589         """Sets the note for the active curve.
590         """
591         self.execute_command(
592             command=self._command_by_name('set note'),
593             args={'note':text})
594
595
596
597     # Playlist panel interface
598
599     def _on_user_delete_playlist(self, _class, method, playlist):
600         pass
601
602     def _on_delete_playlist(self, _class, method, playlist):
603         if hasattr(playlist, 'path') and playlist.path != None:
604             os.remove(playlist.path)
605
606     def _on_user_delete_curve(self, _class, method, playlist, curve):
607         pass
608
609     def _on_delete_curve(self, _class, method, playlist, curve):
610         # TODO: execute_command 'remove curve from playlist'
611         os.remove(curve.path)
612
613     def _on_set_selected_playlist(self, _class, method, playlist):
614         """Call the `jump to playlist` command.
615         """
616         results = self.execute_command(
617             command=self._command_by_name('playlists'))
618         if not isinstance(results[-1], Success):
619             return
620         assert len(results) == 2, results
621         playlists = results[0]
622         matching = [p for p in playlists if p.name == playlist.name]
623         assert len(matching) == 1, matching
624         index = playlists.index(matching[0])
625         results = self.execute_command(
626             command=self._command_by_name('jump to playlist'),
627             args={'index':index})
628
629     def _on_set_selected_curve(self, _class, method, playlist, curve):
630         """Call the `jump to curve` command.
631         """
632         self._on_set_selected_playlist(_class, method, playlist)
633         index = playlist.index(curve)
634         results = self.execute_command(
635             command=self._command_by_name('jump to curve'),
636             args={'index':index})
637         if not isinstance(results[-1], Success):
638             return
639         #results = self.execute_command(
640         #    command=self._command_by_name('get playlist'))
641         #if not isinstance(results[-1], Success):
642         #    return
643         self.execute_command(
644             command=self._command_by_name('get curve'))
645
646
647
648     # Plot panel interface
649
650     def _on_plot_status_text(self, _class, method, text):
651         if 'status bar' in self._c:
652             self._c['status bar'].set_plot_text(text)
653
654
655
656     # Navbar interface
657
658     def _next_curve(self, *args):
659         """Call the `next curve` command.
660         """
661         results = self.execute_command(
662             command=self._command_by_name('next curve'))
663         if isinstance(results[-1], Success):
664             self.execute_command(
665                 command=self._command_by_name('get curve'))
666
667     def _previous_curve(self, *args):
668         """Call the `previous curve` command.
669         """
670         results = self.execute_command(
671             command=self._command_by_name('previous curve'))
672         if isinstance(results[-1], Success):
673             self.execute_command(
674                 command=self._command_by_name('get curve'))
675
676
677
678     # Panel display handling
679
680     def _on_pane_close(self, event):
681         pane = event.pane
682         view = self._c['menu bar']._c['view']
683         if pane.name in  view._c.keys():
684             view._c[pane.name].Check(False)
685         event.Skip()
686
687     def _on_panel_visibility(self, _class, method, panel_name, visible):
688         pane = self._c['manager'].GetPane(panel_name)
689         pane.Show(visible)
690         #if we don't do the following, the Folders pane does not resize properly on hide/show
691         if pane.caption == 'Folders' and pane.IsShown() and pane.IsDocked():
692             #folders_size = pane.GetSize()
693             self.panelFolders.Fit()
694         self._c['manager'].Update()
695
696     def _setup_perspectives(self):
697         """Add perspectives to menubar and _perspectives.
698         """
699         self._perspectives = {
700             'Default': self._c['manager'].SavePerspective(),
701             }
702         path = self.gui.config['perspective path']
703         if os.path.isdir(path):
704             files = sorted(os.listdir(path))
705             for fname in files:
706                 name, extension = os.path.splitext(fname)
707                 if extension != self.gui.config['perspective extension']:
708                     continue
709                 fpath = os.path.join(path, fname)
710                 if not os.path.isfile(fpath):
711                     continue
712                 perspective = None
713                 with open(fpath, 'rU') as f:
714                     perspective = f.readline()
715                 if perspective:
716                     self._perspectives[name] = perspective
717
718         selected_perspective = self.gui.config['active perspective']
719         if not self._perspectives.has_key(selected_perspective):
720             self._set_config('active perspective', 'Default')
721
722         self._restore_perspective(selected_perspective, force=True)
723         self._update_perspective_menu()
724
725     def _update_perspective_menu(self):
726         self._c['menu bar']._c['perspective'].update(
727             sorted(self._perspectives.keys()),
728             self.gui.config['active perspective'])
729
730     def _save_perspective(self, perspective, perspective_dir, name,
731                           extension=None):
732         path = os.path.join(perspective_dir, name)
733         if extension != None:
734             path += extension
735         if not os.path.isdir(perspective_dir):
736             os.makedirs(perspective_dir)
737         with open(path, 'w') as f:
738             f.write(perspective)
739         self._perspectives[name] = perspective
740         self._restore_perspective(name)
741         self._update_perspective_menu()
742
743     def _delete_perspectives(self, perspective_dir, names,
744                              extension=None):
745         self.log.debug('remove perspectives %s from %s'
746                        % (names, perspective_dir))
747         for name in names:
748             path = os.path.join(perspective_dir, name)
749             if extension != None:
750                 path += extension
751             os.remove(path)
752             del(self._perspectives[name])
753         self._update_perspective_menu()
754         if self.gui.config['active perspective'] in names:
755             self._restore_perspective('Default')
756         # TODO: does this bug still apply?
757         # Unfortunately, there is a bug in wxWidgets for win32 (Ticket #3258
758         #   http://trac.wxwidgets.org/ticket/3258 
759         # ) that makes the radio item indicator in the menu disappear.
760         # The code should be fine once this issue is fixed.
761
762     def _restore_perspective(self, name, force=False):
763         if name != self.gui.config['active perspective'] or force == True:
764             self.log.debug('restore perspective %s' % name)
765             self._set_config('active perspective', name)
766             self._c['manager'].LoadPerspective(self._perspectives[name])
767             self._c['manager'].Update()
768             for pane in self._c['manager'].GetAllPanes():
769                 view = self._c['menu bar']._c['view']
770                 if pane.name in view._c.keys():
771                     view._c[pane.name].Check(pane.window.IsShown())
772
773     def _on_save_perspective(self, *args):
774         perspective = self._c['manager'].SavePerspective()
775         name = self.gui.config['active perspective']
776         if name == 'Default':
777             name = 'New perspective'
778         name = select_save_file(
779             directory=self.gui.config['perspective path'],
780             name=name,
781             extension=self.gui.config['perspective extension'],
782             parent=self,
783             message='Enter a name for the new perspective:',
784             caption='Save perspective')
785         if name == None:
786             return
787         self._save_perspective(
788             perspective, self.gui.config['perspective path'], name=name,
789             extension=self.gui.config['perspective extension'])
790
791     def _on_delete_perspective(self, *args, **kwargs):
792         options = sorted([p for p in self._perspectives.keys()
793                           if p != 'Default'])
794         dialog = SelectionDialog(
795             options=options,
796             message="\nPlease check the perspectives\n\nyou want to delete and click 'Delete'.\n",
797             button_id=wx.ID_DELETE,
798             selection_style='multiple',
799             parent=self,
800             title='Delete perspective(s)',
801             style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
802         dialog.CenterOnScreen()
803         dialog.ShowModal()
804         if dialog.canceled == True:
805             return
806         names = [options[i] for i in dialog.selected]
807         dialog.Destroy()
808         self._delete_perspectives(
809             self.gui.config['perspective path'], names=names,
810             extension=self.gui.config['perspective extension'])
811
812     def _on_select_perspective(self, _class, method, name):
813         self._restore_perspective(name)
814
815
816
817 class HookeApp (wx.App):
818     """A :class:`wx.App` wrapper around :class:`HookeFrame`.
819
820     Tosses up a splash screen and then loads :class:`HookeFrame` in
821     its own window.
822     """
823     def __init__(self, gui, commands, inqueue, outqueue, *args, **kwargs):
824         self.gui = gui
825         self.commands = commands
826         self.inqueue = inqueue
827         self.outqueue = outqueue
828         super(HookeApp, self).__init__(*args, **kwargs)
829
830     def OnInit(self):
831         self.SetAppName('Hooke')
832         self.SetVendorName('')
833         self._setup_splash_screen()
834
835         height = self.gui.config['main height']
836         width = self.gui.config['main width']
837         top = self.gui.config['main top']
838         left = self.gui.config['main left']
839
840         # Sometimes, the ini file gets confused and sets 'left' and
841         # 'top' to large negative numbers.  Here we catch and fix
842         # this.  Keep small negative numbers, the user might want
843         # those.
844         if left < -width:
845             left = 0
846         if top < -height:
847             top = 0
848
849         self._c = {
850             'frame': HookeFrame(
851                 self.gui, self.commands, self.inqueue, self.outqueue,
852                 parent=None, title='Hooke',
853                 pos=(left, top), size=(width, height),
854                 style=wx.DEFAULT_FRAME_STYLE|wx.SUNKEN_BORDER|wx.CLIP_CHILDREN),
855             }
856         self._c['frame'].Show(True)
857         self.SetTopWindow(self._c['frame'])
858         return True
859
860     def _setup_splash_screen(self):
861         if self.gui.config['show splash screen'] == True:
862             path = self.gui.config['splash screen image']
863             if os.path.isfile(path):
864                 duration = self.gui.config['splash screen duration']
865                 wx.SplashScreen(
866                     bitmap=wx.Image(path).ConvertToBitmap(),
867                     splashStyle=wx.SPLASH_CENTRE_ON_SCREEN|wx.SPLASH_TIMEOUT,
868                     milliseconds=duration,
869                     parent=None)
870                 wx.Yield()
871                 # For some reason splashDuration and sleep do not
872                 # correspond to each other at least not on Windows.
873                 # Maybe it's because duration is in milliseconds and
874                 # sleep in seconds.  Thus we need to increase the
875                 # sleep time a bit. A factor of 1.2 seems to work.
876                 sleepFactor = 1.2
877                 time.sleep(sleepFactor * duration / 1000)
878
879
880 class GUI (UserInterface):
881     """wxWindows graphical user interface.
882     """
883     def __init__(self):
884         super(GUI, self).__init__(name='gui')
885
886     def default_settings(self):
887         """Return a list of :class:`hooke.config.Setting`\s for any
888         configurable UI settings.
889
890         The suggested section setting is::
891
892             Setting(section=self.setting_section, help=self.__doc__)
893         """
894         return [
895             Setting(section=self.setting_section, help=self.__doc__),
896             Setting(section=self.setting_section, option='icon image',
897                     value=os.path.join('doc', 'img', 'microscope.ico'),
898                     type='file',
899                     help='Path to the hooke icon image.'),
900             Setting(section=self.setting_section, option='show splash screen',
901                     value=True, type='bool',
902                     help='Enable/disable the splash screen'),
903             Setting(section=self.setting_section, option='splash screen image',
904                     value=os.path.join('doc', 'img', 'hooke.jpg'),
905                     type='file',
906                     help='Path to the Hooke splash screen image.'),
907             Setting(section=self.setting_section,
908                     option='splash screen duration',
909                     value=1000, type='int',
910                     help='Duration of the splash screen in milliseconds.'),
911             Setting(section=self.setting_section, option='perspective path',
912                     value=os.path.join('resources', 'gui', 'perspective'),
913                     help='Directory containing perspective files.'), # TODO: allow colon separated list, like $PATH.
914             Setting(section=self.setting_section, option='perspective extension',
915                     value='.txt',
916                     help='Extension for perspective files.'),
917             Setting(section=self.setting_section, option='hide extensions',
918                     value=False, type='bool',
919                     help='Hide file extensions when displaying names.'),
920             Setting(section=self.setting_section, option='plot legend',
921                     value=True, type='bool',
922                     help='Enable/disable the plot legend.'),
923             Setting(section=self.setting_section, option='plot SI format',
924                     value='True', type='bool',
925                     help='Enable/disable SI plot axes numbering.'),
926             Setting(section=self.setting_section, option='plot decimals',
927                     value=2, type='int',
928                     help='Number of decimal places to show if "plot SI format" is enabled.'),
929             Setting(section=self.setting_section, option='folders-workdir',
930                     value='.', type='path',
931                     help='This should probably go...'),
932             Setting(section=self.setting_section, option='folders-filters',
933                     value='.', type='path',
934                     help='This should probably go...'),
935             Setting(section=self.setting_section, option='active perspective',
936                     value='Default',
937                     help='Name of active perspective file (or "Default").'),
938             Setting(section=self.setting_section,
939                     option='folders-filter-index',
940                     value=0, type='int',
941                     help='This should probably go...'),
942             Setting(section=self.setting_section, option='main height',
943                     value=450, type='int',
944                     help='Height of main window in pixels.'),
945             Setting(section=self.setting_section, option='main width',
946                     value=800, type='int',
947                     help='Width of main window in pixels.'),
948             Setting(section=self.setting_section, option='main top',
949                     value=0, type='int',
950                     help='Pixels from screen top to top of main window.'),
951             Setting(section=self.setting_section, option='main left',
952                     value=0, type='int',
953                     help='Pixels from screen left to left of main window.'),
954             Setting(section=self.setting_section, option='selected command',
955                     value='load playlist',
956                     help='Name of the initially selected command.'),
957             ]
958
959     def _app(self, commands, ui_to_command_queue, command_to_ui_queue):
960         redirect = True
961         if __debug__:
962             redirect=False
963         app = HookeApp(gui=self,
964                        commands=commands,
965                        inqueue=ui_to_command_queue,
966                        outqueue=command_to_ui_queue,
967                        redirect=redirect)
968         return app
969
970     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
971         app = self._app(commands, ui_to_command_queue, command_to_ui_queue)
972         app.MainLoop()