diff options
author | Florian Bruhin <me@the-compiler.org> | 2020-05-22 22:13:22 +0200 |
---|---|---|
committer | Florian Bruhin <me@the-compiler.org> | 2020-05-22 22:13:22 +0200 |
commit | 7a7d74dba1c6dfe1f377958e2df8ce0a2b6fed81 (patch) | |
tree | c85fbdd04de8ed580c5d8b612c78477182b58f59 | |
parent | 12a6590364f69d627cddcbe48cc675593827e4c4 (diff) | |
parent | 57bc2b49c6ff6c67a0a8a6967389f03a12473e9f (diff) | |
download | qutebrowser-7a7d74dba1c6dfe1f377958e2df8ce0a2b6fed81.tar.gz qutebrowser-7a7d74dba1c6dfe1f377958e2df8ce0a2b6fed81.zip |
Merge branch 'caret-line'
-rw-r--r-- | doc/changelog.asciidoc | 1 | ||||
-rw-r--r-- | doc/help/commands.asciidoc | 5 | ||||
-rw-r--r-- | doc/help/settings.asciidoc | 1 | ||||
-rw-r--r-- | qutebrowser/browser/browsertab.py | 27 | ||||
-rw-r--r-- | qutebrowser/browser/webengine/webenginetab.py | 17 | ||||
-rw-r--r-- | qutebrowser/browser/webkit/webkittab.py | 198 | ||||
-rw-r--r-- | qutebrowser/components/caretcommands.py | 10 | ||||
-rw-r--r-- | qutebrowser/config/configdata.yml | 1 | ||||
-rw-r--r-- | qutebrowser/javascript/caret.js | 86 | ||||
-rw-r--r-- | qutebrowser/mainwindow/statusbar/bar.py | 12 | ||||
-rw-r--r-- | qutebrowser/mainwindow/tabbedbrowser.py | 2 | ||||
-rw-r--r-- | tests/helpers/fixtures.py | 30 | ||||
-rw-r--r-- | tests/helpers/stubs.py | 4 | ||||
-rw-r--r-- | tests/unit/browser/test_caret.py | 144 |
14 files changed, 415 insertions, 123 deletions
diff --git a/doc/changelog.asciidoc b/doc/changelog.asciidoc index 7a97507e5..46c72e4e2 100644 --- a/doc/changelog.asciidoc +++ b/doc/changelog.asciidoc @@ -40,6 +40,7 @@ Added debugging. - New `colors.contextmenu.disabled.{fg,bg}` settings to customize colors for disabled items in the context menu. +- New line selection mode (`:toggle-selection --line`), bound to `Shift-V` in caret mode. Changed ~~~~~~~ diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index a6a5c3e87..8cd8a62c8 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -1896,8 +1896,13 @@ This acts like readline's yank. [[toggle-selection]] === toggle-selection +Syntax: +:toggle-selection [*--line*]+ + Toggle caret selection mode. +==== optional arguments +* +*-l*+, +*--line*+: Enables line-selection. + == Debugging commands These commands are mainly intended for debugging. They are hidden if qutebrowser was started without the `--debug`-flag. diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index 8548d834a..d7b07db34 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -446,6 +446,7 @@ Default: * +pass:[J]+: +pass:[scroll down]+ * +pass:[K]+: +pass:[scroll up]+ * +pass:[L]+: +pass:[scroll right]+ +* +pass:[V]+: +pass:[toggle-selection --line]+ * +pass:[Y]+: +pass:[yank selection -s]+ * +pass:[[]+: +pass:[move-to-start-of-prev-block]+ * +pass:[]]+: +pass:[move-to-start-of-next-block]+ diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 0a52180e6..b42ee1dac 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -427,13 +427,24 @@ class AbstractZoom(QObject): self._set_factor_internal(self._zoom_factor) +class SelectionState(enum.Enum): + + """Possible states of selection in caret mode. + + NOTE: Names need to line up with SelectionState in caret.js! + """ + + none = 1 + normal = 2 + line = 3 + + class AbstractCaret(QObject): """Attribute ``caret`` of AbstractTab for caret browsing.""" #: Signal emitted when the selection was toggled. - #: (argument - whether the selection is now active) - selection_toggled = pyqtSignal(bool) + selection_toggled = pyqtSignal(SelectionState) #: Emitted when a ``follow_selection`` action is done. follow_selected_done = pyqtSignal() @@ -442,7 +453,6 @@ class AbstractCaret(QObject): parent: QWidget = None) -> None: super().__init__(parent) self._widget = typing.cast(QWidget, None) - self.selection_enabled = False self._mode_manager = mode_manager mode_manager.entered.connect(self._on_mode_entered) mode_manager.left.connect(self._on_mode_left) @@ -499,7 +509,7 @@ class AbstractCaret(QObject): def move_to_end_of_document(self) -> None: raise NotImplementedError - def toggle_selection(self) -> None: + def toggle_selection(self, line: bool = False) -> None: raise NotImplementedError def drop_selection(self) -> None: @@ -825,6 +835,15 @@ class AbstractTabPrivate: def shutdown(self) -> None: raise NotImplementedError + def run_js_sync(self, code: str) -> None: + """Run javascript sync. + + Result will be returned when running JS is complete. + This is only implemented for QtWebKit. + For QtWebEngine, always raises UnsupportedOperationError. + """ + raise NotImplementedError + class AbstractTab(QWidget): diff --git a/qutebrowser/browser/webengine/webenginetab.py b/qutebrowser/browser/webengine/webenginetab.py index 84d35211a..cd305d11a 100644 --- a/qutebrowser/browser/webengine/webenginetab.py +++ b/qutebrowser/browser/webengine/webenginetab.py @@ -395,7 +395,10 @@ class WebEngineCaret(browsertab.AbstractCaret): if enabled is None: log.webview.debug("Ignoring selection status None") return - self.selection_toggled.emit(enabled) + if enabled: + self.selection_toggled.emit(browsertab.SelectionState.normal) + else: + self.selection_toggled.emit(browsertab.SelectionState.none) @pyqtSlot(usertypes.KeyMode) def _on_mode_left(self, mode): @@ -450,8 +453,9 @@ class WebEngineCaret(browsertab.AbstractCaret): def move_to_end_of_document(self): self._js_call('moveToEndOfDocument') - def toggle_selection(self): - self._js_call('toggleSelection', callback=self.selection_toggled.emit) + def toggle_selection(self, line=False): + self._js_call('toggleSelection', line, + callback=self._toggle_sel_translate) def drop_selection(self): self._js_call('dropSelection') @@ -526,6 +530,10 @@ class WebEngineCaret(browsertab.AbstractCaret): code = javascript.assemble('caret', command, *args) self._tab.run_js_async(code, callback) + def _toggle_sel_translate(self, state_str): + state = browsertab.SelectionState[state_str] + self.selection_toggled.emit(state) + class WebEngineScroller(browsertab.AbstractScroller): @@ -1261,6 +1269,9 @@ class WebEngineTabPrivate(browsertab.AbstractTabPrivate): self._tab.action.exit_fullscreen() self._widget.shutdown() + def run_js_sync(self, code): + raise browsertab.UnsupportedOperationError + class WebEngineTab(browsertab.AbstractTab): diff --git a/qutebrowser/browser/webkit/webkittab.py b/qutebrowser/browser/webkit/webkittab.py index 213c7f277..db97af153 100644 --- a/qutebrowser/browser/webkit/webkittab.py +++ b/qutebrowser/browser/webkit/webkittab.py @@ -25,6 +25,7 @@ import xml.etree.ElementTree from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QPoint, QTimer, QSizeF, QSize from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QWidget from PyQt5.QtWebKitWidgets import QWebPage, QWebFrame from PyQt5.QtWebKit import QWebSettings from PyQt5.QtWidgets import QWidget @@ -36,6 +37,7 @@ from qutebrowser.browser.webkit import (webview, tabhistory, webkitelem, from qutebrowser.utils import qtutils, usertypes, utils, log, debug from qutebrowser.keyinput import modeman from qutebrowser.qt import sip +from qutebrowser.keyinput import modeman class WebKitAction(browsertab.AbstractAction): @@ -184,14 +186,18 @@ class WebKitCaret(browsertab.AbstractCaret): parent: QWidget = None) -> None: super().__init__(mode_manager, parent) self._tab = tab + self._selection_state = browsertab.SelectionState.none @pyqtSlot(usertypes.KeyMode) def _on_mode_entered(self, mode): if mode != usertypes.KeyMode.caret: return - self.selection_enabled = self._widget.hasSelection() - self.selection_toggled.emit(self.selection_enabled) + if self._widget.hasSelection(): + self._selection_state = browsertab.SelectionState.normal + else: + self._selection_state = browsertab.SelectionState.none + self.selection_toggled.emit(self._selection_state) settings = self._widget.settings() settings.setAttribute(QWebSettings.CaretBrowsingEnabled, True) @@ -206,7 +212,7 @@ class WebKitCaret(browsertab.AbstractCaret): # # Note: We can't use hasSelection() here, as that's always # true in caret mode. - if not self.selection_enabled: + if self._selection_state is browsertab.SelectionState.none: self._widget.page().currentFrame().evaluateJavaScript( utils.read_file('javascript/position_caret.js')) @@ -214,151 +220,189 @@ class WebKitCaret(browsertab.AbstractCaret): def _on_mode_left(self, _mode): settings = self._widget.settings() if settings.testAttribute(QWebSettings.CaretBrowsingEnabled): - if self.selection_enabled and self._widget.hasSelection(): + if (self._selection_state is not browsertab.SelectionState.none and + self._widget.hasSelection()): # Remove selection if it exists self._widget.triggerPageAction(QWebPage.MoveToNextChar) settings.setAttribute(QWebSettings.CaretBrowsingEnabled, False) - self.selection_enabled = False + self._selection_state = browsertab.SelectionState.none def move_to_next_line(self, count=1): - if not self.selection_enabled: - act = QWebPage.MoveToNextLine - else: + if self._selection_state is not browsertab.SelectionState.none: act = QWebPage.SelectNextLine + else: + act = QWebPage.MoveToNextLine for _ in range(count): self._widget.triggerPageAction(act) + if self._selection_state is browsertab.SelectionState.line: + self._select_line_to_end() def move_to_prev_line(self, count=1): - if not self.selection_enabled: - act = QWebPage.MoveToPreviousLine - else: + if self._selection_state is not browsertab.SelectionState.none: act = QWebPage.SelectPreviousLine + else: + act = QWebPage.MoveToPreviousLine for _ in range(count): self._widget.triggerPageAction(act) + if self._selection_state is browsertab.SelectionState.line: + self._select_line_to_start() def move_to_next_char(self, count=1): - if not self.selection_enabled: - act = QWebPage.MoveToNextChar - else: + if self._selection_state is browsertab.SelectionState.normal: act = QWebPage.SelectNextChar + elif self._selection_state is browsertab.SelectionState.line: + return + else: + act = QWebPage.MoveToNextChar for _ in range(count): self._widget.triggerPageAction(act) def move_to_prev_char(self, count=1): - if not self.selection_enabled: - act = QWebPage.MoveToPreviousChar - else: + if self._selection_state is browsertab.SelectionState.normal: act = QWebPage.SelectPreviousChar + elif self._selection_state is browsertab.SelectionState.line: + return + else: + act = QWebPage.MoveToPreviousChar for _ in range(count): self._widget.triggerPageAction(act) def move_to_end_of_word(self, count=1): - if not self.selection_enabled: - act = [QWebPage.MoveToNextWord] - if utils.is_windows: # pragma: no cover - act.append(QWebPage.MoveToPreviousChar) - else: + if self._selection_state is browsertab.SelectionState.normal: act = [QWebPage.SelectNextWord] if utils.is_windows: # pragma: no cover act.append(QWebPage.SelectPreviousChar) + elif self._selection_state is browsertab.SelectionState.line: + return + else: + act = [QWebPage.MoveToNextWord] + if utils.is_windows: # pragma: no cover + act.append(QWebPage.MoveToPreviousChar) for _ in range(count): for a in act: self._widget.triggerPageAction(a) def move_to_next_word(self, count=1): - if not self.selection_enabled: - act = [QWebPage.MoveToNextWord] - if not utils.is_windows: # pragma: no branch - act.append(QWebPage.MoveToNextChar) - else: + if self._selection_state is browsertab.SelectionState.normal: act = [QWebPage.SelectNextWord] if not utils.is_windows: # pragma: no branch act.append(QWebPage.SelectNextChar) + elif self._selection_state is browsertab.SelectionState.line: + return + else: + act = [QWebPage.MoveToNextWord] + if not utils.is_windows: # pragma: no branch + act.append(QWebPage.MoveToNextChar) for _ in range(count): for a in act: self._widget.triggerPageAction(a) def move_to_prev_word(self, count=1): - if not self.selection_enabled: - act = QWebPage.MoveToPreviousWord - else: + if self._selection_state is browsertab.SelectionState.normal: act = QWebPage.SelectPreviousWord + elif self._selection_state is browsertab.SelectionState.line: + return + else: + act = QWebPage.MoveToPreviousWord for _ in range(count): self._widget.triggerPageAction(act) def move_to_start_of_line(self): - if not self.selection_enabled: - act = QWebPage.MoveToStartOfLine - else: + if self._selection_state is browsertab.SelectionState.normal: act = QWebPage.SelectStartOfLine + elif self._selection_state is browsertab.SelectionState.line: + return + else: + act = QWebPage.MoveToStartOfLine self._widget.triggerPageAction(act) def move_to_end_of_line(self): - if not self.selection_enabled: - act = QWebPage.MoveToEndOfLine - else: + if self._selection_state is browsertab.SelectionState.normal: act = QWebPage.SelectEndOfLine + elif self._selection_state is browsertab.SelectionState.line: + return + else: + act = QWebPage.MoveToEndOfLine self._widget.triggerPageAction(act) def move_to_start_of_next_block(self, count=1): - if not self.selection_enabled: - act = [QWebPage.MoveToNextLine, - QWebPage.MoveToStartOfBlock] - else: + if self._selection_state is not browsertab.SelectionState.none: act = [QWebPage.SelectNextLine, QWebPage.SelectStartOfBlock] + else: + act = [QWebPage.MoveToNextLine, + QWebPage.MoveToStartOfBlock] for _ in range(count): for a in act: self._widget.triggerPageAction(a) + if self._selection_state is browsertab.SelectionState.line: + self._select_line_to_end() def move_to_start_of_prev_block(self, count=1): - if not self.selection_enabled: - act = [QWebPage.MoveToPreviousLine, - QWebPage.MoveToStartOfBlock] - else: + if self._selection_state is not browsertab.SelectionState.none: act = [QWebPage.SelectPreviousLine, QWebPage.SelectStartOfBlock] + else: + act = [QWebPage.MoveToPreviousLine, + QWebPage.MoveToStartOfBlock] for _ in range(count): for a in act: self._widget.triggerPageAction(a) + if self._selection_state is browsertab.SelectionState.line: + self._select_line_to_start() def move_to_end_of_next_block(self, count=1): - if not self.selection_enabled: - act = [QWebPage.MoveToNextLine, - QWebPage.MoveToEndOfBlock] - else: + if self._selection_state is not browsertab.SelectionState.none: act = [QWebPage.SelectNextLine, QWebPage.SelectEndOfBlock] + else: + act = [QWebPage.MoveToNextLine, + QWebPage.MoveToEndOfBlock] for _ in range(count): for a in act: self._widget.triggerPageAction(a) + if self._selection_state is browsertab.SelectionState.line: + self._select_line_to_end() def move_to_end_of_prev_block(self, count=1): - if not self.selection_enabled: - act = [QWebPage.MoveToPreviousLine, QWebPage.MoveToEndOfBlock] - else: + if self._selection_state is not browsertab.SelectionState.none: act = [QWebPage.SelectPreviousLine, QWebPage.SelectEndOfBlock] + else: + act = [QWebPage.MoveToPreviousLine, QWebPage.MoveToEndOfBlock] for _ in range(count): for a in act: self._widget.triggerPageAction(a) + if self._selection_state is browsertab.SelectionState.line: + self._select_line_to_start() def move_to_start_of_document(self): - if not self.selection_enabled: - act = QWebPage.MoveToStartOfDocument - else: + if self._selection_state is not browsertab.SelectionState.none: act = QWebPage.SelectStartOfDocument + else: + act = QWebPage.MoveToStartOfDocument self._widget.triggerPageAction(act) + if self._selection_state is browsertab.SelectionState.line: + self._select_line() def move_to_end_of_document(self): - if not self.selection_enabled: - act = QWebPage.MoveToEndOfDocument - else: + if self._selection_state is not browsertab.SelectionState.none: act = QWebPage.SelectEndOfDocument + else: + act = QWebPage.MoveToEndOfDocument self._widget.triggerPageAction(act) - def toggle_selection(self): - self.selection_enabled = not self.selection_enabled - self.selection_toggled.emit(self.selection_enabled) + def toggle_selection(self, line=False): + if line: + self._selection_state = browsertab.SelectionState.line + self._select_line() + self.reverse_selection() + self._select_line() + self.reverse_selection() + elif self._selection_state is not browsertab.SelectionState.normal: + self._selection_state = browsertab.SelectionState.normal + else: + self._selection_state = browsertab.SelectionState.none + self.selection_toggled.emit(self._selection_state) def drop_selection(self): self._widget.triggerPageAction(QWebPage.MoveToNextChar) @@ -375,6 +419,32 @@ class WebKitCaret(browsertab.AbstractCaret): ); }""") + def _select_line(self): + self._widget.triggerPageAction(QWebPage.SelectStartOfLine) + self.reverse_selection() + self._widget.triggerPageAction(QWebPage.SelectEndOfLine) + self.reverse_selection() + + def _select_line_to_end(self): + # direction of selection (if anchor is to the left or right + # of focus) has to be checked before moving selection + # to the end of line + if self._js_selection_left_to_right(): + self._widget.triggerPageAction(QWebPage.SelectEndOfLine) + + def _select_line_to_start(self): + if not self._js_selection_left_to_right(): + self._widget.triggerPageAction(QWebPage.SelectStartOfLine) + + def _js_selection_left_to_right(self): + """Return True iff the selection's direction is left to right.""" + return self._tab.private_api.run_js_sync(""" + var sel = window.getSelection(); + var position = sel.anchorNode.compareDocumentPosition(sel.focusNode); + (!position && sel.anchorOffset < sel.focusOffset || + position === Node.DOCUMENT_POSITION_FOLLOWING); + """) + def _follow_selected(self, *, tab=False): if QWebSettings.globalSettings().testAttribute( QWebSettings.JavascriptEnabled): @@ -710,6 +780,11 @@ class WebKitTabPrivate(browsertab.AbstractTabPrivate): def shutdown(self): self._widget.shutdown() + def run_js_sync(self, code): + document_element = self._widget.page().mainFrame().documentElement() + result = document_element.evaluateJavaScript(code) + return result + class WebKitTab(browsertab.AbstractTab): @@ -771,8 +846,7 @@ class WebKitTab(browsertab.AbstractTab): def run_js_async(self, code, callback=None, *, world=None): if world is not None and world != usertypes.JsWorld.jseval: log.webview.warning("Ignoring world ID {}".format(world)) - document_element = self._widget.page().mainFrame().documentElement() - result = document_element.evaluateJavaScript(code) + result = self.private_api.run_js_sync(code) if callback is not None: callback(result) diff --git a/qutebrowser/components/caretcommands.py b/qutebrowser/components/caretcommands.py index 173653bd9..966b193de 100644 --- a/qutebrowser/components/caretcommands.py +++ b/qutebrowser/components/caretcommands.py @@ -185,9 +185,13 @@ def move_to_end_of_document(tab: apitypes.Tab) -> None: @cmdutils.register(modes=[cmdutils.KeyMode.caret]) @cmdutils.argument('tab', value=cmdutils.Value.cur_tab) -def toggle_selection(tab: apitypes.Tab) -> None: - """Toggle caret selection mode.""" - tab.caret.toggle_selection() +def toggle_selection(tab: apitypes.Tab, line: bool = False) -> None: + """Toggle caret selection mode. + + Args: + line: Enables line-selection. + """ + tab.caret.toggle_selection(line) @cmdutils.register(modes=[cmdutils.KeyMode.caret]) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index 660bd661c..01e9fe64b 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -3078,6 +3078,7 @@ bindings.default: <Escape>: leave-mode caret: v: toggle-selection + V: toggle-selection --line <Space>: toggle-selection <Ctrl-Space>: drop-selection c: enter-mode normal diff --git a/qutebrowser/javascript/caret.js b/qutebrowser/javascript/caret.js index 55ff6a8b5..d7ba88fe6 100644 --- a/qutebrowser/javascript/caret.js +++ b/qutebrowser/javascript/caret.js @@ -706,6 +706,18 @@ window._qutebrowser.caret = (function() { CaretBrowsing.isCaretVisible = false; /** + * Selection modes. + * NOTE: Values need to line up with SelectionState in browsertab.py! + * + * @type {enum} + */ + CaretBrowsing.SelectionState = { + "NONE": "none", + "NORMAL": "normal", + "LINE": "line", + }; + + /** * The actual caret element, an absolute-positioned flashing line. * @type {Element} */ @@ -887,7 +899,11 @@ window._qutebrowser.caret = (function() { CaretBrowsing.injectCaretStyles(); CaretBrowsing.toggle(); CaretBrowsing.initiated = true; - CaretBrowsing.selectionEnabled = selectionRange > 0; + if (selectionRange > 0) { + CaretBrowsing.selectionState = CaretBrowsing.SelectionState.NORMAL; + } else { + CaretBrowsing.selectionState = CaretBrowsing.SelectionState.NONE; + } }; /** @@ -1145,16 +1161,45 @@ window._qutebrowser.caret = (function() { } }; + CaretBrowsing.reverseSelection = () => { + const sel = window.getSelection(); + sel.setBaseAndExtent( + sel.extentNode, sel.extentOffset, sel.baseNode, + sel.baseOffset + ); + }; + + CaretBrowsing.selectLine = function() { + const sel = window.getSelection(); + sel.modify("extend", "right", "lineboundary"); + CaretBrowsing.reverseSelection(); + sel.modify("extend", "left", "lineboundary"); + CaretBrowsing.reverseSelection(); + }; + + CaretBrowsing.updateLineSelection = function(direction, granularity) { + if (granularity !== "character" && granularity !== "word") { + window. + getSelection(). + modify("extend", direction, granularity); + CaretBrowsing.selectLine(); + } + }; + CaretBrowsing.move = function(direction, granularity, count = 1) { let action = "move"; - if (CaretBrowsing.selectionEnabled) { + if (CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NONE) { action = "extend"; } for (let i = 0; i < count; i++) { - window. - getSelection(). - modify(action, direction, granularity); + if (CaretBrowsing.selectionState === CaretBrowsing.SelectionState.LINE) { + CaretBrowsing.updateLineSelection(direction, granularity); + } else { + window. + getSelection(). + modify(action, direction, granularity); + } } if (CaretBrowsing.isWindows && @@ -1174,7 +1219,7 @@ window._qutebrowser.caret = (function() { CaretBrowsing.moveToBlock = function(paragraph, boundary, count = 1) { let action = "move"; - if (CaretBrowsing.selectionEnabled) { + if (CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NONE) { action = "extend"; } for (let i = 0; i < count; i++) { @@ -1185,6 +1230,10 @@ window._qutebrowser.caret = (function() { window. getSelection(). modify(action, boundary, "paragraphboundary"); + + if (CaretBrowsing.selectionState === CaretBrowsing.SelectionState.LINE) { + CaretBrowsing.selectLine(); + } } }; @@ -1294,14 +1343,14 @@ window._qutebrowser.caret = (function() { funcs.setInitialCursor = () => { if (!CaretBrowsing.initiated) { CaretBrowsing.setInitialCursor(); - return CaretBrowsing.selectionEnabled; + return CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NONE; } if (window.getSelection().toString().length === 0) { positionCaret(); } CaretBrowsing.toggle(); - return CaretBrowsing.selectionEnabled; + return CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NONE; }; funcs.setFlags = (flags) => { @@ -1399,17 +1448,22 @@ window._qutebrowser.caret = (function() { funcs.getSelection = () => window.getSelection().toString(); - funcs.toggleSelection = () => { - CaretBrowsing.selectionEnabled = !CaretBrowsing.selectionEnabled; - return CaretBrowsing.selectionEnabled; + funcs.toggleSelection = (line) => { + if (line) { + CaretBrowsing.selectionState = + CaretBrowsing.SelectionState.LINE; + CaretBrowsing.selectLine(); + CaretBrowsing.finishMove(); + } else if (CaretBrowsing.selectionState !== CaretBrowsing.SelectionState.NORMAL) { + CaretBrowsing.selectionState = CaretBrowsing.SelectionState.NORMAL; + } else { + CaretBrowsing.selectionState = CaretBrowsing.SelectionState.NONE; + } + return CaretBrowsing.selectionState; }; funcs.reverseSelection = () => { - const sel = window.getSelection(); - sel.setBaseAndExtent( - sel.extentNode, sel.extentOffset, sel.baseNode, - sel.baseOffset - ); + CaretBrowsing.reverseSelection(); }; return funcs; diff --git a/qutebrowser/mainwindow/statusbar/bar.py b/qutebrowser/mainwindow/statusbar/bar.py index 119b16584..91bdb0b6e 100644 --- a/qutebrowser/mainwindow/statusbar/bar.py +++ b/qutebrowser/mainwindow/statusbar/bar.py @@ -373,13 +373,17 @@ class StatusBar(QWidget): self.maybe_hide() assert tab.is_private == self._color_flags.private - @pyqtSlot(bool) - def on_caret_selection_toggled(self, selection): + @pyqtSlot(browsertab.SelectionState) + def on_caret_selection_toggled(self, selection_state): """Update the statusbar when entering/leaving caret selection mode.""" - log.statusbar.debug("Setting caret selection {}".format(selection)) - if selection: + log.statusbar.debug("Setting caret selection {}" + .format(selection_state)) + if selection_state is browsertab.SelectionState.normal: self._set_mode_text("caret selection") self._color_flags.caret = ColorFlags.CaretMode.selection + elif selection_state is browsertab.SelectionState.line: + self._set_mode_text("caret line selection") + self._color_flags.caret = ColorFlags.CaretMode.selection else: self._set_mode_text("caret") self._color_flags.caret = ColorFlags.CaretMode.on diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index c1179abbf..25b05a036 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -189,7 +189,7 @@ class TabbedBrowser(QWidget): cur_scroll_perc_changed = pyqtSignal(int, int) cur_load_status_changed = pyqtSignal(usertypes.LoadStatus) cur_fullscreen_requested = pyqtSignal(bool) - cur_caret_selection_toggled = pyqtSignal(bool) + cur_caret_selection_toggled = pyqtSignal(browsertab.SelectionState) close_window = pyqtSignal() resized = pyqtSignal('QRect') current_tab_changed = pyqtSignal(browsertab.AbstractTab) diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index ced27a9f8..60a4f02ba 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -45,11 +45,12 @@ import helpers.stubs as stubsmod from qutebrowser.config import (config, configdata, configtypes, configexc, configfiles, configcache, stylesheet) from qutebrowser.api import config as configapi -from qutebrowser.utils import objreg, standarddir, utils, usertypes +from qutebrowser.utils import objreg, standarddir, utils, usertypes, qtutils from qutebrowser.browser import greasemonkey, history, qutescheme from qutebrowser.browser.webkit import cookies, cache from qutebrowser.misc import savemanager, sql, objects, sessions from qutebrowser.keyinput import modeman +from qutebrowser.qt import sip _qute_scheme_handler = None @@ -64,14 +65,17 @@ class WidgetContainer(QWidget): self._qtbot = qtbot self.vbox = QVBoxLayout(self) qtbot.add_widget(self) + self._widget = None def set_widget(self, widget): self.vbox.addWidget(widget) widget.container = self + self._widget = widget def expose(self): with self._qtbot.waitExposed(self): self.show() + self._widget.setFocus() @pytest.fixture @@ -204,19 +208,23 @@ def web_tab_setup(qtbot, tab_registry, session_manager_stub, @pytest.fixture def webkit_tab(web_tab_setup, qtbot, cookiejar_and_cache, mode_manager, - widget_container, webpage): + widget_container, download_stub, webpage): webkittab = pytest.importorskip('qutebrowser.browser.webkit.webkittab') tab = webkittab.WebKitTab(win_id=0, mode_manager=mode_manager, private=False) widget_container.set_widget(tab) - return tab + yield tab + + # Make sure the tab shuts itself down properly + tab.private_api.shutdown() @pytest.fixture def webengine_tab(web_tab_setup, qtbot, redirect_webengine_data, - tabbed_browser_stubs, mode_manager, widget_container): + tabbed_browser_stubs, mode_manager, widget_container, + monkeypatch): tabwidget = tabbed_browser_stubs[0].widget tabwidget.current_index = 0 tabwidget.index_of = 0 @@ -227,11 +235,25 @@ def webengine_tab(web_tab_setup, qtbot, redirect_webengine_data, tab = webenginetab.WebEngineTab(win_id=0, mode_manager=mode_manager, private=False) widget_container.set_widget(tab) + yield tab + # If a page is still loading here, _on_load_finished could get called # during teardown when session_manager_stub is already deleted. tab.stop() + # Make sure the tab shuts itself down properly + tab.private_api.shutdown() + + # If we wait for the GC to clean things up, there's a segfault inside + # QtWebEngine sometimes (e.g. if we only run + # tests/unit/browser/test_caret.py). + # However, with Qt < 5.12, doing this here will lead to an immediate + # segfault... + monkeypatch.undo() # version_check could be patched + if qtutils.version_check('5.12'): + sip.delete(tab._widget) + @pytest.fixture(params=['webkit', 'webengine']) def web_tab(request): diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index b72907d93..23fe7ac1d 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -615,6 +615,10 @@ class FakeDownloadManager: self.downloads.append(download_item) return download_item + def has_downloads_with_nam(self, _nam): + """Needed during WebView.shutdown().""" + return False + class FakeHistoryProgress: diff --git a/tests/unit/browser/test_caret.py b/tests/unit/browser/test_caret.py index 9b817c4ac..7d1325612 100644 --- a/tests/unit/browser/test_caret.py +++ b/tests/unit/browser/test_caret.py @@ -24,15 +24,19 @@ import textwrap import pytest from PyQt5.QtCore import QUrl -from qutebrowser.utils import utils, qtutils, usertypes +from qutebrowser.utils import usertypes +from qutebrowser.browser import browsertab @pytest.fixture def caret(web_tab, qtbot, mode_manager): - with qtbot.wait_signal(web_tab.load_finished): + web_tab.container.expose() + + with qtbot.wait_signal(web_tab.load_finished, timeout=10000): web_tab.load_url(QUrl('qute://testdata/data/caret.html')) - mode_manager.enter(usertypes.KeyMode.caret) + with qtbot.wait_signal(web_tab.caret.selection_toggled): + mode_manager.enter(usertypes.KeyMode.caret) return web_tab.caret @@ -61,15 +65,21 @@ class Selection: selection = selection.strip() assert selection == expected return + elif not selection and not expected: + return self._qtbot.wait(50) + assert False, 'Failed to get selection!' + def check_multiline(self, expected, *, strip=False): self.check(textwrap.dedent(expected).strip(), strip=strip) - def toggle(self): - with self._qtbot.wait_signal(self._caret.selection_toggled): - self._caret.toggle_selection() + def toggle(self, *, line=False): + """Toggle the selection and return the new selection state.""" + with self._qtbot.wait_signal(self._caret.selection_toggled) as blocker: + self._caret.toggle_selection(line=line) + return blocker.args[0] @pytest.fixture @@ -77,6 +87,18 @@ def selection(qtbot, caret): return Selection(qtbot, caret) +def test_toggle(caret, selection, qtbot): + """Make sure calling toggleSelection produces the correct callback values. + + This also makes sure that the SelectionState enum in JS lines up with the + Python browsertab.SelectionState enum. + """ + assert selection.toggle() == browsertab.SelectionState.normal + assert selection.toggle(line=True) == browsertab.SelectionState.line + assert selection.toggle() == browsertab.SelectionState.normal + assert selection.toggle() == browsertab.SelectionState.none + + class TestDocument: def test_selecting_entire_document(self, caret, selection): @@ -287,17 +309,6 @@ def test_drop_selection(caret, selection): class TestSearch: - @pytest.fixture(autouse=True) - def expose(self, web_tab): - """Expose the web view if needed. - - With QtWebEngine 5.13 on macOS/Windows, searching fails (callback - called with False) when the view isn't exposed. - """ - if qtutils.version_check('5.13') and not utils.is_linux: - web_tab.container.expose() - web_tab.show() - # https://bugreports.qt.io/browse/QTBUG-60673 @pytest.mark.qtbug60673 @@ -340,15 +351,6 @@ class TestFollowSelected: def toggle_js(self, request, config_stub): config_stub.val.content.javascript.enabled = request.param - @pytest.fixture(autouse=True) - def expose(self, web_tab): - """Expose the web view if needed. - - On QtWebKit, or Qt < 5.11 and > 5.12 on QtWebEngine, we need to - show the tab for selections to work properly. - """ - web_tab.container.expose() - def test_follow_selected_without_a_selection(self, qtbot, caret, selection, web_tab, mode_manager): caret.move_to_next_word() # Move cursor away from the link @@ -405,3 +407,93 @@ class TestReverse: caret.reverse_selection() caret.move_to_start_of_line() selection.check("one two three") + + +class TestLineSelection: + + def test_toggle(self, caret, selection): + selection.toggle(line=True) + selection.check("one two three") + + def test_toggle_untoggle(self, caret, selection): + selection.toggle() + selection.check("") + selection.toggle(line=True) + selection.check("one two three") + selection.toggle() + selection.check("one two three") + + def test_from_center(self, caret, selection): + caret.move_to_next_char(4) + selection.toggle(line=True) + selection.check("one two three") + + def test_more_lines(self, caret, selection): + selection.toggle(line=True) + caret.move_to_next_line(2) + selection.check_multiline(""" + one two three + eins zwei drei + + four five six + """, strip=True) + + def test_not_selecting_char(self, caret, selection): + selection.toggle(line=True) + caret.move_to_next_char() + selection.check("one two three") + caret.move_to_prev_char() + selection.check("one two three") + + def test_selecting_prev_next_word(self, caret, selection): + selection.toggle(line=True) + caret.move_to_next_word() + selection.check("one two three") + caret.move_to_prev_word() + selection.check("one two three") + + def test_selecting_end_word(self, caret, selection): + selection.toggle(line=True) + caret.move_to_end_of_word() + selection.check("one two three") + + def test_selecting_prev_next_line(self, caret, selection): + selection.toggle(line=True) + caret.move_to_next_line() + selection.check_multiline(""" + one two three + eins zwei drei + """, strip=True) + caret.move_to_prev_line() + selection.check("one two three") + + def test_not_selecting_start_end_line(self, caret, selection): + selection.toggle(line=True) + caret.move_to_end_of_line() + selection.check("one two three") + caret.move_to_start_of_line() + selection.check("one two three") + + def test_selecting_block(self, caret, selection): + selection.toggle(line=True) + caret.move_to_end_of_next_block() + selection.check_multiline(""" + one two three + eins zwei drei + """, strip=True) + + @pytest.mark.not_mac( + reason='https://github.com/qutebrowser/qutebrowser/issues/5459') + def test_selecting_start_end_document(self, caret, selection): + selection.toggle(line=True) + caret.move_to_end_of_document() + selection.check_multiline(""" + one two three + eins zwei drei + + four five six + vier fünf sechs + """, strip=True) + + caret.move_to_start_of_document() + selection.check("one two three") |