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