Merge branch 'keyboard'

Conflicts:
	TODO
This commit is contained in:
Florian Bruhin 2014-04-25 06:57:57 +02:00
commit d4baf77f78
18 changed files with 807 additions and 391 deletions

1
THANKS
View File

@ -54,6 +54,7 @@ channels:
- scummos - scummos
- svuorela - svuorela
- kpj - kpj
- hyde
Thanks to these projects which were essential while developing qutebrowser: Thanks to these projects which were essential while developing qutebrowser:
- Python - Python

15
TODO
View File

@ -14,7 +14,6 @@ Style
===== =====
Refactor completion widget mess (initializing / changing completions) Refactor completion widget mess (initializing / changing completions)
keypress-signal-foo is a bit of a chaos and might be done better
we probably could replace CompletionModel with QStandardModel... we probably could replace CompletionModel with QStandardModel...
replace foo_bar options with foo-bar replace foo_bar options with foo-bar
reorder options reorder options
@ -52,13 +51,6 @@ Before Blink
session handling / saving session handling / saving
IPC, like dwb -x IPC, like dwb -x
Mode handling?
- Problem: how to detect we're going to insert mode:
-> Detect mouse clicks and use QWebFrame::hitTestContent (only mouse)
-> Use javascript: http://stackoverflow.com/a/2848120/2085149
-> Use microFocusChanged and check active element via:
frame = page.currentFrame()
elem = frame.findFirstElement('*:focus')
Bookmarks Bookmarks
Internationalization Internationalization
Marks Marks
@ -89,6 +81,13 @@ How do we handle empty values in input bar?
Human-readable error messages for unknown settings / wrong interpolations / ... Human-readable error messages for unknown settings / wrong interpolations / ...
auto-click on active hint auto-click on active hint
click hints on enter click hints on enter
- Add mode=[]/no_mode=[] to cmdutils.register so we can avoid executing
commands in the wrong mode
- Add more element-selection-detection code (with options?) based on:
-> javascript: http://stackoverflow.com/a/2848120/2085149
-> microFocusChanged and check active element via:
frame = page.currentFrame()
elem = frame.findFirstElement('*:focus')
Qt Bugs Qt Bugs
======== ========

View File

@ -52,12 +52,14 @@ import qutebrowser.commands.utils as cmdutils
import qutebrowser.config.style as style import qutebrowser.config.style as style
import qutebrowser.config.config as config import qutebrowser.config.config as config
import qutebrowser.network.qutescheme as qutescheme import qutebrowser.network.qutescheme as qutescheme
import qutebrowser.keyinput.modes as modes
import qutebrowser.utils.message as message import qutebrowser.utils.message as message
from qutebrowser.widgets.mainwindow import MainWindow from qutebrowser.widgets.mainwindow import MainWindow
from qutebrowser.widgets.crash import CrashDialog from qutebrowser.widgets.crash import CrashDialog
from qutebrowser.commands.keys import CommandKeyParser from qutebrowser.keyinput.normalmode import NormalKeyParser
from qutebrowser.keyinput.insertmode import InsertKeyParser
from qutebrowser.keyinput.hintmode import HintKeyParser
from qutebrowser.commands.parsers import CommandParser, SearchParser from qutebrowser.commands.parsers import CommandParser, SearchParser
from qutebrowser.browser.hints import HintKeyParser
from qutebrowser.utils.appdirs import AppDirs from qutebrowser.utils.appdirs import AppDirs
from qutebrowser.utils.misc import dotted_getattr from qutebrowser.utils.misc import dotted_getattr
from qutebrowser.utils.debug import set_trace # pylint: disable=unused-import from qutebrowser.utils.debug import set_trace # pylint: disable=unused-import
@ -83,7 +85,6 @@ class QuteBrowser(QApplication):
_timers: List of used QTimers so they don't get GCed. _timers: List of used QTimers so they don't get GCed.
_shutting_down: True if we're currently shutting down. _shutting_down: True if we're currently shutting down.
_quit_status: The current quitting status. _quit_status: The current quitting status.
_mode: The mode we're currently in.
_opened_urls: List of opened URLs. _opened_urls: List of opened URLs.
""" """
@ -93,7 +94,6 @@ class QuteBrowser(QApplication):
self._timers = [] self._timers = []
self._opened_urls = [] self._opened_urls = []
self._shutting_down = False self._shutting_down = False
self._mode = None
sys.excepthook = self._exception_hook sys.excepthook = self._exception_hook
@ -125,15 +125,24 @@ class QuteBrowser(QApplication):
self.commandparser = CommandParser() self.commandparser = CommandParser()
self.searchparser = SearchParser() self.searchparser = SearchParser()
self._keyparsers = { self._keyparsers = {
"normal": CommandKeyParser(self), 'normal': NormalKeyParser(self),
"hint": HintKeyParser(self), 'hint': HintKeyParser(self),
'insert': InsertKeyParser(self),
} }
self._init_cmds() self._init_cmds()
self.mainwindow = MainWindow() self.mainwindow = MainWindow()
modes.init(self)
modes.manager.register('normal', self._keyparsers['normal'].handle)
modes.manager.register('hint', self._keyparsers['hint'].handle)
modes.manager.register('insert', self._keyparsers['insert'].handle,
passthrough=True)
modes.manager.register('command', None, passthrough=True)
self.modeman = modes.manager # for commands
self.installEventFilter(modes.manager)
self.setQuitOnLastWindowClosed(False) self.setQuitOnLastWindowClosed(False)
self._connect_signals() self._connect_signals()
self.set_mode("normal") modes.enter("normal")
self.mainwindow.show() self.mainwindow.show()
self._python_hacks() self._python_hacks()
@ -246,13 +255,12 @@ class QuteBrowser(QApplication):
# misc # misc
self.lastWindowClosed.connect(self.shutdown) self.lastWindowClosed.connect(self.shutdown)
tabs.quit.connect(self.shutdown) tabs.quit.connect(self.shutdown)
tabs.set_mode.connect(self.set_mode)
tabs.currentChanged.connect(self.mainwindow.update_inspector) tabs.currentChanged.connect(self.mainwindow.update_inspector)
# status bar # status bar
tabs.keypress.connect(status.keypress) modes.manager.entered.connect(status.on_mode_entered)
for obj in [kp["normal"], tabs]: modes.manager.left.connect(status.on_mode_left)
obj.set_cmd_text.connect(cmd.set_cmd_text) modes.manager.key_pressed.connect(status.on_key_pressed)
# commands # commands
cmd.got_cmd.connect(self.commandparser.run) cmd.got_cmd.connect(self.commandparser.run)
@ -264,7 +272,6 @@ class QuteBrowser(QApplication):
# hints # hints
kp["hint"].fire_hint.connect(tabs.cur.fire_hint) kp["hint"].fire_hint.connect(tabs.cur.fire_hint)
kp["hint"].abort_hinting.connect(tabs.cur.abort_hinting)
kp["hint"].keystring_updated.connect(tabs.cur.handle_hint_key) kp["hint"].keystring_updated.connect(tabs.cur.handle_hint_key)
tabs.hint_strings_updated.connect(kp["hint"].on_hint_strings_updated) tabs.hint_strings_updated.connect(kp["hint"].on_hint_strings_updated)
@ -272,6 +279,7 @@ class QuteBrowser(QApplication):
message.bridge.error.connect(status.disp_error) message.bridge.error.connect(status.disp_error)
message.bridge.info.connect(status.txt.set_temptext) message.bridge.info.connect(status.txt.set_temptext)
message.bridge.text.connect(status.txt.set_normaltext) message.bridge.text.connect(status.txt.set_normaltext)
message.bridge.set_cmd_text.connect(cmd.set_cmd_text)
# config # config
self.config.style_changed.connect(style.invalidate_caches) self.config.style_changed.connect(style.invalidate_caches)
@ -401,20 +409,6 @@ class QuteBrowser(QApplication):
logging.debug("maybe_quit quitting.") logging.debug("maybe_quit quitting.")
self.quit() self.quit()
@pyqtSlot(str)
def set_mode(self, mode):
"""Set a key input mode.
Args:
mode: The new mode to set, as an index for self._keyparsers.
"""
if self._mode is not None:
oldhandler = self._keyparsers[self._mode]
self.mainwindow.tabs.keypress.disconnect(oldhandler.handle)
handler = self._keyparsers[mode]
self.mainwindow.tabs.keypress.connect(handler.handle)
self._mode = mode
@cmdutils.register(instance='', maxsplit=0) @cmdutils.register(instance='', maxsplit=0)
def pyeval(self, s): def pyeval(self, s):
"""Evaluate a python string and display the results as a webpage. """Evaluate a python string and display the results as a webpage.

