From 695712e50c3871f00959b838909545ff12ae3d6b Mon Sep 17 00:00:00 2001
From: Artur Shaik <ashaihullin@gmail.com>
Date: Thu, 9 Apr 2015 22:55:42 +0600
Subject: [PATCH] Basic caret and visual modes implementation

Allow user switch in caret mode for browsing with caret, and visual mode
for select and yank text with keyboard.

Default keybindings is c or v for caret mode, and again v for visual mode. All
basic movements provided by WebAction enum implemened with vim-like
bindings. Yanking with y and Y for selection and clipboard respectively.

There is bug/feature in WebKit that after caret enabled, caret doesn't
show until mouse click (or sometimes Tab helps). So I add some workaround
for that with mouse event. I think should be better aproach.

Signed-off-by: Artur Shaik <ashaihullin@gmail.com>
---
 qutebrowser/browser/commands.py         | 191 ++++++++++++++++++++++++
 qutebrowser/browser/webview.py          |  38 ++++-
 qutebrowser/config/configdata.py        |  33 ++++
 qutebrowser/keyinput/modeman.py         |   5 +
 qutebrowser/keyinput/modeparsers.py     |  24 +++
 qutebrowser/mainwindow/tabbedbrowser.py |   3 +-
 qutebrowser/utils/usertypes.py          |   2 +-
 7 files changed, 293 insertions(+), 3 deletions(-)

diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py
index 7dc0a35fd..93488bd04 100644
--- a/qutebrowser/browser/commands.py
+++ b/qutebrowser/browser/commands.py
@@ -40,6 +40,7 @@ from qutebrowser.browser import webelem
 from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils,
                                objreg, utils)
 from qutebrowser.misc import editor
+from qutebrowser.keyinput import modeman
 
 
 class CommandDispatcher:
@@ -1071,3 +1072,193 @@ class CommandDispatcher:
                 elem.evaluateJavaScript("this.value='{}'".format(text))
         except webelem.IsNullError:
             raise cmdexc.CommandError("Element vanished while editing!")
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_next_line(self):
+        """Move the cursor or select to the next line."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToNextLine
+        else:
+            act = QWebPage.SelectNextLine
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_prev_line(self):
+        """Move the cursor or select to the prev line."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToPreviousLine
+        else:
+            act = QWebPage.SelectPreviousLine
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_next_char(self):
+        """Move the cursor or select to the next char."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToNextChar
+        else:
+            act = QWebPage.SelectNextChar
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_prev_char(self):
+        """Move the cursor or select to the prev char."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToPreviousChar
+        else:
+            act = QWebPage.SelectPreviousChar
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_end_of_word(self):
+        """Move the cursor or select to the next word."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToNextWord
+        else:
+            act = QWebPage.SelectNextWord
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_next_word(self):
+        """Move the cursor or select to the next word."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = [QWebPage.MoveToNextWord, QWebPage.MoveToNextChar]
+        else:
+            act = [QWebPage.SelectNextWord, QWebPage.SelectNextChar]
+        for a in act:
+            self._current_widget().triggerPageAction(a)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_prev_word(self):
+        """Move the cursor or select to the prev word."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToPreviousWord
+        else:
+            act = QWebPage.SelectPreviousWord
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_start_of_line(self):
+        """Move the cursor or select to the start of line."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToStartOfLine
+        else:
+            act = QWebPage.SelectStartOfLine
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_end_of_line(self):
+        """Move the cursor or select to the end of line."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToEndOfLine
+        else:
+            act = QWebPage.SelectEndOfLine
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_start_of_block(self):
+        """Move the cursor or select to the start of block."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToStartOfBlock
+        else:
+            act = QWebPage.SelectStartOfBlock
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_end_of_block(self):
+        """Move the cursor or select to the end of block."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToEndOfBlock
+        else:
+            act = QWebPage.SelectEndOfBlock
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_start_of_document(self):
+        """Move the cursor or select to the start of document."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToStartOfDocument
+        else:
+            act = QWebPage.SelectStartOfDocument
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.caret, usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def move_to_end_of_document(self):
+        """Move the cursor or select to the end of document."""
+        modemanager = modeman._get_modeman(self._win_id)
+        if modemanager.mode == usertypes.KeyMode.caret:
+            act = QWebPage.MoveToEndOfDocument
+        else:
+            act = QWebPage.SelectEndOfDocument
+        self._current_widget().triggerPageAction(act)
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def yank_selected(self, sel=False):
+        """Yank selected text to the clipboard or primary selection.
+
+        Args:
+            sel: Use the primary selection instead of the clipboard.
+        """
+        s = self._current_widget().selectedText()
+        if not self._current_widget().hasSelection() or len(s) == 0:
+            message.info(self._win_id, "Nothing to yank")
+            return
+
+        clipboard = QApplication.clipboard()
+        if sel and clipboard.supportsSelection():
+            mode = QClipboard.Selection
+            target = "primary selection"
+        else:
+            mode = QClipboard.Clipboard
+            target = "clipboard"
+        log.misc.debug("Yanking to {}: '{}'".format(target, s))
+        clipboard.setText(s, mode)
+        message.info(self._win_id, "{} {} yanked to {}"
+                .format(len(s), "char" if len(s) == 1 else "chars", target))
+
+    @cmdutils.register(instance='command-dispatcher',
+                       modes=[usertypes.KeyMode.visual],
+                       hide=True, scope='window')
+    def drop_selection(self):
+        """Drop selection and stay in visual mode."""
+        self._current_widget().triggerPageAction(QWebPage.MoveToNextChar)
diff --git a/qutebrowser/browser/webview.py b/qutebrowser/browser/webview.py
index ee88e4089..19c34f9c3 100644
--- a/qutebrowser/browser/webview.py
+++ b/qutebrowser/browser/webview.py
@@ -23,10 +23,11 @@ import sys
 import itertools
 import functools
 
