Added point-click status notification to gui.panel.plot.
[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 ...interaction import Request, BooleanRequest, ReloadUserInterfaceConfig
50 from ...ui import UserInterface, CommandMessage
51 from .dialog.selection import Selection as SelectionDialog
52 from .dialog.save_file import select_save_file
53 from . import menu as menu
54 from . import navbar as navbar
55 from . import panel as panel
56 from .panel.propertyeditor import props_from_argument, props_from_setting
57 from . import statusbar as statusbar
58
59
60 class HookeFrame (wx.Frame):
61     """The main Hooke-interface window.    
62     """
63     def __init__(self, gui, commands, inqueue, outqueue, *args, **kwargs):
64         super(HookeFrame, self).__init__(*args, **kwargs)
65         self.log = logging.getLogger('hooke')
66         self.gui = gui
67         self.commands = commands
68         self.inqueue = inqueue
69         self.outqueue = outqueue
70         self._perspectives = {}  # {name: perspective_str}
71         self._c = {}
72
73         self.SetIcon(wx.Icon(self.gui.config['icon image'], 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
118         self.execute_command(
119                 command=self._command_by_name('load playlist'),
120                 args={'input':'test/data/test'},#vclamp_picoforce/playlist'},
121                 )
122         self.execute_command(
123                 command=self._command_by_name('load playlist'),
124                 args={'input':'test/data/vclamp_picoforce/playlist'},
125                 )
126         self.execute_command(
127                 command=self._command_by_name('polymer fit'),
128                 args={'block':1, 'bounds':[918, 1103]},
129                 )
130         return # TODO: cleanup
131         self.playlists = self._c['playlist'].Playlists
132         self._displayed_plot = None
133         #load default list, if possible
134         self.do_loadlist(self.GetStringFromConfig('core', 'preferences', 'playlists'))
135
136
137     # GUI maintenance
138
139     def _setup_panels(self):
140         client_size = self.GetClientSize()
141         for p,style in [
142 #            ('folders', wx.GenericDirCtrl(
143 #                    parent=self,
144 #                    dir=self.gui.config['folders-workdir'],
145 #                    size=(200, 250),
146 #                    style=wx.DIRCTRL_SHOW_FILTERS,
147 #                    filter=self.gui.config['folders-filters'],
148 #                    defaultFilter=self.gui.config['folders-filter-index']), 'left'),
149             (panel.PANELS['playlist'](
150                     callbacks={
151                         'delete_playlist':self._on_user_delete_playlist,
152                         '_delete_playlist':self._on_delete_playlist,
153                         'delete_curve':self._on_user_delete_curve,
154                         '_delete_curve':self._on_delete_curve,
155                         '_on_set_selected_playlist':self._on_set_selected_playlist,
156                         '_on_set_selected_curve':self._on_set_selected_curve,
157                         },
158                     parent=self,
159                     style=wx.WANTS_CHARS|wx.NO_BORDER,
160                     # WANTS_CHARS so the panel doesn't eat the Return key.
161 #                    size=(160, 200),
162                     ), 'left'),
163             (panel.PANELS['note'](
164                     callbacks = {
165                         '_on_update':self._on_update_note,
166                         },
167                     parent=self,
168                     style=wx.WANTS_CHARS|wx.NO_BORDER,
169 #                    size=(160, 200),
170                     ), 'left'),
171 #            ('notebook', Notebook(
172 #                    parent=self,
173 #                    pos=wx.Point(client_size.x, client_size.y),
174 #                    size=wx.Size(430, 200),
175 #                    style=aui.AUI_NB_DEFAULT_STYLE
176 #                    | aui.AUI_NB_TAB_EXTERNAL_MOVE | wx.NO_BORDER), 'center'),
177             (panel.PANELS['commands'](
178                     commands=self.commands,
179                     selected=self.gui.config['selected command'],
180                     callbacks={
181                         'execute': self.execute_command,
182                         'select_plugin': self.select_plugin,
183                         'select_command': self.select_command,
184 #                        'selection_changed': self.panelProperties.select(self, method, command),  #SelectedTreeItem = selected_item,
185                         },
186                     parent=self,
187                     style=wx.WANTS_CHARS|wx.NO_BORDER,
188                     # WANTS_CHARS so the panel doesn't eat the Return key.
189 #                    size=(160, 200),
190                     ), 'right'),
191             (panel.PANELS['propertyeditor'](
192                     callbacks={},
193                     parent=self,
194                     style=wx.WANTS_CHARS,
195                     # WANTS_CHARS so the panel doesn't eat the Return key.
196                     ), 'center'),
197 #            ('assistant', wx.TextCtrl(
198 #                    parent=self,
199 #                    pos=wx.Point(0, 0),
200 #                    size=wx.Size(150, 90),
201 #                    style=wx.NO_BORDER|wx.TE_MULTILINE), 'right'),
202             (panel.PANELS['plot'](
203                     callbacks={
204                         '_set_status_text': self._on_plot_status_text,
205                         },
206                     parent=self,
207                     style=wx.WANTS_CHARS|wx.NO_BORDER,
208                     # WANTS_CHARS so the panel doesn't eat the Return key.
209 #                    size=(160, 200),
210                     ), 'center'),
211             (panel.PANELS['output'](
212                     parent=self,
213                     pos=wx.Point(0, 0),
214                     size=wx.Size(150, 90),
215                     style=wx.TE_READONLY|wx.NO_BORDER|wx.TE_MULTILINE),
216              'bottom'),
217 #            ('results', panel.results.Results(self), 'bottom'),
218             ]:
219             self._add_panel(p, style)
220         #self._c['assistant'].SetEditable(False)
221
222     def _add_panel(self, panel, style):
223         self._c[panel.name] = panel
224         m_name = panel.managed_name
225         info = aui.AuiPaneInfo().Name(m_name).Caption(m_name)
226         info.PaneBorder(False).CloseButton(True).MaximizeButton(False)
227         if style == 'top':
228             info.Top()
229         elif style == 'center':
230             info.CenterPane()
231         elif style == 'left':
232             info.Left()
233         elif style == 'right':
234             info.Right()
235         else:
236             assert style == 'bottom', style
237             info.Bottom()
238         self._c['manager'].AddPane(panel, info)
239
240     def _setup_toolbars(self):
241         self._c['navigation bar'] = navbar.NavBar(
242             callbacks={
243                 'next': self._next_curve,
244                 'previous': self._previous_curve,
245                 },
246             parent=self,
247             style=wx.TB_FLAT | wx.TB_NODIVIDER)
248         self._c['manager'].AddPane(
249             self._c['navigation bar'],
250             aui.AuiPaneInfo().Name('Navigation').Caption('Navigation'
251                 ).ToolbarPane().Top().Layer(1).Row(1).LeftDockable(False
252                 ).RightDockable(False))
253
254     def _bind_events(self):
255         # TODO: figure out if we can use the eventManager for menu
256         # ranges and events of 'self' without raising an assertion
257         # fail error.
258         self.Bind(wx.EVT_ERASE_BACKGROUND, self._on_erase_background)
259         self.Bind(wx.EVT_SIZE, self._on_size)
260         self.Bind(wx.EVT_CLOSE, self._on_close)
261         self.Bind(aui.EVT_AUI_PANE_CLOSE, self.OnPaneClose)
262         self.Bind(aui.EVT_AUINOTEBOOK_PAGE_CLOSE, self._on_notebook_page_close)
263
264         return # TODO: cleanup
265         treeCtrl = self._c['folders'].GetTreeCtrl()
266         treeCtrl.Bind(wx.EVT_LEFT_DCLICK, self._on_dir_ctrl_left_double_click)
267         
268         #property editor
269         self.panelProperties.pg.Bind(wxpg.EVT_PG_CHANGED, self.OnPropGridChanged)
270         #results panel
271         self.panelResults.results_list.OnCheckItem = self.OnResultsCheck
272
273     def _on_about(self, *args):
274         dialog = wx.MessageDialog(
275             parent=self,
276             message=self.gui._splash_text(extra_info={
277                     'get-details':'click "Help -> License"'},
278                                           wrap=False),
279             caption='About Hooke',
280             style=wx.OK|wx.ICON_INFORMATION)
281         dialog.ShowModal()
282         dialog.Destroy()
283
284     def _on_close(self, *args):
285         self.log.info('closing GUI framework')
286         # apply changes
287         self.gui.config['main height'] = str(self.GetSize().GetHeight())
288         self.gui.config['main left'] = str(self.GetPosition()[0])
289         self.gui.config['main top'] = str(self.GetPosition()[1])
290         self.gui.config['main width'] = str(self.GetSize().GetWidth())
291         # push changes back to Hooke.config?
292         self._c['manager'].UnInit()
293         del self._c['manager']
294         self.Destroy()
295
296
297
298     # Panel utility functions
299
300     def _file_name(self, name):
301         """Cleanup names according to configured preferences.
302         """
303         if self.gui.config['hide extensions'] == True:
304             name,ext = os.path.splitext(name)
305         return name
306
307
308
309     # Command handling
310
311     def _command_by_name(self, name):
312         cs = [c for c in self.commands if c.name == name]
313         if len(cs) == 0:
314             raise KeyError(name)
315         elif len(cs) > 1:
316             raise Exception('Multiple commands named "%s"' % name)
317         return cs[0]
318
319     def execute_command(self, _class=None, method=None,
320                         command=None, args=None):
321         if args == None:
322             args = {}
323         if ('property editor' in self._c
324             and self.gui.config['selected command'] == command):
325             for name,value in self._c['property editor'].get_values().items():
326                 arg = self._c['property editor']._argument_from_label.get(
327                     name, None)
328                 if arg == None:
329                     continue
330                 elif arg.count == 1:
331                     args[arg.name] = value
332                     continue
333                 # deal with counted arguments
334                 if arg.name not in args:
335                     args[arg.name] = {}
336                 index = int(name[len(arg.name):])
337                 args[arg.name][index] = value
338             for arg in command.arguments:
339                 if arg.count != 1 and arg.name in args:
340                     keys = sorted(args[arg.name].keys())
341                     assert keys == range(arg.count), keys
342                     args[arg.name] = [args[arg.name][i]
343                                       for i in range(arg.count)]
344         self.log.debug('executing %s with %s' % (command.name, args))
345         self.inqueue.put(CommandMessage(command, args))
346         results = []
347         while True:
348             msg = self.outqueue.get()
349             results.append(msg)
350             if isinstance(msg, Exit):
351                 self._on_close()
352                 break
353             elif isinstance(msg, CommandExit):
354                 # TODO: display command complete
355                 break
356             elif isinstance(msg, ReloadUserInterfaceConfig):
357                 self.gui.reload_config(msg.config)
358                 continue
359             elif isinstance(msg, Request):
360                 h = handler.HANDLERS[msg.type]
361                 h.run(self, msg)  # TODO: pause for response?
362                 continue
363         pp = getattr(
364             self, '_postprocess_%s' % command.name.replace(' ', '_'),
365             self._postprocess_text)
366         pp(command=command, args=args, results=results)
367         return results
368
369     def _handle_request(self, msg):
370         """Repeatedly try to get a response to `msg`.
371         """
372         if prompt == None:
373             raise NotImplementedError('_%s_request_prompt' % msg.type)
374         prompt_string = prompt(msg)
375         parser = getattr(self, '_%s_request_parser' % msg.type, None)
376         if parser == None:
377             raise NotImplementedError('_%s_request_parser' % msg.type)
378         error = None
379         while True:
380             if error != None:
381                 self.cmd.stdout.write(''.join([
382                         error.__class__.__name__, ': ', str(error), '\n']))
383             self.cmd.stdout.write(prompt_string)
384             value = parser(msg, self.cmd.stdin.readline())
385             try:
386                 response = msg.response(value)
387                 break
388             except ValueError, error:
389                 continue
390         self.inqueue.put(response)
391
392
393
394     # Command-specific postprocessing
395
396     def _postprocess_text(self, command, args={}, results=[]):
397         """Print the string representation of the results to the Results window.
398
399         This is similar to :class:`~hooke.ui.commandline.DoCommand`'s
400         approach, except that :class:`~hooke.ui.commandline.DoCommand`
401         doesn't print some internally handled messages
402         (e.g. :class:`~hooke.interaction.ReloadUserInterfaceConfig`).
403         """
404         for result in results:
405             if isinstance(result, CommandExit):
406                 self._c['output'].write(result.__class__.__name__+'\n')
407             self._c['output'].write(str(result).rstrip()+'\n')
408
409     def _postprocess_load_playlist(self, command, args={}, results=None):
410         """Update `self` to show the playlist.
411         """
412         if not isinstance(results[-1], Success):
413             self._postprocess_text(command, results=results)
414             return
415         assert len(results) == 2, results
416         playlist = results[0]
417         self._c['playlist']._c['tree'].add_playlist(playlist)
418
419     def _postprocess_get_playlist(self, command, args={}, results=[]):
420         if not isinstance(results[-1], Success):
421             self._postprocess_text(command, results=results)
422             return
423         assert len(results) == 2, results
424         playlist = results[0]
425         self._c['playlist']._c['tree'].update_playlist(playlist)
426
427     def _postprocess_get_curve(self, command, args={}, results=[]):
428         """Update `self` to show the curve.
429         """
430         if not isinstance(results[-1], Success):
431             self._postprocess_text(command, results=results)
432             return
433         assert len(results) == 2, results
434         curve = results[0]
435         if args.get('curve', None) == None:
436             # the command defaults to the current curve of the current playlist
437             results = self.execute_command(
438                 command=self._command_by_name('get playlist'))
439             playlist = results[0]
440         else:
441             raise NotImplementedError()
442         if 'note' in self._c:
443             self._c['note'].set_text(curve.info['note'])
444         if 'playlist' in self._c:
445             self._c['playlist']._c['tree'].set_selected_curve(
446                 playlist, curve)
447         if 'plot' in self._c:
448             self._c['plot'].set_curve(curve, config=self.gui.config)
449
450     def _postprocess_next_curve(self, command, args={}, results=[]):
451         """No-op.  Only call 'next curve' via `self._next_curve()`.
452         """
453         pass
454
455     def _postprocess_previous_curve(self, command, args={}, results=[]):
456         """No-op.  Only call 'previous curve' via `self._previous_curve()`.
457         """
458         pass
459
460     def _postprocess_zero_block_surface_contact_point(
461         self, command, args={}, results=[]):
462         """Update the curve, since the available columns may have changed.
463         """
464         if isinstance(results[-1], Success):
465             self.execute_command(
466                 command=self._command_by_name('get curve'))
467  
468     def _postprocess_add_block_force_array(
469         self, command, args={}, results=[]):
470         """Update the curve, since the available columns may have changed.
471         """
472         if isinstance(results[-1], Success):
473             self.execute_command(
474                 command=self._command_by_name('get curve'))
475
476
477
478     # TODO: cruft
479
480     def _GetActiveFileIndex(self):
481         lib.playlist.Playlist = self.GetActivePlaylist()
482         #get the selected item from the tree
483         selected_item = self._c['playlist']._c['tree'].GetSelection()
484         #test if a playlist or a curve was double-clicked
485         if self._c['playlist']._c['tree'].ItemHasChildren(selected_item):
486             return -1
487         else:
488             count = 0
489             selected_item = self._c['playlist']._c['tree'].GetPrevSibling(selected_item)
490             while selected_item.IsOk():
491                 count += 1
492                 selected_item = self._c['playlist']._c['tree'].GetPrevSibling(selected_item)
493             return count
494
495     def _GetPlaylistTab(self, name):
496         for index, page in enumerate(self._c['notebook']._tabs._pages):
497             if page.caption == name:
498                 return index
499         return -1
500
501     def select_plugin(self, _class=None, method=None, plugin=None):
502         pass
503
504     def AddPlaylistFromFiles(self, files=[], name='Untitled'):
505         if files:
506             playlist = lib.playlist.Playlist(self, self.drivers)
507             for item in files:
508                 playlist.add_curve(item)
509         if playlist.count > 0:
510             playlist.name = self._GetUniquePlaylistName(name)
511             playlist.reset()
512             self.AddTayliss(playlist)
513
514     def AppliesPlotmanipulator(self, name):
515         '''
516         Returns True if the plotmanipulator 'name' is applied, False otherwise
517         name does not contain 'plotmanip_', just the name of the plotmanipulator (e.g. 'flatten')
518         '''
519         return self.GetBoolFromConfig('core', 'plotmanipulators', name)
520
521     def ApplyPlotmanipulators(self, plot, plot_file):
522         '''
523         Apply all active plotmanipulators.
524         '''
525         if plot is not None and plot_file is not None:
526             manipulated_plot = copy.deepcopy(plot)
527             for plotmanipulator in self.plotmanipulators:
528                 if self.GetBoolFromConfig('core', 'plotmanipulators', plotmanipulator.name):
529                     manipulated_plot = plotmanipulator.method(manipulated_plot, plot_file)
530             return manipulated_plot
531
532     def GetActiveFigure(self):
533         playlist_name = self.GetActivePlaylistName()
534         figure = self.playlists[playlist_name].figure
535         if figure is not None:
536             return figure
537         return None
538
539     def GetActiveFile(self):
540         playlist = self.GetActivePlaylist()
541         if playlist is not None:
542             return playlist.get_active_file()
543         return None
544
545     def GetActivePlot(self):
546         playlist = self.GetActivePlaylist()
547         if playlist is not None:
548             return playlist.get_active_file().plot
549         return None
550
551     def GetDisplayedPlot(self):
552         plot = copy.deepcopy(self.displayed_plot)
553         #plot.curves = []
554         #plot.curves = copy.deepcopy(plot.curves)
555         return plot
556
557     def GetDisplayedPlotCorrected(self):
558         plot = copy.deepcopy(self.displayed_plot)
559         plot.curves = []
560         plot.curves = copy.deepcopy(plot.corrected_curves)
561         return plot
562
563     def GetDisplayedPlotRaw(self):
564         plot = copy.deepcopy(self.displayed_plot)
565         plot.curves = []
566         plot.curves = copy.deepcopy(plot.raw_curves)
567         return plot
568
569     def GetDockArt(self):
570         return self._c['manager'].GetArtProvider()
571
572     def GetPlotmanipulator(self, name):
573         '''
574         Returns a plot manipulator function from its name
575         '''
576         for plotmanipulator in self.plotmanipulators:
577             if plotmanipulator.name == name:
578                 return plotmanipulator
579         return None
580
581     def HasPlotmanipulator(self, name):
582         '''
583         returns True if the plotmanipulator 'name' is loaded, False otherwise
584         '''
585         for plotmanipulator in self.plotmanipulators:
586             if plotmanipulator.command == name:
587                 return True
588         return False
589
590
591     def _on_dir_ctrl_left_double_click(self, event):
592         file_path = self.panelFolders.GetPath()
593         if os.path.isfile(file_path):
594             if file_path.endswith('.hkp'):
595                 self.do_loadlist(file_path)
596         event.Skip()
597
598     def _on_erase_background(self, event):
599         event.Skip()
600
601     def _on_notebook_page_close(self, event):
602         ctrl = event.GetEventObject()
603         playlist_name = ctrl.GetPageText(ctrl._curpage)
604         self.DeleteFromPlaylists(playlist_name)
605
606     def OnPaneClose(self, event):
607         event.Skip()
608
609     def OnPropGridChanged (self, event):
610         prop = event.GetProperty()
611         if prop:
612             item_section = self.panelProperties.SelectedTreeItem
613             item_plugin = self._c['commands']._c['tree'].GetItemParent(item_section)
614             plugin = self._c['commands']._c['tree'].GetItemText(item_plugin)
615             config = self.gui.config[plugin]
616             property_section = self._c['commands']._c['tree'].GetItemText(item_section)
617             property_key = prop.GetName()
618             property_value = prop.GetDisplayedString()
619
620             config[property_section][property_key]['value'] = property_value
621
622     def OnResultsCheck(self, index, flag):
623         results = self.GetActivePlot().results
624         if results.has_key(self.results_str):
625             results[self.results_str].results[index].visible = flag
626             results[self.results_str].update()
627             self.UpdatePlot()
628
629
630     def _on_size(self, event):
631         event.Skip()
632
633     def UpdatePlaylistsTreeSelection(self):
634         playlist = self.GetActivePlaylist()
635         if playlist is not None:
636             if playlist.index >= 0:
637                 self._c['status bar'].set_playlist(playlist)
638                 self.UpdateNote()
639                 self.UpdatePlot()
640
641     def _on_curve_select(self, playlist, curve):
642         #create the plot tab and add playlist to the dictionary
643         plotPanel = panel.plot.PlotPanel(self, ID_FirstPlot + len(self.playlists))
644         notebook_tab = self._c['notebook'].AddPage(plotPanel, playlist.name, True)
645         #tab_index = self._c['notebook'].GetSelection()
646         playlist.figure = plotPanel.get_figure()
647         self.playlists[playlist.name] = playlist
648         #self.playlists[playlist.name] = [playlist, figure]
649         self._c['status bar'].set_playlist(playlist)
650         self.UpdateNote()
651         self.UpdatePlot()
652
653
654     def _on_playlist_left_doubleclick(self):
655         index = self._c['notebook'].GetSelection()
656         current_playlist = self._c['notebook'].GetPageText(index)
657         if current_playlist != playlist_name:
658             index = self._GetPlaylistTab(playlist_name)
659             self._c['notebook'].SetSelection(index)
660         self._c['status bar'].set_playlist(playlist)
661         self.UpdateNote()
662         self.UpdatePlot()
663
664     def _on_playlist_delete(self, playlist):
665         notebook = self.Parent.plotNotebook
666         index = self.Parent._GetPlaylistTab(playlist.name)
667         notebook.SetSelection(index)
668         notebook.DeletePage(notebook.GetSelection())
669         self.Parent.DeleteFromPlaylists(playlist_name)
670
671
672
673     # Command panel interface
674
675     def select_command(self, _class, method, command):
676         #self.select_plugin(plugin=command.plugin)
677         if 'assistant' in self._c:
678             self._c['assitant'].ChangeValue(command.help)
679         self._c['property editor'].clear()
680         self._c['property editor']._argument_from_label = {}
681         for argument in command.arguments:
682             if argument.name == 'help':
683                 continue
684
685             results = self.execute_command(
686                 command=self._command_by_name('playlists'))
687             if not isinstance(results[-1], Success):
688                 self._postprocess_text(command, results=results)
689                 playlists = []
690             else:
691                 playlists = results[0]
692
693             results = self.execute_command(
694                 command=self._command_by_name('playlist curves'))
695             if not isinstance(results[-1], Success):
696                 self._postprocess_text(command, results=results)
697                 curves = []
698             else:
699                 curves = results[0]
700
701             ret = props_from_argument(
702                 argument, curves=curves, playlists=playlists)
703             if ret == None:
704                 continue  # property intentionally not handled (yet)
705             for label,p in ret:
706                 self._c['property editor'].append_property(p)
707                 self._c['property editor']._argument_from_label[label] = (
708                     argument)
709
710         self.gui.config['selected command'] = command  # TODO: push to engine
711
712
713
714     # Note panel interface
715
716     def _on_update_note(self, _class, method, text):
717         """Sets the note for the active curve.
718         """
719         self.execute_command(
720             command=self._command_by_name('set note'),
721             args={'note':text})
722
723
724
725     # Playlist panel interface
726
727     def _on_user_delete_playlist(self, _class, method, playlist):
728         pass
729
730     def _on_delete_playlist(self, _class, method, playlist):
731         if hasattr(playlist, 'path') and playlist.path != None:
732             os.remove(playlist.path)
733
734     def _on_user_delete_curve(self, _class, method, playlist, curve):
735         pass
736
737     def _on_delete_curve(self, _class, method, playlist, curve):
738         os.remove(curve.path)
739
740     def _on_set_selected_playlist(self, _class, method, playlist):
741         """Call the `jump to playlist` command.
742         """
743         results = self.execute_command(
744             command=self._command_by_name('playlists'))
745         if not isinstance(results[-1], Success):
746             return
747         assert len(results) == 2, results
748         playlists = results[0]
749         matching = [p for p in playlists if p.name == playlist.name]
750         assert len(matching) == 1, matching
751         index = playlists.index(matching[0])
752         results = self.execute_command(
753             command=self._command_by_name('jump to playlist'),
754             args={'index':index})
755
756     def _on_set_selected_curve(self, _class, method, playlist, curve):
757         """Call the `jump to curve` command.
758         """
759         self._on_set_selected_playlist(_class, method, playlist)
760         index = playlist.index(curve)
761         results = self.execute_command(
762             command=self._command_by_name('jump to curve'),
763             args={'index':index})
764         if not isinstance(results[-1], Success):
765             return
766         #results = self.execute_command(
767         #    command=self._command_by_name('get playlist'))
768         #if not isinstance(results[-1], Success):
769         #    return
770         self.execute_command(
771             command=self._command_by_name('get curve'))
772
773
774
775     # Plot panel interface
776
777     def _on_plot_status_text(self, _class, method, text):
778         if 'status bar' in self._c:
779             self._c['status bar'].set_plot_text(text)
780
781
782
783     # Navbar interface
784
785     def _next_curve(self, *args):
786         """Call the `next curve` command.
787         """
788         results = self.execute_command(
789             command=self._command_by_name('next curve'))
790         if isinstance(results[-1], Success):
791             self.execute_command(
792                 command=self._command_by_name('get curve'))
793
794     def _previous_curve(self, *args):
795         """Call the `previous curve` command.
796         """
797         results = self.execute_command(
798             command=self._command_by_name('previous curve'))
799         if isinstance(results[-1], Success):
800             self.execute_command(
801                 command=self._command_by_name('get curve'))
802
803
804
805     # Panel display handling
806
807     def _on_panel_visibility(self, _class, method, panel_name, visible):
808         pane = self._c['manager'].GetPane(panel_name)
809         pane.Show(visible)
810         #if we don't do the following, the Folders pane does not resize properly on hide/show
811         if pane.caption == 'Folders' and pane.IsShown() and pane.IsDocked():
812             #folders_size = pane.GetSize()
813             self.panelFolders.Fit()
814         self._c['manager'].Update()
815
816     def _setup_perspectives(self):
817         """Add perspectives to menubar and _perspectives.
818         """
819         self._perspectives = {
820             'Default': self._c['manager'].SavePerspective(),
821             }
822         path = self.gui.config['perspective path']
823         if os.path.isdir(path):
824             files = sorted(os.listdir(path))
825             for fname in files:
826                 name, extension = os.path.splitext(fname)
827                 if extension != self.gui.config['perspective extension']:
828                     continue
829                 fpath = os.path.join(path, fname)
830                 if not os.path.isfile(fpath):
831                     continue
832                 perspective = None
833                 with open(fpath, 'rU') as f:
834                     perspective = f.readline()
835                 if perspective:
836                     self._perspectives[name] = perspective
837
838         selected_perspective = self.gui.config['active perspective']
839         if not self._perspectives.has_key(selected_perspective):
840             self.gui.config['active perspective'] = 'Default'  # TODO: push to engine's Hooke
841
842         self._restore_perspective(selected_perspective, force=True)
843         self._update_perspective_menu()
844
845     def _update_perspective_menu(self):
846         self._c['menu bar']._c['perspective'].update(
847             sorted(self._perspectives.keys()),
848             self.gui.config['active perspective'])
849
850     def _save_perspective(self, perspective, perspective_dir, name,
851                           extension=None):
852         path = os.path.join(perspective_dir, name)
853         if extension != None:
854             path += extension
855         if not os.path.isdir(perspective_dir):
856             os.makedirs(perspective_dir)
857         with open(path, 'w') as f:
858             f.write(perspective)
859         self._perspectives[name] = perspective
860         self._restore_perspective(name)
861         self._update_perspective_menu()
862
863     def _delete_perspectives(self, perspective_dir, names,
864                              extension=None):
865         self.log.debug('remove perspectives %s from %s'
866                        % (names, perspective_dir))
867         for name in names:
868             path = os.path.join(perspective_dir, name)
869             if extension != None:
870                 path += extension
871             os.remove(path)
872             del(self._perspectives[name])
873         self._update_perspective_menu()
874         if self.gui.config['active perspective'] in names:
875             self._restore_perspective('Default')
876         # TODO: does this bug still apply?
877         # Unfortunately, there is a bug in wxWidgets for win32 (Ticket #3258
878         #   http://trac.wxwidgets.org/ticket/3258 
879         # ) that makes the radio item indicator in the menu disappear.
880         # The code should be fine once this issue is fixed.
881
882     def _restore_perspective(self, name, force=False):
883         if name != self.gui.config['active perspective'] or force == True:
884             self.log.debug('restore perspective %s' % name)
885             self.gui.config['active perspective'] = name  # TODO: push to engine's Hooke
886             self._c['manager'].LoadPerspective(self._perspectives[name])
887             self._c['manager'].Update()
888             for pane in self._c['manager'].GetAllPanes():
889                 view = self._c['menu bar']._c['view']
890                 if pane.name in view._c.keys():
891                     view._c[pane.name].Check(pane.window.IsShown())
892
893     def _on_save_perspective(self, *args):
894         perspective = self._c['manager'].SavePerspective()
895         name = self.gui.config['active perspective']
896         if name == 'Default':
897             name = 'New perspective'
898         name = select_save_file(
899             directory=self.gui.config['perspective path'],
900             name=name,
901             extension=self.gui.config['perspective extension'],
902             parent=self,
903             message='Enter a name for the new perspective:',
904             caption='Save perspective')
905         if name == None:
906             return
907         self._save_perspective(
908             perspective, self.gui.config['perspective path'], name=name,
909             extension=self.gui.config['perspective extension'])
910
911     def _on_delete_perspective(self, *args, **kwargs):
912         options = sorted([p for p in self._perspectives.keys()
913                           if p != 'Default'])
914         dialog = SelectionDialog(
915             options=options,
916             message="\nPlease check the perspectives\n\nyou want to delete and click 'Delete'.\n",
917             button_id=wx.ID_DELETE,
918             selection_style='multiple',
919             parent=self,
920             title='Delete perspective(s)',
921             style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER)
922         dialog.CenterOnScreen()
923         dialog.ShowModal()
924         if dialog.canceled == True:
925             return
926         names = [options[i] for i in dialog.selected]
927         dialog.Destroy()
928         self._delete_perspectives(
929             self.gui.config['perspective path'], names=names,
930             extension=self.gui.config['perspective extension'])
931
932     def _on_select_perspective(self, _class, method, name):
933         self._restore_perspective(name)
934
935
936
937 class HookeApp (wx.App):
938     """A :class:`wx.App` wrapper around :class:`HookeFrame`.
939
940     Tosses up a splash screen and then loads :class:`HookeFrame` in
941     its own window.
942     """
943     def __init__(self, gui, commands, inqueue, outqueue, *args, **kwargs):
944         self.gui = gui
945         self.commands = commands
946         self.inqueue = inqueue
947         self.outqueue = outqueue
948         super(HookeApp, self).__init__(*args, **kwargs)
949
950     def OnInit(self):
951         self.SetAppName('Hooke')
952         self.SetVendorName('')
953         self._setup_splash_screen()
954
955         height = self.gui.config['main height']
956         width = self.gui.config['main width']
957         top = self.gui.config['main top']
958         left = self.gui.config['main left']
959
960         # Sometimes, the ini file gets confused and sets 'left' and
961         # 'top' to large negative numbers.  Here we catch and fix
962         # this.  Keep small negative numbers, the user might want
963         # those.
964         if left < -width:
965             left = 0
966         if top < -height:
967             top = 0
968
969         self._c = {
970             'frame': HookeFrame(
971                 self.gui, self.commands, self.inqueue, self.outqueue,
972                 parent=None, title='Hooke',
973                 pos=(left, top), size=(width, height),
974                 style=wx.DEFAULT_FRAME_STYLE|wx.SUNKEN_BORDER|wx.CLIP_CHILDREN),
975             }
976         self._c['frame'].Show(True)
977         self.SetTopWindow(self._c['frame'])
978         return True
979
980     def _setup_splash_screen(self):
981         if self.gui.config['show splash screen'] == True:
982             path = self.gui.config['splash screen image']
983             if os.path.isfile(path):
984                 duration = self.gui.config['splash screen duration']
985                 wx.SplashScreen(
986                     bitmap=wx.Image(path).ConvertToBitmap(),
987                     splashStyle=wx.SPLASH_CENTRE_ON_SCREEN|wx.SPLASH_TIMEOUT,
988                     milliseconds=duration,
989                     parent=None)
990                 wx.Yield()
991                 # For some reason splashDuration and sleep do not
992                 # correspond to each other at least not on Windows.
993                 # Maybe it's because duration is in milliseconds and
994                 # sleep in seconds.  Thus we need to increase the
995                 # sleep time a bit. A factor of 1.2 seems to work.
996                 sleepFactor = 1.2
997                 time.sleep(sleepFactor * duration / 1000)
998
999
1000 class GUI (UserInterface):
1001     """wxWindows graphical user interface.
1002     """
1003     def __init__(self):
1004         super(GUI, self).__init__(name='gui')
1005
1006     def default_settings(self):
1007         """Return a list of :class:`hooke.config.Setting`\s for any
1008         configurable UI settings.
1009
1010         The suggested section setting is::
1011
1012             Setting(section=self.setting_section, help=self.__doc__)
1013         """
1014         return [
1015             Setting(section=self.setting_section, help=self.__doc__),
1016             Setting(section=self.setting_section, option='icon image',
1017                     value=os.path.join('doc', 'img', 'microscope.ico'),
1018                     type='file',
1019                     help='Path to the hooke icon image.'),
1020             Setting(section=self.setting_section, option='show splash screen',
1021                     value=True, type='bool',
1022                     help='Enable/disable the splash screen'),
1023             Setting(section=self.setting_section, option='splash screen image',
1024                     value=os.path.join('doc', 'img', 'hooke.jpg'),
1025                     type='file',
1026                     help='Path to the Hooke splash screen image.'),
1027             Setting(section=self.setting_section,
1028                     option='splash screen duration',
1029                     value=1000, type='int',
1030                     help='Duration of the splash screen in milliseconds.'),
1031             Setting(section=self.setting_section, option='perspective path',
1032                     value=os.path.join('resources', 'gui', 'perspective'),
1033                     help='Directory containing perspective files.'), # TODO: allow colon separated list, like $PATH.
1034             Setting(section=self.setting_section, option='perspective extension',
1035                     value='.txt',
1036                     help='Extension for perspective files.'),
1037             Setting(section=self.setting_section, option='hide extensions',
1038                     value=False, type='bool',
1039                     help='Hide file extensions when displaying names.'),
1040             Setting(section=self.setting_section, option='plot legend',
1041                     value=True, type='bool',
1042                     help='Enable/disable the plot legend.'),
1043             Setting(section=self.setting_section, option='plot SI format',
1044                     value='True', type='bool',
1045                     help='Enable/disable SI plot axes numbering.'),
1046             Setting(section=self.setting_section, option='plot decimals',
1047                     value=2, type='int',
1048                     help='Number of decimal places to show if "plot SI format" is enabled.'),
1049             Setting(section=self.setting_section, option='folders-workdir',
1050                     value='.', type='path',
1051                     help='This should probably go...'),
1052             Setting(section=self.setting_section, option='folders-filters',
1053                     value='.', type='path',
1054                     help='This should probably go...'),
1055             Setting(section=self.setting_section, option='active perspective',
1056                     value='Default',
1057                     help='Name of active perspective file (or "Default").'),
1058             Setting(section=self.setting_section,
1059                     option='folders-filter-index',
1060                     value=0, type='int',
1061                     help='This should probably go...'),
1062             Setting(section=self.setting_section, option='main height',
1063                     value=450, type='int',
1064                     help='Height of main window in pixels.'),
1065             Setting(section=self.setting_section, option='main width',
1066                     value=800, type='int',
1067                     help='Width of main window in pixels.'),
1068             Setting(section=self.setting_section, option='main top',
1069                     value=0, type='int',
1070                     help='Pixels from screen top to top of main window.'),
1071             Setting(section=self.setting_section, option='main left',
1072                     value=0, type='int',
1073                     help='Pixels from screen left to left of main window.'),
1074             Setting(section=self.setting_section, option='selected command',
1075                     value='load playlist',
1076                     help='Name of the initially selected command.'),
1077             ]
1078
1079     def _app(self, commands, ui_to_command_queue, command_to_ui_queue):
1080         redirect = True
1081         if __debug__:
1082             redirect=False
1083         app = HookeApp(gui=self,
1084                        commands=commands,
1085                        inqueue=ui_to_command_queue,
1086                        outqueue=command_to_ui_queue,
1087                        redirect=redirect)
1088         return app
1089
1090     def run(self, commands, ui_to_command_queue, command_to_ui_queue):
1091         app = self._app(commands, ui_to_command_queue, command_to_ui_queue)
1092         app.MainLoop()