View File

@ -207,11 +207,6 @@ class CurCommandDispatcher(QObject):
"""Fire a completed hint.""" """Fire a completed hint."""
self._tabs.currentWidget().hintmanager.fire(keystr) self._tabs.currentWidget().hintmanager.fire(keystr)
@pyqtSlot()
def abort_hinting(self):
"""Abort hinting."""
self._tabs.currentWidget().hintmanager.stop()
@pyqtSlot(str, int) @pyqtSlot(str, int)
def search(self, text, flags): def search(self, text, flags):
"""Search for text in the current page. """Search for text in the current page.

View File

@ -26,69 +26,20 @@ from PyQt5.QtGui import QMouseEvent, QClipboard
from PyQt5.QtWidgets import QApplication from PyQt5.QtWidgets import QApplication
import qutebrowser.config.config as config import qutebrowser.config.config as config
import qutebrowser.keyinput.modes as modes
import qutebrowser.utils.message as message import qutebrowser.utils.message as message
import qutebrowser.utils.url as urlutils import qutebrowser.utils.url as urlutils
from qutebrowser.utils.keyparser import KeyParser import qutebrowser.utils.webelem as webelem
ElemTuple = namedtuple('ElemTuple', 'elem, label') ElemTuple = namedtuple('ElemTuple', 'elem, label')
class HintKeyParser(KeyParser):
"""KeyParser for hints.
Class attributes:
supports_count: If the keyparser should support counts.
Signals:
fire_hint: When a hint keybinding was completed.
Arg: the keystring/hint string pressed.
abort_hinting: Esc pressed, so abort hinting.
"""
supports_count = False
fire_hint = pyqtSignal(str)
abort_hinting = pyqtSignal()
def _handle_modifier_key(self, e):
"""We don't support modifiers here, but we'll handle escape in here.
Emit:
abort_hinting: Emitted if hinting was aborted.
"""
if e.key() == Qt.Key_Escape:
self._keystring = ''
self.abort_hinting.emit()
return True
return False
def execute(self, cmdstr, count=None):
"""Handle a completed keychain.
Emit:
fire_hint: Always emitted.
"""
self.fire_hint.emit(cmdstr)
def on_hint_strings_updated(self, strings):
"""Handler for HintManager's hint_strings_updated.
Args:
strings: A list of hint strings.
"""
self.bindings = {s: s for s in strings}
class HintManager(QObject): class HintManager(QObject):
"""Manage drawing hints over links or other elements. """Manage drawing hints over links or other elements.
Class attributes: Class attributes:
SELECTORS: CSS selectors for the different highlighting modes.
FILTERS: A dictionary of filter functions for the modes.
The filter for "links" filters javascript:-links and a-tags
without "href".
HINT_CSS: The CSS template to use for hints. HINT_CSS: The CSS template to use for hints.
Attributes: Attributes:
@ -104,34 +55,14 @@ class HintManager(QObject):
Signals: Signals:
hint_strings_updated: Emitted when the possible hint strings changed. hint_strings_updated: Emitted when the possible hint strings changed.
arg: A list of hint strings. arg: A list of hint strings.
set_mode: Emitted when the input mode should be changed.
arg: The new mode, as a string.
mouse_event: Mouse event to be posted in the web view. mouse_event: Mouse event to be posted in the web view.
arg: A QMouseEvent arg: A QMouseEvent
openurl: Open a new url openurl: Open a new url
arg 0: URL to open as a string. arg 0: URL to open as a string.
arg 1: true if it should be opened in a new tab, else false. arg 1: true if it should be opened in a new tab, else false.
set_open_target: Set a new target to open the links in. set_open_target: Set a new target to open the links in.
set_cmd_text: Emitted when the commandline text should be set.
""" """
SELECTORS = {
"all": ("a, textarea, select, input:not([type=hidden]), button, "
"frame, iframe, [onclick], [onmousedown], [role=link], "
"[role=option], [role=button], img"),
"links": "a",
"images": "img",
"editable": ("input[type=text], input[type=email], input[type=url],"
"input[type=tel], input[type=number], "
"input[type=password], input[type=search], textarea"),
"url": "[src], [href]",
}
FILTERS = {
"links": (lambda e: e.hasAttribute("href") and
urlutils.qurl(e.attribute("href")).scheme() != "javascript"),
}
HINT_CSS = """ HINT_CSS = """
color: {config[colors][hints.fg]}; color: {config[colors][hints.fg]};
background: {config[colors][hints.bg]}; background: {config[colors][hints.bg]};
@ -146,10 +77,8 @@ class HintManager(QObject):
""" """
hint_strings_updated = pyqtSignal(list) hint_strings_updated = pyqtSignal(list)
set_mode = pyqtSignal(str)
mouse_event = pyqtSignal('QMouseEvent') mouse_event = pyqtSignal('QMouseEvent')
set_open_target = pyqtSignal(str) set_open_target = pyqtSignal(str)
set_cmd_text = pyqtSignal(str)
def __init__(self, parent=None): def __init__(self, parent=None):
"""Constructor. """Constructor.
@ -162,6 +91,7 @@ class HintManager(QObject):
self._frame = None self._frame = None
self._target = None self._target = None
self._baseurl = None self._baseurl = None
modes.manager.left.connect(self.on_mode_left)
def _hint_strings(self, elems): def _hint_strings(self, elems):
"""Calculate the hint strings for elems. """Calculate the hint strings for elems.
@ -312,19 +242,6 @@ class HintManager(QObject):
message.info('URL yanked to {}'.format('primary selection' if sel message.info('URL yanked to {}'.format('primary selection' if sel
else 'clipboard')) else 'clipboard'))
def _set_cmd_text(self, link, command):
"""Fill the command line with an element link.
Args:
link: The URL to open.
command: The command to use.
Emit:
set_cmd_text: Always emitted.
"""
self.set_cmd_text.emit(':{} {}'.format(command,
urlutils.urlstring(link)))
def _resolve_link(self, elem): def _resolve_link(self, elem):
"""Resolve a link and check if we want to keep it. """Resolve a link and check if we want to keep it.
@ -353,27 +270,16 @@ class HintManager(QObject):
Emit: Emit:
hint_strings_updated: Emitted to update keypraser. hint_strings_updated: Emitted to update keypraser.
set_mode: Emitted to enter hinting mode
""" """
self._target = target self._target = target
self._baseurl = baseurl self._baseurl = baseurl
self._frame = frame self._frame = frame
elems = frame.findAllElements(self.SELECTORS[mode]) elems = frame.findAllElements(webelem.SELECTORS[mode])
filterfunc = self.FILTERS.get(mode, lambda e: True) filterfunc = webelem.FILTERS.get(mode, lambda e: True)
visible_elems = [] visible_elems = []
for e in elems: for e in elems:
if not filterfunc(e): if filterfunc(e) and webelem.is_visible(e, self._frame):
continue visible_elems.append(e)
rect = e.geometry()
if (not rect.isValid()) and rect.x() == 0:
# Most likely an invisible link
continue
framegeom = frame.geometry()
framegeom.translate(frame.scrollPosition())
if not framegeom.contains(rect.topLeft()):
# out of screen
continue
visible_elems.append(e)
if not visible_elems: if not visible_elems:
message.error("No elements found.") message.error("No elements found.")
return return
@ -395,23 +301,7 @@ class HintManager(QObject):
self._elems[string] = ElemTuple(e, label) self._elems[string] = ElemTuple(e, label)
frame.contentsSizeChanged.connect(self.on_contents_size_changed) frame.contentsSizeChanged.connect(self.on_contents_size_changed)
self.hint_strings_updated.emit(strings) self.hint_strings_updated.emit(strings)
self.set_mode.emit("hint") modes.enter("hint")
def stop(self):
"""Stop hinting.
Emit:
set_mode: Emitted to leave hinting mode.
"""
for elem in self._elems.values():
elem.label.removeFromDocument()
self._frame.contentsSizeChanged.disconnect(
self.on_contents_size_changed)
self._elems = {}
self._target = None
self._frame = None
self.set_mode.emit("normal")
message.clear()
def handle_partial_key(self, keystr): def handle_partial_key(self, keystr):
"""Handle a new partial keypress.""" """Handle a new partial keypress."""
@ -451,9 +341,10 @@ class HintManager(QObject):
'cmd_tab': 'tabopen', 'cmd_tab': 'tabopen',
'cmd_bgtab': 'backtabopen', 'cmd_bgtab': 'backtabopen',
} }
self._set_cmd_text(link, commands[self._target]) message.set_cmd_text(':{} {}'.format(commands[self._target],
urlutils.urlstring(link)))
if self._target != 'rapid': if self._target != 'rapid':
self.stop() modes.leave("hint")
@pyqtSlot('QSize') @pyqtSlot('QSize')
def on_contents_size_changed(self, _size): def on_contents_size_changed(self, _size):
@ -463,3 +354,17 @@ class HintManager(QObject):
css = self.HINT_CSS.format(left=rect.x(), top=rect.y(), css = self.HINT_CSS.format(left=rect.x(), top=rect.y(),
config=config.instance) config=config.instance)
elems.label.setAttribute("style", css) elems.label.setAttribute("style", css)
@pyqtSlot(str)
def on_mode_left(self, mode):
"""Stop hinting when hinting mode was left."""
if mode != "hint":
return
for elem in self._elems.values():
elem.label.removeFromDocument()
self._frame.contentsSizeChanged.disconnect(
self.on_contents_size_changed)
self._elems = {}
self._target = None
self._frame = None
message.clear()

