widgets: consolidate context menu handling for text widgets
[git-cola.git] / cola / widgets / grep.py
blob31c79bac9a68d41c12d52bb599bfd45819355095
1 from __future__ import absolute_import, division, print_function, unicode_literals
3 from qtpy import QtCore
4 from qtpy import QtWidgets
5 from qtpy.QtCore import Qt
6 from qtpy.QtCore import Signal
8 from ..i18n import N_
9 from ..utils import Group
10 from .. import cmds
11 from .. import core
12 from .. import hotkeys
13 from .. import utils
14 from .. import qtutils
15 from ..qtutils import get
16 from .standard import Dialog
17 from .text import HintedLineEdit
18 from .text import VimHintedPlainTextEdit
19 from .text import VimTextBrowser
20 from . import defs
23 def grep(context):
24 """Prompt and use 'git grep' to find the content."""
25 widget = new_grep(context, parent=qtutils.active_window())
26 widget.show()
27 return widget
30 def new_grep(context, text=None, parent=None):
31 """Construct a new Grep dialog"""
32 widget = Grep(context, parent=parent)
33 if text:
34 widget.search_for(text)
35 return widget
38 def parse_grep_line(line):
39 """Parse a grep result line into (filename, line_number, content)"""
40 try:
41 filename, line_number, contents = line.split(':', 2)
42 result = (filename, line_number, contents)
43 except ValueError:
44 result = None
45 return result
48 def goto_grep(context, line):
49 """Called when Search -> Grep's right-click 'goto' action."""
50 parsed_line = parse_grep_line(line)
51 if parsed_line:
52 filename, line_number, _ = parsed_line
53 cmds.do(
54 cmds.Edit,
55 context,
56 [filename],
57 line_number=line_number,
58 background_editor=True,
62 class GrepThread(QtCore.QThread):
63 """Gather `git grep` results in a background thread"""
65 result = Signal(object, object, object)
67 def __init__(self, context, parent):
68 QtCore.QThread.__init__(self, parent)
69 self.context = context
70 self.query = None
71 self.shell = False
72 self.regexp_mode = '--basic-regexp'
74 def run(self):
75 if self.query is None:
76 return
77 git = self.context.git
78 query = self.query
79 if self.shell:
80 args = utils.shell_split(query)
81 else:
82 args = [query]
83 status, out, err = git.grep(self.regexp_mode, n=True, _readonly=True, *args)
84 if query == self.query:
85 self.result.emit(status, out, err)
86 else:
87 self.run()
90 class Grep(Dialog):
91 """A dialog for searching content using `git grep`"""
93 def __init__(self, context, parent=None):
94 Dialog.__init__(self, parent)
95 self.context = context
96 self.grep_result = ''
98 self.setWindowTitle(N_('Search'))
99 if parent is not None:
100 self.setWindowModality(Qt.WindowModal)
102 self.edit_action = qtutils.add_action(self, N_('Edit'), self.edit, hotkeys.EDIT)
104 self.refresh_action = qtutils.add_action(
105 self, N_('Refresh'), self.search, *hotkeys.REFRESH_HOTKEYS
108 self.input_label = QtWidgets.QLabel('git grep')
109 self.input_label.setFont(qtutils.diff_font(context))
111 self.input_txt = HintedLineEdit(
112 context, N_('command-line arguments'), parent=self
115 self.regexp_combo = combo = QtWidgets.QComboBox()
116 combo.setToolTip(N_('Choose the "git grep" regular expression mode'))
117 items = [N_('Basic Regexp'), N_('Extended Regexp'), N_('Fixed String')]
118 combo.addItems(items)
119 combo.setCurrentIndex(0)
120 combo.setEditable(False)
122 tooltip0 = N_('Search using a POSIX basic regular expression')
123 tooltip1 = N_('Search using a POSIX extended regular expression')
124 tooltip2 = N_('Search for a fixed string')
125 combo.setItemData(0, tooltip0, Qt.ToolTipRole)
126 combo.setItemData(1, tooltip1, Qt.ToolTipRole)
127 combo.setItemData(2, tooltip2, Qt.ToolTipRole)
128 combo.setItemData(0, '--basic-regexp', Qt.UserRole)
129 combo.setItemData(1, '--extended-regexp', Qt.UserRole)
130 combo.setItemData(2, '--fixed-strings', Qt.UserRole)
132 self.result_txt = GrepTextView(context, N_('grep result...'), self)
133 self.preview_txt = PreviewTextView(context, self)
134 self.preview_txt.setFocusProxy(self.result_txt)
136 self.edit_button = qtutils.edit_button(default=True)
137 qtutils.button_action(self.edit_button, self.edit_action)
139 self.refresh_button = qtutils.refresh_button()
140 qtutils.button_action(self.refresh_button, self.refresh_action)
142 text = N_('Shell arguments')
143 tooltip = N_(
144 'Parse arguments using a shell.\n'
145 'Queries with spaces will require "double quotes".'
147 self.shell_checkbox = qtutils.checkbox(
148 text=text, tooltip=tooltip, checked=False
150 self.close_button = qtutils.close_button()
152 self.refresh_group = Group(self.refresh_action, self.refresh_button)
153 self.refresh_group.setEnabled(False)
155 self.edit_group = Group(self.edit_action, self.edit_button)
156 self.edit_group.setEnabled(False)
158 self.input_layout = qtutils.hbox(
159 defs.no_margin,
160 defs.button_spacing,
161 self.input_label,
162 self.input_txt,
163 self.regexp_combo,
166 self.bottom_layout = qtutils.hbox(
167 defs.no_margin,
168 defs.button_spacing,
169 self.refresh_button,
170 self.shell_checkbox,
171 qtutils.STRETCH,
172 self.close_button,
173 self.edit_button,
176 self.splitter = qtutils.splitter(Qt.Vertical, self.result_txt, self.preview_txt)
178 self.mainlayout = qtutils.vbox(
179 defs.margin,
180 defs.no_spacing,
181 self.input_layout,
182 self.splitter,
183 self.bottom_layout,
185 self.setLayout(self.mainlayout)
187 thread = self.worker_thread = GrepThread(context, self)
188 thread.result.connect(self.process_result, type=Qt.QueuedConnection)
190 # pylint: disable=no-member
191 self.input_txt.textChanged.connect(lambda s: self.search())
192 self.regexp_combo.currentIndexChanged.connect(lambda x: self.search())
193 self.result_txt.leave.connect(self.input_txt.setFocus)
194 self.result_txt.cursorPositionChanged.connect(self.update_preview)
196 qtutils.add_action(
197 self.input_txt,
198 'Focus Results',
199 self.focus_results,
200 hotkeys.DOWN,
201 *hotkeys.ACCEPT
203 qtutils.add_action(self, 'Focus Input', self.focus_input, hotkeys.FOCUS)
205 qtutils.connect_toggle(self.shell_checkbox, lambda x: self.search())
206 qtutils.connect_button(self.close_button, self.close)
207 qtutils.add_close_action(self)
209 self.init_size(parent=parent)
211 def focus_input(self):
212 """Focus the grep input field and select the text"""
213 self.input_txt.setFocus()
214 self.input_txt.selectAll()
216 def focus_results(self):
217 """Give focus to the results window"""
218 self.result_txt.setFocus()
220 def regexp_mode(self):
221 """Return the selected grep regex mode"""
222 idx = self.regexp_combo.currentIndex()
223 return self.regexp_combo.itemData(idx, Qt.UserRole)
225 def search(self):
226 """Initiate a search by starting the GrepThread"""
227 self.edit_group.setEnabled(False)
228 self.refresh_group.setEnabled(False)
230 query = get(self.input_txt)
231 if len(query) < 2:
232 self.result_txt.clear()
233 self.preview_txt.clear()
234 return
235 self.worker_thread.query = query
236 self.worker_thread.shell = get(self.shell_checkbox)
237 self.worker_thread.regexp_mode = self.regexp_mode()
238 self.worker_thread.start()
240 def search_for(self, txt):
241 """Set the initial value of the input text"""
242 self.input_txt.set_value(txt)
244 def text_scroll(self):
245 """Return the scrollbar value for the results window"""
246 scrollbar = self.result_txt.verticalScrollBar()
247 if scrollbar:
248 return get(scrollbar)
249 return None
251 def set_text_scroll(self, scroll):
252 """Set the scrollbar value for the results window"""
253 scrollbar = self.result_txt.verticalScrollBar()
254 if scrollbar and scroll is not None:
255 scrollbar.setValue(scroll)
257 def text_offset(self):
258 """Return the cursor's offset within the result text"""
259 return self.result_txt.textCursor().position()
261 def set_text_offset(self, offset):
262 """Set the text cursor from an offset"""
263 cursor = self.result_txt.textCursor()
264 cursor.setPosition(offset)
265 self.result_txt.setTextCursor(cursor)
267 def process_result(self, status, out, err):
268 """Apply the results from grep to the widgets"""
269 if status == 0:
270 value = out + err
271 elif out + err:
272 value = 'git grep: ' + out + err
273 else:
274 value = ''
276 # save scrollbar and text cursor
277 scroll = self.text_scroll()
278 offset = min(len(value), self.text_offset())
280 self.grep_result = value
281 self.result_txt.set_value(value)
282 # restore
283 self.set_text_scroll(scroll)
284 self.set_text_offset(offset)
286 enabled = status == 0
287 self.edit_group.setEnabled(enabled)
288 self.refresh_group.setEnabled(True)
289 if not value:
290 self.preview_txt.clear()
292 def update_preview(self):
293 """Update the file preview window"""
294 parsed_line = parse_grep_line(self.result_txt.selected_line())
295 if parsed_line:
296 filename, line_number, _ = parsed_line
297 self.preview_txt.preview(filename, line_number)
299 def edit(self):
300 """Launch an editor on the currently selected line"""
301 goto_grep(self.context, self.result_txt.selected_line())
303 def export_state(self):
304 """Export persistent settings"""
305 state = super(Grep, self).export_state()
306 state['sizes'] = get(self.splitter)
307 return state
309 def apply_state(self, state):
310 """Apply persistent settings"""
311 result = super(Grep, self).apply_state(state)
312 try:
313 self.splitter.setSizes(state['sizes'])
314 except (AttributeError, KeyError, ValueError, TypeError):
315 result = False
316 return result
319 # pylint: disable=too-many-ancestors
320 class GrepTextView(VimHintedPlainTextEdit):
321 """A text view with hotkeys for launching editors"""
323 def __init__(self, context, hint, parent):
324 VimHintedPlainTextEdit.__init__(self, context, hint, parent=parent)
325 self.context = context
326 self.goto_action = qtutils.add_action(self, 'Launch Editor', self.edit)
327 self.goto_action.setShortcut(hotkeys.EDIT)
328 self.menu_actions.append(self.goto_action)
330 def edit(self):
331 goto_grep(self.context, self.selected_line())
334 class PreviewTask(qtutils.Task):
335 """Asynchronous task for loading file content"""
337 def __init__(self, filename, line_number):
338 qtutils.Task.__init__(self)
340 self.content = ''
341 self.filename = filename
342 self.line_number = line_number
344 def task(self):
345 try:
346 self.content = core.read(self.filename, errors='ignore')
347 except IOError:
348 pass
349 return (self.filename, self.content, self.line_number)
352 # pylint: disable=too-many-ancestors
353 class PreviewTextView(VimTextBrowser):
354 """Preview window for file contents"""
356 def __init__(self, context, parent):
357 VimTextBrowser.__init__(self, context, parent)
358 self.filename = None
359 self.content = None
360 self.runtask = qtutils.RunTask(parent=self)
362 def preview(self, filename, line_number):
363 """Preview the a file at the specified line number"""
365 if filename != self.filename:
366 request = PreviewTask(filename, line_number)
367 self.runtask.start(request, finish=self.show_preview)
368 else:
369 self.scroll_to_line(line_number)
371 def clear(self):
372 self.filename = ''
373 self.content = ''
374 super(PreviewTextView, self).clear()
376 def show_preview(self, task):
377 """Show the results of the asynchronous file read"""
379 filename = task.filename
380 content = task.content
381 line_number = task.line_number
383 if filename != self.filename:
384 self.filename = filename
385 self.content = content
386 self.set_value(content)
388 self.scroll_to_line(line_number)
390 def scroll_to_line(self, line_number):
391 """Scroll to the specified line number"""
392 try:
393 line_num = int(line_number) - 1
394 except ValueError:
395 return
397 self.numbers.set_highlighted(line_num)
398 cursor = self.textCursor()
399 cursor.setPosition(0)
400 self.setTextCursor(cursor)
401 self.ensureCursorVisible()
403 cursor.movePosition(cursor.Down, cursor.MoveAnchor, line_num)
404 cursor.movePosition(cursor.EndOfLine, cursor.KeepAnchor)
405 self.setTextCursor(cursor)
406 self.ensureCursorVisible()
408 scrollbar = self.verticalScrollBar()
409 step = scrollbar.pageStep() // 2
410 position = scrollbar.sliderPosition() + step
411 scrollbar.setSliderPosition(position)
412 self.ensureCursorVisible()