Commits

Robert Kern committed b4b18b8

Initial checkin.

Comments (0)

Files changed (4)

qtgrin/__init__.py

Empty file added.

qtgrin/images/bookmark_add.png

Added
New image
+#!/usr/bin/env python
+# -*- coding: UTF-8 -*-
+
+from collections import defaultdict
+import csv
+import gzip
+import os
+import re
+import shlex
+import subprocess
+import threading
+
+from PyQt4 import QtGui
+from PyQt4.QtCore import Qt
+
+from enthought.etsconfig.api import ETSConfig
+ETSConfig.toolkit = 'qt4'
+ETSConfig.company = 'qtgrin'
+
+from enthought.pyface.image_resource import ImageResource
+from enthought.traits import api as traits
+from enthought.traits.ui import api as ui
+from enthought.traits.ui import menu
+from enthought.traits.ui.key_bindings import KeyBinding, KeyBindings
+from enthought.traits.ui.qt4.extra.qt_view import QtView
+from enthought.traits.ui.tabular_adapter import TabularAdapter
+
+import grin
+
+
+style_sheet = """
+QTableView {
+    font-family: "Courier New";
+    font-size: 10pt;
+}
+"""
+
+class SelectBookmarkAction(menu.Action):
+    """ Select a bookmark.
+    """
+
+    # The QtGrin instance.
+    app = traits.WeakRef()
+
+
+    def perform(self, event=None):
+        if self.app is not None:
+            roots = None
+            for name, dirs in self.app.bookmarks:
+                if name == self.name:
+                    roots = dirs
+                    break
+            if roots is not None:
+                self.app.current_root = ''
+                self.app.other_roots = roots
+
+
+class Searcher(traits.HasTraits):
+    """ Model for performing a single search.
+    """
+
+    #### Configuration traits #################################################
+
+    # The root directories to search.
+    roots = traits.List(traits.Str)
+
+    # The search regex.
+    regex = traits.Str()
+
+    # Whether or not we should ignore case.
+    ignore_case = traits.Bool(False)
+
+
+    #### State traits ##########################################################
+
+    # The grepper object.
+    grepper = traits.Property(traits.Instance(grin.GrepText),
+        depends_on=['regex', 'ignore_case'])
+    def _get_grepper(self):
+        flags = 0
+        if self.ignore_case:
+            flags |= re.I
+        regex = re.compile(self.regex, flags)
+        grepper = grin.GrepText(regex)
+        return grepper
+
+    # The results of the search.
+    results = traits.List(traits.Tuple(traits.Str, traits.Int,
+        traits.Enum(grin.PRE, grin.MATCH, grin.POST), traits.Str, traits.List))
+
+
+    def search(self):
+        """ Perform the search and place the results incrementally into the
+        results list.
+
+        We don't return the results directly because this function will
+        typically be run in a thread and communicated back to the main GUI
+        thread through event notifications.
+        """
+        self.results = []
+        p = grin.get_grin_arg_parser()
+        args = p.parse_args(['fake_regex'] + self.roots)
+        openers = dict(text=open, gzip=gzip.open)
+        for filename, kind in grin.get_filenames(args):
+            opener = openers[kind]
+            with opener(filename, 'r') as f:
+                unique_context = self.grepper.do_grep(f)
+            self.results.extend([(filename,) + tup for tup in unique_context])
+
+
+class GetAName(traits.HasTraits):
+    """ Simple dialog for getting a name from the user.
+    """
+
+    # The requested value.
+    name = traits.Str()
+
+    # The title text.
+    title = traits.Str()
+
+    def default_traits_view(self):
+        traits_view = ui.View(
+            ui.Item('name'),
+            title=self.title,
+            buttons=['OK', 'Cancel'],
+            width=300,
+            height=200,
+        )
+        return traits_view
+
+
+class ResultsAdapter(TabularAdapter):
+    columns = [
+        ('File', 'filename'),
+        ('#', 'lineno'),
+        ('Line', 'linetext'),
+    ]
+
+    filename_width = traits.Int(400)
+    lineno_width = traits.Int(50)
+    linetext_width = traits.Int(1000)
+    filename_text = traits.Property()
+    lineno_text = traits.Property()
+    linetext_text = traits.Property()
+    lineno_alignment = traits.Str('right')
+
+    def _get_filename_text(self):
+        return self.item[0]
+
+    def _get_lineno_text(self):
+        return str(self.item[1]+1)
+
+    def _get_linetext_text(self):
+        return self.item[3]
+
+
+class QtGrin(ui.Controller):
+    """ A simple GUI interface for grin.
+    """
+
+    # The current root directory to search.
+    current_root = traits.Str()
+
+    # The other directories to search.
+    other_roots = traits.List(traits.Str)
+
+    # Browse directories.
+    browse_dir = traits.ToolbarButton(u'\u2026')
+
+    # Add a directory to the list of roots.
+    add_root = traits.ToolbarButton('+')
+
+    # Bookmark the current set of roots.
+    bookmark = traits.ToolbarButton(image=ImageResource('bookmark_add'))
+
+    # The bookmarks.
+    # (name, [dir1, dir2, ...])
+    bookmarks = traits.List(traits.Tuple(traits.Str, traits.List(traits.Str)))
+
+    # Trigger the search.
+    search = traits.Button('Search', style='toolbar')
+
+    # The double-clicked row in the results table.
+    results_dclick = traits.Event()
+
+    # The command line template for editing a file.
+    command_template = traits.Str('gvim +{lineno} "{filename}"')
+
+    # All of the root directories.
+    all_roots = traits.Property(traits.List(traits.Str),
+        depends_on=['current_root', 'other_roots', 'other_roots_items'])
+
+    # The menu group with dynamic bookmarks.
+    bookmark_menu_group = traits.Instance(menu.ActionGroup,
+        kw=dict(id='bookmarks'))
+
+    # The bookmark file.
+    bookmark_file = traits.Str()
+    def _bookmark_file_default(self):
+        config_dir = ETSConfig.get_application_data(create=True)
+        bookmark_file = os.path.join(config_dir, 'bookmarks.csv')
+        return bookmark_file
+
+    # The bookmark menu.
+    bookmark_menu = traits.Instance(menu.Menu)
+    def _bookmark_menu_default(self):
+        return menu.Menu(
+            self.bookmark_menu_group,
+            name=u'Bookmarks',
+        )
+
+    # Whether we are currently searching or not.
+    searching = traits.Bool(False)
+
+    # The worker thread.
+    worker = traits.Any()
+    def _worker_default(self):
+        worker = threading.Thread(target=self._thread_main)
+        worker.daemon = True
+        return worker
+
+    # Notify the worker thread that there is a new query to search.
+    new_query = traits.Any()
+    def _new_query_default(self):
+        return threading.Event()
+
+
+    def default_traits_view(self):
+        traits_view = QtView(
+            ui.VGroup(
+                ui.HGroup(
+                    ui.VGroup(
+                        ui.HGroup(
+                            ui.UCustom('controller.search', enabled_when='object.regex.strip()'),
+                            ui.UItem('regex',
+                                editor=ui.TextEditor(auto_set=True),
+                                springy=True, has_focus=True),
+                        ),
+                        ui.Item('ignore_case'),
+                        label="Regex",
+                        enabled_when='not controller.searching',
+                        show_border=True,
+                    ),
+                    ui.VGroup(
+                        ui.HGroup(
+                            ui.UItem('controller.current_root', springy=True),
+                            ui.UCustom('controller.browse_dir'),
+                            ui.UCustom('controller.add_root',
+                                enabled_when="controller.current_root.strip()"),
+                            ui.UCustom('controller.bookmark',
+                                enabled_when="controller.all_roots"),
+                        ),
+                        ui.VGroup(
+                            ui.UItem('controller.other_roots',
+                                editor=ui.ListStrEditor(
+                                    title='Roots',
+                                    operations=['delete'],
+                                ),
+                                height=-60,
+                            ),
+                        ),
+                        label="Directories",
+                        show_border=True,
+                        enabled_when='not controller.searching',
+                    ),
+                    springy=False,
+                ),
+                ui.VGroup(
+                    ui.UItem('results',
+                        editor=ui.TabularEditor(
+                            adapter=ResultsAdapter(),
+                            dclicked='controller.results_dclick',
+                            operations=[],
+                            editable=False,
+                        ),
+                        height=600,
+                        springy=True,
+                    ),
+                    springy=True,
+                ),
+            ),
+            key_bindings=KeyBindings(
+                KeyBinding(
+                    binding1='Enter',
+                    description='Search',
+                    method_name='do_search',
+                ),
+                KeyBinding(
+                    binding1='Cmd-D',
+                    description='Bookmark',
+                    method_name='do_bookmark',
+                ),
+            ),
+            style_sheet=style_sheet,
+            menubar=menu.MenuBar(
+                self.bookmark_menu,
+            ),
+            title="Grin",
+            width=800,
+            height=600,
+            id='QtGrin_View',
+            resizable=True,
+        )
+        return traits_view
+
+    def init(self, info):
+        """ Initialize the controls of a user interface.
+        """
+        self.worker.start()
+        table_views = info.ui.control.findChildren(QtGui.QTableView)
+        for tv in table_views:
+            tv.setAlternatingRowColors(True)
+            tv.setTextElideMode(Qt.ElideLeft)
+        self.load_bookmark_csv()
+        return True
+
+    def closed(self, info, is_ok):
+        """ Handles a dialog-based user interface being closed by the user.
+        """
+        self.save_bookmark_csv()
+
+    def save_bookmark_csv(self, filename=None):
+        """ Save the bookmarks.
+        """
+        if filename is None:
+            filename = self.bookmark_file
+        with open(filename, 'w') as f:
+            w = csv.writer(f)
+            for name, roots in self.bookmarks:
+                for dir in roots:
+                    w.writerow([name, os.path.abspath(dir)])
+
+    def load_bookmark_csv(self, filename=None):
+        """ Load the bookmarks from CSV.
+        """
+        if filename is None:
+            filename = self.bookmark_file
+        if not os.path.isfile(filename):
+            return
+        bookmark_map = defaultdict(list)
+        name_order = []
+        try:
+            with open(filename, 'r') as f:
+                for row in csv.reader(f):
+                    row = [x.strip() for x in row if x.strip()]
+                    if len(row) != 2:
+                        continue
+                    name, root = row
+                    bookmark_map[name].append(root)
+                    if name not in name_order:
+                        name_order.append(name)
+        except csv.Error:
+            # FIXME: raise a dialog?
+            return
+
+        bookmarks = []
+        for name in name_order:
+            bookmarks.append((name, bookmark_map[name]))
+        self.bookmarks = bookmarks
+
+    def do_search(self, info):
+        """ Perform the search.
+        """
+        self._search_changed()
+
+    def do_bookmark(self, info):
+        """ Bookmark the directories.
+        """
+        self._bookmark_changed()
+
+    def _thread_main(self):
+        """ Loop in a worker thread waiting for someone, anyone to press the
+        Search button.
+        """
+        while True:
+            self.new_query.wait()
+            self.searching = True
+            self.new_query.clear()
+            self.model.search()
+            self.searching = False
+
+    @traits.on_trait_change('bookmarks,bookmarks_items')
+    def _update_bookmark_menu(self):
+        """ Update the bookmarks menu.
+        """
+        self.bookmark_menu_group.destroy()
+        self.bookmark_menu_group.clear()
+        for name, dirs in self.bookmarks:
+            self.bookmark_menu_group.append(SelectBookmarkAction(name=name, app=self))
+        self.bookmark_menu.changed = True
+
+    def _add_root_changed(self):
+        """ Add the given directory to the list of roots.
+        """
+        current_root = self.current_root.strip()
+        if current_root:
+            self.other_roots.append(current_root)
+        self.current_root = ''
+
+    def _browse_dir_changed(self):
+        """ Open a directory browser.
+        """
+        dlg = QtGui.QFileDialog(self.info.ui.control)
+        dlg.selectFile(self.current_root.strip())
+        dlg.setFileMode(QtGui.QFileDialog.Directory)
+        if dlg.exec_() == QtGui.QDialog.Accepted:
+            files = dlg.selectedFiles()
+            if len(files) > 0:
+                filename = unicode(files[0])
+                self.current_root = filename
+
+    @traits.cached_property
+    def _get_all_roots(self):
+        """ Clean up the list of root directories.
+        """
+        roots = filter(None, [self.current_root.strip()] + self.other_roots)
+        # Uniquify the list in order.
+        seen_roots = set()
+        all_roots = []
+        for d in roots:
+            # FIXME: normalize? We might want to display relative paths, so
+            # leave it out for now.
+            if d not in seen_roots:
+                all_roots.append(d)
+            seen_roots.add(d)
+        return all_roots
+
+    def _search_changed(self):
+        """ Perform the search.
+        """
+        if self.model.regex.strip() != '':
+            self.model.roots = self.all_roots
+            self.new_query.set()
+
+    def _bookmark_changed(self):
+        """ Bookmark the current set of directories.
+        """
+        dlg = GetAName(title='Enter a name for the bookmark')
+        ui = dlg.edit_traits(parent=self.info.ui.control, kind='livemodal')
+        if ui.result:
+            self.bookmarks.append((dlg.name, self.all_roots))
+
+    def _run_editor(self, filename, lineno=1):
+        """ Run the editor on the file with the given line number.
+        """
+        command = self.command_template.format(filename=filename, lineno=lineno)
+        p = subprocess.Popen(shlex.split(command))
+
+    def _results_dclick_changed(self, event):
+        """ Open the given line in an editor.
+        """
+        if event is not None:
+            self._run_editor(event.item[0], event.item[1]+1)
+
+
+def main():
+    import argparse
+    parser = argparse.ArgumentParser()
+    parser.add_argument('dirs', nargs='*')
+
+    args = parser.parse_args()
+    qg = QtGrin(model=Searcher())
+    if args.dirs:
+        qg.other_roots = args.dirs
+    else:
+        qg.current_root = os.getcwd()
+    qg.configure_traits()
+
+if __name__ == '__main__':
+    main()
+from setuptools import setup
+
+
+setup(
+    name='qtgrin',
+    description="Traits UI and Qt interface to grin searches.",
+    packages=['qtgrin'],
+    entry_points = dict(
+        gui_scripts = [
+            "qtgrin = qtgrin:main",
+        ],
+    ),
+)