View File

@ -1,123 +0,0 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""Parse keypresses/keychains in the main window.
Module attributes:
STARTCHARS: Possible chars for starting a commandline input.
"""
import logging
from PyQt5.QtCore import pyqtSignal, pyqtSlot
import qutebrowser.config.config as config
from qutebrowser.utils.keyparser import KeyParser
from qutebrowser.commands.parsers import (CommandParser, ArgumentCountError,
NoSuchCommandError)
STARTCHARS = ":/?"
class CommandKeyParser(KeyParser):
"""Keyparser for command bindings.
Class attributes:
supports_count: If the keyparser should support counts.
Attributes:
commandparser: Commandparser instance.
Signals:
set_cmd_text: Emitted when the statusbar should set a partial command.
arg: Text to set.
"""
set_cmd_text = pyqtSignal(str)
supports_count = True
def __init__(self, parent=None):
super().__init__(parent)
self.commandparser = CommandParser()
self.read_config()
def _run_or_fill(self, cmdstr, count=None, ignore_exc=True):
"""Run the command in cmdstr or fill the statusbar if args missing.
Args:
cmdstr: The command string.
count: Optional command count.
ignore_exc: Ignore exceptions.
Emit:
set_cmd_text: If a partial command should be printed to the
statusbar.
"""
try:
self.commandparser.run(cmdstr, count=count, ignore_exc=ignore_exc)
except NoSuchCommandError:
pass
except ArgumentCountError:
logging.debug('Filling statusbar with partial command {}'.format(
cmdstr))
self.set_cmd_text.emit(':{} '.format(cmdstr))
def _handle_single_key(self, e):
"""Override _handle_single_key to abort if the key is a startchar.
Args:
e: the KeyPressEvent from Qt.
Emit:
set_cmd_text: If the keystring should be shown in the statusbar.
"""
txt = e.text().strip()
if not self._keystring and any(txt == c for c in STARTCHARS):
self.set_cmd_text.emit(txt)
return
super()._handle_single_key(e)
def execute(self, cmdstr, count=None):
"""Handle a completed keychain."""
self._run_or_fill(cmdstr, count, ignore_exc=False)
@pyqtSlot(str, str)
def on_config_changed(self, section, _option):
"""Re-read the config if a keybinding was changed."""
if section == 'keybind':
self.read_config()
def read_config(self):
"""Read the configuration.
Config format: key = command, e.g.:
gg = scrollstart
"""
sect = config.instance['keybind']
if not sect.items():
logging.warn("No keybindings defined!")
for (key, cmd) in sect.items():
if key.startswith('@') and key.endswith('@'):
# normalize keystring
keystr = self._normalize_keystr(key.strip('@'))
logging.debug('registered mod key: {} -> {}'.format(keystr,
cmd))
self.modifier_bindings[keystr] = cmd
else:
logging.debug('registered key: {} -> {}'.format(key, cmd))
self.bindings[key] = cmd

View File

@ -68,16 +68,26 @@ SECTION_DESC = {
'keybind': ( 'keybind': (
"Bindings from a key(chain) to a command.\n" "Bindings from a key(chain) to a command.\n"
"For special keys (can't be part of a keychain), enclose them in " "For special keys (can't be part of a keychain), enclose them in "
"@-signs. For modifiers, you can use either - or + as delimiters, and " "<...>. For modifiers, you can use either - or + as delimiters, and "
"these names:\n" "these names:\n"
" Control: Control, Ctrl\n" " Control: Control, Ctrl\n"
" Meta: Meta, Windows, Mod4\n" " Meta: Meta, Windows, Mod4\n"
" Alt: Alt, Mod1\n" " Alt: Alt, Mod1\n"
" Shift: Shift\n" " Shift: Shift\n"
"For simple keys (no @ signs), a capital letter means the key is " "For simple keys (no <>-signs), a capital letter means the key is "
"pressed with Shift. For modifier keys (with @ signs), you need " "pressed with Shift. For special keys (with <>-signs), you need "
"to explicitely add \"Shift-\" to match a key pressed with shift. " "to explicitely add \"Shift-\" to match a key pressed with shift. "
"You can bind multiple commands by separating them with \";;\"."), "You can bind multiple commands by separating them with \";;\"."),
'keybind.insert': (
"Keybindings for insert mode.\n"
"Since normal keypresses are passed through, only special keys are "
"supported in this mode.\n"
"An useful command to map here is the hidden command leave_mode."),
'keybind.hint': (
"Keybindings for hint mode.\n"
"Since normal keypresses are passed through, only special keys are "
"supported in this mode.\n"
"An useful command to map here is the hidden command leave_mode."),
'aliases': ( 'aliases': (
"Aliases for commands.\n" "Aliases for commands.\n"
"By default, no aliases are defined. Example which adds a new command " "By default, no aliases are defined. Example which adds a new command "
@ -166,6 +176,16 @@ DATA = OrderedDict([
('cmd_timeout', ('cmd_timeout',
SettingValue(types.Int(minval=0), "500"), SettingValue(types.Int(minval=0), "500"),
"Timeout for ambiguous keybindings."), "Timeout for ambiguous keybindings."),
('insert_mode_on_plugins',
SettingValue(types.Bool(), "true"),
"Whether to switch to insert mode when clicking flash and other "
"plugins."),
('auto_insert_mode',
SettingValue(types.Bool(), "true"),
"Whether to automatically enter insert mode if an editable element "
"is focused after page load."),
)), )),
('tabbar', sect.KeyValue( ('tabbar', sect.KeyValue(
@ -395,15 +415,27 @@ DATA = OrderedDict([
('PP', 'tabpaste sel'), ('PP', 'tabpaste sel'),
('-', 'zoomout'), ('-', 'zoomout'),
('+', 'zoomin'), ('+', 'zoomin'),
('@Ctrl-Q@', 'quit'), ('<Ctrl-Q>', 'quit'),
('@Ctrl-Shift-T@', 'undo'), ('<Ctrl-Shift-T>', 'undo'),
('@Ctrl-W@', 'tabclose'), ('<Ctrl-W>', 'tabclose'),
('@Ctrl-T@', 'tabopen about:blank'), ('<Ctrl-T>', 'tabopen about:blank'),
('@Ctrl-F@', 'scroll_page 0 1'), ('<Ctrl-F>', 'scroll_page 0 1'),
('@Ctrl-B@', 'scroll_page 0 -1'), ('<Ctrl-B>', 'scroll_page 0 -1'),
('@Ctrl-D@', 'scroll_page 0 0.5'), ('<Ctrl-D>', 'scroll_page 0 0.5'),
('@Ctrl-U@', 'scroll_page 0 -0.5'), ('<Ctrl-U>', 'scroll_page 0 -0.5'),
('@Backspace@', 'back'), ('<Backspace>', 'back'),
)),
('keybind.insert', sect.ValueList(
types.KeyBindingName(), types.KeyBinding(),
('<Escape>', 'leave_mode'),
('<Ctrl-C>', 'leave_mode'),
)),
('keybind.hint', sect.ValueList(
types.KeyBindingName(), types.KeyBinding(),
('<Escape>', 'leave_mode'),
('<Ctrl-C>', 'leave_mode'),
)), )),
('aliases', sect.ValueList( ('aliases', sect.ValueList(

View File

@ -0,0 +1,18 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""Modules related to keyboard input and mode handling."""

