diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 2dafe1e88..2e8f071a3 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -23,6 +23,10 @@ Added - New `:edit-url` command to edit the URL in an external editor. - New `network -> custom-headers` setting to send custom headers with every request. - New `{url:pretty}` commandline replacement which gets replaced by the decoded URL. +- New marks to remember a scroll position: + - New `:jump-mark` command to jump to a mark, bound to `'` + - New `:set-mark` command to set a mark, bound to ```(backtick) + - The `'` mark gets set when moving away (hinting link with anchor, searching, etc.) so you can move back with `''` Changed ~~~~~~~ diff --git a/README.asciidoc b/README.asciidoc index d9912aa3d..f02f71a73 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -158,6 +158,7 @@ Contributors, sorted by the number of commits in descending order: * Joel Torstensson * Patric Schmitz * Claude +* Ryan Roden-Corrent * meles5 * Tarcisio Fedrizzi * Artur Shaik @@ -183,7 +184,6 @@ Contributors, sorted by the number of commits in descending order: * Zach-Button * Halfwit * rikn00 -* Ryan Roden-Corrent * Michael Ilsaas * Martin Zimmermann * Brian Jackson diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index ab8ad9796..10dda0517 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -29,6 +29,7 @@ |<>|Open main startpage in current tab. |<>|Toggle the web inspector. |<>|Evaluate a JavaScript string. +|<>|Jump to the mark named by `key`. |<>|Execute a command after some time. |<>|Open typical prev/next links or navigate using the URL path. |<>|Open a URL in the current/[count]th tab. @@ -50,6 +51,7 @@ |<>|Save a session. |<>|Set an option. |<>|Preset the statusbar to some text. +|<>|Set a mark at the current scroll position in the current tab. |<>|Spawn a command in a shell. |<>|Stop loading in the current/[count]th tab. |<>|Duplicate the current tab. @@ -377,6 +379,15 @@ Evaluate a JavaScript string. * This command does not split arguments after the last argument and handles quotes literally. * With this command, +;;+ is interpreted literally instead of splitting off a second command. +[[jump-mark]] +=== jump-mark +Syntax: +:jump-mark 'key'+ + +Jump to the mark named by `key`. + +==== positional arguments +* +'key'+: mark identifier; capital indicates a global mark + [[later]] === later Syntax: +:later 'ms' 'command'+ @@ -649,6 +660,15 @@ Preset the statusbar to some text. * This command does not split arguments after the last argument and handles quotes literally. * With this command, +;;+ is interpreted literally instead of splitting off a second command. +[[set-mark]] +=== set-mark +Syntax: +:set-mark 'key'+ + +Set a mark at the current scroll position in the current tab. + +==== positional arguments +* +'key'+: mark identifier; capital indicates a global mark + [[spawn]] === spawn Syntax: +:spawn [*--userscript*] [*--verbose*] [*--detach*] 'cmdline'+ @@ -1244,7 +1264,7 @@ Scroll the current tab by 'count * dx/dy' pixels. ==== positional arguments * +'dx'+: How much to scroll in x-direction. -* +'dy'+: How much to scroll in x-direction. +* +'dy'+: How much to scroll in y-direction. ==== count multiplier diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index dbd06d2b3..36e8f5781 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -472,6 +472,9 @@ class CommandDispatcher: bg: Open in a background tab. window: Open in a new window. """ + # save the pre-jump position in the special ' mark + self.set_mark("'") + cmdutils.check_exclusive((tab, bg, window), 'tbw') widget = self._current_widget() frame = widget.page().currentFrame() @@ -500,7 +503,7 @@ class CommandDispatcher: Args: dx: How much to scroll in x-direction. - dy: How much to scroll in x-direction. + dy: How much to scroll in y-direction. count: multiplier """ dx *= count @@ -582,6 +585,9 @@ class CommandDispatcher: horizontal: Scroll horizontally instead of vertically. count: Percentage to scroll. """ + # save the pre-jump position in the special ' mark + self.set_mark("'") + if perc is None and count is None: perc = 100 elif perc is None: @@ -1414,6 +1420,7 @@ class CommandDispatcher: text: The text to search for. reverse: Reverse search direction. """ + self.set_mark("'") view = self._current_widget() self._clear_search(view, text) flags = 0 @@ -1444,6 +1451,7 @@ class CommandDispatcher: Args: count: How many elements to ignore. """ + self.set_mark("'") view = self._current_widget() self._clear_search(view, self._tabbed_browser.search_text) @@ -1464,6 +1472,7 @@ class CommandDispatcher: Args: count: How many elements to ignore. """ + self.set_mark("'") view = self._current_widget() self._clear_search(view, self._tabbed_browser.search_text) @@ -1882,3 +1891,21 @@ class CommandDispatcher: self.openurl, bg=bg, tab=tab, window=window, count=count)) ed.edit(url or self._current_url().toString()) + + @cmdutils.register(instance='command-dispatcher', scope='window') + def set_mark(self, key): + """Set a mark at the current scroll position in the current tab. + + Args: + key: mark identifier; capital indicates a global mark + """ + self._tabbed_browser.set_mark(key) + + @cmdutils.register(instance='command-dispatcher', scope='window') + def jump_mark(self, key): + """Jump to the mark named by `key`. + + Args: + key: mark identifier; capital indicates a global mark + """ + self._tabbed_browser.jump_mark(key) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 3b71c0266..9ec14388f 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -466,6 +466,13 @@ class HintManager(QObject): QMouseEvent(QEvent.MouseButtonRelease, pos, Qt.LeftButton, Qt.NoButton, modifiers), ] + + if context.target in [Target.normal, Target.current]: + # Set the pre-jump mark ', so we can jump back here after following + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=self._win_id) + tabbed_browser.set_mark("'") + if context.target == Target.current: elem.remove_blank_target() for evt in events: diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index dcdc27520..d89ed2a4c 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -1442,6 +1442,8 @@ KEY_DATA = collections.OrderedDict([ ('search-prev', ['N']), ('enter-mode insert', ['i']), ('enter-mode caret', ['v']), + ('enter-mode set_mark', ['`']), + ('enter-mode jump_mark', ["'"]), ('yank', ['yy']), ('yank -s', ['yY']), ('yank -t', ['yt']), diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 5402bce77..f2b87eea6 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -67,9 +67,13 @@ class BaseKeyParser(QObject): Signals: keystring_updated: Emitted when the keystring is updated. arg: New keystring. + request_leave: Emitted to request leaving a mode. + arg 0: Mode to leave. + arg 1: Reason for leaving. """ keystring_updated = pyqtSignal(str) + request_leave = pyqtSignal(usertypes.KeyMode, str) do_log = True passthrough = False diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index fb948ec18..5a581b97e 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -21,13 +21,13 @@ import functools -from PyQt5.QtCore import pyqtSignal, Qt, QObject, QEvent +from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QObject, QEvent from PyQt5.QtWidgets import QApplication from qutebrowser.keyinput import modeparsers, keyparser from qutebrowser.config import config from qutebrowser.commands import cmdexc, cmdutils -from qutebrowser.utils import usertypes, log, objreg, utils +from qutebrowser.utils import usertypes, log, objreg, utils, usertypes class KeyEvent: @@ -78,6 +78,8 @@ def init(win_id, parent): warn=False), KM.yesno: modeparsers.PromptKeyParser(win_id, modeman), KM.caret: modeparsers.CaretKeyParser(win_id, modeman), + KM.set_mark: modeparsers.MarkKeyParser(win_id, KM.set_mark, modeman), + KM.jump_mark: modeparsers.MarkKeyParser(win_id, KM.jump_mark, modeman), } objreg.register('keyparsers', keyparsers, scope='window', window=win_id) modeman.destroyed.connect( @@ -223,6 +225,7 @@ class ModeManager(QObject): assert isinstance(mode, usertypes.KeyMode) assert parser is not None self._parsers[mode] = parser + parser.request_leave.connect(self.leave) def enter(self, mode, reason=None, only_if_normal=False): """Enter a new mode. @@ -268,11 +271,12 @@ class ModeManager(QObject): raise cmdexc.CommandError("Mode {} does not exist!".format(mode)) self.enter(m, 'command') + @pyqtSlot(usertypes.KeyMode, str) def leave(self, mode, reason=None): """Leave a key mode. Args: - mode: The name of the mode to leave. + mode: The mode to leave as a usertypes.KeyMode member. reason: Why the mode was left. """ if self.mode != mode: diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 7ad622114..f8c54d79a 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -230,3 +230,55 @@ class CaretKeyParser(keyparser.CommandKeyParser): super().__init__(win_id, parent, supports_count=True, supports_chains=True) self.read_config('caret') + + +class MarkKeyParser(keyparser.BaseKeyParser): + + """KeyParser for set_mark and jump_mark mode. + + Attributes: + _mode: Either KeyMode.set_mark or KeyMode.jump_mark. + """ + + def __init__(self, win_id, mode, parent=None): + super().__init__(win_id, parent, supports_count=False, + supports_chains=False) + self._mode = mode + + def handle(self, e): + """Override handle to always match the next key and create a mark. + + Args: + e: the KeyPressEvent from Qt. + + Return: + True if event has been handled, False otherwise. + """ + if utils.keyevent_to_string(e) is None: + # this is a modifier key, let it pass and keep going + return False + + key = e.text() + + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=self._win_id) + + if self._mode == usertypes.KeyMode.set_mark: + tabbed_browser.set_mark(key) + elif self._mode == usertypes.KeyMode.jump_mark: + tabbed_browser.jump_mark(key) + else: + raise ValueError("{} is not a valid mark mode".format(self._mode)) + + self.request_leave.emit(self._mode, "valid mark key") + + return True + + @pyqtSlot(str) + def on_keyconfig_changed(self, mode): + """MarkKeyParser has no config section (no bindable keys).""" + pass + + def execute(self, cmdstr, _keytype, count=None): + """Should never be called on MarkKeyParser.""" + assert False diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 7c6c4e9c8..fb5e86db5 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -30,7 +30,8 @@ from qutebrowser.config import config from qutebrowser.keyinput import modeman from qutebrowser.mainwindow import tabwidget from qutebrowser.browser import signalfilter, webview -from qutebrowser.utils import log, usertypes, utils, qtutils, objreg, urlutils +from qutebrowser.utils import (log, usertypes, utils, qtutils, objreg, + urlutils, message) UndoEntry = collections.namedtuple('UndoEntry', ['url', 'history']) @@ -64,6 +65,8 @@ class TabbedBrowser(tabwidget.TabWidget): _tab_insert_idx_right: Same as above, for 'right'. _undo_stack: List of UndoEntry namedtuples of closed tabs. shutting_down: Whether we're currently shutting down. + _local_marks: Jump markers local to each page + _global_marks: Jump markers used across all pages Signals: cur_progress: Progress of the current tab changed (loadProgress). @@ -114,6 +117,8 @@ class TabbedBrowser(tabwidget.TabWidget): self._now_focused = None self.search_text = None self.search_flags = 0 + self._local_marks = {} + self._global_marks = {} objreg.get('config').changed.connect(self.update_favicons) objreg.get('config').changed.connect(self.update_window_title) objreg.get('config').changed.connect(self.update_tab_titles) @@ -637,3 +642,53 @@ class TabbedBrowser(tabwidget.TabWidget): self._now_focused.wheelEvent(e) else: e.ignore() + + def set_mark(self, key): + """Set a mark at the current scroll position in the current tab. + + Args: + key: mark identifier; capital indicates a global mark + """ + # strip the fragment as it may interfere with scrolling + url = self.current_url().adjusted(QUrl.RemoveFragment) + point = self.currentWidget().page().currentFrame().scrollPosition() + + if key.isupper(): + self._global_marks[key] = point, url + else: + if url not in self._local_marks: + self._local_marks[url] = {} + self._local_marks[url][key] = point + + def jump_mark(self, key): + """Jump to the mark named by `key`. + + Args: + key: mark identifier; capital indicates a global mark + """ + # consider urls that differ only in fragment to be identical + urlkey = self.current_url().adjusted(QUrl.RemoveFragment) + frame = self.currentWidget().page().currentFrame() + + if key.isupper() and key in self._global_marks: + point, url = self._global_marks[key] + + @pyqtSlot(bool) + def callback(ok): + if ok: + self.cur_load_finished.disconnect(callback) + frame.setScrollPosition(point) + + self.openurl(url, newtab=False) + self.cur_load_finished.connect(callback) + elif urlkey in self._local_marks and key in self._local_marks[urlkey]: + point = self._local_marks[urlkey][key] + + # save the pre-jump position in the special ' mark + # this has to happen after we read the mark, otherwise jump_mark + # "'" would just jump to the current position every time + self.set_mark("'") + + frame.setScrollPosition(point) + else: + message.error(self._win_id, "Mark {} is not set".format(key)) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 5e29ee535..0af42b291 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -231,7 +231,8 @@ ClickTarget = enum('ClickTarget', ['normal', 'tab', 'tab_bg', 'window']) # Key input modes KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt', - 'insert', 'passthrough', 'caret']) + 'insert', 'passthrough', 'caret', 'set_mark', + 'jump_mark']) # Available command completions diff --git a/tests/integration/data/marks.html b/tests/integration/data/marks.html new file mode 100644 index 000000000..574e2cdd1 --- /dev/null +++ b/tests/integration/data/marks.html @@ -0,0 +1,16 @@ + + + + + Marks I + + +

