source: sasview/src/sas/sasgui/perspectives/fitting/fitpanel.py @ 58a255b

ESS_GUIESS_GUI_DocsESS_GUI_batch_fittingESS_GUI_bumps_abstractionESS_GUI_iss1116ESS_GUI_iss879ESS_GUI_iss959ESS_GUI_openclESS_GUI_orderingESS_GUI_sync_sascalccostrafo411magnetic_scattrelease-4.2.2ticket-1009ticket-1094-headlessticket-1242-2d-resolutionticket-1243ticket-1249ticket885unittest-saveload
Last change on this file since 58a255b was 67b0a99, checked in by krzywon, 8 years ago

#761: Constrained fit pages are now deleted if they no longer have any data in them when other data sets are deleted. #826: Fixed a bug where dQ smearing option was unavailable for 1D data sets.

  • Property mode set to 100644
File size: 22.1 KB
RevLine 
[fa09d62]1"""
2FitPanel class contains fields allowing to fit  models and  data
3
4:note: For Fit to be performed the user should check at least one parameter
5    on fit Panel window.
[2f4b430]6
[fa09d62]7"""
8import wx
9from wx.aui import AuiNotebook as nb
10
[d85c194]11from sas.sasgui.guiframe.panel_base import PanelBase
[e6de6b8]12from sas.sasgui.guiframe.events import PanelOnFocusEvent, StatusEvent
[d85c194]13from sas.sasgui.guiframe.dataFitting import check_data_validity
[a95ae9a]14from sas.sasgui.perspectives.fitting.simfitpage import SimultaneousFitPage
[fa09d62]15
16import basepage
17import models
18_BOX_WIDTH = 80
19
[a95ae9a]20
[fa09d62]21class FitPanel(nb, PanelBase):
22    """
23    FitPanel class contains fields allowing to fit  models and  data
[2f4b430]24
[fa09d62]25    :note: For Fit to be performed the user should check at least one parameter
26        on fit Panel window.
[2f4b430]27
[fa09d62]28    """
[a95ae9a]29    # Internal name for the AUI manager
[fa09d62]30    window_name = "Fit panel"
[a95ae9a]31    # Title to appear on top of the window
[fa09d62]32    window_caption = "Fit Panel "
33    CENTER_PANE = True
[2f4b430]34
[fa09d62]35    def __init__(self, parent, manager=None, *args, **kwargs):
36        """
37        """
[6f16e25]38        nb.__init__(self, parent, wx.ID_ANY,
[2f4b430]39                    style=wx.aui.AUI_NB_WINDOWLIST_BUTTON |
40                    wx.aui.AUI_NB_DEFAULT_STYLE |
[fa09d62]41                    wx.CLIP_CHILDREN)
42        PanelBase.__init__(self, parent)
[a95ae9a]43        # self.SetWindowStyleFlag(style=nb.FNB_FANCY_TABS)
[fa09d62]44        self._manager = manager
45        self.parent = parent
46        self.event_owner = None
[a95ae9a]47        # dictionary of miodel {model class name, model class}
[fa09d62]48        self.menu_mng = models.ModelManager()
49        self.model_list_box = self.menu_mng.get_model_list()
[a95ae9a]50        # pageClosedEvent = nb.EVT_FLATNOTEBOOK_PAGE_CLOSING
[fa09d62]51        self.model_dictionary = self.menu_mng.get_model_dictionary()
52        self.pageClosedEvent = wx.aui.EVT_AUINOTEBOOK_PAGE_CLOSE
[2f4b430]53
[fa09d62]54        self.Bind(self.pageClosedEvent, self.on_close_page)
[a95ae9a]55        # save the title of the last page tab added
[fa09d62]56        self.fit_page_name = {}
[a95ae9a]57        # list of existing fit page
[fa09d62]58        self.opened_pages = {}
[a95ae9a]59        # index of fit page
[fa09d62]60        self.fit_page_index = 0
[a95ae9a]61        # index of batch page
[fa09d62]62        self.batch_page_index = 0
[a95ae9a]63        # page of simultaneous fit
[fa09d62]64        self.sim_page = None
65        self.batch_page = None
[a95ae9a]66        # get the state of a page
[fa09d62]67        self.Bind(basepage.EVT_PAGE_INFO, self._onGetstate)
68        self.Bind(basepage.EVT_PREVIOUS_STATE, self._onUndo)
69        self.Bind(basepage.EVT_NEXT_STATE, self._onRedo)
70        self.Bind(wx.aui.EVT_AUINOTEBOOK_PAGE_CHANGED, self.on_page_changing)
71        self.Bind(wx.aui.EVT_AUINOTEBOOK_PAGE_CLOSED, self.on_closed)
[2f4b430]72
[fa09d62]73    def on_closed(self, event):
74        """
75        """
76        if self.GetPageCount() == 0:
77            self.add_empty_page()
78            self.enable_close_button()
[2f4b430]79
[fa09d62]80    def save_project(self, doc=None):
81        """
82        return an xml node containing state of the panel
[a95ae9a]83        that guiframe can write to file
[fa09d62]84        """
[a95ae9a]85        # Iterate through all pages and check for batch fitting
86        batch_state = None
87        if self.sim_page is not None:
88            batch_state = self.sim_page.set_state()
89
[fa09d62]90        for uid, page in self.opened_pages.iteritems():
[a95ae9a]91            data = page.get_data()
92            # state must be cloned
93            state = page.get_state().clone()
94            if data is not None or page.model is not None:
95                new_doc = self._manager.state_reader.write_toXML(data,
96                                                                 state,
97                                                                 batch_state)
98                if doc is not None and hasattr(doc, "firstChild"):
99                    child = new_doc.firstChild.firstChild
100                    doc.firstChild.appendChild(child)
101                else:
102                    doc = new_doc
103
[fa09d62]104        return doc
[2f4b430]105
[fa09d62]106    def update_model_list(self):
107        """
108        """
109        temp = self.menu_mng.update()
110        if len(temp):
111            self.model_list_box = temp
112        return temp
[2f4b430]113
[fa09d62]114    def reset_pmodel_list(self):
115        """
116        """
[f66d9d1]117        temp = self.menu_mng.plugins_reset()
[fa09d62]118        if len(temp):
119            self.model_list_box = temp
120        return temp
[2f4b430]121
[fa09d62]122    def get_page_by_id(self, uid):
123        """
124        """
125        if uid not in self.opened_pages:
126            msg = "Fitpanel cannot find ID: %s in self.opened_pages" % str(uid)
127            raise ValueError, msg
128        else:
129            return self.opened_pages[uid]
[2f4b430]130
[fa09d62]131    def on_page_changing(self, event):
132        """
133        calls the function when the current event handler has exited. avoiding
134        to call panel on focus on a panel that is currently deleted
135        """
136        wx.CallAfter(self.helper_on_page_change)
[2f4b430]137
[fa09d62]138    def helper_on_page_change(self):
139        """
140        """
141        pos = self.GetSelection()
142        if pos != -1:
143            selected_page = self.GetPage(pos)
[2f4b430]144            wx.PostEvent(self._manager.parent,
[fa09d62]145                         PanelOnFocusEvent(panel=selected_page))
146        self.enable_close_button()
[2f4b430]147
[fa09d62]148    def on_set_focus(self, event):
149        """
150        """
151        pos = self.GetSelection()
152        if pos != -1:
153            selected_page = self.GetPage(pos)
[2f4b430]154            wx.PostEvent(self._manager.parent,
[fa09d62]155                         PanelOnFocusEvent(panel=selected_page))
[2f4b430]156
[fa09d62]157    def get_data(self):
158        """
159        get the data in the current page
160        """
161        pos = self.GetSelection()
162        if pos != -1:
163            selected_page = self.GetPage(pos)
164            return selected_page.get_data()
[2f4b430]165
[fa09d62]166    def set_model_state(self, state):
167        """
168        receive a state to reset the model in the current page
169        """
170        pos = self.GetSelection()
171        if pos != -1:
172            selected_page = self.GetPage(pos)
173            selected_page.set_model_state(state)
[2f4b430]174
[fa09d62]175    def get_state(self):
176        """
[e6de6b8]177        return the state of the current selected page
[fa09d62]178        """
179        pos = self.GetSelection()
180        if pos != -1:
181            selected_page = self.GetPage(pos)
182            return selected_page.get_state()
[2f4b430]183
[fa09d62]184    def close_all(self):
185        """
186        remove all pages, used when a svs file is opened
187        """
[2f4b430]188
[e6de6b8]189        # use while-loop, for-loop will not do the job well.
[c8e1996]190        while (self.GetPageCount() > 0):
[67b0a99]191            page = self.GetPage(self.GetPageCount() - 1)
[fa09d62]192            if self._manager.parent.panel_on_focus == page:
193                self._manager.parent.panel_on_focus = None
194            self._close_helper(selected_page=page)
[67b0a99]195            self.DeletePage(self.GetPageCount() - 1)
[e6de6b8]196        # Clear list of names
[fa09d62]197        self.fit_page_name = {}
[e6de6b8]198        # Clear list of opened pages
[fa09d62]199        self.opened_pages = {}
[998ca90]200        self.fit_page_index = 0
201        self.batch_page_index = 0
[2f4b430]202
[fa09d62]203    def set_state(self, state):
204        """
205        Restore state of the panel
206        """
207        page_is_opened = False
208        if state is not None:
209            for uid, panel in self.opened_pages.iteritems():
[e6de6b8]210                # Don't return any panel is the exact same page is created
[fa09d62]211                if uid == panel.uid and panel.data == state.data:
212                    # the page is still opened
213                    panel.reset_page(state=state)
214                    panel.save_current_state()
215                    page_is_opened = True
216            if not page_is_opened:
217                if state.data.__class__.__name__ != 'list':
[e6de6b8]218                    # To support older state file format
[fa09d62]219                    list_data = [state.data]
220                else:
[e6de6b8]221                    # Todo: need new file format for the list
[fa09d62]222                    list_data = state.data
223                panel = self._manager.add_fit_page(data=list_data)
224                # add data associated to the page created
225                if panel is not None:
226                    self._manager.store_data(uid=panel.uid,
227                                             data_list=list_data,
228                                             caption=panel.window_caption)
229                    panel.reset_page(state=state)
230                    panel.save_current_state()
[2f4b430]231
[fa09d62]232    def clear_panel(self):
233        """
234        Clear and close all panels, used by guimanager
235        """
[e6de6b8]236        # close all panels only when svs file opened
[fa09d62]237        self.close_all()
[998ca90]238        self.sim_page = None
239        self.batch_page = None
[2f4b430]240
[fa09d62]241    def on_close_page(self, event=None):
242        """
243        close page and remove all references to the closed page
244        """
245        selected_page = self.GetPage(self.GetSelection())
[c8e1996]246        if self.GetPageCount() == 1:
[e6de6b8]247            if selected_page.get_data() is not None:
[fa09d62]248                if event is not None:
249                    event.Veto()
250                return
251        self._close_helper(selected_page=selected_page)
[2f4b430]252
[fa09d62]253    def close_page_with_data(self, deleted_data):
254        """
255        close a fit page when its data is completely remove from the graph
256        """
257        if deleted_data is None:
258            return
259        for index in range(self.GetPageCount()):
260            selected_page = self.GetPage(index)
261            if hasattr(selected_page, "get_data"):
262                data = selected_page.get_data()
[2f4b430]263
[fa09d62]264                if data is None:
[e6de6b8]265                    # the fitpanel exists and only the initial fit page is open
266                    # with no selected data
[fa09d62]267                    return
268                if data.id == deleted_data.id:
269                    self._close_helper(selected_page)
270                    self.DeletePage(index)
271                    break
[2f4b430]272
[fa09d62]273    def set_manager(self, manager):
274        """
275        set panel manager
[2f4b430]276
[fa09d62]277        :param manager: instance of plugin fitting
278        """
279        self._manager = manager
280        for pos in range(self.GetPageCount()):
281            page = self.GetPage(pos)
282            if page is not None:
283                page.set_manager(self._manager)
284
285    def set_model_list(self, dict):
286        """
287        copy a dictionary of model into its own dictionary
[2f4b430]288
[c8e1996]289        :param dict: dictionnary made of model name as key and model class
[ac7be54]290            as value
[fa09d62]291        """
292        self.model_list_box = dict
[2f4b430]293
[fa09d62]294    def set_model_dict(self, m_dict):
295        """
296        copy a dictionary of model name -> model object
297
298        :param m_dict: dictionary linking model name -> model object
299        """
300
301    def get_current_page(self):
302        """
303        :return: the current page selected
[2f4b430]304
[fa09d62]305        """
306        return self.GetPage(self.GetSelection())
[2f4b430]307
[fa09d62]308    def add_sim_page(self, caption="Const & Simul Fit"):
309        """
310        Add the simultaneous fit page
311        """
312        from simfitpage import SimultaneousFitPage
313        page_finder = self._manager.get_page_finder()
314        if caption == "Const & Simul Fit":
315            self.sim_page = SimultaneousFitPage(self, page_finder=page_finder,
[c8e1996]316                                                 id=wx.ID_ANY, batch_on=False)
[fa09d62]317            self.sim_page.window_caption = caption
318            self.sim_page.window_name = caption
319            self.sim_page.uid = wx.NewId()
320            self.AddPage(self.sim_page, caption, True)
321            self.sim_page.set_manager(self._manager)
322            self.enable_close_button()
323            return self.sim_page
324        else:
325            self.batch_page = SimultaneousFitPage(self, batch_on=True,
[e6de6b8]326                                                  page_finder=page_finder)
[fa09d62]327            self.batch_page.window_caption = caption
328            self.batch_page.window_name = caption
329            self.batch_page.uid = wx.NewId()
330            self.AddPage(self.batch_page, caption, True)
331            self.batch_page.set_manager(self._manager)
332            self.enable_close_button()
333            return self.batch_page
[2f4b430]334
[fa09d62]335    def add_empty_page(self):
336        """
337        add an empty page
338        """
339        if self.batch_on:
340            from batchfitpage import BatchFitPage
341            panel = BatchFitPage(parent=self)
342            self.batch_page_index += 1
343            caption = "BatchPage" + str(self.batch_page_index)
344            panel.set_index_model(self.batch_page_index)
345        else:
[a95ae9a]346            # Increment index of fit page
[e6de6b8]347            from fitpage import FitPage
[fa09d62]348            panel = FitPage(parent=self)
349            self.fit_page_index += 1
350            caption = "FitPage" + str(self.fit_page_index)
351            panel.set_index_model(self.fit_page_index)
352        panel.batch_on = self.batch_on
353        panel._set_save_flag(not panel.batch_on)
354        panel.set_model_dictionary(self.model_dictionary)
355        panel.populate_box(model_dict=self.model_list_box)
356        panel.formfactor_combo_init()
357        panel.set_manager(self._manager)
358        panel.window_caption = caption
359        panel.window_name = caption
360        self.AddPage(panel, caption, select=True)
361        self.opened_pages[panel.uid] = panel
362        self._manager.create_fit_problem(panel.uid)
363        self._manager.page_finder[panel.uid].add_data(panel.get_data())
364        self.enable_close_button()
365        panel.on_set_focus(None)
366        return panel
[2f4b430]367
[fa09d62]368    def enable_close_button(self):
369        """
370        display the close button on tab for more than 1 tabs else remove the
371        close button
372        """
373        if self.GetPageCount() <= 1:
374            style = self.GetWindowStyleFlag()
375            flag = wx.aui.AUI_NB_CLOSE_ON_ACTIVE_TAB
376            if style & wx.aui.AUI_NB_CLOSE_ON_ACTIVE_TAB == flag:
377                style = style & ~wx.aui.AUI_NB_CLOSE_ON_ACTIVE_TAB
378                self.SetWindowStyle(style)
379        else:
380            style = self.GetWindowStyleFlag()
381            flag = wx.aui.AUI_NB_CLOSE_ON_ACTIVE_TAB
382            if style & wx.aui.AUI_NB_CLOSE_ON_ACTIVE_TAB != flag:
383                style |= wx.aui.AUI_NB_CLOSE_ON_ACTIVE_TAB
384                self.SetWindowStyle(style)
[2f4b430]385
[fa09d62]386    def delete_data(self, data):
387        """
388        Delete the given data
389        """
390        if data.__class__.__name__ != "list":
391            raise ValueError, "Fitpanel delete_data expect list of id"
392        else:
393            for page in self.opened_pages.values():
394                pos = self.GetPageIndex(page)
395                temp_data = page.get_data()
396                if temp_data is not None and temp_data.id in data:
397                    self.SetSelection(pos)
398                    self.on_close_page(event=None)
399                    temp = self.GetSelection()
400                    self.DeletePage(temp)
[67b0a99]401            if self.sim_page is not None:
402                if len(self.sim_page.model_list) == 0:
403                    pos = self.GetPageIndex(self.sim_page)
404                    self.SetSelection(pos)
405                    self.on_close_page(event=None)
406                    temp = self.GetSelection()
407                    self.DeletePage(temp)
408                    self.sim_page = None
409                    self.batch_on = False
[fa09d62]410            if self.GetPageCount() == 0:
411                self._manager.on_add_new_page(event=None)
[2f4b430]412
[fa09d62]413    def set_data_on_batch_mode(self, data_list):
414        """
415        Add all data to a single tab when the application is on Batch mode.
[2f4b430]416        However all data in the set of data must be either 1D or 2D type.
417        This method presents option to select the data type before creating a
[fa09d62]418        tab.
419        """
420        data_1d_list = []
421        data_2d_list = []
422        group_id_1d = wx.NewId()
423        # separate data into data1d and data2d list
424        for data in data_list:
425            if data.__class__.__name__ == "Data1D":
426                data.group_id = group_id_1d
427                data_1d_list.append(data)
428            if data.__class__.__name__ == "Data2D":
429                data.group_id = wx.NewId()
430                data_2d_list.append(data)
431        page = None
432        for p in self.opened_pages.values():
[e6de6b8]433            # check if there is an empty page to fill up
[fa09d62]434            if not check_data_validity(p.get_data()) and p.batch_on:
[2f4b430]435
[e6de6b8]436                # make sure data get placed in 1D empty tab if data is 1D
437                # else data get place on 2D tab empty tab
[fa09d62]438                enable2D = p.get_view_mode()
439                if (data.__class__.__name__ == "Data2D" and enable2D)\
440                or (data.__class__.__name__ == "Data1D" and not enable2D):
441                    page = p
442                    break
443        if data_1d_list and data_2d_list:
444            # need to warning the user that this batch is a special case
[c8e1996]445            from sas.sasgui.perspectives.fitting.fitting_widgets import \
446                BatchDataDialog
[fa09d62]447            dlg = BatchDataDialog(self)
448            if dlg.ShowModal() == wx.ID_OK:
449                data_type = dlg.get_data()
450                dlg.Destroy()
[e6de6b8]451                if page is None:
[fa09d62]452                    page = self.add_empty_page()
453                if data_type == 1:
[e6de6b8]454                    # user has selected only data1D
[fa09d62]455                    page.fill_data_combobox(data_1d_list)
456                elif data_type == 2:
457                    page.fill_data_combobox(data_2d_list)
458            else:
[e6de6b8]459                # the batch analysis is canceled
[fa09d62]460                dlg.Destroy()
461                return None
462        else:
463            if page is None:
464                page = self.add_empty_page()
465            if data_1d_list and not data_2d_list:
[c8e1996]466                # only on type of data
[fa09d62]467                page.fill_data_combobox(data_1d_list)
468            elif not data_1d_list and data_2d_list:
469                page.fill_data_combobox(data_2d_list)
[2f4b430]470
[fa09d62]471        pos = self.GetPageIndex(page)
472        page.batch_on = self.batch_on
473        page._set_save_flag(not page.batch_on)
474        self.SetSelection(pos)
475        self.opened_pages[page.uid] = page
476        return page
[2f4b430]477
[fa09d62]478    def set_data(self, data_list):
479        """
480        Add a fitting page on the notebook contained by fitpanel
[2f4b430]481
[c8e1996]482        :param data_list: data to fit
[2f4b430]483
[fa09d62]484        :return panel : page just added for further used.
485        is used by fitting module
[2f4b430]486
[fa09d62]487        """
488        if not data_list:
489            return None
490        if self.batch_on:
491            return self.set_data_on_batch_mode(data_list)
492        else:
493            data = None
494            try:
495                data = data_list[0]
[e6de6b8]496            except Exception:
[fa09d62]497                # for 'fitv' files
498                data_list = [data]
499                data = data_list[0]
[2f4b430]500
[fa09d62]501            if data is None:
502                return None
503        for page in self.opened_pages.values():
[e6de6b8]504            # check if the selected data existing in the fitpanel
[fa09d62]505            pos = self.GetPageIndex(page)
506            if not check_data_validity(page.get_data()) and not page.batch_on:
[e6de6b8]507                # make sure data get placed in 1D empty tab if data is 1D
508                # else data get place on 2D tab empty tab
[fa09d62]509                enable2D = page.get_view_mode()
510                if (data.__class__.__name__ == "Data2D" and enable2D)\
[e6de6b8]511                   or (data.__class__.__name__ == "Data1D" and not enable2D):
[fa09d62]512                    page.batch_on = self.batch_on
513                    page._set_save_flag(not page.batch_on)
514                    page.fill_data_combobox(data_list)
[e6de6b8]515                    # caption = "FitPage" + str(self.fit_page_index)
[fa09d62]516                    self.SetPageText(pos, page.window_caption)
517                    self.SetSelection(pos)
518                    return page
[a95ae9a]519        # create new page and add data
[fa09d62]520        page = self.add_empty_page()
521        pos = self.GetPageIndex(page)
522        page.fill_data_combobox(data_list)
523        self.opened_pages[page.uid] = page
524        self.SetSelection(pos)
525        return page
[2f4b430]526
[fa09d62]527    def _onGetstate(self, event):
528        """
529        copy the state of a page
530        """
531        page = event.page
532        if page.uid in self.fit_page_name:
533            self.fit_page_name[page.uid].appendItem(page.createMemento())
[2f4b430]534
[fa09d62]535    def _onUndo(self, event):
536        """
537        return the previous state of a given page is available
538        """
539        page = event.page
540        if page.uid in self.fit_page_name:
541            if self.fit_page_name[page.uid].getCurrentPosition() == 0:
542                state = None
543            else:
544                state = self.fit_page_name[page.uid].getPreviousItem()
545                page._redo.Enable(True)
546            page.reset_page(state)
[2f4b430]547
[fa09d62]548    def _onRedo(self, event):
549        """
550        return the next state available
551        """
552        page = event.page
553        if page.uid in self.fit_page_name:
554            length = len(self.fit_page_name[page.uid])
555            if self.fit_page_name[page.uid].getCurrentPosition() == length - 1:
556                state = None
557                page._redo.Enable(False)
558                page._redo.Enable(True)
559            else:
560                state = self.fit_page_name[page.uid].getNextItem()
561            page.reset_page(state)
[2f4b430]562
[fa09d62]563    def _close_helper(self, selected_page):
564        """
565        Delete the given page from the notebook
566        """
[e6de6b8]567        # remove hint page
568        # if selected_page == self.hint_page:
[fa09d62]569        #    return
[e6de6b8]570        # removing sim_page
[fa09d62]571        if selected_page == self.sim_page:
572            self._manager.sim_page = None
573            return
574        if selected_page == self.batch_page:
575            self._manager.batch_page = None
576            return
[e6de6b8]577        # closing other pages
[fa09d62]578        state = selected_page.createMemento()
579        page_finder = self._manager.get_page_finder()
[e6de6b8]580        # removing fit page
[fa09d62]581        data = selected_page.get_data()
[e6de6b8]582        # Don' t remove plot for 2D
[fa09d62]583        flag = True
584        if data.__class__.__name__ == 'Data2D':
585            flag = False
586        if selected_page in page_finder:
[e6de6b8]587            # Delete the name of the page into the list of open page
[fa09d62]588            for uid, list in self.opened_pages.iteritems():
[e6de6b8]589                # Don't return any panel is the exact same page is created
[fa09d62]590                if flag and selected_page.uid == uid:
591                    self._manager.remove_plot(uid, theory=False)
592                    break
593            del page_finder[selected_page]
[2f4b430]594
[e6de6b8]595        # Delete the name of the page into the list of open page
[fa09d62]596        for uid, list in self.opened_pages.iteritems():
[e6de6b8]597            # Don't return any panel is the exact same page is created
[fa09d62]598            if selected_page.uid == uid:
599                del self.opened_pages[selected_page.uid]
600                break
[e6de6b8]601        # remove the check box link to the model name of the selected_page
[fa09d62]602        try:
603            self.sim_page.draw_page()
604        except:
[e6de6b8]605            # that page is already deleted no need to remove check box on
606            # non existing page
[fa09d62]607            pass
608        try:
609            self.batch_page.draw_page()
610        except:
[e6de6b8]611            # that page is already deleted no need to remove check box on
612            # non existing page
[fa09d62]613            pass
Note: See TracBrowser for help on using the repository browser.