View File

@ -0,0 +1,58 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""KeyChainParser for "hint" mode."""
from PyQt5.QtCore import pyqtSignal, Qt
from qutebrowser.keyinput.keyparser import CommandKeyParser
class HintKeyParser(CommandKeyParser):
"""KeyChainParser for hints.
Signals:
fire_hint: When a hint keybinding was completed.
Arg: the keystring/hint string pressed.
"""
fire_hint = pyqtSignal(str)
def __init__(self, parent=None):
super().__init__(parent, supports_count=False, supports_chains=True)
self.read_config('keybind.hint')
def execute(self, cmdstr, keytype, count=None):
"""Handle a completed keychain.
Emit:
fire_hint: Emitted if keytype is TYPE_CHAIN
"""
if keytype == self.TYPE_CHAIN:
self.fire_hint.emit(cmdstr)
else:
# execute as command
super().execute(cmdstr, keytype, count)
def on_hint_strings_updated(self, strings):
"""Handler for HintManager's hint_strings_updated.
Args:
strings: A list of hint strings.
"""
self.bindings = {s: s for s in strings}

View File

@ -0,0 +1,30 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""KeyParser for "insert" mode."""
import qutebrowser.keyinput.modes as modes
from qutebrowser.keyinput.keyparser import CommandKeyParser
class InsertKeyParser(CommandKeyParser):
"""KeyParser for insert mode."""
def __init__(self, parent=None):
super().__init__(parent, supports_chains=False)
self.read_config('keybind.insert')

View File

