From 7aaaadac1a1710c5cc84f16c77d9a43f3cee664f Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Thu, 6 Oct 2016 22:24:04 +0200 Subject: [PATCH 01/18] Add keyboard macros --- doc/help/commands.asciidoc | 29 +++++++++++++++++++++ qutebrowser/commands/runners.py | 17 ++++++++++--- qutebrowser/config/configdata.py | 2 ++ qutebrowser/misc/utilcmds.py | 39 +++++++++++++++++++++++++++++ tests/end2end/features/misc.feature | 22 ++++++++++++++++ 5 files changed, 105 insertions(+), 4 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 8cc5184f8..0eee3d918 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -59,10 +59,12 @@ It is possible to run or bind multiple commands by separating them with `;;`. |<>|Load a quickmark. |<>|Save the current page as a quickmark. |<>|Quit qutebrowser. +|<>|Start or stop recording a macro. |<>|Reload the current/[count]th tab. |<>|Repeat a given command. |<>|Report a bug in qutebrowser. |<>|Restart qutebrowser while keeping existing tabs open. +|<>|Run a recorded macro. |<>|Save configs and state. |<>|Search for a text on the current page. With no text, clear results. |<>|Delete a session. @@ -602,6 +604,18 @@ Save the current page as a quickmark. === quit Quit qutebrowser. +[[record-macro]] +=== record-macro +Syntax: +:record-macro ['name']+ + +Start or stop recording a macro. + +==== positional arguments +* +'name'+: Which name to give the macro. + +==== note +* This command does not split arguments after the last argument and handles quotes literally. + [[reload]] === reload Syntax: +:reload [*--force*]+ @@ -637,6 +651,21 @@ Report a bug in qutebrowser. === restart Restart qutebrowser while keeping existing tabs open. +[[run-macro]] +=== run-macro +Syntax: +:run-macro ['name']+ + +Run a recorded macro. + +==== positional arguments +* +'name'+: Which macro to run. + +==== count +How many times to run the macro. + +==== note +* This command does not split arguments after the last argument and handles quotes literally. + [[save]] === save Syntax: +:save ['what' ['what' ...]]+ diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 3d74fc266..e83ca7dbb 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -27,13 +27,15 @@ from PyQt5.QtCore import pyqtSlot, QUrl, QObject from qutebrowser.config import config, configexc from qutebrowser.commands import cmdexc, cmdutils -from qutebrowser.utils import message, objreg, qtutils, utils +from qutebrowser.utils import message, objreg, qtutils, usertypes, utils from qutebrowser.misc import split ParseResult = collections.namedtuple('ParseResult', ['cmd', 'args', 'cmdline', 'count']) last_command = {} +macro = {} +recording_macro = None def _current_url(tabbed_browser): @@ -301,10 +303,17 @@ class CommandRunner(QObject): else: result.cmd.run(self._win_id, args) + parsed_command = (self._parse_count(text)[1], + count if count is not None else result.count) + if result.cmdline[0] != 'repeat-command': - last_command[cur_mode] = ( - self._parse_count(text)[1], - count if count is not None else result.count) + last_command[cur_mode] = parsed_command + + if (recording_macro is not None and + cur_mode == usertypes.KeyMode.normal and + result.cmdline[0] not in ['record-macro', 'run-macro', + 'set-cmd-text']): + macro[recording_macro].append(parsed_command) @pyqtSlot(str, int) @pyqtSlot(str) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 3b62f3183..672382ae9 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -1634,6 +1634,8 @@ KEY_DATA = collections.OrderedDict([ ('follow-selected', RETURN_KEYS), ('follow-selected -t', ['', '']), ('repeat-command', ['.']), + ('record-macro', ['q']), + ('run-macro', ['@']), ])), ('insert', collections.OrderedDict([ diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 2447afd53..743ba169c 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -234,6 +234,45 @@ def repeat_command(win_id, count=None): commandrunner.run(cmd[0], count if count is not None else cmd[1]) +@cmdutils.register(maxsplit=0) +@cmdutils.argument('name') +def record_macro(name=""): + """Start or stop recording a macro. + + Args: + name: Which name to give the macro. + """ + if runners.recording_macro is None: + message.info("Defining macro...") + runners.macro[name] = [] + runners.recording_macro = name + elif runners.recording_macro == name: + message.info("Macro defined.") + runners.recording_macro = None + else: + raise cmdexc.CommandError( + "Already recording macro '{}'".format(runners.recording_macro)) + + +@cmdutils.register(maxsplit=0) +@cmdutils.argument('win_id', win_id=True) +@cmdutils.argument('count', count=True) +@cmdutils.argument('name') +def run_macro(win_id, count=1, name=""): + """Run a recorded macro. + + Args: + count: How many times to run the macro. + name: Which macro to run. + """ + if name not in runners.macro: + raise cmdexc.CommandError("No macro defined!") + commandrunner = runners.CommandRunner(win_id) + for _ in range(count): + for cmd in runners.macro[name]: + commandrunner.run(*cmd) + + @cmdutils.register(debug=True, name='debug-log-capacity') def log_capacity(capacity: int): """Change the number of log lines to be stored in RAM. diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index c24f0ea9f..62254f866 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -622,6 +622,28 @@ Feature: Various utility commands. history: - url: http://localhost:*/data/hello3.txt + Scenario: Recording a simple macro + Given I open data/scroll/simple.html + And I run :tab-only + When I run :scroll down with count 5 + And I run :record-macro + And I run :scroll up + And I run :scroll up + And I run :record-macro + And I run :run-macro with count 2 + Then the page should not be scrolled + + Scenario: Recording a named macro + Given I open data/scroll/simple.html + And I run :tab-only + When I run :scroll down with count 5 + And I run :record-macro foo + And I run :scroll up + And I run :scroll up + And I run :record-macro foo + And I run :run-macro foo with count 2 + Then the page should not be scrolled + ## Variables Scenario: {url} as part of an argument From 33ff0ba715961454da244fd356023a5dae527cc9 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Sat, 8 Oct 2016 21:17:47 +0200 Subject: [PATCH 02/18] Interactively get macro register --- doc/help/commands.asciidoc | 14 ++----- qutebrowser/commands/runners.py | 2 + qutebrowser/keyinput/modeman.py | 6 ++- qutebrowser/keyinput/modeparsers.py | 22 ++++++---- qutebrowser/misc/utilcmds.py | 55 ++++++++++++++++--------- qutebrowser/utils/usertypes.py | 2 +- tests/end2end/features/keyinput.feature | 8 ++-- tests/end2end/features/misc.feature | 6 ++- 8 files changed, 68 insertions(+), 47 deletions(-) diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 0eee3d918..5d7cdaf87 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -606,15 +606,12 @@ Quit qutebrowser. [[record-macro]] === record-macro -Syntax: +:record-macro ['name']+ +Syntax: +:record-macro ['register']+ Start or stop recording a macro. ==== positional arguments -* +'name'+: Which name to give the macro. - -==== note -* This command does not split arguments after the last argument and handles quotes literally. +* +'register'+: Which register to store the macro in. [[reload]] === reload @@ -653,19 +650,16 @@ Restart qutebrowser while keeping existing tabs open. [[run-macro]] === run-macro -Syntax: +:run-macro ['name']+ +Syntax: +:run-macro ['register']+ Run a recorded macro. ==== positional arguments -* +'name'+: Which macro to run. +* +'register'+: Which macro to run. ==== count How many times to run the macro. -==== note -* This command does not split arguments after the last argument and handles quotes literally. - [[save]] === save Syntax: +:save ['what' ['what' ...]]+ diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index e83ca7dbb..35464cb9f 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -34,8 +34,10 @@ from qutebrowser.misc import split ParseResult = collections.namedtuple('ParseResult', ['cmd', 'args', 'cmdline', 'count']) last_command = {} + macro = {} recording_macro = None +macro_count = {} def _current_url(tabbed_browser): diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 71a83b9a3..e66e9711c 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -78,8 +78,10 @@ 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), + KM.set_mark: modeparsers.RegisterKeyParser(win_id, KM.set_mark, modeman), + KM.jump_mark: modeparsers.RegisterKeyParser(win_id, KM.jump_mark, modeman), + KM.record_macro: modeparsers.RegisterKeyParser(win_id, KM.record_macro, modeman), + KM.run_macro: modeparsers.RegisterKeyParser(win_id, KM.run_macro, modeman), } objreg.register('keyparsers', keyparsers, scope='window', window=win_id) modeman.destroyed.connect( diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index aa788ddc3..c27f4fc73 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -27,6 +27,7 @@ from PyQt5.QtCore import pyqtSlot, Qt from qutebrowser.config import config from qutebrowser.keyinput import keyparser +from qutebrowser.misc import utilcmds from qutebrowser.utils import usertypes, log, objreg, utils @@ -264,12 +265,13 @@ class CaretKeyParser(keyparser.CommandKeyParser): self.read_config('caret') -class MarkKeyParser(keyparser.BaseKeyParser): +class RegisterKeyParser(keyparser.BaseKeyParser): - """KeyParser for set_mark and jump_mark mode. + """KeyParser for modes that record a register key. Attributes: - _mode: Either KeyMode.set_mark or KeyMode.jump_mark. + _mode: One of KeyMode.set_mark, KeyMode.jump_mark, KeyMode.record_macro + and KeyMode.run_macro. """ def __init__(self, win_id, mode, parent=None): @@ -278,7 +280,7 @@ class MarkKeyParser(keyparser.BaseKeyParser): self._mode = mode def handle(self, e): - """Override handle to always match the next key and create a mark. + """Override handle to always match the next key and use the register. Args: e: the KeyPressEvent from Qt. @@ -299,18 +301,22 @@ class MarkKeyParser(keyparser.BaseKeyParser): tabbed_browser.set_mark(key) elif self._mode == usertypes.KeyMode.jump_mark: tabbed_browser.jump_mark(key) + elif self._mode == usertypes.KeyMode.record_macro: + utilcmds.record_macro(key) + elif self._mode == usertypes.KeyMode.run_macro: + utilcmds.run_macro(self._win_id, key) else: - raise ValueError("{} is not a valid mark mode".format(self._mode)) + raise ValueError("{} is not a valid register key".format(self._mode)) - self.request_leave.emit(self._mode, "valid mark key") + self.request_leave.emit(self._mode, "valid register key") return True @pyqtSlot(str) def on_keyconfig_changed(self, mode): - """MarkKeyParser has no config section (no bindable keys).""" + """RegisterKeyParser has no config section (no bindable keys).""" pass def execute(self, cmdstr, _keytype, count=None): - """Should never be called on MarkKeyParser.""" + """Should never be called on RegisterKeyParser.""" assert False diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 743ba169c..cb984d73d 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -234,42 +234,57 @@ def repeat_command(win_id, count=None): commandrunner.run(cmd[0], count if count is not None else cmd[1]) -@cmdutils.register(maxsplit=0) -@cmdutils.argument('name') -def record_macro(name=""): +@cmdutils.register(instance='mode-manager', name='record-macro', scope='window') +@cmdutils.argument('register') +def record_macro_command(self, register=None): """Start or stop recording a macro. Args: - name: Which name to give the macro. + register: Which register to store the macro in. """ if runners.recording_macro is None: - message.info("Defining macro...") - runners.macro[name] = [] - runners.recording_macro = name - elif runners.recording_macro == name: - message.info("Macro defined.") - runners.recording_macro = None + if register is None: + self.enter(usertypes.KeyMode.record_macro, 'record_macro') + else: + record_macro(register) else: - raise cmdexc.CommandError( - "Already recording macro '{}'".format(runners.recording_macro)) + message.info("Macro recorded.") + runners.recording_macro = None -@cmdutils.register(maxsplit=0) +def record_macro(register): + """Start recording a macro.""" + message.info("Recording macro...") + runners.macro[register] = [] + runners.recording_macro = register + + +@cmdutils.register(instance='mode-manager', name='run-macro', scope='window') @cmdutils.argument('win_id', win_id=True) @cmdutils.argument('count', count=True) -@cmdutils.argument('name') -def run_macro(win_id, count=1, name=""): +@cmdutils.argument('register') +def run_macro_command(self, win_id, count=1, register=None): """Run a recorded macro. Args: count: How many times to run the macro. - name: Which macro to run. + register: Which macro to run. """ - if name not in runners.macro: - raise cmdexc.CommandError("No macro defined!") + runners.macro_count[win_id] = count + if register is None: + self.enter(usertypes.KeyMode.run_macro, 'run_macro') + else: + run_macro(win_id, register) + + +def run_macro(win_id, register): + """Run a recorded macro.""" + if register not in runners.macro: + raise cmdexc.CommandError( + "No macro recorded in '{}'!".format(register)) commandrunner = runners.CommandRunner(win_id) - for _ in range(count): - for cmd in runners.macro[name]: + for _ in range(runners.macro_count[win_id]): + for cmd in runners.macro[register]: commandrunner.run(*cmd) diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 6dd3d9674..39d7e4209 100644 --- a/qutebrowser/utils/usertypes.py +++ b/qutebrowser/utils/usertypes.py @@ -233,7 +233,7 @@ ClickTarget = enum('ClickTarget', ['normal', 'tab', 'tab_bg', 'window', # Key input modes KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt', 'insert', 'passthrough', 'caret', 'set_mark', - 'jump_mark']) + 'jump_mark', 'record_macro', 'run_macro']) # Available command completions diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index e02b33d71..48703d940 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -121,11 +121,11 @@ Feature: Keyboard input When I open data/keyinput/log.html And I set general -> log-javascript-console to info And I set input -> forward-unbound-keys to all - And I press the key "q" + And I press the key "," And I press the key "" - # q - Then the javascript message "key press: 81" should be logged - And the javascript message "key release: 81" should be logged + # , + Then the javascript message "key press: 188" should be logged + And the javascript message "key release: 188" should be logged # And the javascript message "key press: 112" should be logged And the javascript message "key release: 112" should be logged diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 62254f866..bb8811292 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -625,18 +625,20 @@ Feature: Various utility commands. Scenario: Recording a simple macro Given I open data/scroll/simple.html And I run :tab-only - When I run :scroll down with count 5 + When I run :scroll down with count 6 And I run :record-macro + And I press the key "a" And I run :scroll up And I run :scroll up And I run :record-macro And I run :run-macro with count 2 + And I press the key "a" Then the page should not be scrolled Scenario: Recording a named macro Given I open data/scroll/simple.html And I run :tab-only - When I run :scroll down with count 5 + When I run :scroll down with count 6 And I run :record-macro foo And I run :scroll up And I run :scroll up From 87899cb6b32bef1d6160cd9dc3067973f19513dd Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Sat, 8 Oct 2016 22:00:26 +0200 Subject: [PATCH 03/18] Fix long lines --- qutebrowser/keyinput/modeman.py | 12 ++++++++---- qutebrowser/keyinput/modeparsers.py | 3 ++- qutebrowser/misc/utilcmds.py | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index e66e9711c..d09fb20f2 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -78,10 +78,14 @@ 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.RegisterKeyParser(win_id, KM.set_mark, modeman), - KM.jump_mark: modeparsers.RegisterKeyParser(win_id, KM.jump_mark, modeman), - KM.record_macro: modeparsers.RegisterKeyParser(win_id, KM.record_macro, modeman), - KM.run_macro: modeparsers.RegisterKeyParser(win_id, KM.run_macro, modeman), + KM.set_mark: modeparsers.RegisterKeyParser(win_id, KM.set_mark, + modeman), + KM.jump_mark: modeparsers.RegisterKeyParser(win_id, KM.jump_mark, + modeman), + KM.record_macro: modeparsers.RegisterKeyParser(win_id, KM.record_macro, + modeman), + KM.run_macro: modeparsers.RegisterKeyParser(win_id, KM.run_macro, + modeman), } objreg.register('keyparsers', keyparsers, scope='window', window=win_id) modeman.destroyed.connect( diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index c27f4fc73..ba94e4696 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -306,7 +306,8 @@ class RegisterKeyParser(keyparser.BaseKeyParser): elif self._mode == usertypes.KeyMode.run_macro: utilcmds.run_macro(self._win_id, key) else: - raise ValueError("{} is not a valid register key".format(self._mode)) + raise ValueError( + "{} is not a valid register key".format(self._mode)) self.request_leave.emit(self._mode, "valid register key") diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index cb984d73d..010d9f33b 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -234,7 +234,8 @@ def repeat_command(win_id, count=None): commandrunner.run(cmd[0], count if count is not None else cmd[1]) -@cmdutils.register(instance='mode-manager', name='record-macro', scope='window') +@cmdutils.register(instance='mode-manager', name='record-macro', + scope='window') @cmdutils.argument('register') def record_macro_command(self, register=None): """Start or stop recording a macro. From f2b05a03951d41dd19130660a3a22c9fe739db73 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Thu, 20 Oct 2016 02:50:00 +0200 Subject: [PATCH 04/18] Move keyboard macro system to MacroRecorder object --- qutebrowser/app.py | 3 + qutebrowser/commands/runners.py | 34 +++++----- qutebrowser/keyinput/macros.py | 99 +++++++++++++++++++++++++++++ qutebrowser/keyinput/modeparsers.py | 6 +- qutebrowser/misc/utilcmds.py | 55 ---------------- 5 files changed, 123 insertions(+), 74 deletions(-) create mode 100644 qutebrowser/keyinput/macros.py diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 68397bbd3..9c812cff6 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -49,6 +49,7 @@ from qutebrowser.browser import urlmarks, adblock, history, browsertab from qutebrowser.browser.webkit import cookies, cache, downloads from qutebrowser.browser.webkit.network import (webkitqutescheme, proxy, networkmanager) +from qutebrowser.keyinput import macros from qutebrowser.mainwindow import mainwindow from qutebrowser.misc import (readline, ipc, savemanager, sessions, crashsignal, earlyinit) @@ -157,6 +158,8 @@ def init(args, crash_handler): QDesktopServices.setUrlHandler('https', open_desktopservices_url) QDesktopServices.setUrlHandler('qute', open_desktopservices_url) + macros.init() + log.init.debug("Init done!") crash_handler.raise_crashdlg() diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 5d068422e..6399d9d9f 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -34,10 +34,6 @@ from qutebrowser.misc import split ParseResult = collections.namedtuple('ParseResult', ['cmd', 'args', 'cmdline']) last_command = {} -macro = {} -recording_macro = None -macro_count = {} - def _current_url(tabbed_browser): """Convenience method to get the current url.""" @@ -263,27 +259,33 @@ class CommandRunner(QObject): text: The text to parse. count: The count to pass to the command. """ - for result in self.parse_all(text): - mode_manager = objreg.get('mode-manager', scope='window', - window=self._win_id) - cur_mode = mode_manager.mode + record_last_command = True + record_macro = True + mode_manager = objreg.get('mode-manager', scope='window', + window=self._win_id) + cur_mode = mode_manager.mode + + for result in self.parse_all(text): if result.cmd.no_replace_variables: args = result.args else: args = replace_variables(self._win_id, result.args) result.cmd.run(self._win_id, args, count=count) - parsed_command = (text, count) + if result.cmdline[0] == 'repeat-command': + record_last_command = False - if result.cmdline[0] != 'repeat-command': - last_command[cur_mode] = parsed_command + if result.cmdline[0] in ['record-macro', 'run-macro', + 'set-cmd-text']: + record_macro = False - if (recording_macro is not None and - cur_mode == usertypes.KeyMode.normal and - result.cmdline[0] not in ['record-macro', 'run-macro', - 'set-cmd-text']): - macro[recording_macro].append(parsed_command) + if record_last_command: + last_command[cur_mode] = (text, count) + + if record_macro and cur_mode == usertypes.KeyMode.normal: + macro_recorder = objreg.get('macro-recorder') + macro_recorder.record(text, count) @pyqtSlot(str, int) @pyqtSlot(str) diff --git a/qutebrowser/keyinput/macros.py b/qutebrowser/keyinput/macros.py new file mode 100644 index 000000000..48c1f0a1d --- /dev/null +++ b/qutebrowser/keyinput/macros.py @@ -0,0 +1,99 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Jan Verbeek (blyxxyz) +# +# 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 . + +"""Keyboard macro system.""" + +from qutebrowser.commands import cmdexc, cmdutils, runners +from qutebrowser.utils import message, objreg, usertypes + + +class MacroRecorder: + """An object for recording and running keyboard macros.""" + + def __init__(self): + self.macro = {} + self.recording_macro = None + self.macro_count = {} + + @cmdutils.register(instance='macro-recorder', name='record-macro') + @cmdutils.argument('win_id', win_id=True) + @cmdutils.argument('register') + def record_macro_command(self, win_id, register=None): + """Start or stop recording a macro. + + Args: + register: Which register to store the macro in. + """ + if self.recording_macro is None: + if register is None: + mode_manager = objreg.get('mode-manager', scope='window', + window=win_id) + mode_manager.enter(usertypes.KeyMode.record_macro, + 'record_macro') + else: + self.record_macro(register) + else: + message.info("Macro recorded.") + self.recording_macro = None + + def record_macro(self, register): + """Start recording a macro.""" + message.info("Recording macro...") + self.macro[register] = [] + self.recording_macro = register + + @cmdutils.register(instance='macro-recorder', name='run-macro') + @cmdutils.argument('win_id', win_id=True) + @cmdutils.argument('count', count=True) + @cmdutils.argument('register') + def run_macro_command(self, win_id, count=1, register=None): + """Run a recorded macro. + + Args: + count: How many times to run the macro. + register: Which macro to run. + """ + self.macro_count[win_id] = count + if register is None: + mode_manager = objreg.get('mode-manager', scope='window', + window=win_id) + mode_manager.enter(usertypes.KeyMode.run_macro, 'run_macro') + else: + self.run_macro(win_id, register) + + def run_macro(self, win_id, register): + """Run a recorded macro.""" + if register not in self.macro: + raise cmdexc.CommandError( + "No macro recorded in '{}'!".format(register)) + commandrunner = runners.CommandRunner(win_id) + for _ in range(self.macro_count[win_id]): + for cmd in self.macro[register]: + commandrunner.run_safely(*cmd) + + def record(self, text, count): + """Record a command if a macro is being recorded.""" + if self.recording_macro is not None: + self.macro[self.recording_macro].append((text, count)) + + +def init(): + """Initialize the MacroRecorder.""" + macro_recorder = MacroRecorder() + objreg.register('macro-recorder', macro_recorder) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index ba94e4696..17814be68 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -27,7 +27,6 @@ from PyQt5.QtCore import pyqtSlot, Qt from qutebrowser.config import config from qutebrowser.keyinput import keyparser -from qutebrowser.misc import utilcmds from qutebrowser.utils import usertypes, log, objreg, utils @@ -296,15 +295,16 @@ class RegisterKeyParser(keyparser.BaseKeyParser): tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) + macro_recorder = objreg.get('macro-recorder') if self._mode == usertypes.KeyMode.set_mark: tabbed_browser.set_mark(key) elif self._mode == usertypes.KeyMode.jump_mark: tabbed_browser.jump_mark(key) elif self._mode == usertypes.KeyMode.record_macro: - utilcmds.record_macro(key) + macro_recorder.record_macro(key) elif self._mode == usertypes.KeyMode.run_macro: - utilcmds.run_macro(self._win_id, key) + macro_recorder.run_macro(self._win_id, key) else: raise ValueError( "{} is not a valid register key".format(self._mode)) diff --git a/qutebrowser/misc/utilcmds.py b/qutebrowser/misc/utilcmds.py index 4213dbc97..62202998c 100644 --- a/qutebrowser/misc/utilcmds.py +++ b/qutebrowser/misc/utilcmds.py @@ -251,61 +251,6 @@ def repeat_command(win_id, count=None): commandrunner.run(cmd[0], count if count is not None else cmd[1]) -@cmdutils.register(instance='mode-manager', name='record-macro', - scope='window') -@cmdutils.argument('register') -def record_macro_command(self, register=None): - """Start or stop recording a macro. - - Args: - register: Which register to store the macro in. - """ - if runners.recording_macro is None: - if register is None: - self.enter(usertypes.KeyMode.record_macro, 'record_macro') - else: - record_macro(register) - else: - message.info("Macro recorded.") - runners.recording_macro = None - - -def record_macro(register): - """Start recording a macro.""" - message.info("Recording macro...") - runners.macro[register] = [] - runners.recording_macro = register - - -@cmdutils.register(instance='mode-manager', name='run-macro', scope='window') -@cmdutils.argument('win_id', win_id=True) -@cmdutils.argument('count', count=True) -@cmdutils.argument('register') -def run_macro_command(self, win_id, count=1, register=None): - """Run a recorded macro. - - Args: - count: How many times to run the macro. - register: Which macro to run. - """ - runners.macro_count[win_id] = count - if register is None: - self.enter(usertypes.KeyMode.run_macro, 'run_macro') - else: - run_macro(win_id, register) - - -def run_macro(win_id, register): - """Run a recorded macro.""" - if register not in runners.macro: - raise cmdexc.CommandError( - "No macro recorded in '{}'!".format(register)) - commandrunner = runners.CommandRunner(win_id) - for _ in range(runners.macro_count[win_id]): - for cmd in runners.macro[register]: - commandrunner.run(*cmd) - - @cmdutils.register(debug=True, name='debug-log-capacity') def log_capacity(capacity: int): """Change the number of log lines to be stored in RAM. From 21289a80adda7c0226a493084f21ec6267c85edf Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Thu, 20 Oct 2016 17:37:02 +0200 Subject: [PATCH 05/18] Catch RegisterKeyParser command errors --- qutebrowser/keyinput/modeparsers.py | 30 +++++++++++++++++------------ tests/end2end/features/misc.feature | 15 +++++++++++++++ 2 files changed, 33 insertions(+), 12 deletions(-) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 17814be68..7b8f4b207 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -23,11 +23,14 @@ Module attributes: STARTCHARS: Possible chars for starting a commandline input. """ +import traceback + from PyQt5.QtCore import pyqtSlot, Qt +from qutebrowser.commands import cmdexc from qutebrowser.config import config from qutebrowser.keyinput import keyparser -from qutebrowser.utils import usertypes, log, objreg, utils +from qutebrowser.utils import usertypes, log, message, objreg, utils STARTCHARS = ":/?" @@ -297,17 +300,20 @@ class RegisterKeyParser(keyparser.BaseKeyParser): window=self._win_id) macro_recorder = objreg.get('macro-recorder') - if self._mode == usertypes.KeyMode.set_mark: - tabbed_browser.set_mark(key) - elif self._mode == usertypes.KeyMode.jump_mark: - tabbed_browser.jump_mark(key) - elif self._mode == usertypes.KeyMode.record_macro: - macro_recorder.record_macro(key) - elif self._mode == usertypes.KeyMode.run_macro: - macro_recorder.run_macro(self._win_id, key) - else: - raise ValueError( - "{} is not a valid register key".format(self._mode)) + try: + if self._mode == usertypes.KeyMode.set_mark: + tabbed_browser.set_mark(key) + elif self._mode == usertypes.KeyMode.jump_mark: + tabbed_browser.jump_mark(key) + elif self._mode == usertypes.KeyMode.record_macro: + macro_recorder.record_macro(key) + elif self._mode == usertypes.KeyMode.run_macro: + macro_recorder.run_macro(self._win_id, key) + else: + raise ValueError( + "{} is not a valid register key".format(self._mode)) + except (cmdexc.CommandMetaError, cmdexc.CommandError) as err: + message.error(str(err), stack=traceback.format_exc()) self.request_leave.emit(self._mode, "valid register key") diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index 0727f97cb..e66cfc94b 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -648,6 +648,21 @@ Feature: Various utility commands. And I run :run-macro foo with count 2 Then the page should not be scrolled + Scenario: Running an invalid macro + Given I open data/scroll/simple.html + And I run :tab-only + When I run :run-macro + And I press the key "b" + Then the error "No macro recorded in 'b'!" should be shown + And no crash should happen + + Scenario: Running an invalid named macro + Given I open data/scroll/simple.html + And I run :tab-only + When I run :run-macro bar + Then the error "No macro recorded in 'bar'!" should be shown + And no crash should happen + ## Variables Scenario: {url} as part of an argument From 479c8e56b4a50e4db998073ded2affbe8b77a31c Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 9 Nov 2016 13:09:10 +0100 Subject: [PATCH 06/18] Improve macro code style, info messages --- qutebrowser/keyinput/macros.py | 48 +++++++++++++++++------------ qutebrowser/keyinput/modeparsers.py | 2 +- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/qutebrowser/keyinput/macros.py b/qutebrowser/keyinput/macros.py index 48c1f0a1d..c37885456 100644 --- a/qutebrowser/keyinput/macros.py +++ b/qutebrowser/keyinput/macros.py @@ -20,16 +20,26 @@ """Keyboard macro system.""" from qutebrowser.commands import cmdexc, cmdutils, runners +from qutebrowser.keyinput import modeman from qutebrowser.utils import message, objreg, usertypes class MacroRecorder: - """An object for recording and running keyboard macros.""" + + """An object for recording and running keyboard macros. + + Attributes: + _macros: A list of commands for each macro register. + _recording_macro: The register to which a macro is being recorded. + _macro_count: The count passed to run_macro_command for each window. + Stored for use by run_macro, which may be called from + keyinput/modeparsers.py after a key input. + """ def __init__(self): - self.macro = {} - self.recording_macro = None - self.macro_count = {} + self._macros = {} + self._recording_macro = None + self._macro_count = {} @cmdutils.register(instance='macro-recorder', name='record-macro') @cmdutils.argument('win_id', win_id=True) @@ -40,23 +50,22 @@ class MacroRecorder: Args: register: Which register to store the macro in. """ - if self.recording_macro is None: + if self._recording_macro is None: if register is None: - mode_manager = objreg.get('mode-manager', scope='window', - window=win_id) + mode_manager = modeman.instance(win_id) mode_manager.enter(usertypes.KeyMode.record_macro, 'record_macro') else: self.record_macro(register) else: - message.info("Macro recorded.") - self.recording_macro = None + message.info("Macro '{}' recorded.".format(self._recording_macro)) + self._recording_macro = None def record_macro(self, register): """Start recording a macro.""" - message.info("Recording macro...") - self.macro[register] = [] - self.recording_macro = register + message.info("Recording macro '{}'...".format(register)) + self._macros[register] = [] + self._recording_macro = register @cmdutils.register(instance='macro-recorder', name='run-macro') @cmdutils.argument('win_id', win_id=True) @@ -69,28 +78,27 @@ class MacroRecorder: count: How many times to run the macro. register: Which macro to run. """ - self.macro_count[win_id] = count + self._macro_count[win_id] = count if register is None: - mode_manager = objreg.get('mode-manager', scope='window', - window=win_id) + mode_manager = modeman.instance(win_id) mode_manager.enter(usertypes.KeyMode.run_macro, 'run_macro') else: self.run_macro(win_id, register) def run_macro(self, win_id, register): """Run a recorded macro.""" - if register not in self.macro: + if register not in self._macros: raise cmdexc.CommandError( "No macro recorded in '{}'!".format(register)) commandrunner = runners.CommandRunner(win_id) - for _ in range(self.macro_count[win_id]): - for cmd in self.macro[register]: + for _ in range(self._macro_count[win_id]): + for cmd in self._macros[register]: commandrunner.run_safely(*cmd) def record(self, text, count): """Record a command if a macro is being recorded.""" - if self.recording_macro is not None: - self.macro[self.recording_macro].append((text, count)) + if self._recording_macro is not None: + self._macros[self._recording_macro].append((text, count)) def init(): diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index 7b8f4b207..b3ac47d3e 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -311,7 +311,7 @@ class RegisterKeyParser(keyparser.BaseKeyParser): macro_recorder.run_macro(self._win_id, key) else: raise ValueError( - "{} is not a valid register key".format(self._mode)) + "{} is not a valid register mode".format(self._mode)) except (cmdexc.CommandMetaError, cmdexc.CommandError) as err: message.error(str(err), stack=traceback.format_exc()) From f1c3bc89ec10daa11b8d89eae872a057841bdc75 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 9 Nov 2016 14:56:41 +0100 Subject: [PATCH 07/18] Further cleanup --- qutebrowser/commands/runners.py | 6 +++--- qutebrowser/keyinput/macros.py | 4 +--- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index 6399d9d9f..cf19c0a4e 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -27,6 +27,7 @@ from PyQt5.QtCore import pyqtSlot, QUrl, QObject from qutebrowser.config import config, configexc from qutebrowser.commands import cmdexc, cmdutils +from qutebrowser.keyinput import modeman from qutebrowser.utils import message, objreg, qtutils, usertypes, utils from qutebrowser.misc import split @@ -262,8 +263,7 @@ class CommandRunner(QObject): record_last_command = True record_macro = True - mode_manager = objreg.get('mode-manager', scope='window', - window=self._win_id) + mode_manager = modeman.instance(self._win_id) cur_mode = mode_manager.mode for result in self.parse_all(text): @@ -285,7 +285,7 @@ class CommandRunner(QObject): if record_macro and cur_mode == usertypes.KeyMode.normal: macro_recorder = objreg.get('macro-recorder') - macro_recorder.record(text, count) + macro_recorder.record_command(text, count) @pyqtSlot(str, int) @pyqtSlot(str) diff --git a/qutebrowser/keyinput/macros.py b/qutebrowser/keyinput/macros.py index c37885456..8176e5652 100644 --- a/qutebrowser/keyinput/macros.py +++ b/qutebrowser/keyinput/macros.py @@ -43,7 +43,6 @@ class MacroRecorder: @cmdutils.register(instance='macro-recorder', name='record-macro') @cmdutils.argument('win_id', win_id=True) - @cmdutils.argument('register') def record_macro_command(self, win_id, register=None): """Start or stop recording a macro. @@ -70,7 +69,6 @@ class MacroRecorder: @cmdutils.register(instance='macro-recorder', name='run-macro') @cmdutils.argument('win_id', win_id=True) @cmdutils.argument('count', count=True) - @cmdutils.argument('register') def run_macro_command(self, win_id, count=1, register=None): """Run a recorded macro. @@ -95,7 +93,7 @@ class MacroRecorder: for cmd in self._macros[register]: commandrunner.run_safely(*cmd) - def record(self, text, count): + def record_command(self, text, count): """Record a command if a macro is being recorded.""" if self._recording_macro is not None: self._macros[self._recording_macro].append((text, count)) From a778b7184c7d524684bfd324c610908fc429b744 Mon Sep 17 00:00:00 2001 From: Jan Verbeek Date: Wed, 9 Nov 2016 19:07:56 +0100 Subject: [PATCH 08/18] Revert back to objreg to avoid circular import --- qutebrowser/commands/runners.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/qutebrowser/commands/runners.py b/qutebrowser/commands/runners.py index cf19c0a4e..f6a8e07e2 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -27,7 +27,6 @@ from PyQt5.QtCore import pyqtSlot, QUrl, QObject from qutebrowser.config import config, configexc from qutebrowser.commands import cmdexc, cmdutils -from qutebrowser.keyinput import modeman from qutebrowser.utils import message, objreg, qtutils, usertypes, utils from qutebrowser.misc import split @@ -263,7 +262,8 @@ class CommandRunner(QObject): record_last_command = True record_macro = True - mode_manager = modeman.instance(self._win_id) + mode_manager = objreg.get('mode-manager', scope='window', + window=self._win_id) cur_mode = mode_manager.mode for result in self.parse_all(text): From 2a094ce35c1502ba17c4455c2d97e044502c9bba Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 06:51:17 +0100 Subject: [PATCH 09/18] Update changelog --- CHANGELOG.asciidoc | 1 + README.asciidoc | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 256edb489..8e7b898e5 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -49,6 +49,7 @@ Added the `readability-lxml` python package) - New `cast` userscript to show a video on a Google Chromecast - New `:run-with-count` command which replaces the (undocumented) `:count:command` syntax. +- New `:record-macro` (`q`) and `:run-macro` (`@`) commands for keyboard macros. Changed ~~~~~~~ diff --git a/README.asciidoc b/README.asciidoc index 22b21dd83..a253ea6fb 100644 --- a/README.asciidoc +++ b/README.asciidoc @@ -147,9 +147,9 @@ Contributors, sorted by the number of commits in descending order: * Daniel Schadt * Ryan Roden-Corrent * Jakub Klinkovský +* Jan Verbeek * Antoni Boucher * Lamar Pavel -* Jan Verbeek * Marshall Lochbaum * Bruno Oliveira * Alexander Cogneau From c7fb99878fc9963adfeb3eb773c329c43d1aec6b Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 06:51:59 +0100 Subject: [PATCH 10/18] Move macro tests to keyinput.feature --- tests/end2end/features/keyinput.feature | 41 +++++++++++++++++++++++++ tests/end2end/features/misc.feature | 39 ----------------------- 2 files changed, 41 insertions(+), 39 deletions(-) diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index c60cb0a17..886c70761 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -196,3 +196,44 @@ Feature: Keyboard input When I run :fake-key -g x And I wait for "got keypress in mode KeyMode.normal - delegating to " in the log Then no crash should happen + + # Macros + + Scenario: Recording a simple macro + Given I open data/scroll/simple.html + And I run :tab-only + When I run :scroll down with count 6 + And I run :record-macro + And I press the key "a" + And I run :scroll up + And I run :scroll up + And I run :record-macro + And I run :run-macro with count 2 + And I press the key "a" + Then the page should not be scrolled + + Scenario: Recording a named macro + Given I open data/scroll/simple.html + And I run :tab-only + When I run :scroll down with count 6 + And I run :record-macro foo + And I run :scroll up + And I run :scroll up + And I run :record-macro foo + And I run :run-macro foo with count 2 + Then the page should not be scrolled + + Scenario: Running an invalid macro + Given I open data/scroll/simple.html + And I run :tab-only + When I run :run-macro + And I press the key "b" + Then the error "No macro recorded in 'b'!" should be shown + And no crash should happen + + Scenario: Running an invalid named macro + Given I open data/scroll/simple.html + And I run :tab-only + When I run :run-macro bar + Then the error "No macro recorded in 'bar'!" should be shown + And no crash should happen diff --git a/tests/end2end/features/misc.feature b/tests/end2end/features/misc.feature index c193a1379..46e4dc287 100644 --- a/tests/end2end/features/misc.feature +++ b/tests/end2end/features/misc.feature @@ -530,45 +530,6 @@ Feature: Various utility commands. history: - url: http://localhost:*/data/hello3.txt - Scenario: Recording a simple macro - Given I open data/scroll/simple.html - And I run :tab-only - When I run :scroll down with count 6 - And I run :record-macro - And I press the key "a" - And I run :scroll up - And I run :scroll up - And I run :record-macro - And I run :run-macro with count 2 - And I press the key "a" - Then the page should not be scrolled - - Scenario: Recording a named macro - Given I open data/scroll/simple.html - And I run :tab-only - When I run :scroll down with count 6 - And I run :record-macro foo - And I run :scroll up - And I run :scroll up - And I run :record-macro foo - And I run :run-macro foo with count 2 - Then the page should not be scrolled - - Scenario: Running an invalid macro - Given I open data/scroll/simple.html - And I run :tab-only - When I run :run-macro - And I press the key "b" - Then the error "No macro recorded in 'b'!" should be shown - And no crash should happen - - Scenario: Running an invalid named macro - Given I open data/scroll/simple.html - And I run :tab-only - When I run :run-macro bar - Then the error "No macro recorded in 'bar'!" should be shown - And no crash should happen - ## Variables Scenario: {url} as part of an argument From 3884271505db4ebf9a5cf423b7213ae8a5736ff7 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 06:54:02 +0100 Subject: [PATCH 11/18] check_coverage: Add keyinput.macros to WHITELISTED_FILES --- scripts/dev/check_coverage.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 698e04b79..eeda04287 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -163,7 +163,10 @@ PERFECT_FILES = [ # 100% coverage because of end2end tests, but no perfect unit tests yet. -WHITELISTED_FILES = ['qutebrowser/browser/webkit/webkitinspector.py'] +WHITELISTED_FILES = [ + 'qutebrowser/browser/webkit/webkitinspector.py', + 'qutebrowser/keyinput/macros.py', +] class Skipped(Exception): From 22cd42c515b95dfe483232db5085b918a9bad811 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 06:56:01 +0100 Subject: [PATCH 12/18] test requirements: Update Mako to 1.0.6 --- misc/requirements/requirements-tests.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/misc/requirements/requirements-tests.txt b/misc/requirements/requirements-tests.txt index 9d9bed265..b4c22823c 100644 --- a/misc/requirements/requirements-tests.txt +++ b/misc/requirements/requirements-tests.txt @@ -11,7 +11,7 @@ httpbin==0.5.0 hypothesis==3.6.0 itsdangerous==0.24 # Jinja2==2.8 -Mako==1.0.5 +Mako==1.0.6 # MarkupSafe==0.23 parse==1.6.6 parse-type==0.3.4 From 2ef85d6c3561bcc856274a5c823192e1f40216df Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 07:16:52 +0100 Subject: [PATCH 13/18] Fix macros with a mode-switching command --- qutebrowser/keyinput/basekeyparser.py | 3 ++- qutebrowser/keyinput/modeman.py | 14 +++++++++++--- qutebrowser/keyinput/modeparsers.py | 2 +- tests/end2end/features/keyinput.feature | 12 ++++++++++++ tests/unit/keyinput/test_modeman.py | 2 +- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 59ac51a60..3114f4663 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -70,10 +70,11 @@ class BaseKeyParser(QObject): request_leave: Emitted to request leaving a mode. arg 0: Mode to leave. arg 1: Reason for leaving. + arg 2: Ignore the request if we're not in that mode """ keystring_updated = pyqtSignal(str) - request_leave = pyqtSignal(usertypes.KeyMode, str) + request_leave = pyqtSignal(usertypes.KeyMode, str, bool) do_log = True passthrough = False diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index d09fb20f2..d541072ce 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -276,16 +276,24 @@ 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): + @pyqtSlot(usertypes.KeyMode, str, bool) + def leave(self, mode, reason=None, maybe=False): """Leave a key mode. Args: mode: The mode to leave as a usertypes.KeyMode member. reason: Why the mode was left. + maybe: If set, ignore the request if we're not in that mode. """ if self.mode != mode: - raise NotInModeError("Not in mode {}!".format(mode)) + if maybe: + log.modes.debug("Ignoring leave request for {} (reason {}) as " + "we're in mode {}".format( + mode, reason, self.mode)) + return + else: + raise NotInModeError("Not in mode {}!".format(mode)) + log.modes.debug("Leaving mode {}{}".format( mode, '' if reason is None else ' (reason: {})'.format(reason))) # leaving a mode implies clearing keychain, see diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index b3ac47d3e..6fa881ad7 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -315,7 +315,7 @@ class RegisterKeyParser(keyparser.BaseKeyParser): except (cmdexc.CommandMetaError, cmdexc.CommandError) as err: message.error(str(err), stack=traceback.format_exc()) - self.request_leave.emit(self._mode, "valid register key") + self.request_leave.emit(self._mode, "valid register key", True) return True diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index 886c70761..7da83efbd 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -237,3 +237,15 @@ Feature: Keyboard input When I run :run-macro bar Then the error "No macro recorded in 'bar'!" should be shown And no crash should happen + + Scenario: Running a macro with a mode-switching command + When I open data/hints/html/simple.html + And I run :record-macro a + And I run :hint links normal + And I wait for "hints: *" in the log + And I run :leave-mode + And I run :record-macro a + And I run :run-macro + And I press the key "a" + And I wait for "hints: *" in the log + Then no crash should happen diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index 4d89b4671..b9f2db572 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -28,7 +28,7 @@ class FakeKeyparser(QObject): """A fake BaseKeyParser which doesn't handle anything.""" - request_leave = pyqtSignal(usertypes.KeyMode, str) + request_leave = pyqtSignal(usertypes.KeyMode, str, bool) def __init__(self): super().__init__() From bbd842bd82b261871cac9d931e3335ba69c06afd Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 07:19:45 +0100 Subject: [PATCH 14/18] Get rid of modeman.maybe_leave --- qutebrowser/browser/commands.py | 4 ++-- qutebrowser/browser/hints.py | 7 ++++--- qutebrowser/browser/mouse.py | 10 ++++------ qutebrowser/keyinput/modeman.py | 13 ++----------- qutebrowser/mainwindow/prompt.py | 8 ++++---- qutebrowser/mainwindow/tabbedbrowser.py | 10 +++++----- 6 files changed, 21 insertions(+), 31 deletions(-) diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index a100a28cd..b4e3e5507 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -742,8 +742,8 @@ class CommandDispatcher: message.info("{} {} yanked to {}".format( len(s), "char" if len(s) == 1 else "chars", target)) if not keep: - modeman.maybe_leave(self._win_id, KeyMode.caret, - "yank selected") + modeman.leave(self._win_id, KeyMode.caret, "yank selected", + maybe=True) @cmdutils.register(instance='command-dispatcher', scope='window') @cmdutils.argument('count', count=True) diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 136b769b6..f28f25b29 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -50,7 +50,8 @@ class HintingError(Exception): def on_mode_entered(mode, win_id): """Stop hinting when insert mode was entered.""" if mode == usertypes.KeyMode.insert: - modeman.maybe_leave(win_id, usertypes.KeyMode.hint, 'insert mode') + modeman.leave(win_id, usertypes.KeyMode.hint, 'insert mode', + maybe=True) class HintLabel(QLabel): @@ -859,8 +860,8 @@ class HintManager(QObject): raise ValueError("No suitable handler found!") if not self._context.rapid: - modeman.maybe_leave(self._win_id, usertypes.KeyMode.hint, - 'followed') + modeman.leave(self._win_id, usertypes.KeyMode.hint, 'followed', + maybe=True) else: # Reset filtering self.filter_hints(None) diff --git a/qutebrowser/browser/mouse.py b/qutebrowser/browser/mouse.py index aaa5dd82f..3855a853f 100644 --- a/qutebrowser/browser/mouse.py +++ b/qutebrowser/browser/mouse.py @@ -151,9 +151,8 @@ class MouseEventFilter(QObject): else: log.mouse.debug("Clicked non-editable element!") if config.get('input', 'auto-leave-insert-mode'): - modeman.maybe_leave(self._tab.win_id, - usertypes.KeyMode.insert, - 'click') + modeman.leave(self._tab.win_id, usertypes.KeyMode.insert, + 'click', maybe=True) def _mouserelease_insertmode(self): """If we have an insertmode check scheduled, handle it.""" @@ -174,9 +173,8 @@ class MouseEventFilter(QObject): else: log.mouse.debug("Clicked non-editable element (delayed)!") if config.get('input', 'auto-leave-insert-mode'): - modeman.maybe_leave(self._tab.win_id, - usertypes.KeyMode.insert, - 'click-delayed') + modeman.leave(self._tab.win_id, usertypes.KeyMode.insert, + 'click-delayed', maybe=True) self._tab.elements.find_focused(mouserelease_insertmode_cb) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index d541072ce..d86b4996f 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -106,18 +106,9 @@ def enter(win_id, mode, reason=None, only_if_normal=False): instance(win_id).enter(mode, reason, only_if_normal) -def leave(win_id, mode, reason=None): +def leave(win_id, mode, reason=None, *, maybe=False): """Leave the mode 'mode'.""" - instance(win_id).leave(mode, reason) - - -def maybe_leave(win_id, mode, reason=None): - """Convenience method to leave 'mode' without exceptions.""" - try: - instance(win_id).leave(mode, reason) - except NotInModeError as e: - # This is rather likely to happen, so we only log to debug log. - log.modes.debug("{} (leave reason: {})".format(e, reason)) + instance(win_id).leave(mode, reason, maybe=maybe) class ModeManager(QObject): diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index f5ed10009..6e2ab68d6 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -314,8 +314,8 @@ class PromptContainer(QWidget): if not question.interrupted: # If this question was interrupted, we already connected the signal question.aborted.connect( - lambda: modeman.maybe_leave(self._win_id, prompt.KEY_MODE, - 'aborted')) + lambda: modeman.leave(self._win_id, prompt.KEY_MODE, 'aborted', + maybe=True)) modeman.enter(self._win_id, prompt.KEY_MODE, 'question asked') self.setSizePolicy(prompt.sizePolicy()) @@ -328,7 +328,7 @@ class PromptContainer(QWidget): @pyqtSlot(usertypes.KeyMode) def _on_prompt_done(self, key_mode): """Leave the prompt mode in this window if a question was answered.""" - modeman.maybe_leave(self._win_id, key_mode, ':prompt-accept') + modeman.leave(self._win_id, key_mode, ':prompt-accept', maybe=True) @pyqtSlot(usertypes.KeyMode) def _on_global_mode_left(self, mode): @@ -339,7 +339,7 @@ class PromptContainer(QWidget): """ if mode not in [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]: return - modeman.maybe_leave(self._win_id, mode, 'left in other window') + modeman.leave(self._win_id, mode, 'left in other window', maybe=True) item = self._layout.takeAt(0) if item is not None: widget = item.widget() diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py index 083054769..13b4f1884 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -473,10 +473,10 @@ class TabbedBrowser(tabwidget.TabWidget): @pyqtSlot() def on_cur_load_started(self): """Leave insert/hint mode when loading started.""" - modeman.maybe_leave(self._win_id, usertypes.KeyMode.insert, - 'load started') - modeman.maybe_leave(self._win_id, usertypes.KeyMode.hint, - 'load started') + modeman.leave(self._win_id, usertypes.KeyMode.insert, 'load started', + maybe=True) + modeman.leave(self._win_id, usertypes.KeyMode.hint, 'load started', + maybe=True) @pyqtSlot(browsertab.AbstractTab, str) def on_title_changed(self, tab, text): @@ -567,7 +567,7 @@ class TabbedBrowser(tabwidget.TabWidget): tab.setFocus() for mode in [usertypes.KeyMode.hint, usertypes.KeyMode.insert, usertypes.KeyMode.caret, usertypes.KeyMode.passthrough]: - modeman.maybe_leave(self._win_id, mode, 'tab changed') + modeman.leave(self._win_id, mode, 'tab changed', maybe=True) if self._now_focused is not None: objreg.register('last-focused-tab', self._now_focused, update=True, scope='window', window=self._win_id) From f1bba45db5c3d8d0de4ab0ef85be1620450f824a Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 07:51:19 +0100 Subject: [PATCH 15/18] Revert "Serve broken qutebrowser logo via qute:resources" This reverts commit 37fa7431b03842f55bd1d1cfe57a059aa9d7bfe5. --- qutebrowser/browser/qutescheme.py | 15 ++------------- .../browser/webkit/network/filescheme.py | 2 +- qutebrowser/browser/webkit/webpage.py | 3 +-- qutebrowser/html/error.html | 2 +- qutebrowser/utils/jinja.py | 19 ++++--------------- tests/unit/utils/test_jinja.py | 16 +++------------- 6 files changed, 12 insertions(+), 45 deletions(-) diff --git a/qutebrowser/browser/qutescheme.py b/qutebrowser/browser/qutescheme.py index 98c9ba961..3c426c232 100644 --- a/qutebrowser/browser/qutescheme.py +++ b/qutebrowser/browser/qutescheme.py @@ -24,7 +24,6 @@ Module attributes: _HANDLERS: The handlers registered via decorators. """ -import mimetypes import urllib.parse import qutebrowser @@ -103,7 +102,7 @@ class add_handler: # pylint: disable=invalid-name url=url.toDisplayString(), error='{} is not available with this ' 'backend'.format(url.toDisplayString()), - icon='', qutescheme=True) + icon='') return 'text/html', html @@ -238,8 +237,7 @@ def qute_help(url): "repository, please run scripts/asciidoc2html.py. " "If you're running a released version this is a bug, please " "use :report to report it.", - icon='', - qutescheme=True) + icon='') return 'text/html', html urlpath = url.path() if not urlpath or urlpath == '/': @@ -255,12 +253,3 @@ def qute_help(url): else: data = utils.read_file(path) return 'text/html', data - - -@add_handler('resource') -def qute_resource(url): - """Serve resources via a qute://resource/... URL.""" - data = utils.read_file(url.path(), binary=True) - mimetype, _encoding = mimetypes.guess_type(url.fileName()) - assert mimetype is not None, url - return mimetype, data diff --git a/qutebrowser/browser/webkit/network/filescheme.py b/qutebrowser/browser/webkit/network/filescheme.py index 5787b0a34..cd0a6d489 100644 --- a/qutebrowser/browser/webkit/network/filescheme.py +++ b/qutebrowser/browser/webkit/network/filescheme.py @@ -102,7 +102,7 @@ def dirbrowser_html(path): html = jinja.render('error.html', title="Error while reading directory", url='file:///{}'.format(path), error=str(e), - icon='', qutescheme=False) + icon='') return html.encode('UTF-8', errors='xmlcharrefreplace') files = get_file_list(path, all_files, os.path.isfile) diff --git a/qutebrowser/browser/webkit/webpage.py b/qutebrowser/browser/webkit/webpage.py index 47adc0aa3..09feed3d5 100644 --- a/qutebrowser/browser/webkit/webpage.py +++ b/qutebrowser/browser/webkit/webpage.py @@ -178,8 +178,7 @@ class BrowserPage(QWebPage): title = "Error loading page: {}".format(urlstr) error_html = jinja.render( 'error.html', - title=title, url=urlstr, error=error_str, icon='', - qutescheme=False) + title=title, url=urlstr, error=error_str, icon='') errpage.content = error_html.encode('utf-8') errpage.encoding = 'utf-8' return True diff --git a/qutebrowser/html/error.html b/qutebrowser/html/error.html index b903f39b0..80bd0cf61 100644 --- a/qutebrowser/html/error.html +++ b/qutebrowser/html/error.html @@ -73,7 +73,7 @@ function searchFor(uri) {
- +

Unable to load page

diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py index afaa8caa3..f12184290 100644 --- a/qutebrowser/utils/jinja.py +++ b/qutebrowser/utils/jinja.py @@ -26,7 +26,7 @@ import traceback import jinja2 import jinja2.exceptions -from qutebrowser.utils import utils, urlutils, log, qtutils +from qutebrowser.utils import utils, urlutils, log from PyQt5.QtCore import QUrl @@ -64,25 +64,14 @@ def _guess_autoescape(template_name): return ext in ['html', 'htm', 'xml'] -def resource_url(path, qutescheme=False): +def resource_url(path): """Load images from a relative path (to qutebrowser). Arguments: path: The relative path to the image - qutescheme: If the logo needs to be served via a qute:// scheme. - This is the case when we want to show an error page from - there. """ - if qutescheme: - url = QUrl() - url.setScheme('qute') - url.setHost('resource') - url.setPath('/' + path) - qtutils.ensure_valid(url) - return url.toString(QUrl.FullyEncoded) - else: - full_path = utils.resource_filename(path) - return QUrl.fromLocalFile(full_path).toString(QUrl.FullyEncoded) + image = utils.resource_filename(path) + return QUrl.fromLocalFile(image).toString(QUrl.FullyEncoded) def render(template, **kwargs): diff --git a/tests/unit/utils/test_jinja.py b/tests/unit/utils/test_jinja.py index 28640ca07..cea237d22 100644 --- a/tests/unit/utils/test_jinja.py +++ b/tests/unit/utils/test_jinja.py @@ -39,10 +39,8 @@ def patch_read_file(monkeypatch): """A read_file which returns a simple template if the path is right.""" if path == os.path.join('html', 'test.html'): return """Hello {{var}}""" - elif path == os.path.join('html', 'resource_url.html'): - return """{{ resource_url('utils/testfile', False) }}""" - elif path == os.path.join('html', 'resource_url_qute.html'): - return """{{ resource_url('utils/testfile', True) }}""" + elif path == os.path.join('html', 'test2.html'): + return """{{ resource_url('utils/testfile') }}""" elif path == os.path.join('html', 'undef.html'): return """{{ does_not_exist() }}""" elif path == os.path.join('html', 'undef_error.html'): @@ -61,7 +59,7 @@ def test_simple_template(): def test_resource_url(): """Test resource_url() which can be used from templates.""" - data = jinja.render('resource_url.html') + data = jinja.render('test2.html') print(data) url = QUrl(data) assert url.isValid() @@ -77,14 +75,6 @@ def test_resource_url(): assert f.read().splitlines()[0] == "Hello World!" -def test_resource_url_qutescheme(): - """Test resource_url() which can be used from templates.""" - data = jinja.render('resource_url_qute.html') - print(data) - url = QUrl(data) - assert url == QUrl('qute://resource/utils/testfile') - - def test_not_found(): """Test with a template which does not exist.""" with pytest.raises(jinja2.TemplateNotFound) as excinfo: From bddda6b7782be865097a84fbd5901c5c216d0fbe Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 08:37:12 +0100 Subject: [PATCH 16/18] Use a data: URL for the broken qutebrowser logo It blows our HTML up, but we use error.html from various places with various security policies, so we can't rely on being able to load file:// URLs. --- qutebrowser/html/error.html | 4 ++-- qutebrowser/utils/jinja.py | 13 +++++++++++++ tests/unit/utils/test_jinja.py | 32 +++++++++++++++++++++++++++++++- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/qutebrowser/html/error.html b/qutebrowser/html/error.html index 80bd0cf61..ef50682ef 100644 --- a/qutebrowser/html/error.html +++ b/qutebrowser/html/error.html @@ -73,13 +73,13 @@ function searchFor(uri) {
- +

Unable to load page

Error while opening {{ url }}:

{{ error }}



- +
diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py index f12184290..a8d746b58 100644 --- a/qutebrowser/utils/jinja.py +++ b/qutebrowser/utils/jinja.py @@ -22,6 +22,8 @@ import os import os.path import traceback +import mimetypes +import base64 import jinja2 import jinja2.exceptions @@ -74,6 +76,16 @@ def resource_url(path): return QUrl.fromLocalFile(image).toString(QUrl.FullyEncoded) +def data_url(path): + """Get a data: url for the broken qutebrowser logo.""" + data = utils.read_file(path, binary=True) + filename = utils.resource_filename(path) + mimetype = mimetypes.guess_type(filename) + assert mimetype is not None, path + b64 = base64.b64encode(data).decode('ascii') + return 'data:{};charset=utf-8;base64,{}'.format(mimetype[0], b64) + + def render(template, **kwargs): """Render the given template and pass the given arguments to it.""" try: @@ -89,3 +101,4 @@ def render(template, **kwargs): _env = jinja2.Environment(loader=Loader('html'), autoescape=_guess_autoescape) _env.globals['resource_url'] = resource_url _env.globals['file_url'] = urlutils.file_url +_env.globals['data_url'] = data_url diff --git a/tests/unit/utils/test_jinja.py b/tests/unit/utils/test_jinja.py index cea237d22..eb7a621fc 100644 --- a/tests/unit/utils/test_jinja.py +++ b/tests/unit/utils/test_jinja.py @@ -34,21 +34,42 @@ from qutebrowser.utils import utils, jinja def patch_read_file(monkeypatch): """pytest fixture to patch utils.read_file.""" real_read_file = utils.read_file + real_resource_filename = utils.resource_filename - def _read_file(path): + def _read_file(path, binary=False): """A read_file which returns a simple template if the path is right.""" if path == os.path.join('html', 'test.html'): + assert not binary return """Hello {{var}}""" elif path == os.path.join('html', 'test2.html'): + assert not binary return """{{ resource_url('utils/testfile') }}""" + elif path == os.path.join('html', 'test3.html'): + assert not binary + return """{{ data_url('testfile.txt') }}""" + elif path == 'testfile.txt': + assert binary + return b'foo' elif path == os.path.join('html', 'undef.html'): + assert not binary return """{{ does_not_exist() }}""" elif path == os.path.join('html', 'undef_error.html'): + assert not binary return real_read_file(path) else: raise IOError("Invalid path {}!".format(path)) + def _resource_filename(path): + if path == 'utils/testfile': + return real_resource_filename(path) + elif path == 'testfile.txt': + return path + else: + raise IOError("Invalid path {}!".format(path)) + monkeypatch.setattr('qutebrowser.utils.jinja.utils.read_file', _read_file) + monkeypatch.setattr('qutebrowser.utils.jinja.utils.resource_filename', + _resource_filename) def test_simple_template(): @@ -75,6 +96,15 @@ def test_resource_url(): assert f.read().splitlines()[0] == "Hello World!" +def test_data_url(): + """Test data_url() which can be used from templates.""" + data = jinja.render('test3.html') + print(data) + url = QUrl(data) + assert url.isValid() + assert data == 'data:text/plain;charset=utf-8;base64,Zm9v' # 'foo' + + def test_not_found(): """Test with a template which does not exist.""" with pytest.raises(jinja2.TemplateNotFound) as excinfo: From adcd8a722056c253a06023e8600c7421bec368d6 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 08:53:44 +0100 Subject: [PATCH 17/18] Fix lint --- qutebrowser/browser/mouse.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qutebrowser/browser/mouse.py b/qutebrowser/browser/mouse.py index 3855a853f..63b5b8751 100644 --- a/qutebrowser/browser/mouse.py +++ b/qutebrowser/browser/mouse.py @@ -152,7 +152,7 @@ class MouseEventFilter(QObject): log.mouse.debug("Clicked non-editable element!") if config.get('input', 'auto-leave-insert-mode'): modeman.leave(self._tab.win_id, usertypes.KeyMode.insert, - 'click', maybe=True) + 'click', maybe=True) def _mouserelease_insertmode(self): """If we have an insertmode check scheduled, handle it.""" From d8d7b42c439caa881e03faa932ad197342393851 Mon Sep 17 00:00:00 2001 From: Florian Bruhin Date: Thu, 10 Nov 2016 09:03:56 +0100 Subject: [PATCH 18/18] Stabilize macro tests --- tests/end2end/features/keyinput.feature | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index 7da83efbd..6b671825d 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -203,24 +203,30 @@ Feature: Keyboard input Given I open data/scroll/simple.html And I run :tab-only When I run :scroll down with count 6 + And I wait until the scroll position changed And I run :record-macro And I press the key "a" And I run :scroll up And I run :scroll up + And I wait until the scroll position changed And I run :record-macro And I run :run-macro with count 2 And I press the key "a" + And I wait until the scroll position changed to 0/0 Then the page should not be scrolled Scenario: Recording a named macro Given I open data/scroll/simple.html And I run :tab-only When I run :scroll down with count 6 + And I wait until the scroll position changed And I run :record-macro foo And I run :scroll up And I run :scroll up + And I wait until the scroll position changed And I run :record-macro foo And I run :run-macro foo with count 2 + And I wait until the scroll position changed to 0/0 Then the page should not be scrolled Scenario: Running an invalid macro