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 diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 5d76b1eca..478e82258 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,15 @@ Save the current page as a quickmark. === quit Quit qutebrowser. +[[record-macro]] +=== record-macro +Syntax: +:record-macro ['register']+ + +Start or stop recording a macro. + +==== positional arguments +* +'register'+: Which register to store the macro in. + [[reload]] === reload Syntax: +:reload [*--force*]+ @@ -637,6 +648,18 @@ Report a bug in qutebrowser. === restart Restart qutebrowser while keeping existing tabs open. +[[run-macro]] +=== run-macro +Syntax: +:run-macro ['register']+ + +Run a recorded macro. + +==== positional arguments +* +'register'+: Which macro to run. + +==== count +How many times to run the macro. + [[save]] === save Syntax: +:save ['what' ['what' ...]]+ 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 diff --git a/qutebrowser/app.py b/qutebrowser/app.py index 04967bd64..7a9260bc7 100644 --- a/qutebrowser/app.py +++ b/qutebrowser/app.py @@ -49,6 +49,7 @@ from qutebrowser.browser import (urlmarks, adblock, history, browsertab, downloads) from qutebrowser.browser.webkit import cookies, cache from qutebrowser.browser.webkit.network import networkmanager +from qutebrowser.keyinput import macros from qutebrowser.mainwindow import mainwindow, prompt 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/browser/commands.py b/qutebrowser/browser/commands.py index caf87a178..aa0ad0a36 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -792,8 +792,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..63b5b8751 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/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/commands/runners.py b/qutebrowser/commands/runners.py index a4644b2a3..f6a8e07e2 100644 --- a/qutebrowser/commands/runners.py +++ b/qutebrowser/commands/runners.py @@ -27,7 +27,7 @@ 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 @@ -259,19 +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) - if result.cmdline[0] != 'repeat-command': - last_command[cur_mode] = (text, count) + if result.cmdline[0] == 'repeat-command': + record_last_command = False + + if result.cmdline[0] in ['record-macro', 'run-macro', + 'set-cmd-text']: + record_macro = False + + 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_command(text, count) @pyqtSlot(str, int) @pyqtSlot(str) diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index 2e9c504ea..a5b21e8e2 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -1670,6 +1670,8 @@ KEY_DATA = collections.OrderedDict([ ('follow-selected -t', ['', '']), ('repeat-command', ['.']), ('tab-pin', ['']), + ('record-macro', ['q']), + ('run-macro', ['@']), ])), ('insert', collections.OrderedDict([ diff --git a/qutebrowser/html/error.html b/qutebrowser/html/error.html index b903f39b0..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/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/macros.py b/qutebrowser/keyinput/macros.py new file mode 100644 index 000000000..8176e5652 --- /dev/null +++ b/qutebrowser/keyinput/macros.py @@ -0,0 +1,105 @@ +# 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.keyinput import modeman +from qutebrowser.utils import message, objreg, usertypes + + +class MacroRecorder: + + """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._macros = {} + self._recording_macro = None + self._macro_count = {} + + @cmdutils.register(instance='macro-recorder', name='record-macro') + @cmdutils.argument('win_id', win_id=True) + 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 = modeman.instance(win_id) + mode_manager.enter(usertypes.KeyMode.record_macro, + 'record_macro') + else: + self.record_macro(register) + else: + 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 '{}'...".format(register)) + self._macros[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) + 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 = 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._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._macros[register]: + commandrunner.run_safely(*cmd) + + 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)) + + +def init(): + """Initialize the MacroRecorder.""" + macro_recorder = MacroRecorder() + objreg.register('macro-recorder', macro_recorder) diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 71a83b9a3..d86b4996f 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -78,8 +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.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( @@ -100,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): @@ -270,16 +267,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 aa788ddc3..6fa881ad7 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 = ":/?" @@ -264,12 +267,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 +282,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. @@ -294,23 +298,32 @@ class MarkKeyParser(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) - else: - raise ValueError("{} is not a valid mark mode".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 mode".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 mark key") + self.request_leave.emit(self._mode, "valid register key", True) 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/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 59c11666b..334bdc349 100644 --- a/qutebrowser/mainwindow/tabbedbrowser.py +++ b/qutebrowser/mainwindow/tabbedbrowser.py @@ -474,10 +474,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): @@ -568,7 +568,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) diff --git a/qutebrowser/utils/jinja.py b/qutebrowser/utils/jinja.py index afaa8caa3..a8d746b58 100644 --- a/qutebrowser/utils/jinja.py +++ b/qutebrowser/utils/jinja.py @@ -22,11 +22,13 @@ import os import os.path import traceback +import mimetypes +import base64 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 +66,24 @@ 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 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): @@ -100,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/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py index 89480d241..1d2788ba1 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/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): diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index 04dcdbbac..6b671825d 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 @@ -196,3 +196,62 @@ 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 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 + 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 + + 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__() diff --git a/tests/unit/utils/test_jinja.py b/tests/unit/utils/test_jinja.py index 28640ca07..eb7a621fc 100644 --- a/tests/unit/utils/test_jinja.py +++ b/tests/unit/utils/test_jinja.py @@ -34,23 +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', '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'): + 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(): @@ -61,7 +80,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,12 +96,13 @@ 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') +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 == QUrl('qute://resource/utils/testfile') + assert url.isValid() + assert data == 'data:text/plain;charset=utf-8;base64,Zm9v' # 'foo' def test_not_found():