@ -21,15 +21,18 @@ import re
import logging import logging
from functools import partial from functools import partial
from PyQt5.QtCore import pyqtSignal, Qt, QObject, QTimer from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QObject, QTimer
from PyQt5.QtGui import QKeySequence from PyQt5.QtGui import QKeySequence
import qutebrowser.config.config as config import qutebrowser.config.config as config
import qutebrowser.utils.message as message
from qutebrowser.commands.parsers import (CommandParser, ArgumentCountError,
NoSuchCommandError)
class KeyParser(QObject): class KeyParser(QObject):
"""Parser for vim-like key sequences. """Parser for vim-like key sequences and shortcuts.
Not intended to be instantiated directly. Subclasses have to override Not intended to be instantiated directly. Subclasses have to override
execute() to do whatever they want to. execute() to do whatever they want to.
@ -40,13 +43,18 @@ class KeyParser(QObject):
MATCH_DEFINITIVE: Constant for a full match (keychain matches exactly). MATCH_DEFINITIVE: Constant for a full match (keychain matches exactly).
MATCH_AMBIGUOUS: There are both a partial and a definitive match. MATCH_AMBIGUOUS: There are both a partial and a definitive match.
MATCH_NONE: Constant for no match (no more matches possible). MATCH_NONE: Constant for no match (no more matches possible).
supports_count: If the keyparser should support counts.
TYPE_CHAIN: execute() was called via a chain-like keybinding
TYPE_SPECIAL: execute() was called via a special keybinding
Attributes: Attributes:
bindings: Bound keybindings
special_bindings: Bound special bindings (<Foo>).
_keystring: The currently entered key sequence _keystring: The currently entered key sequence
_timer: QTimer for delayed execution. _timer: QTimer for delayed execution.
bindings: Bound keybindings _confsectname: The name of the configsection.
modifier_bindings: Bound modifier bindings. _supports_count: Whether count is supported
_supports_chains: Whether keychains are supported
Signals: Signals:
keystring_updated: Emitted when the keystring is updated. keystring_updated: Emitted when the keystring is updated.
@ -60,18 +68,46 @@ class KeyParser(QObject):
MATCH_AMBIGUOUS = 2 MATCH_AMBIGUOUS = 2
MATCH_NONE = 3 MATCH_NONE = 3
supports_count = False TYPE_CHAIN = 0
TYPE_SPECIAL = 1
def __init__(self, parent=None, bindings=None, modifier_bindings=None): def __init__(self, parent=None, supports_count=None,
supports_chains=False):
super().__init__(parent) super().__init__(parent)
self._timer = None self._timer = None
self._confsectname = None
self._keystring = '' self._keystring = ''
self.bindings = {} if bindings is None else bindings if supports_count is None:
self.modifier_bindings = ({} if modifier_bindings is None supports_count = supports_chains
else modifier_bindings) self._supports_count = supports_count
self._supports_chains = supports_chains
self.bindings = {}
self.special_bindings = {}
def _handle_modifier_key(self, e): def _normalize_keystr(self, keystr):
"""Handle a new keypress with modifiers. """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q.
Args:
keystr: The key combination as a string.
Return:
The normalized keystring.
"""
replacements = [
('Control', 'Ctrl'),
('Windows', 'Meta'),
('Mod1', 'Alt'),
('Mod4', 'Meta'),
]
for (orig, repl) in replacements:
keystr = keystr.replace(orig, repl)
for mod in ['Ctrl', 'Meta', 'Alt', 'Shift']:
keystr = keystr.replace(mod + '-', mod + '+')
keystr = QKeySequence(keystr).toString()
return keystr
def _handle_special_key(self, e):
"""Handle a new keypress with special keys (<Foo>).
Return True if the keypress has been handled, and False if not. Return True if the keypress has been handled, and False if not.
@ -92,19 +128,16 @@ class KeyParser(QObject):
return False return False
mod = e.modifiers() mod = e.modifiers()
modstr = '' modstr = ''
if not mod & (Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier):
# won't be a shortcut with modifiers
return False
for (mask, s) in modmask2str.items(): for (mask, s) in modmask2str.items():
if mod & mask: if mod & mask:
modstr += s + '+' modstr += s + '+'
keystr = QKeySequence(e.key()).toString() keystr = QKeySequence(e.key()).toString()
try: try:
cmdstr = self.modifier_bindings[modstr + keystr] cmdstr = self.special_bindings[modstr + keystr]
except KeyError: except KeyError:
logging.debug('No binding found for {}.'.format(modstr + keystr)) logging.debug('No binding found for {}.'.format(modstr + keystr))
return True return False
self.execute(cmdstr) self.execute(cmdstr, self.TYPE_SPECIAL)
return True return True
def _handle_single_key(self, e): def _handle_single_key(self, e):
@ -116,17 +149,20 @@ class KeyParser(QObject):
Args: Args:
e: the KeyPressEvent from Qt. e: the KeyPressEvent from Qt.
Return:
True if event has been handled, False otherwise.
""" """
logging.debug('Got key: {} / text: "{}"'.format(e.key(), e.text())) logging.debug('Got key: {} / text: "{}"'.format(e.key(), e.text()))
txt = e.text().strip() txt = e.text().strip()
if not txt: if not txt:
logging.debug('Ignoring, no text') logging.debug('Ignoring, no text')
return return False
self._stop_delayed_exec() self._stop_delayed_exec()
self._keystring += txt self._keystring += txt
if self.supports_count: if self._supports_count:
(countstr, cmd_input) = re.match(r'^(\d*)(.*)', (countstr, cmd_input) = re.match(r'^(\d*)(.*)',
self._keystring).groups() self._keystring).groups()
count = int(countstr) if countstr else None count = int(countstr) if countstr else None
@ -135,13 +171,14 @@ class KeyParser(QObject):
count = None count = None
if not cmd_input: if not cmd_input:
return # Only a count, no command yet, but we handled it
return True
(match, binding) = self._match_key(cmd_input) (match, binding) = self._match_key(cmd_input)
if match == self.MATCH_DEFINITIVE: if match == self.MATCH_DEFINITIVE:
self._keystring = '' self._keystring = ''
self.execute(binding, count) self.execute(binding, self.TYPE_CHAIN, count)
elif match == self.MATCH_AMBIGUOUS: elif match == self.MATCH_AMBIGUOUS:
self._handle_ambiguous_match(binding, count) self._handle_ambiguous_match(binding, count)
elif match == self.MATCH_PARTIAL: elif match == self.MATCH_PARTIAL:
@ -151,6 +188,8 @@ class KeyParser(QObject):
logging.debug('Giving up with "{}", no matches'.format( logging.debug('Giving up with "{}", no matches'.format(
self._keystring)) self._keystring))
self._keystring = '' self._keystring = ''
return False
return True
def _match_key(self, cmd_input): def _match_key(self, cmd_input):
"""Try to match a given keystring with any bound keychain. """Try to match a given keystring with any bound keychain.
@ -213,7 +252,7 @@ class KeyParser(QObject):
if time == 0: if time == 0:
# execute immediately # execute immediately
self._keystring = '' self._keystring = ''
self.execute(binding, count) self.execute(binding, self.TYPE_CHAIN, count)
else: else:
# execute in `time' ms # execute in `time' ms
logging.debug("Scheduling execution of {} in {}ms".format(binding, logging.debug("Scheduling execution of {} in {}ms".format(binding,
@ -225,28 +264,6 @@ class KeyParser(QObject):
count)) count))
self._timer.start() self._timer.start()
def _normalize_keystr(self, keystr):
"""Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q.
Args:
keystr: The key combination as a string.
Return:
The normalized keystring.
"""
replacements = [
('Control', 'Ctrl'),
('Windows', 'Meta'),
('Mod1', 'Alt'),
('Mod4', 'Meta'),
]
for (orig, repl) in replacements:
keystr = keystr.replace(orig, repl)
for mod in ['Ctrl', 'Meta', 'Alt', 'Shift']:
keystr = keystr.replace(mod + '-', mod + '+')
keystr = QKeySequence(keystr).toString()
return keystr
def delayed_exec(self, command, count): def delayed_exec(self, command, count):
"""Execute a delayed command. """Execute a delayed command.
@ -260,11 +277,7 @@ class KeyParser(QObject):
self._timer = None self._timer = None
self._keystring = '' self._keystring = ''
self.keystring_updated.emit(self._keystring) self.keystring_updated.emit(self._keystring)
self.execute(command, count) self.execute(command, self.TYPE_CHAIN, count)
def execute(self, cmdstr, count=None):
"""Execute an action when a binding is triggered."""
raise NotImplementedError
def handle(self, e): def handle(self, e):
"""Handle a new keypress and call the respective handlers. """Handle a new keypress and call the respective handlers.
@ -275,7 +288,95 @@ class KeyParser(QObject):
Emit: Emit:
keystring_updated: If a new keystring should be set. keystring_updated: If a new keystring should be set.
""" """
handled = self._handle_modifier_key(e) handled = self._handle_special_key(e)
if not handled: if handled or not self._supports_chains:
self._handle_single_key(e) return handled
self.keystring_updated.emit(self._keystring) handled = self._handle_single_key(e)
self.keystring_updated.emit(self._keystring)
return handled
def read_config(self, sectname=None):
"""Read the configuration.
Config format: key = command, e.g.:
<Ctrl+Q> = quit
Args:
sectname: Name of the section to read.
"""
if sectname is None:
if self._confsectname is None:
raise ValueError("read_config called with no section, but "
"None defined so far!")
sectname = self._confsectname
else:
self._confsectname = sectname
sect = config.instance[sectname]
if not sect.items():
logging.warn("No keybindings defined!")
for (key, cmd) in sect.items():
if key.startswith('<') and key.endswith('>'):
keystr = self._normalize_keystr(key[1:-1])
logging.debug("registered special key: {} -> {}".format(keystr,
cmd))
self.special_bindings[keystr] = cmd
elif self._supports_chains:
logging.debug("registered key: {} -> {}".format(key, cmd))
self.bindings[key] = cmd
else:
logging.warn(
"Ignoring keychain \"{}\" in section \"{}\" because "
"keychains are not supported there.".format(key, sectname))
def execute(self, cmdstr, keytype, count=None):
"""Handle a completed keychain.
Args:
cmdstr: The command to execute as a string.
keytype: TYPE_CHAIN or TYPE_SPECIAL
count: The count if given.
"""
raise NotImplementedError
@pyqtSlot(str, str)
def on_config_changed(self, section, _option):
"""Re-read the config if a keybinding was changed."""
if self._confsectname is None:
raise AttributeError("on_config_changed called but no section "
"defined!")
if section == self._confsectname:
self.read_config()
class CommandKeyParser(KeyParser):
"""KeyChainParser for command bindings.
Attributes:
commandparser: Commandparser instance.
"""
def __init__(self, parent=None, supports_count=None,
supports_chains=False):
super().__init__(parent, supports_count, supports_chains)
self.commandparser = CommandParser()
def _run_or_fill(self, cmdstr, count=None, ignore_exc=True):
"""Run the command in cmdstr or fill the statusbar if args missing.
Args:
cmdstr: The command string.
count: Optional command count.
ignore_exc: Ignore exceptions.
"""
try:
self.commandparser.run(cmdstr, count=count, ignore_exc=ignore_exc)
except NoSuchCommandError:
pass
except ArgumentCountError:
logging.debug('Filling statusbar with partial command {}'.format(
cmdstr))
message.set_cmd_text(':{} '.format(cmdstr))
def execute(self, cmdstr, _keytype, count=None):
self._run_or_fill(cmdstr, count, ignore_exc=False)