Top

+ Top + Bottom +
Holy Grail
+
Waldo
+
Holy Grail
+

Bottom

+ + diff --git a/tests/integration/features/marks.feature b/tests/integration/features/marks.feature new file mode 100644 index 000000000..3a35791b8 --- /dev/null +++ b/tests/integration/features/marks.feature @@ -0,0 +1,91 @@ +Feature: Setting positional marks + + Background: + Given I open data/marks.html + And I run :tab-only + + ## :set-mark, :jump-mark + + Scenario: Setting and jumping to a local mark + When I run :scroll-px 5 10 + And I run :set-mark a + And I run :scroll-px 0 20 + And I run :jump-mark a + Then the page should be scrolled to 5 10 + + Scenario: Jumping back after jumping to a particular percentage + When I run :scroll-px 10 20 + And I run :scroll-perc 100 + And I run :jump-mark "'" + Then the page should be scrolled to 10 20 + + Scenario: Setting the same local mark on another page + When I run :scroll-px 5 10 + And I run :set-mark a + And I open data/marks.html + And I run :scroll-px 0 20 + And I run :set-mark a + And I run :jump-mark a + Then the page should be scrolled to 0 20 + + Scenario: Jumping to a local mark after returning to a page + When I run :scroll-px 5 10 + And I run :set-mark a + And I open data/numbers/1.txt + And I run :set-mark a + And I open data/marks.html + And I run :jump-mark a + Then the page should be scrolled to 5 10 + + Scenario: Setting and jumping to a global mark + When I run :scroll-px 5 20 + And I run :set-mark A + And I open data/numbers/1.txt + And I run :jump-mark A + Then data/marks.html should be loaded + And the page should be scrolled to 5 20 + + Scenario: Jumping to an unset mark + When I run :jump-mark b + Then the error "Mark b is not set" should be shown + + Scenario: Jumping to a local mark that was set on another page + When I run :set-mark b + And I open data/numbers/1.txt + And I run :jump-mark b + Then the error "Mark b is not set" should be shown + + Scenario: Jumping to a local mark after changing fragments + When I open data/marks.html#top + And I run :scroll 'top' + And I run :scroll-px 10 10 + And I run :set-mark a + When I open data/marks.html#bottom + And I run :jump-mark a + Then the page should be scrolled to 10 10 + + Scenario: Jumping back after following a link + When I run :hint links normal + And I run :follow-hint s + And I run :jump-mark "'" + Then the page should be scrolled to 0 0 + + Scenario: Jumping back after searching + When I run :scroll-px 20 15 + And I run :search Waldo + And I run :jump-mark "'" + Then the page should be scrolled to 20 15 + + Scenario: Jumping back after search-next + When I run :search Grail + And I run :search-next + And I run :jump-mark "'" + Then the page should be scrolled to 0 0 + + Scenario: Hovering a hint does not set the ' mark + When I run :scroll-px 30 20 + And I run :scroll-perc 0 + And I run :hint links hover + And I run :follow-hint s + And I run :jump-mark "'" + Then the page should be scrolled to 30 20 diff --git a/tests/integration/features/test_marks.py b/tests/integration/features/test_marks.py new file mode 100644 index 000000000..b2777cbe4 --- /dev/null +++ b/tests/integration/features/test_marks.py @@ -0,0 +1,29 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +import pytest_bdd as bdd +bdd.scenarios('marks.feature') + + +@bdd.then(bdd.parsers.parse("the page should be scrolled to {x} {y}")) +def check_y(quteproc, x, y): + data = quteproc.get_session() + pos = data['windows'][0]['tabs'][0]['history'][-1]['scroll-pos'] + assert int(x) == pos['x'] + assert int(y) == pos['y'] diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index d0569f7de..b2cd90963 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -22,14 +22,17 @@ import pytest from qutebrowser.keyinput import modeman as modeman_module from qutebrowser.utils import usertypes -from PyQt5.QtCore import Qt +from PyQt5.QtCore import Qt, QObject, pyqtSignal -class FakeKeyparser: +class FakeKeyparser(QObject): """A fake BaseKeyParser which doesn't handle anything.""" + request_leave = pyqtSignal(usertypes.KeyMode, str) + def __init__(self): + super().__init__() self.passthrough = False def handle(self, evt):