-from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QTimer, QUrl
+from PyQt5.QtCore import pyqtSignal, pyqtSlot, Qt, QTimer, QUrl, QPoint
 from PyQt5.QtWidgets import QApplication, QStyleFactory
 from PyQt5.QtWebKit import QWebSettings
 from PyQt5.QtWebKitWidgets import QWebView, QWebPage
+from PyQt5.QtGui import QMouseEvent
 
 from qutebrowser.config import config
 from qutebrowser.keyinput import modeman
@@ -69,6 +70,7 @@ class WebView(QWebView):
         _check_insertmode: If True, in mouseReleaseEvent we should check if we
                            need to enter/leave insert mode.
         _default_zoom_changed: Whether the zoom was changed from the default.
+        _caret_exist: Whether caret already has focus element
 
     Signals:
         scroll_pos_changed: Scroll percentage of current tab changed.
@@ -142,6 +144,7 @@ class WebView(QWebView):
         self.viewing_source = False
         self.setZoomFactor(float(config.get('ui', 'default-zoom')) / 100)
         self._default_zoom_changed = False
+        self._caret_exist = False
         objreg.get('config').changed.connect(self.on_config_changed)
         if config.get('input', 'rocker-gestures'):
             self.setContextMenuPolicy(Qt.PreventContextMenu)
@@ -427,6 +430,31 @@ class WebView(QWebView):
                               "entered.".format(mode))
             self.setFocusPolicy(Qt.NoFocus)
 
+            self._caret_exist = False
+        elif mode in (usertypes.KeyMode.caret, usertypes.KeyMode.visual):
+            self.settings().setAttribute(QWebSettings.CaretBrowsingEnabled, True)
+
+            tabbed_browser = objreg.get('tabbed-browser', scope='window',
+                                        window=self.win_id)
+            if self.tab_id == tabbed_browser._now_focused.tab_id and not self._caret_exist:
+                """ 
+                Here is a workaround for auto position enabled caret. 
+                Unfortunatly, caret doesn't appear until you click 
+                mouse button on element. I have such behavior in dwb, 
+                so I decided to implement this workaround.
+                May be should be reworked.
+                """
+                frame = self.page().currentFrame()
+                halfWidth = frame.scrollBarGeometry(Qt.Horizontal).width() / 2
+                point = QPoint(halfWidth,1)
+                event = QMouseEvent(QMouseEvent.MouseButtonPress, point, point, 
+                        point, Qt.LeftButton, Qt.LeftButton, Qt.NoModifier)
+                QApplication.sendEvent(self, event)
+
+                self._caret_exist = True
+        else:
+            self._caret_exist = False
+
     @pyqtSlot(usertypes.KeyMode)
     def on_mode_left(self, mode):
         """Restore focus policy if status-input modes were left."""