View File

@ -0,0 +1,215 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""Mode manager singleton which handles the current keyboard mode.
Module attributes:
manager: The ModeManager instance.
"""
import logging
from PyQt5.QtGui import QWindow
from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QEvent
import qutebrowser.config.config as config
import qutebrowser.commands.utils as cmdutils
import qutebrowser.utils.debug as debug
manager = None
def init(parent=None):
"""Initialize the global ModeManager.
This needs to be done by hand because the import time is before Qt is ready
for everything.
Args:
parent: Parent to use for ModeManager.
"""
global manager
manager = ModeManager(parent)
def enter(mode):
"""Enter the mode 'mode'."""
manager.enter(mode)
def leave(mode):
"""Leave the mode 'mode'."""
manager.leave(mode)
def maybe_leave(mode):
"""Convenience method to leave 'mode' without exceptions."""
try:
manager.leave(mode)
except ValueError:
pass
class ModeManager(QObject):
"""Manager for keyboard modes.
Attributes:
mode: The current mode (readonly property).
passthrough: A list of modes in which to pass through events.
_handlers: A dictionary of modes and their handlers.
_mode_stack: A list of the modes we're currently in, with the active
one on the right.
Signals:
entered: Emitted when a mode is entered.
arg: Name of the entered mode.
left: Emitted when a mode is left.
arg: Name of the left mode.
key_pressed: A key was pressed.
"""
entered = pyqtSignal(str)
left = pyqtSignal(str)
key_pressed = pyqtSignal('QKeyEvent')
def __init__(self, parent=None):
super().__init__(parent)
self._handlers = {}
self.passthrough = []
self._mode_stack = []
@property
def mode(self):
"""Read-only property for the current mode."""
if not self._mode_stack:
return None
return self._mode_stack[-1]
def register(self, mode, handler, passthrough=False):
"""Register a new mode.
Args:
mode: The name of the mode.
handler: Handler for keyPressEvents.
passthrough: Whether to pass keybindings in this mode through to
the widgets.
"""
self._handlers[mode] = handler
if passthrough:
self.passthrough.append(mode)
def enter(self, mode):
"""Enter a new mode.
Args:
mode; The name of the mode to enter.
Emit:
entered: With the new mode name.
"""
logging.debug("Switching mode to {}".format(mode))
if mode not in self._handlers:
raise ValueError("No handler for mode {}".format(mode))
if self._mode_stack and self._mode_stack[-1] == mode:
logging.debug("Already at end of stack, doing nothing")
return
self._mode_stack.append(mode)
logging.debug("New mode stack: {}".format(self._mode_stack))
self.entered.emit(mode)
def leave(self, mode):
"""Leave a mode.
Args:
mode; The name of the mode to leave.
Emit:
left: With the old mode name.
"""
try:
self._mode_stack.remove(mode)
except ValueError:
raise ValueError("Mode {} not on mode stack!".format(mode))
logging.debug("Leaving mode {}".format(mode))
logging.debug("New mode stack: {}".format(self._mode_stack))
self.left.emit(mode)
# FIXME handle modes=[] and not_modes=[] params
@cmdutils.register(instance='modeman', name='leave_mode', hide=True)
def leave_current_mode(self):
if self.mode == "normal":
raise ValueError("Can't leave normal mode!")
self.leave(self.mode)
def eventFilter(self, obj, evt):
"""Filter all events based on the currently set mode.
Also calls the real keypress handler.
Emit:
key_pressed: When a key was actually pressed.
"""
if self.mode is None:
# We got events before mode is set, so just pass them through.
return False
typ = evt.type()
if typ not in [QEvent.KeyPress, QEvent.KeyRelease]:
# We're not interested in non-key-events so we pass them through.
return False
if not isinstance(obj, QWindow):
# We already handled this same event at some point earlier, so
# we're not interested in it anymore.
logging.debug("Got event {} for {} -> ignoring".format(
debug.EVENTS[typ], obj.__class__.__name__))
return False
logging.debug("Got event {} for {}".format(
debug.EVENTS[typ], obj.__class__.__name__))
handler = self._handlers[self.mode]
if self.mode in self.passthrough:
# We're currently in a passthrough mode so we pass everything
# through.*and* let the passthrough keyhandler know.
# FIXME what if we leave the passthrough mode right here?
logging.debug("We're in a passthrough mode -> passing through")
if typ == QEvent.KeyPress:
logging.debug("KeyPress, calling handler {}".format(handler))
self.key_pressed.emit(evt)
if handler is not None:
handler(evt)
else:
logging.debug("KeyRelease, not calling anything")
return False
else:
logging.debug("We're in a non-passthrough mode")
if typ == QEvent.KeyPress:
# KeyPress in a non-passthrough mode - call handler and filter
# event from widgets (unless unhandled and configured to pass
# unhandled events through)
logging.debug("KeyPress, calling handler {} and "
"filtering".format(handler))
self.key_pressed.emit(evt)
handled = handler(evt) if handler is not None else False
return True
else:
# KeyRelease in a non-passthrough mode - filter event and
# ignore it entirely.
logging.debug("KeyRelease, not calling anything and filtering")
return True

View File

@ -0,0 +1,51 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""Parse keypresses/keychains in the main window.
Module attributes:
STARTCHARS: Possible chars for starting a commandline input.
"""
import logging
import qutebrowser.utils.message as message
from qutebrowser.keyinput.keyparser import CommandKeyParser
STARTCHARS = ":/?"
class NormalKeyParser(CommandKeyParser):
def __init__(self, parent=None):
super().__init__(parent, supports_count=True, supports_chains=True)
self.read_config('keybind')
def _handle_single_key(self, e):
"""Override _handle_single_key to abort if the key is a startchar.
Args:
e: the KeyPressEvent from Qt.
Return:
True if event has been handled, False otherwise.
"""
txt = e.text().strip()
if not self._keystring and any(txt == c for c in STARTCHARS):
message.set_cmd_text(txt)
return True
return super()._handle_single_key(e)

View File

@ -57,6 +57,11 @@ def clear():
bridge.text.emit('') bridge.text.emit('')
def set_cmd_text(txt):
"""Set the statusbar command line to a preset text."""
bridge.set_cmd_text.emit(txt)
class MessageBridge(QObject): class MessageBridge(QObject):
"""Bridge for messages to be shown in the statusbar.""" """Bridge for messages to be shown in the statusbar."""
@ -64,3 +69,4 @@ class MessageBridge(QObject):
error = pyqtSignal(str) error = pyqtSignal(str)
info = pyqtSignal(str) info = pyqtSignal(str)
text = pyqtSignal(str) text = pyqtSignal(str)
set_cmd_text = pyqtSignal(str)

View File

