Repository: kivy/kivy

Issue:
TextInput: cursor changes position when scrolling with mousewheel
In TextInput, when scrolling text using mousewheel, cursor changes position (row). Checked on Windows and Ubuntu

```python
from kivy.app import App
from kivy.lang import Builder

KV = """
TextInput
    font_size: 20
    text: app.text
"""

class MyApp(App):
    def build(self):
        self.text = '\n'.join(['some text, line '+str(i) for i in range(200)])
        self.root = Builder.load_string(KV)

MyApp().run()
```


Discussion:

Resolving patch:
diff --git a/kivy/uix/textinput.py b/kivy/uix/textinput.py
--- a/kivy/uix/textinput.py
+++ b/kivy/uix/textinput.py
@@ -499,6 +499,11 @@ def __init__(self, **kwargs):
         self._do_blink_cursor_ev = Clock.create_trigger(
             self._do_blink_cursor, .5, interval=True)
         self._refresh_line_options_ev = None
+
+        # [from; to) range of lines being partially or fully rendered
+        # in TextInput's viewport
+        self._visible_lines_range = 0, 0
+
         self.interesting_keys = {
             8: 'backspace',
             13: 'enter',
@@ -1186,7 +1191,12 @@ def do_cursor_movement(self, action, control=False, alt=False):
                         row += 1
                 else:
                     col, row = col + 1, row
-        self.cursor = (col, row)
+
+        dont_move_cursor = control and action in ['cursor_up', 'cursor_down']
+        if dont_move_cursor:
+            self._trigger_update_graphics()
+        else:
+            self.cursor = (col, row)
 
     def get_cursor_from_xy(self, x, y):
         '''Return the (row, col) of the cursor from an (x, y) position.
@@ -1364,27 +1374,32 @@ def on_touch_down(self, touch):
 
         # Check for scroll wheel
         if 'button' in touch.profile and touch.button.startswith('scroll'):
+            # TODO: implement 'scrollleft' and 'scrollright'
             scroll_type = touch.button[6:]
             if scroll_type == 'down':
                 if self.multiline:
-                    if self.scroll_y <= 0:
-                        return True
-                    self.scroll_y -= self.line_height
+                    if self.scroll_y > 0:
+                        self.scroll_y -= self.line_height
+                        self._trigger_update_graphics()
                 else:
-                    if self.scroll_x <= 0:
-                        return True
-                    self.scroll_x -= self.line_height
+                    if self.scroll_x > 0:
+                        self.scroll_x -= self.line_height
+                        self._trigger_update_graphics()
             if scroll_type == 'up':
                 if self.multiline:
-                    if (self._lines_rects[-1].pos[1] > self.y +
-                            self.line_height):
-                        return True
-                    self.scroll_y += self.line_height
+                    viewport_height = self.height\
+                                      - self.padding[1] - self.padding[3]
+                    text_height = len(self._lines) * (self.line_height
+                                                      + self.line_spacing)
+                    if viewport_height < text_height - self.scroll_y:
+                        self.scroll_y += self.line_height
+                        self._trigger_update_graphics()
                 else:
-                    if (self.scroll_x + self.width >=
+                    if (self.scroll_x + self.width <
                             self._lines_rects[-1].texture.size[0]):
-                        return True
-                    self.scroll_x += self.line_height
+                        self.scroll_x += self.line_height
+                        self._trigger_update_graphics()
+            return True
 
         touch.grab(self)
         self._touch_count += 1
@@ -1994,8 +2009,12 @@ def _update_graphics(self, *largs):
         find_base_dir = Label.find_base_direction
         auto_halign_r = halign == 'auto' and base_dir and 'rtl' in base_dir
 
+        fst_visible_ln = None
         for line_num, value in enumerate(lines):
-            if miny <= y <= maxy + dy:
+            if miny < y < maxy + dy:
+                if fst_visible_ln is None:
+                    fst_visible_ln = line_num
+
                 texture = labels[line_num]
                 size = list(texture.size)
                 texc = texture.tex_coords[:]
@@ -2065,9 +2084,17 @@ def _update_graphics(self, *largs):
                 r.texture = texture
                 r.tex_coords = texc
                 add(r)
+            elif y <= miny:
+                line_num -= 1
+                break
 
             y -= dy
 
+        if fst_visible_ln is not None:
+            self._visible_lines_range = (fst_visible_ln, line_num + 1)
+        else:
+            self._visible_lines_range = 0, 0
+
         self._update_graphics_selection()
 
     def _update_graphics_selection(self):
@@ -2186,14 +2213,26 @@ def _get_cursor_pos(self):
         else:
             x = left + cursor_offset - self.scroll_x
 
-        if x < left:
-            self.scroll_x = 0
-            x = left
-        if y > top:
-            y = top
-            self.scroll_y = 0
         return x, y
 
+    def _get_cursor_visual_height(self):
+        # Return the height of the cursor's visible part
+        _, cy = map(int, self.cursor_pos)
+        max_y = self.top - self.padding[1]
+        min_y = self.y + self.padding[3]
+
+        lh = self.line_height
+        if cy > max_y:
+            return lh - min(lh, cy - max_y)
+        else:
+            return min(lh, max(0, cy - min_y))
+
+    def _get_cursor_visual_pos(self):
+        # Return the position of the cursor's top visible point
+        cx, cy = map(int, self.cursor_pos)
+        max_y = self.top - self.padding[3]
+        return [cx, min(max_y, cy)]
+
     def _get_line_options(self):
         # Get or create line options, to be used for Label creation
         if self._line_options is None:
@@ -2575,6 +2614,12 @@ def _refresh_hint_text(self):
     _insert_int_pat = re.compile(u'^-?[0-9]*$')
     _insert_float_pat = re.compile(u'^-?[0-9]*\\.?[0-9]*$')
     _cursor_blink = BooleanProperty(False)
+    _cursor_visual_pos = AliasProperty(
+        _get_cursor_visual_pos, None, bind=['cursor_pos']
+    )
+    _cursor_visual_height = AliasProperty(
+        _get_cursor_visual_height, None, bind=['cursor_pos']
+    )
 
     readonly = BooleanProperty(False)
     '''If True, the user will not be able to change the content of a textinput.


Test patch:
diff --git a/kivy/tests/common.py b/kivy/tests/common.py
--- a/kivy/tests/common.py
+++ b/kivy/tests/common.py
@@ -10,7 +10,7 @@
 320x240 pixels.
 '''
 
-__all__ = ('GraphicUnitTest', )
+__all__ = ('GraphicUnitTest', 'UTMotionEvent')
 
 import unittest
 import logging
@@ -429,3 +429,12 @@ def depack(self, args):
 
         # run depack after we set the values
         super(UnitTestTouch, self).depack(args)
+
+
+class UTMotionEvent(MotionEvent):
+    def depack(self, args):
+        self.is_touch = True
+        self.sx = args['x']
+        self.sy = args['y']
+        self.profile = ['pos']
+        super(UTMotionEvent, self).depack(args)
diff --git a/kivy/tests/test_uix_scrollview.py b/kivy/tests/test_uix_scrollview.py
--- a/kivy/tests/test_uix_scrollview.py
+++ b/kivy/tests/test_uix_scrollview.py
@@ -1,11 +1,12 @@
 from kivy.tests.common import GraphicUnitTest
 
-from kivy.input.motionevent import MotionEvent
 from kivy.uix.gridlayout import GridLayout
 from kivy.uix.scrollview import ScrollView
 from kivy.uix.label import Label
 from kivy.base import EventLoop
 from kivy.clock import Clock
+from kivy.tests.common import UTMotionEvent
+
 from time import sleep
 from itertools import count
 
@@ -13,15 +14,6 @@
 touch_id = count()
 
 
-class UTMotionEvent(MotionEvent):
-    def depack(self, args):
-        self.is_touch = True
-        self.sx = args['x']
-        self.sy = args['y']
-        self.profile = ['pos']
-        super(UTMotionEvent, self).depack(args)
-
-
 class _TestGrid(GridLayout):
     def __init__(self, **kwargs):
         kwargs['cols'] = 1
diff --git a/kivy/tests/test_uix_textinput.py b/kivy/tests/test_uix_textinput.py
--- a/kivy/tests/test_uix_textinput.py
+++ b/kivy/tests/test_uix_textinput.py
@@ -4,11 +4,15 @@
 '''
 
 import unittest
+from itertools import count
 
-from kivy.tests.common import GraphicUnitTest
+from kivy.tests.common import GraphicUnitTest, UTMotionEvent
 from kivy.uix.textinput import TextInput
+from kivy.uix.widget import Widget
 from kivy.clock import Clock
 
+touch_id = count()
+
 
 class TextInputTest(unittest.TestCase):
 
@@ -353,6 +357,151 @@ def test_cursor_blink(self):
             self.advance_frames(int(0.01 * Clock._max_fps) + 1)
             self.assertTrue(ti._do_blink_cursor_ev.is_triggered)
 
+    def test_visible_lines_range(self):
+        ti = self.make_scrollable_text_input()
+        assert ti._visible_lines_range == (20, 30)
+
+        ti.height = ti_height_for_x_lines(ti, 2.5)
+        ti.do_cursor_movement('cursor_home', control=True)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (0, 3)
+
+        ti.height = ti_height_for_x_lines(ti, 0)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (0, 0)
+
+    def test_keyboard_scroll(self):
+        ti = self.make_scrollable_text_input()
+
+        prev_cursor = ti.cursor
+        ti.do_cursor_movement('cursor_home', control=True)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (0, 10)
+        assert prev_cursor != ti.cursor
+
+        prev_cursor = ti.cursor
+        ti.do_cursor_movement('cursor_down', control=True)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (1, 11)
+        # cursor position (col and row) should not be
+        # changed by "ctrl + cursor_down" and "ctrl + cursor_up"
+        assert prev_cursor == ti.cursor
+
+        prev_cursor = ti.cursor
+        ti.do_cursor_movement('cursor_up', control=True)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (0, 10)
+        assert prev_cursor == ti.cursor
+
+        prev_cursor = ti.cursor
+        ti.do_cursor_movement('cursor_end', control=True)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (20, 30)
+        assert prev_cursor != ti.cursor
+
+    def test_scroll_doesnt_move_cursor(self):
+        ti = self.make_scrollable_text_input()
+
+        from kivy.base import EventLoop
+        win = EventLoop.window
+        touch = UTMotionEvent("unittest", next(touch_id), {
+            "x": ti.center_x / float(win.width),
+            "y": ti.center_y / float(win.height),
+        })
+        touch.profile.append('button')
+        touch.button = 'scrolldown'
+
+        prev_cursor = ti.cursor
+        assert ti._visible_lines_range == (20, 30)
+        EventLoop.post_dispatch_input("begin", touch)
+        EventLoop.post_dispatch_input("end", touch)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (19, 29)
+        assert ti.cursor == prev_cursor
+
+    def test_vertical_scroll_doesnt_depend_on_lines_rendering(self):
+        # TextInput.on_touch_down was checking the possibility to scroll_up
+        # using the positions of the rendered lines' rects. These positions
+        # don't change when the lines are skipped (e.g. during fast scroll
+        # or ctrl+cursor_home) which lead to scroll freeze
+        ti = self.make_scrollable_text_input()
+
+        # move viewport to the first line
+        ti.do_cursor_movement('cursor_home', control=True)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (0, 10)
+
+        from kivy.base import EventLoop
+        win = EventLoop.window
+
+        # slowly scroll to the last line to render all lines at least once
+        for _ in range(30):  # little overscroll is important for detection
+            touch = UTMotionEvent("unittest", next(touch_id), {
+                "x": ti.center_x / float(win.width),
+                "y": ti.center_y / float(win.height),
+            })
+            touch.profile.append('button')
+            touch.button = 'scrollup'
+
+            EventLoop.post_dispatch_input("begin", touch)
+            EventLoop.post_dispatch_input("end", touch)
+            self.advance_frames(1)
+        assert ti._visible_lines_range == (20, 30)
+
+        # jump to the first line again
+        ti.do_cursor_movement('cursor_home', control=True)
+
+        # temp fix: only change of cursor position triggers update as for now
+        ti._trigger_update_graphics()
+
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (0, 10)
+
+        # scrolling up should work now
+        touch = UTMotionEvent("unittest", next(touch_id), {
+            "x": ti.center_x / float(win.width),
+            "y": ti.center_y / float(win.height),
+        })
+        touch.profile.append('button')
+        touch.button = 'scrollup'
+        EventLoop.post_dispatch_input("begin", touch)
+        EventLoop.post_dispatch_input("end", touch)
+        self.advance_frames(1)
+        assert ti._visible_lines_range == (1, 11)
+
+    def make_scrollable_text_input(self, num_of_lines=30, n_lines_to_show=10):
+        """Prepare and start rendering the scrollable text input.
+
+           num_of_lines -- amount of dummy lines used as contents
+           n_lines_to_show -- amount of lines to fit in viewport
+        """
+        # create TextInput instance with dummy contents
+        text = '\n'.join(map(str, range(num_of_lines)))
+        ti = TextInput(text=text)
+        ti.focus = True
+
+        # use container to have flexible TextInput size
+        container = Widget()
+        container.add_widget(ti)
+        self.render(container)
+
+        # change TextInput's size to contain the needed amount of lines
+        ti.height = ti_height_for_x_lines(ti, n_lines_to_show)
+        self.advance_frames(1)
+        return ti
+
+
+def ti_height_for_x_lines(ti, x):
+    """Calculate TextInput height required to display x lines in viewport.
+
+    ti -- TextInput object being used
+    x -- number of lines to display
+    """
+    padding_top = ti.padding[1]
+    padding_bottom = ti.padding[3]
+    return int((ti.line_height + ti.line_spacing) * x
+               + padding_top + padding_bottom)
+
 
 if __name__ == '__main__':
     import unittest