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