@ -0,0 +1,74 @@
# Copyright 2014 Florian Bruhin (The Compiler) <mail@qutebrowser.org>
#
# 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 <http://www.gnu.org/licenses/>.
"""Utilities related to QWebElements.
Module attributes:
SELECTORS: CSS selectors for different groups of elements.
FILTERS: A dictionary of filter functions for the modes.
The filter for "links" filters javascript:-links and a-tags
without "href".
"""
import qutebrowser.utils.url as urlutils
SELECTORS = {
'all': ('a, textarea, select, input:not([type=hidden]), button, '
'frame, iframe, [onclick], [onmousedown], [role=link], '
'[role=option], [role=button], img'),
'links': 'a',
'images': 'img',
'editable': ('input[type=text], input[type=email], input[type=url], '
'input[type=tel], input[type=number], '
'input[type=password], input[type=search], textarea'),
'url': '[src], [href]',
}
SELECTORS['editable_focused'] = ', '.join(
[sel.strip() + ':focus' for sel in SELECTORS['editable'].split(',')])
FILTERS = {
'links': (lambda e: e.hasAttribute('href') and
urlutils.qurl(e.attribute('href')).scheme() != 'javascript'),
}
def is_visible(e, frame=None):
"""Check whether the element is currently visible in its frame.
Args:
e: The QWebElement to check.
frame: The QWebFrame in which the element should be visible in.
If None, the element's frame is used.
Return:
True if the element is visible, False otherwise.
"""
if e.isNull():
raise ValueError("Element is a null-element!")
if frame is None:
frame = e.webFrame()
rect = e.geometry()
if (not rect.isValid()) and rect.x() == 0:
# Most likely an invisible link
return False
framegeom = frame.geometry()
framegeom.translate(frame.scrollPosition())
if not framegeom.contains(rect.topLeft()):
# out of screen
return False
return True

View File

@ -27,7 +27,9 @@ from PyQt5.QtWebKitWidgets import QWebView, QWebPage
import qutebrowser.utils.url as urlutils import qutebrowser.utils.url as urlutils
import qutebrowser.config.config as config import qutebrowser.config.config as config
import qutebrowser.keyinput.modes as modes
import qutebrowser.utils.message as message import qutebrowser.utils.message as message
import qutebrowser.utils.webelem as webelem
from qutebrowser.browser.webpage import BrowserPage from qutebrowser.browser.webpage import BrowserPage
from qutebrowser.browser.hints import HintManager from qutebrowser.browser.hints import HintManager
from qutebrowser.utils.signals import SignalCache from qutebrowser.utils.signals import SignalCache
@ -84,6 +86,8 @@ class BrowserTab(QWebView):
self.page_.setLinkDelegationPolicy(QWebPage.DelegateAllLinks) self.page_.setLinkDelegationPolicy(QWebPage.DelegateAllLinks)
self.page_.linkHovered.connect(self.linkHovered) self.page_.linkHovered.connect(self.linkHovered)
self.linkClicked.connect(self.on_link_clicked) self.linkClicked.connect(self.on_link_clicked)
self.loadStarted.connect(lambda: modes.maybe_leave("insert"))
self.loadFinished.connect(self.on_load_finished)
# FIXME find some way to hide scrollbars without setScrollBarPolicy # FIXME find some way to hide scrollbars without setScrollBarPolicy
def _init_neighborlist(self): def _init_neighborlist(self):
@ -109,6 +113,38 @@ class BrowserTab(QWebView):
logging.debug("Everything destroyed, calling callback") logging.debug("Everything destroyed, calling callback")
self._shutdown_callback() self._shutdown_callback()
def _is_editable(self, hitresult):
"""Check if a hit result needs keyboard focus.
Args:
hitresult: A QWebHitTestResult
"""
# FIXME is this algorithm accurate?
if hitresult.isContentEditable():
# text fields and the like
return True
if not config.get('general', 'insert_mode_on_plugins'):
return False
elem = hitresult.element()
tag = elem.tagName().lower()
if tag in ['embed', 'applet']:
# Flash/Java/...
return True
if tag == 'object':
# Could be Flash/Java/..., could be image/audio/...
if not elem.hasAttribute("type"):
logging.debug("<object> without type clicked...")
return False
objtype = elem.attribute("type")
if (objtype.startswith("application/") or
elem.hasAttribute("classid")):
# Let's hope flash/java stuff has an application/* mimetype OR
# at least a classid attribute. Oh, and let's home images/...
# DON"T have a classid attribute. HTML sucks.
logging.debug("<object type=\"{}\"> clicked.".format(objtype))
return True
return False
def openurl(self, url): def openurl(self, url):
"""Open an URL in the browser. """Open an URL in the browser.
@ -209,6 +245,20 @@ class BrowserTab(QWebView):
self.setFocus() self.setFocus()
QApplication.postEvent(self, evt) QApplication.postEvent(self, evt)
@pyqtSlot(bool)
def on_load_finished(self, _ok):
"""Handle auto_insert_mode after loading finished."""
if not config.get('general', 'auto_insert_mode'):
return
frame = self.page_.currentFrame()
elem = frame.findFirstElement(
webelem.SELECTORS['editable_focused'])
logging.debug("focus element: {}".format(not elem.isNull()))
if elem.isNull():
modes.maybe_leave("insert")
else:
modes.enter("insert")
@pyqtSlot(str) @pyqtSlot(str)
def set_force_open_target(self, target): def set_force_open_target(self, target):
"""Change the forced link target. Setter for _force_open_target. """Change the forced link target. Setter for _force_open_target.
@ -249,12 +299,13 @@ class BrowserTab(QWebView):
return super().paintEvent(e) return super().paintEvent(e)
def mousePressEvent(self, e): def mousePressEvent(self, e):
"""Check if a link was clicked with the middle button or Ctrl. """Extend QWidget::mousePressEvent().
Extend the superclass mousePressEvent(). This does the following things:
- Check if a link was clicked with the middle button or Ctrl and
This also is a bit of a hack, but it seems it's the only possible way. set the _open_target attribute accordingly.
Set the _open_target attribute accordingly. - Emit the editable_elem_selected signal if an editable element was
clicked.
Args: Args:
e: The arrived event. e: The arrived event.
@ -262,6 +313,20 @@ class BrowserTab(QWebView):
Return: Return:
The superclass return value. The superclass return value.
""" """
pos = e.pos()
frame = self.page_.frameAt(pos)
pos -= frame.geometry().topLeft()
hitresult = frame.hitTestContent(pos)
if self._is_editable(hitresult):
logging.debug("Clicked editable element!")
modes.enter("insert")
else:
logging.debug("Clicked non-editable element!")
try:
modes.leave("insert")
except ValueError:
pass
if self._force_open_target is not None: if self._force_open_target is not None:
self._open_target = self._force_open_target self._open_target = self._force_open_target
self._force_open_target = None self._force_open_target = None

View File

