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