@@ -434,8 +462,16 @@ class WebView(QWebView):
                     usertypes.KeyMode.yesno):
             log.webview.debug("Restoring focus policy because mode {} was "
                               "left.".format(mode))
+        elif mode in (usertypes.KeyMode.caret, usertypes.KeyMode.visual):
+            if self.settings().testAttribute(QWebSettings.CaretBrowsingEnabled):
+                if mode == usertypes.KeyMode.visual and self.hasSelection():
+                    # Remove selection if exist
+                    self.triggerPageAction(QWebPage.MoveToNextChar)
+                self.settings().setAttribute(QWebSettings.CaretBrowsingEnabled, False)
+
         self.setFocusPolicy(Qt.WheelFocus)
 
+
     def createWindow(self, wintype):
         """Called by Qt when a page wants to create a new window.
 
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
index fea34ab91..079b0da87 100644
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -1019,6 +1019,8 @@ KEY_SECTION_DESC = {
         " * `prompt-accept`: Confirm the entered value.\n"
         " * `prompt-yes`: Answer yes to a yes/no question.\n"
         " * `prompt-no`: Answer no to a yes/no question."),
+    'caret': (
+        ""),
 }
 
 
@@ -1083,6 +1085,7 @@ KEY_DATA = collections.OrderedDict([
         ('search-next', ['n']),
         ('search-prev', ['N']),
         ('enter-mode insert', ['i']),
+        ('enter-mode caret', ['c', 'v']),
         ('yank', ['yy']),
         ('yank -s', ['yY']),
         ('yank -t', ['yt']),
@@ -1178,6 +1181,36 @@ KEY_DATA = collections.OrderedDict([
         ('rl-delete-char', ['<Ctrl-?>']),
         ('rl-backward-delete-char', ['<Ctrl-H>']),
     ])),
+
+    ('visual', collections.OrderedDict([
+        ('yank-selected', ['y']),
+        ('yank-selected -s', ['Y']),
+        ('drop-selection', ['v']),
+        ('enter-mode caret', ['c']),
+    ])),
+
+    ('caret', collections.OrderedDict([
+        ('enter-mode visual', ['v']),
+        ('enter-mode normal', ['c']),
+    ])),
+
+    ('caret,visual', collections.OrderedDict([
+        ('move-to-next-line', ['j']),
+        ('move-to-prev-line', ['k']),
+        ('move-to-next-char', ['l']),
+        ('move-to-prev-char', ['h']),
+        ('move-to-end-of-word', ['e']),
+        ('move-to-next-word', ['w']),
+        ('move-to-prev-word', ['b']),
+        ('move-to-start-of-line', ['0']),
+        ('move-to-end-of-line', ['$']),
+        ('move-to-start-of-document', ['gg']),
+        ('move-to-end-of-document', ['G']),
+        ('scroll -50 0', ['H']),
+        ('scroll 0 50', ['J']),
+        ('scroll 0 -50', ['K']),
+        ('scroll 50 0', ['L']),
+    ])),
 ])
 
 
diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py
index 4699de8e9..d956cfc08 100644
--- a/qutebrowser/keyinput/modeman.py
+++ b/qutebrowser/keyinput/modeman.py
@@ -25,6 +25,7 @@ from PyQt5.QtGui import QWindow
 from PyQt5.QtCore import pyqtSignal, Qt, QObject, QEvent
 from PyQt5.QtWidgets import QApplication
 from PyQt5.QtWebKitWidgets import QWebView
+from PyQt5.QtWebKit import QWebSettings
 
 from qutebrowser.keyinput import modeparsers, keyparser
 from qutebrowser.config import config
@@ -79,6 +80,8 @@ def init(win_id, parent):
         KM.prompt: keyparser.PassthroughKeyParser(win_id, 'prompt', modeman,
                                                   warn=False),
         KM.yesno: modeparsers.PromptKeyParser(win_id, modeman),
+        KM.caret: modeparsers.CaretKeyParser(win_id, modeman),
+        KM.visual: modeparsers.VisualKeyParser(win_id, modeman),
     }
     objreg.register('keyparsers', keyparsers, scope='window', window=win_id)
     modeman.destroyed.connect(
@@ -93,6 +96,8 @@ def init(win_id, parent):
                      passthrough=True)
     modeman.register(KM.prompt, keyparsers[KM.prompt].handle, passthrough=True)
     modeman.register(KM.yesno, keyparsers[KM.yesno].handle)
+    modeman.register(KM.caret, keyparsers[KM.caret].handle, passthrough=True)
+    modeman.register(KM.visual, keyparsers[KM.visual].handle, passthrough=True)
     return modeman
 
 
diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py
index 7ca4eabc7..4a545f53e 100644
--- a/qutebrowser/keyinput/modeparsers.py
+++ b/qutebrowser/keyinput/modeparsers.py
@@ -218,3 +218,27 @@ class HintKeyParser(keyparser.CommandKeyParser):
         hintmanager = objreg.get('hintmanager', scope='tab',
                                  window=self._win_id, tab='current')
         hintmanager.handle_partial_key(keystr)
+
+class CaretKeyParser(keyparser.CommandKeyParser):
+
+    """KeyParser for Caret mode."""
+
+    def __init__(self, win_id, parent=None):
+        super().__init__(win_id, parent, supports_count=True,
+                         supports_chains=True)
+        self.read_config('caret')
+
+    def __repr__(self):
+        return utils.get_repr(self)
+
+class VisualKeyParser(keyparser.CommandKeyParser):
+
+    """KeyParser for Visual mode."""
+
+    def __init__(self, win_id, parent=None):
+        super().__init__(win_id, parent, supports_count=True,
+                         supports_chains=True)
+        self.read_config('visual')
+
+    def __repr__(self):
+        return utils.get_repr(self)
diff --git a/qutebrowser/mainwindow/tabbedbrowser.py b/qutebrowser/mainwindow/tabbedbrowser.py
index 30cdf1245..5e2d40cf0 100644
--- a/qutebrowser/mainwindow/tabbedbrowser.py
+++ b/qutebrowser/mainwindow/tabbedbrowser.py
@@ -546,7 +546,8 @@ class TabbedBrowser(tabwidget.TabWidget):
         tab = self.widget(idx)
         log.modes.debug("Current tab changed, focusing {!r}".format(tab))
         tab.setFocus()
-        for mode in (usertypes.KeyMode.hint, usertypes.KeyMode.insert):
+        for mode in (usertypes.KeyMode.hint, usertypes.KeyMode.insert,
+                usertypes.KeyMode.caret, usertypes.KeyMode.visual):
             modeman.maybe_leave(self._win_id, mode, 'tab changed')
         if self._now_focused is not None:
             objreg.register('last-focused-tab', self._now_focused, update=True,
diff --git a/qutebrowser/utils/usertypes.py b/qutebrowser/utils/usertypes.py
index 7371f3114..ba5957155 100644
--- a/qutebrowser/utils/usertypes.py
+++ b/qutebrowser/utils/usertypes.py
@@ -231,7 +231,7 @@ ClickTarget = enum('ClickTarget', ['normal', 'tab', 'tab_bg', 'window'])
 
 # Key input modes
 KeyMode = enum('KeyMode', ['normal', 'hint', 'command', 'yesno', 'prompt',
-                           'insert', 'passthrough'])
+                           'insert', 'passthrough', 'caret', 'visual'])
 
 
 # Available command completions