@ -23,8 +23,9 @@ from PyQt5.QtWidgets import (QWidget, QLineEdit, QProgressBar, QLabel,
QShortcut) QShortcut)
from PyQt5.QtGui import QPainter, QKeySequence, QValidator from PyQt5.QtGui import QPainter, QKeySequence, QValidator
import qutebrowser.keyinput.modes as modes
from qutebrowser.keyinput.normalmode import STARTCHARS
from qutebrowser.config.style import set_register_stylesheet, get_stylesheet from qutebrowser.config.style import set_register_stylesheet, get_stylesheet
import qutebrowser.commands.keys as keys
from qutebrowser.utils.url import urlstring from qutebrowser.utils.url import urlstring
from qutebrowser.commands.parsers import split_cmdline from qutebrowser.commands.parsers import split_cmdline
from qutebrowser.models.cmdhistory import (History, HistoryEmptyError, from qutebrowser.models.cmdhistory import (History, HistoryEmptyError,
@ -157,7 +158,7 @@ class StatusBar(QWidget):
self.txt.errortext = '' self.txt.errortext = ''
@pyqtSlot('QKeyEvent') @pyqtSlot('QKeyEvent')
def keypress(self, e): def on_key_pressed(self, e):
"""Hide temporary error message if a key was pressed. """Hide temporary error message if a key was pressed.
Args: Args:
@ -169,6 +170,18 @@ class StatusBar(QWidget):
self.txt.set_temptext('') self.txt.set_temptext('')
self.clear_error() self.clear_error()
@pyqtSlot(str)
def on_mode_entered(self, mode):
"""Mark certain modes in the commandline."""
if mode in modes.manager.passthrough:
self.txt.normaltext = "-- {} MODE --".format(mode.upper())
@pyqtSlot(str)
def on_mode_left(self, mode):
"""Clear marked mode."""
if mode in modes.manager.passthrough:
self.txt.normaltext = ""
def resizeEvent(self, e): def resizeEvent(self, e):
"""Extend resizeEvent of QWidget to emit a resized signal afterwards. """Extend resizeEvent of QWidget to emit a resized signal afterwards.
@ -331,7 +344,7 @@ class _Command(QLineEdit):
""" """
# FIXME we should consider the cursor position. # FIXME we should consider the cursor position.
text = self.text() text = self.text()
if text[0] in keys.STARTCHARS: if text[0] in STARTCHARS:
prefix = text[0] prefix = text[0]
text = text[1:] text = text[1:]
else: else:
@ -342,8 +355,18 @@ class _Command(QLineEdit):
self.setFocus() self.setFocus()
self.show_cmd.emit() self.show_cmd.emit()
def focusInEvent(self, e):
"""Extend focusInEvent to enter command mode."""
modes.enter("command")
super().focusInEvent(e)
def focusOutEvent(self, e): def focusOutEvent(self, e):
"""Clear the statusbar text if it's explicitely unfocused. """Extend focusOutEvent to do several tasks.
- Clear the statusbar text if it's explicitely unfocused.
- Leave command mode
- Clear completion selection
- Hide completion
Args: Args:
e: The QFocusEvent. e: The QFocusEvent.
@ -352,6 +375,7 @@ class _Command(QLineEdit):
clear_completion_selection: Always emitted. clear_completion_selection: Always emitted.
hide_completion: Always emitted so the completion is hidden. hide_completion: Always emitted so the completion is hidden.
""" """
modes.leave("command")
if e.reason() in [Qt.MouseFocusReason, Qt.TabFocusReason, if e.reason() in [Qt.MouseFocusReason, Qt.TabFocusReason,
Qt.BacktabFocusReason, Qt.OtherFocusReason]: Qt.BacktabFocusReason, Qt.OtherFocusReason]:
self.setText('') self.setText('')
@ -376,7 +400,7 @@ class _CommandValidator(QValidator):
Return: Return:
A tuple (status, string, pos) as a QValidator should. A tuple (status, string, pos) as a QValidator should.
""" """
if any(string.startswith(c) for c in keys.STARTCHARS): if any(string.startswith(c) for c in STARTCHARS):
return (QValidator.Acceptable, string, pos) return (QValidator.Acceptable, string, pos)
else: else:
return (QValidator.Invalid, string, pos) return (QValidator.Invalid, string, pos)

View File

@ -69,10 +69,6 @@ class TabbedBrowser(TabWidget):
arg 2: y-position in %. arg 2: y-position in %.
hint_strings_updated: Hint strings were updated. hint_strings_updated: Hint strings were updated.
arg: A list of hint strings. arg: A list of hint strings.
set_mode: The input mode should be changed.
arg: The new mode as a string.
keypress: A key was pressed.
arg: The QKeyEvent leading to the keypress.
shutdown_complete: The shuttdown is completed. shutdown_complete: The shuttdown is completed.
quit: The last tab was closed, quit application. quit: The last tab was closed, quit application.
resized: Emitted when the browser window has resized, so the completion resized: Emitted when the browser window has resized, so the completion
@ -88,9 +84,6 @@ class TabbedBrowser(TabWidget):
cur_link_hovered = pyqtSignal(str, str, str) cur_link_hovered = pyqtSignal(str, str, str)
cur_scroll_perc_changed = pyqtSignal(int, int) cur_scroll_perc_changed = pyqtSignal(int, int)
hint_strings_updated = pyqtSignal(list) hint_strings_updated = pyqtSignal(list)
set_cmd_text = pyqtSignal(str)
set_mode = pyqtSignal(str)
keypress = pyqtSignal('QKeyEvent')
shutdown_complete = pyqtSignal() shutdown_complete = pyqtSignal()
quit = pyqtSignal() quit = pyqtSignal()
resized = pyqtSignal('QRect') resized = pyqtSignal('QRect')
@ -143,8 +136,6 @@ class TabbedBrowser(TabWidget):
tab.urlChanged.connect(self._filter.create(self.cur_url_changed)) tab.urlChanged.connect(self._filter.create(self.cur_url_changed))
# hintmanager # hintmanager
tab.hintmanager.hint_strings_updated.connect(self.hint_strings_updated) tab.hintmanager.hint_strings_updated.connect(self.hint_strings_updated)
tab.hintmanager.set_mode.connect(self.set_mode)
tab.hintmanager.set_cmd_text.connect(self.set_cmd_text)
# misc # misc
tab.titleChanged.connect(self.on_title_changed) tab.titleChanged.connect(self.on_title_changed)
tab.open_tab.connect(self.tabopen) tab.open_tab.connect(self.tabopen)
@ -242,23 +233,15 @@ class TabbedBrowser(TabWidget):
@cmdutils.register(instance='mainwindow.tabs', hide=True) @cmdutils.register(instance='mainwindow.tabs', hide=True)
def tabopencur(self): def tabopencur(self):
"""Set the statusbar to :tabopen and the current URL. """Set the statusbar to :tabopen and the current URL."""
Emit:
set_cmd_text prefilled with :tabopen $URL
"""
url = urlutils.urlstring(self.currentWidget().url()) url = urlutils.urlstring(self.currentWidget().url())
self.set_cmd_text.emit(':tabopen ' + url) message.set_cmd_text(':tabopen ' + url)
@cmdutils.register(instance='mainwindow.tabs', hide=True) @cmdutils.register(instance='mainwindow.tabs', hide=True)
def opencur(self): def opencur(self):
"""Set the statusbar to :open and the current URL. """Set the statusbar to :open and the current URL."""
Emit:
set_cmd_text prefilled with :open $URL
"""
url = urlutils.urlstring(self.currentWidget().url()) url = urlutils.urlstring(self.currentWidget().url())
self.set_cmd_text.emit(':open ' + url) message.set_cmd_text(':open ' + url)
@cmdutils.register(instance='mainwindow.tabs', name='undo') @cmdutils.register(instance='mainwindow.tabs', name='undo')
def undo_close(self): def undo_close(self):
@ -360,18 +343,6 @@ class TabbedBrowser(TabWidget):
else: else:
logging.debug('ignoring title change') logging.debug('ignoring title change')
def keyPressEvent(self, e):
"""Extend TabWidget (QWidget)'s keyPressEvent to emit a signal.
Args:
e: The QKeyPressEvent
Emit:
keypress: Always emitted.
"""
self.keypress.emit(e)
super().keyPressEvent(e)
def resizeEvent(self, e): def resizeEvent(self, e):
"""Extend resizeEvent of QWidget to emit a resized signal afterwards. """Extend resizeEvent of QWidget to emit a resized signal afterwards.