Repository: kivy/kivy

Issue:
TextInput: long text overflows textinput's border
**Software Versions**
* Python: 3.7.5
* OS: Android, Windows (happens independently of OS)
* Kivy: 1.11.1
* Kivy installation method: (not sure?)

**Describe the bug**
With single line textinputs (multiline: False) long texts will overflow and the last letter typed in will not be visible.

To me, it seems to be an issue of the cursor. When the text overflows, if I press the right arrow (i.e. move the cursor right) I am then able to read the whole text. So the text does fit into the textinput, it is the cursor that seems to misbehave (instead of moving after the n typed letter, it stays at n-1).

**Expected behavior**
The last letter typed should be clearly visible.

**To Reproduce**

```python
from kivy.app import App
from kivy.core.window import Window
from kivy.lang import Builder
from kivy.uix.gridlayout import GridLayout
from kivy.uix.screenmanager import Screen, ScreenManager

class MainGrid(GridLayout):
    pass

kv = Builder.load_string(
'''
MainGrid:

<MainGrid>:
    size_hint: 1, 1
    cols: 1
    StackLayout:
        size_hint: 1,1
        TextInput:
            size_hint: 1, 1
            multiline: False
''')

class TrialApp(App):
    def build(self):
        return kv

if __name__ == "__main__":
    TrialApp().run()
```

**Additional context**
None



Discussion:
Can you try with `Kivy==2.0.0rc3`?
> Kivy==2.0.0rc3

You mean putting that in the buildozer.spec?
Yes and post here if it solved the issue.
Just tested it. Still happening with Kivy==2.0.0rc3 (and also with kivy==master)

Resolving patch:
diff --git a/kivy/core/window/__init__.py b/kivy/core/window/__init__.py
--- a/kivy/core/window/__init__.py
+++ b/kivy/core/window/__init__.py
@@ -353,6 +353,11 @@ class WindowBase(EventDispatcher):
     A list of Kivy gl backend names that may not be used with this window.
     """
 
+    managed_textinput = False
+    """
+    True if this Window class uses `on_textinput` to insert text, internal.
+    """
+
     children = ListProperty([])
     '''List of the children of this window.
 
diff --git a/kivy/core/window/window_sdl2.py b/kivy/core/window/window_sdl2.py
--- a/kivy/core/window/window_sdl2.py
+++ b/kivy/core/window/window_sdl2.py
@@ -149,6 +149,8 @@ class WindowSDL(WindowBase):
 
     _do_resize_ev = None
 
+    managed_textinput = True
+
     def __init__(self, **kwargs):
         self._pause_loop = False
         self._win = _WindowSDL2Storage()
diff --git a/kivy/uix/textinput.py b/kivy/uix/textinput.py
--- a/kivy/uix/textinput.py
+++ b/kivy/uix/textinput.py
@@ -1,4 +1,3 @@
-# -*- encoding: utf-8 -*-
 '''
 Text Input
 ==========
@@ -91,8 +90,7 @@ class CapitalInput(TextInput):
 
         def insert_text(self, substring, from_undo=False):
             s = substring.upper()
-            return super(CapitalInput, self).insert_text(s,\
- from_undo=from_undo)
+            return super().insert_text(s, from_undo=from_undo)
 
 Or to only allow floats (0 - 9 and a single period)::
 
@@ -104,9 +102,11 @@ def insert_text(self, substring, from_undo=False):
             if '.' in self.text:
                 s = re.sub(pat, '', substring)
             else:
-                s = '.'.join([re.sub(pat, '', s) for s in\
- substring.split('.', 1)])
-            return super(FloatInput, self).insert_text(s, from_undo=from_undo)
+                s = '.'.join(
+                    re.sub(pat, '', s)
+                    for s in substring.split('.', 1)
+                )
+            return super().insert_text(s, from_undo=from_undo)
 
 Default shortcuts
 -----------------
@@ -141,13 +141,12 @@ def insert_text(self, substring, from_undo=False):
 '''
 
 
-__all__ = ('TextInput', )
-
-
 import re
 import sys
+import math
 from os import environ
 from weakref import ref
+from itertools import chain, islice
 
 from kivy.animation import Animation
 from kivy.base import EventLoop
@@ -173,6 +172,20 @@ def insert_text(self, substring, from_undo=False):
     BooleanProperty, AliasProperty, OptionProperty, \
     ListProperty, ObjectProperty, VariableListProperty, ColorProperty
 
+__all__ = ('TextInput', )
+
+
+if 'KIVY_DOC' in environ:
+    def triggered(*_, **__):
+        def decorator_func(func):
+            def decorated_func(*args, **kwargs):
+                return func(*args, **kwargs)
+            return decorated_func
+        return decorator_func
+else:
+    from kivy.clock import triggered
+
+
 Cache_register = Cache.register
 Cache_append = Cache.append
 Cache_get = Cache.get
@@ -227,7 +240,7 @@ class Selector(ButtonBehavior, Image):
     matrix = ObjectProperty()
 
     def __init__(self, **kwargs):
-        super(Selector, self).__init__(**kwargs)
+        super().__init__(**kwargs)
         self.matrix = self.target.get_window_matrix()
 
         with self.canvas.before:
@@ -239,16 +252,17 @@ def __init__(self, **kwargs):
             PopMatrix()
 
     def update_transform(self, cb):
-        m = self.target.get_window_matrix()
-        if self.matrix != m:
-            self.matrix = m
+        matrix = self.target.get_window_matrix()
+        if self.matrix != matrix:
+            self.matrix = matrix
             self.transform.identity()
             self.transform.transform(self.matrix)
 
     def transform_touch(self, touch):
         matrix = self.matrix.inverse()
         touch.apply_transform_2d(
-            lambda x, y: matrix.transform_point(x, y, 0)[:2])
+            lambda x, y: matrix.transform_point(x, y, 0)[:2]
+        )
 
     def on_touch_down(self, touch):
         if self.parent is not EventLoop.window:
@@ -260,7 +274,7 @@ def on_touch_down(self, touch):
             self._touch_diff = self.top - touch.y
             if self.collide_point(*touch.pos):
                 FocusBehavior.ignored_touch.append(touch)
-            return super(Selector, self).on_touch_down(touch)
+            return super().on_touch_down(touch)
         finally:
             touch.pop()
 
@@ -284,7 +298,7 @@ class TextInputCutCopyPaste(Bubble):
 
     def __init__(self, **kwargs):
         self.mode = 'normal'
-        super(TextInputCutCopyPaste, self).__init__(**kwargs)
+        super().__init__(**kwargs)
         self._check_parent_ev = Clock.schedule_interval(self._check_parent, .5)
         self.matrix = self.textinput.get_window_matrix()
 
@@ -314,7 +328,7 @@ def on_touch_down(self, touch):
             self.transform_touch(touch)
             if self.collide_point(*touch.pos):
                 FocusBehavior.ignored_touch.append(touch)
-            return super(TextInputCutCopyPaste, self).on_touch_down(touch)
+            return super().on_touch_down(touch)
         finally:
             touch.pop()
 
@@ -326,7 +340,7 @@ def on_touch_up(self, touch):
                 if ref(child) in touch.grab_list:
                     touch.grab_current = child
                     break
-            return super(TextInputCutCopyPaste, self).on_touch_up(touch)
+            return super().on_touch_up(touch)
         finally:
             touch.pop()
 
@@ -527,9 +541,10 @@ def __init__(self, **kwargs):
             305: 'ctrl_L',
             306: 'ctrl_R',
             308: 'alt_L',
-            307: 'alt_R'}
+            307: 'alt_R'
+        }
 
-        super(TextInput, self).__init__(**kwargs)
+        super().__init__(**kwargs)
 
         fbind = self.fbind
         refresh_line_options = self._trigger_refresh_line_options
@@ -595,20 +610,26 @@ def cursor_index(self, cursor=None):
         if not cursor:
             cursor = self.cursor
         try:
-            l = self._lines
-            if len(l) == 0:
+            lines = self._lines
+            if not lines:
                 return 0
-            lf = self._lines_flags
-            index, cr = cursor
-            for row in range(cr):
-                if row >= len(l):
-                    continue
-                index += len(l[row])
-                if lf[row] & FL_IS_LINEBREAK:
+
+            flags = self._lines_flags
+            index, cursor_row = cursor
+
+            for _, line, flag in zip(
+                range(min(cursor_row, len(lines))),
+                lines,
+                flags
+            ):
+                index += len(line)
+                if flag & FL_IS_LINEBREAK:
                     index += 1
-            if lf[cr] & FL_IS_LINEBREAK:
+
+            if flags[cursor_row] & FL_IS_LINEBREAK:
                 index += 1
             return index
+
         except IndexError:
             return 0
 
@@ -618,10 +639,10 @@ def cursor_offset(self):
         offset = 0
         row = int(self.cursor_row)
         col = int(self.cursor_col)
-        _lines = self._lines
-        if col and row < len(_lines):
+        lines = self._lines
+        if col and row < len(lines):
             offset = self._get_text_width(
-                _lines[row][:col],
+                lines[row][:col],
                 self.tab_width,
                 self._label_cached
             )
@@ -633,18 +654,21 @@ def get_cursor_from_index(self, index):
         index = boundary(index, 0, len(self.text))
         if index <= 0:
             return 0, 0
-        lf = self._lines_flags
-        l = self._lines
+        flags = self._lines_flags
+        lines = self._lines
+        if not lines:
+            return 0, 0
+
         i = 0
-        for row in range(len(l)):
-            ni = i + len(l[row])
-            if lf[row] & FL_IS_LINEBREAK:
-                ni += 1
+        for row, line in enumerate(lines):
+            count = i + len(line)
+            if flags[row] & FL_IS_LINEBREAK:
+                count += 1
                 i += 1
-            if ni >= index:
+            if count >= index:
                 return index - i, row
-            i = ni
-        return index, row
+            i = count
+        return int(index), int(row)
 
     def select_text(self, start, end):
         ''' Select a portion of text displayed in this TextInput.
@@ -660,9 +684,9 @@ def select_text(self, start, end):
         '''
         if end < start:
             raise Exception('end must be superior to start')
-        m = len(self.text)
-        self._selection_from = boundary(start, 0, m)
-        self._selection_to = boundary(end, 0, m)
+        text_length = len(self.text)
+        self._selection_from = boundary(start, 0, text_length)
+        self._selection_to = boundary(end, 0, text_length)
         self._selection_finished = True
         self._update_selection(True)
         self._update_graphics_selection()
@@ -712,12 +736,11 @@ def insert_text(self, substring, from_undo=False):
             if not substring:
                 return
 
-        cc, cr = self.cursor
-        sci = self.cursor_index
-        ci = sci()
-        text = self._lines[cr]
+        col, row = self.cursor
+        cindex = self.cursor_index()
+        text = self._lines[row]
         len_str = len(substring)
-        new_text = text[:cc] + substring + text[cc:]
+        new_text = text[:col] + substring + text[col:]
         if mode is not None:
             if mode == 'int':
                 if not re.match(self._insert_int_pat, new_text):
@@ -725,7 +748,7 @@ def insert_text(self, substring, from_undo=False):
             elif mode == 'float':
                 if not re.match(self._insert_float_pat, new_text):
                     return
-        self._set_line_text(cr, new_text)
+        self._set_line_text(row, new_text)
 
         wrap = (self._get_text_width(
             new_text,
@@ -737,17 +760,20 @@ def insert_text(self, substring, from_undo=False):
             # Allows for faster typing of text when the amount of text in
             # TextInput gets large.
 
-            start, finish, lines,\
-                lineflags, len_lines = self._get_line_from_cursor(cr, new_text)
+            (
+                start, finish, lines, lineflags, len_lines
+            ) = self._get_line_from_cursor(row, new_text)
+
             # calling trigger here could lead to wrong cursor positioning
             # and repeating of text when keys are added rapidly in a automated
             # fashion. From Android Keyboard for example.
-            self._refresh_text_from_property('insert', start, finish, lines,
-                                             lineflags, len_lines)
+            self._refresh_text_from_property(
+                'insert', start, finish, lines, lineflags, len_lines
+            )
 
-        self.cursor = self.get_cursor_from_index(ci + len_str)
+        self.cursor = self.get_cursor_from_index(cindex + len_str)
         # handle undo and redo
-        self._set_unredo_insert(ci, ci + len_str, substring, from_undo)
+        self._set_unredo_insert(cindex, cindex + len_str, substring, from_undo)
 
     def _get_line_from_cursor(self, start, new_text):
         # get current paragraph from cursor position
@@ -771,8 +797,10 @@ def _set_unredo_insert(self, ci, sci, substring, from_undo):
         # handle undo and redo
         if from_undo:
             return
-        self._undo.append({'undo_command': ('insert', ci, sci),
-                           'redo_command': (ci, substring)})
+        self._undo.append({
+            'undo_command': ('insert', ci, sci),
+            'redo_command': (ci, substring)
+        })
         # reset redo when undo is appended to
         self._redo = []
 
@@ -799,8 +827,8 @@ def do_redo(self):
             _get_cusror_from_index = self.get_cursor_from_index
 
             if undo_type == 'insert':
-                ci, substring = x_item['redo_command']
-                self.cursor = _get_cusror_from_index(ci)
+                cindex, substring = x_item['redo_command']
+                self.cursor = _get_cusror_from_index(cindex)
                 self.insert_text(substring, True)
             elif undo_type == 'bkspc':
                 self.cursor = _get_cusror_from_index(x_item['redo_command'])
@@ -810,12 +838,12 @@ def do_redo(self):
                 self._shift_lines(direction, rows, cursor, True)
             else:
                 # delsel
-                ci, sci = x_item['redo_command']
-                self._selection_from = ci
-                self._selection_to = sci
+                cindex, scindex = x_item['redo_command']
+                self._selection_from = cindex
+                self._selection_to = scindex
                 self._selection = True
                 self.delete_selection(True)
-                self.cursor = _get_cusror_from_index(ci)
+                self.cursor = _get_cusror_from_index(cindex)
             self._undo.append(x_item)
         except IndexError:
             # reached at top of undo list
@@ -836,9 +864,9 @@ def do_undo(self):
             self.cursor = self.get_cursor_from_index(x_item['undo_command'][1])
 
             if undo_type == 'insert':
-                ci, sci = x_item['undo_command'][1:]
-                self._selection_from = ci
-                self._selection_to = sci
+                cindex, scindex = x_item['undo_command'][1:]
+                self._selection_from = cindex
+                self._selection_to = scindex
                 self._selection = True
                 self.delete_selection(True)
             elif undo_type == 'bkspc':
@@ -868,35 +896,37 @@ def do_backspace(self, from_undo=False, mode='bkspc'):
         # IME system handles its own backspaces
         if self.readonly or self._ime_composition:
             return
-        cc, cr = self.cursor
+        col, row = self.cursor
         _lines = self._lines
-        text = _lines[cr]
+        text = _lines[row]
         cursor_index = self.cursor_index()
-        text_last_line = _lines[cr - 1]
+        text_last_line = _lines[row - 1]
 
-        if cc == 0 and cr == 0:
+        if col == 0 and row == 0:
             return
         _lines_flags = self._lines_flags
-        start = cr
-        if cc == 0:
-            substring = u'\n' if _lines_flags[cr] else u' '
+        start = row
+        if col == 0:
+            substring = u'\n' if _lines_flags[row] else u' '
             new_text = text_last_line + text
-            self._set_line_text(cr - 1, new_text)
-            self._delete_line(cr)
-            start = cr - 1
+            self._set_line_text(row - 1, new_text)
+            self._delete_line(row)
+            start = row - 1
         else:
-            # ch = text[cc-1]
-            substring = text[cc - 1]
-            new_text = text[:cc - 1] + text[cc:]
-            self._set_line_text(cr, new_text)
+            # ch = text[col-1]
+            substring = text[col - 1]
+            new_text = text[:col - 1] + text[col:]
+            self._set_line_text(row, new_text)
 
         # refresh just the current line instead of the whole text
-        start, finish, lines, lineflags, len_lines =\
+        start, finish, lines, lineflags, len_lines = (
             self._get_line_from_cursor(start, new_text)
+        )
         # avoid trigger refresh, leads to issue with
         # keys/text send rapidly through code.
-        self._refresh_text_from_property('del', start, finish, lines,
-                                         lineflags, len_lines)
+        self._refresh_text_from_property(
+            'del', start, finish, lines, lineflags, len_lines
+        )
 
         self.cursor = self.get_cursor_from_index(cursor_index - 1)
         # handle undo and redo
@@ -926,6 +956,7 @@ def _move_cursor_word_left(self, index=None):
         if col == 0:
             row -= 1
             col = len(lines[row])
+
         while True:
             matches = list(self._re_whitespace.finditer(lines[row], 0, col))
             if not matches:
@@ -936,6 +967,7 @@ def _move_cursor_word_left(self, index=None):
                     col = len(lines[row])
                     continue
                 return 0, row
+
             match = matches[-1]
             mpos = match.end()
             if mpos == col:
@@ -963,6 +995,7 @@ def _move_cursor_word_right(self, index=None):
         if col == len(lines[row]):
             row += 1
             col = 0
+
         while True:
             matches = list(self._re_whitespace.finditer(lines[row], col))
             if not matches:
@@ -973,6 +1006,7 @@ def _move_cursor_word_right(self, index=None):
                     col = 0
                     continue
                 return len(lines[row]), row
+
             match = matches[0]
             mpos = match.start()
             if mpos == col:
@@ -1012,13 +1046,15 @@ def _expand_rows(self, rfrom, rto=None):
             rto += 1
         return max(0, rfrom), min(rmax, rto)
 
-    def _shift_lines(self, direction, rows=None, old_cursor=None,
-                     from_undo=False):
+    def _shift_lines(
+        self, direction, rows=None, old_cursor=None, from_undo=False
+    ):
         if self._selection_callback:
             if from_undo:
                 self._selection_callback.cancel()
             else:
                 return
+
         lines = self._lines
         flags = list(reversed(self._lines_flags))
         labels = self._lines_labels
@@ -1047,7 +1083,7 @@ def _shift_lines(self, direction, rows=None, old_cursor=None,
                 psrow, perow = self._expand_rows(erow)
                 rows = ((srow, erow), (psrow, perow))
 
-        if rows:
+        else:
             (srow, erow), (psrow, perow) = rows
             if direction < 0:
                 m1srow, m1erow = psrow, perow
@@ -1059,21 +1095,40 @@ def _shift_lines(self, direction, rows=None, old_cursor=None,
                 m2srow, m2erow = psrow, perow
                 cdiff = perow - psrow
                 xdiff = erow - srow
-            self._lines_flags = list(reversed(
-                flags[:m1srow] + flags[m2srow:m2erow] + flags[m1srow:m1erow] +
-                flags[m2erow:]))
-            self._lines[:] = (lines[:m1srow] + lines[m2srow:m2erow] +
-                              lines[m1srow:m1erow] + lines[m2erow:])
-            self._lines_labels = (labels[:m1srow] + labels[m2srow:m2erow] +
-                                  labels[m1srow:m1erow] + labels[m2erow:])
-            self._lines_rects = (rects[:m1srow] + rects[m2srow:m2erow] +
-                                 rects[m1srow:m1erow] + rects[m2erow:])
+
+            self._lines_flags = list(reversed(chain(
+                flags[:m1srow],
+                flags[m2srow:m2erow],
+                flags[m1srow:m1erow],
+                flags[m2erow:],
+            )))
+            self._lines[:] = (
+                lines[:m1srow]
+                + lines[m2srow:m2erow]
+                + lines[m1srow:m1erow]
+                + lines[m2erow:]
+            )
+            self._lines_labels = (
+                labels[:m1srow]
+                + labels[m2srow:m2erow]
+                + labels[m1srow:m1erow]
+                + labels[m2erow:]
+            )
+            self._lines_rects = (
+                rects[:m1srow]
+                + rects[m2srow:m2erow]
+                + rects[m1srow:m1erow]
+                + rects[m2erow:]
+            )
             self._trigger_update_graphics()
             csrow = srow + cdiff
             cerow = erow + cdiff
-            sel = (self.cursor_index((0, csrow)),
-                   self.cursor_index((0, cerow)))
+            sel = (
+                self.cursor_index((0, csrow)),
+                self.cursor_index((0, cerow))
+            )
             self.cursor = self.cursor_col, self.cursor_row + cdiff
+
             if not from_undo:
                 undo_rows = ((srow + cdiff, erow + cdiff),
                              (psrow - xdiff, perow - xdiff))
@@ -1090,6 +1145,43 @@ def cb(dt):
                 self._selection_callback = None
             self._selection_callback = Clock.schedule_once(cb)
 
+    @property
+    def pgmove_speed(self):
+        """how much vertical distance hitting pg_up or pg_down will move
+        """
+        return int(
+            self.height
+            / (self.line_height + self.line_spacing) - 1
+        )
+
+    def _move_cursor_up(self, col, row, control=False, alt=False):
+        if self.multiline and control:
+            self.scroll_y = max(0, self.scroll_y - self.line_height)
+        elif not self.readonly and self.multiline and alt:
+            self._shift_lines(-1)
+            return
+        else:
+            row = max(row - 1, 0)
+            col = min(len(self._lines[row]), col)
+
+        return col, row
+
+    def _move_cursor_down(self, col, row, control, alt):
+        if self.multiline and control:
+            maxy = self.minimum_height - self.height
+            self.scroll_y = max(
+                0,
+                min(maxy, self.scroll_y + self.line_height)
+            )
+        elif not self.readonly and self.multiline and alt:
+            self._shift_lines(1)
+            return
+        else:
+            row = min(row + 1, len(self._lines) - 1)
+            col = min(len(self._lines[row]), col)
+
+        return col, row
+
     def do_cursor_movement(self, action, control=False, alt=False):
         '''Move the cursor relative to its current position.
         Action can be one of :
@@ -1119,46 +1211,45 @@ def do_cursor_movement(self, action, control=False, alt=False):
         '''
         if not self._lines:
             return
-        pgmove_speed = int(self.height /
-            (self.line_height + self.line_spacing) - 1)
+
         col, row = self.cursor
         if action == 'cursor_up':
-            if self.multiline and control:
-                self.scroll_y = max(0, self.scroll_y - self.line_height)
-            elif not self.readonly and self.multiline and alt:
-                self._shift_lines(-1)
-                return
+            result = self._move_cursor_up(col, row, control, alt)
+            if result:
+                col, row = result
             else:
-                row = max(row - 1, 0)
-                col = min(len(self._lines[row]), col)
-        elif action == 'cursor_down':
-            if self.multiline and control:
-                maxy = self.minimum_height - self.height
-                self.scroll_y = max(0, min(maxy,
-                                           self.scroll_y + self.line_height))
-            elif not self.readonly and self.multiline and alt:
-                self._shift_lines(1)
                 return
+
+        elif action == 'cursor_down':
+            result = self._move_cursor_down(col, row, control, alt)
+            if result:
+                col, row = result
             else:
-                row = min(row + 1, len(self._lines) - 1)
-                col = min(len(self._lines[row]), col)
+                return
+
         elif action == 'cursor_home':
             col = 0
             if control:
                 row = 0
+
         elif action == 'cursor_end':
             if control:
                 row = len(self._lines) - 1
             col = len(self._lines[row])
+
         elif action == 'cursor_pgup':
-            row = max(0, row - pgmove_speed)
+            row = max(0, row - self.pgmove_speed)
             col = min(len(self._lines[row]), col)
+
         elif action == 'cursor_pgdown':
-            row = min(row + pgmove_speed, len(self._lines) - 1)
+            row = min(row + self.pgmove_speed, len(self._lines) - 1)
             col = min(len(self._lines[row]), col)
-        elif (self._selection and self._selection_finished and
-                self._selection_from < self._selection_to and
-                action == 'cursor_left'):
+
+        elif (
+            self._selection and self._selection_finished
+            and self._selection_from < self._selection_to
+            and action == 'cursor_left'
+        ):
             current_selection_to = self._selection_to
             while self._selection_from != current_selection_to:
                 current_selection_to -= 1
@@ -1167,9 +1258,12 @@ def do_cursor_movement(self, action, control=False, alt=False):
                 else:
                     row -= 1
                     col = len(self._lines[row])
-        elif (self._selection and self._selection_finished and
-                self._selection_from > self._selection_to and
-                action == 'cursor_right'):
+
+        elif (
+            self._selection and self._selection_finished
+            and self._selection_from > self._selection_to
+            and action == 'cursor_right'
+        ):
             current_selection_to = self._selection_to
             while self._selection_from != current_selection_to:
                 current_selection_to += 1
@@ -1189,6 +1283,7 @@ def do_cursor_movement(self, action, control=False, alt=False):
                         col = len(self._lines[row])
                 else:
                     col, row = col - 1, row
+
         elif action == 'cursor_right':
             if not self.password and control:
                 col, row = self._move_cursor_word_right()
@@ -1204,42 +1299,58 @@ def do_cursor_movement(self, action, control=False, alt=False):
         if dont_move_cursor:
             self._trigger_update_graphics()
         else:
-            self.cursor = (col, row)
+            self.cursor = col, row
 
     def get_cursor_from_xy(self, x, y):
         '''Return the (col, row) of the cursor from an (x, y) position.
         '''
-        padding_left = self.padding[0]
-        padding_top = self.padding[1]
-        l = self._lines
+        padding_left, padding_top, padding_right, padding_bottom = self.padding
+
+        lines = self._lines
         dy = self.line_height + self.line_spacing
-        cx = x - self.x
-        scrl_y = self.scroll_y
-        scrl_x = self.scroll_x
-        scrl_y = scrl_y / dy if scrl_y > 0 else 0
-        cy = (self.top - padding_top + scrl_y * dy) - y
-        cy = int(boundary(round(cy / dy - 0.5), 0, len(l) - 1))
-        _get_text_width = self._get_text_width
-        _tab_width = self.tab_width
-        _label_cached = self._label_cached
+        cursor_x = x - self.x
+        scroll_y = self.scroll_y
+        scroll_x = self.scroll_x
+        scroll_y = scroll_y / dy if scroll_y > 0 else 0
+
+        cursor_y = (self.top - padding_top + scroll_y * dy) - y
+        cursor_y = int(boundary(
+            round(cursor_y / dy - 0.5),
+            0,
+            len(lines) - 1
+        ))
+
+        get_text_width = self._get_text_width
+        tab_width = self.tab_width
+        label_cached = self._label_cached
+
         # Offset for horizontal text alignment
         xoff = 0
         halign = self.halign
         base_dir = self.base_direction or self._resolved_base_dir
         auto_halign_r = halign == 'auto' and base_dir and 'rtl' in base_dir
+
         if halign == 'center':
-            viewport_width = self.width - padding_left - self.padding[2]  # _r
-            xoff = int((viewport_width - self._get_row_width(cy)) / 2)
+            viewport_width = self.width - padding_left - padding_right
+            xoff = int((viewport_width - self._get_row_width(cursor_y)) / 2)
+
         elif halign == 'right' or auto_halign_r:
-            viewport_width = self.width - padding_left - self.padding[2]  # _r
-            xoff = viewport_width - self._get_row_width(cy)
-        for i in range(0, len(l[cy])):
-            if xoff + _get_text_width(l[cy][:i], _tab_width, _label_cached) + \
-                  _get_text_width(l[cy][i], _tab_width, _label_cached) * 0.6 +\
-                  padding_left > cx + scrl_x:
-                cx = i
+            viewport_width = self.width - padding_left - padding_right
+            xoff = viewport_width - self._get_row_width(cursor_y)
+
+        for i in range(0, len(lines[cursor_y])):
+            line_y = lines[cursor_y]
+
+            if cursor_x + scroll_x < (
+                xoff
+                + get_text_width(line_y[:i], tab_width, label_cached)
+                + get_text_width(line_y[i], tab_width, label_cached) * 0.6
+                + padding_left
+            ):
+                cursor_x = i
                 break
-        return cx, cy
+
+        return int(cursor_x), int(cursor_y)
 
     #
     # Selection control
@@ -1260,29 +1371,31 @@ def delete_selection(self, from_undo=False):
         if self.readonly:
             return
         self._hide_handles(EventLoop.window)
-        scrl_x = self.scroll_x
-        scrl_y = self.scroll_y
+        scroll_x = self.scroll_x
+        scroll_y = self.scroll_y
         cc, cr = self.cursor
         if not self._selection:
             return
         text = self.text
-        a, b = self._selection_from, self._selection_to
-        if a > b:
-            a, b = b, a
-        self.cursor = cursor = self.get_cursor_from_index(a)
-        start = cursor
-        finish = self.get_cursor_from_index(b)
-        cur_line = self._lines[start[1]][:start[0]] +\
-            self._lines[finish[1]][finish[0]:]
+        a, b = sorted((self._selection_from, self._selection_to))
+
+        self.cursor = (start_col, start_row) = self.get_cursor_from_index(a)
+        end_col, end_row = self.get_cursor_from_index(b)
+
+        cur_line = (
+            self._lines[start_row][:start_col]
+            + self._lines[end_row][end_col:]
+        )
         lines, lineflags = self._split_smart(cur_line)
-        len_lines = len(lines)
-        if start[1] == finish[1]:
-            self._set_line_text(start[1], cur_line)
+
+        if start_row == end_row:
+            self._set_line_text(start_row, cur_line)
         else:
-            self._refresh_text_from_property('del', start[1], finish[1], lines,
-                                             lineflags, len_lines)
-        self.scroll_x = scrl_x
-        self.scroll_y = scrl_y
+            self._refresh_text_from_property(
+                'del', start_row, end_row, lines, lineflags, len(lines)
+            )
+        self.scroll_x = scroll_x
+        self.scroll_y = scroll_y
         # handle undo and redo for delete selection
         self._set_unredo_delsel(a, b, text[a:b], from_undo)
         self.cancel_selection()
@@ -1374,7 +1487,7 @@ def on_touch_down(self, touch):
         touch_pos = touch.pos
         if not self.collide_point(*touch_pos):
             return False
-        if super(TextInput, self).on_touch_down(touch):
+        if super().on_touch_down(touch):
             return True
 
         if self.focus:
@@ -1492,20 +1605,24 @@ def on_touch_up(self, touch):
 
     def _handle_pressed(self, instance):
         self._hide_cut_copy_paste()
-        sf, st = self._selection_from, self.selection_to
-        if sf > st:
-            self._selection_from, self._selection_to = st, sf
+        from_, to_ = self._selection_from, self.selection_to
+        if from_ > to_:
+            self._selection_from, self._selection_to = to_, from_
 
     def _handle_released(self, instance):
-        sf, st = self._selection_from, self.selection_to
-        if sf == st:
+        if self._selection_from == self.selection_to:
             return
 
         self._update_selection()
         self._show_cut_copy_paste(
-            (instance.right if instance is self._handle_left else instance.x,
-             instance.top + self.line_height),
-            EventLoop.window)
+            (
+                instance.right
+                if instance is self._handle_left
+                else instance.x,
+                instance.top + self.line_height
+            ),
+            EventLoop.window
+        )
 
     def _handle_move(self, instance, touch):
         if touch.grab_current != instance:
@@ -1524,7 +1641,8 @@ def _handle_move(self, instance, touch):
 
         cursor = get_cursor(
             x,
-            y + instance._touch_diff + (self.line_height / 2))
+            y + instance._touch_diff + (self.line_height / 2)
+        )
 
         if instance != touch.grab_current:
             return
@@ -1534,13 +1652,12 @@ def _handle_move(self, instance, touch):
             self._position_handles(mode='middle')
             return
 
-        ci = self.cursor_index(cursor=cursor)
-        sf, st = self._selection_from, self.selection_to
+        cindex = self.cursor_index(cursor=cursor)
 
         if instance == handle_left:
-            self._selection_from = ci
+            self._selection_from = cindex
         elif instance == handle_right:
-            self._selection_to = ci
+            self._selection_to = cindex
         self._trigger_update_graphics()
         self._trigger_position_handles()
 
@@ -1632,9 +1749,10 @@ def _show_handles(self, dt):
             anim.start(self._handle_right)
             anim.start(self._handle_left)
 
-    def _show_cut_copy_paste(self, pos, win, parent_changed=False,
-                             mode='', pos_in_window=False, *l):
-        # Show a bubble with cut copy and paste buttons
+    def _show_cut_copy_paste(
+        self, pos, win, parent_changed=False, mode='', pos_in_window=False, *l
+    ):
+        """Show a bubble with cut copy and paste buttons"""
         if not self.use_bubble:
             return
 
@@ -1642,8 +1760,14 @@ def _show_cut_copy_paste(self, pos, win, parent_changed=False,
         if bubble is None:
             self._bubble = bubble = TextInputCutCopyPaste(textinput=self)
             self.fbind('parent', self._show_cut_copy_paste, pos, win, True)
-            self.bind(focus=lambda *args: self._hide_cut_copy_paste(win))
-            self.bind(cursor_pos=lambda *args: self._hide_cut_copy_paste(win))
+
+            def hide_(*args):
+                return self._hide_cut_copy_paste(win)
+
+            self.bind(
+                focus=hide_,
+                cursor_pos=hide_,
+            )
         else:
             win.remove_widget(bubble)
             if not self.parent:
@@ -1674,8 +1798,10 @@ def _show_cut_copy_paste(self, pos, win, parent_changed=False,
             # bubble beyond right of window
             if bubble_pos[1] > (win_size[1] - bubble_size[1]):
                 # bubble above window height
-                bubble_pos = (win_size[0] - bubble_hw,
-                             (t_pos[1]) - (lh + ls + inch(.25)))
+                bubble_pos = (
+                    win_size[0] - bubble_hw,
+                    (t_pos[1]) - (lh + ls + inch(.25))
+                )
                 bubble.arrow_pos = 'top_right'
             else:
                 bubble_pos = (win_size[0] - bubble_hw, bubble_pos[1])
@@ -1683,8 +1809,10 @@ def _show_cut_copy_paste(self, pos, win, parent_changed=False,
         else:
             if bubble_pos[1] > (win_size[1] - bubble_size[1]):
                 # bubble above window height
-                bubble_pos = (bubble_pos[0],
-                             (t_pos[1]) - (lh + ls + inch(.25)))
+                bubble_pos = (
+                    bubble_pos[0],
+                    (t_pos[1]) - (lh + ls + inch(.25))
+                )
                 bubble.arrow_pos = 'top_mid'
             else:
                 bubble.arrow_pos = 'bottom_mid'
@@ -1714,19 +1842,21 @@ def _hide_cut_copy_paste(self, win=None):
 
     @staticmethod
     def _reload_remove_observer(wr):
-        # called when the textinput is deleted
+        """called when the textinput is deleted"""
         if wr in _textinput_list:
             _textinput_list.remove(wr)
 
     def _on_textinput_focused(self, instance, value, *largs):
-
         win = EventLoop.window
         self.cancel_selection()
         self._hide_cut_copy_paste(win)
 
         if value:
-            if (not (self.readonly or self.disabled) or _is_desktop and
-                    self._keyboard_mode == 'system'):
+            if (
+                not (self.readonly or self.disabled)
+                or _is_desktop
+                and self._keyboard_mode == 'system'
+            ):
                 self._trigger_cursor_reset()
                 self._editable = True
             else:
@@ -1784,7 +1914,7 @@ def _update_cutbuffer(self, *args):
         CutBuffer.set_cutbuffer(self.selection_text)
 
     def _get_text_width(self, text, tab_width, _label_cached):
-        # Return the width of a text, according to the current line options
+        """Return the width of a text, according to the current line options"""
         kw = self._get_line_options()
 
         try:
@@ -1807,7 +1937,7 @@ def _get_text_width(self, text, tab_width, _label_cached):
         return width
 
     def on_cursor_blink(self, instance, value):
-        # trigger blink event reset to switch blinking while focused
+        """trigger blink event reset to switch blinking while focused"""
         self._reset_cursor_blink()
 
     def _do_blink_cursor(self, dt):
@@ -1829,14 +1959,16 @@ def _reset_cursor_blink(self, *args):
         self._do_blink_cursor_ev()
 
     def on_cursor(self, instance, value):
-        # When the cursor is moved, reset cursor blinking to keep it showing,
-        # and update all the graphics.
+        """
+        When the cursor is moved, reset cursor blinking to keep it showing,
+        and update all the graphics.
+        """
         if self.focus:
             self._trigger_cursor_reset()
         self._trigger_update_graphics()
 
     def _delete_line(self, idx):
-        # Delete current line, and fix cursor position
+        """Delete current line, and fix cursor position"""
         assert(idx < len(self._lines))
         self._lines_flags.pop(idx)
         self._lines_labels.pop(idx)
@@ -1844,7 +1976,7 @@ def _delete_line(self, idx):
         self.cursor = self.cursor
 
     def _set_line_text(self, line_num, text):
-        # Set current line with other text than the default one.
+        """Set current line with other text than the default one."""
         self._lines_labels[line_num] = self._create_line_label(text)
         self._lines[line_num] = text
 
@@ -1882,8 +2014,10 @@ def _refresh_text_from_property(self, *largs):
         self._refresh_text(self.text, *largs)
 
     def _refresh_text(self, text, *largs):
-        # Refresh all the lines from a new text.
-        # By using cache in internal functions, this method should be fast.
+        """
+        Refresh all the lines from a new text.
+        By using cache in internal functions, this method should be fast.
+        """
         mode = 'all'
         if len(largs) > 1:
             mode, start, finish, _lines, _lines_flags, len_lines = largs
@@ -1926,8 +2060,10 @@ def _refresh_text(self, text, *largs):
         # now, if the text change, maybe the cursor is not at the same place as
         # before. so, try to set the cursor on the good place
         row = self.cursor_row
-        self.cursor = self.get_cursor_from_index(self.cursor_index()
-                                                 if cursor is None else cursor)
+        self.cursor = self.get_cursor_from_index(
+            self.cursor_index() if cursor is None else cursor
+        )
+
         # if we back to a new line, reset the scroll, otherwise, the effect is
         # ugly
         if self.cursor_row != row:
@@ -1976,29 +2112,32 @@ def _trigger_update_graphics(self, *largs):
         self._update_graphics_ev()
 
     def _update_graphics(self, *largs):
-        # Update all the graphics according to the current internal values.
-        #
-        # This is a little bit complex, cause we have to :
+        """
+        Update all the graphics according to the current internal values.
+        """
+
+        # This is a little bit complex, because we have to :
         #     - handle scroll_x
         #     - handle padding
         #     - create rectangle for the lines matching the viewport
         #     - crop the texture coordinates to match the viewport
-        #
+
         # This is the first step of graphics, the second is the selection.
 
         self.canvas.clear()
-        add = self.canvas.add
 
-        lh = self.line_height
-        dy = lh + self.line_spacing
+        line_height = self.line_height
+        dy = line_height + self.line_spacing
 
         # adjust view if the cursor is going outside the bounds
-        sx = self.scroll_x
-        sy = self.scroll_y
+        scroll_x = self.scroll_x
+        scroll_y = self.scroll_y
 
         # draw labels
-        if not self._lines or (
-                not self._lines[0] and len(self._lines) == 1):
+        if (
+            not self._lines
+            or (not self._lines[0] and len(self._lines) == 1)
+        ):
             rects = self._hint_text_rects
             labels = self._hint_text_labels
             lines = self._hint_text_lines
@@ -2006,91 +2145,38 @@ def _update_graphics(self, *largs):
             rects = self._lines_rects
             labels = self._lines_labels
             lines = self._lines
+
         padding_left, padding_top, padding_right, padding_bottom = self.padding
         x = self.x + padding_left
-        y = self.top - padding_top + sy
+        y = self.top - padding_top + scroll_y
         miny = self.y + padding_bottom
         maxy = self.top - padding_top
         halign = self.halign
         base_dir = self.base_direction
-        find_base_dir = Label.find_base_direction
+
         auto_halign_r = halign == 'auto' and base_dir and 'rtl' in base_dir
 
         fst_visible_ln = None
+        viewport_pos = scroll_x, 0
         for line_num, value in enumerate(lines):
             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[:]
-
-                # calcul coordinate
-                viewport_pos = sx, 0
-                vw = self.width - padding_left - padding_right
-                vh = self.height - padding_top - padding_bottom
-                tw, th = list(map(float, size))
-                oh, ow = tch, tcw = texc[1:3]
-                tcx, tcy = 0, 0
-
-                # adjust size/texcoord according to viewport
-                if viewport_pos:
-                    tcx, tcy = viewport_pos
-                    tcx = tcx / tw * (ow)
-                    tcy = tcy / th * oh
-                if tw - viewport_pos[0] < vw:
-                    tcw = tcw - tcx
-                    size[0] = tcw * size[0]
-                elif vw < tw:
-                    tcw = (vw / tw) * tcw
-                    size[0] = vw
-                if vh < th:
-                    tch = (vh / th) * tch
-                    size[1] = vh
-
-                # cropping
-                mlh = lh
-                if y > maxy:
-                    vh = (maxy - y + lh)
-                    tch = (vh / float(lh)) * oh
-                    tcy = oh - tch
-                    size[1] = vh
-                if y - lh < miny:
-                    diff = miny - (y - lh)
-                    y += diff
-                    vh = lh - diff
-                    tch = (vh / float(lh)) * oh
-                    size[1] = vh
-
-                texc = (
-                    tcx,
-                    tcy + tch,
-                    tcx + tcw,
-                    tcy + tch,
-                    tcx + tcw,
-                    tcy,
-                    tcx,
-                    tcy)
-
-                # Horizontal alignment
-                xoffset = 0
-                if not base_dir:
-                    base_dir = self._resolved_base_dir = find_base_dir(value)
-                    if base_dir and halign == 'auto':
-                        auto_halign_r = 'rtl' in base_dir
-                if halign == 'center':
-                    xoffset = int((vw - size[0]) / 2.)
-                elif halign == 'right' or auto_halign_r:
-                    xoffset = max(0, int(vw - size[0]))
-
-                # add rectangle.
-                r = rects[line_num]
-                r.pos = int(xoffset + x), int(y - mlh)
-                r.size = size
-                r.texture = texture
-                r.tex_coords = texc
-                add(r)
+                y = self._draw_line(
+                    value,
+                    line_num,
+                    labels[line_num],
+                    viewport_pos,
+                    line_height,
+                    miny,
+                    maxy,
+                    x,
+                    y,
+                    base_dir,
+                    halign,
+                    rects,
+                )
             elif y <= miny:
                 line_num -= 1
                 break
@@ -2104,79 +2190,227 @@ def _update_graphics(self, *largs):
 
         self._update_graphics_selection()
 
+    def _draw_line(
+        self,
+        value,
+        line_num,
+        texture,
+        viewport_pos,
+        line_height,
+        miny,
+        maxy,
+        x,
+        y,
+        base_dir,
+        halign,
+        rects,
+    ):
+        size = list(texture.size)
+        texcoords = texture.tex_coords[:]
+
+        # compute coordinate
+        padding_left, padding_top, padding_right, padding_bottom = self.padding
+        viewport_width = self.width - padding_left - padding_right
+        viewport_height = self.height - padding_top - padding_bottom
+        texture_width, texture_height = size
+        original_height, original_width = tch, tcw = texcoords[1:3]
+
+        # adjust size/texcoord according to viewport
+        if viewport_pos:
+            tcx, tcy = viewport_pos
+            tcx = tcx / texture_width * original_width
+            tcy = tcy / texture_height * original_height
+
+        else:
+            tcx, tcy = 0, 0
+
+        if texture_width * (1 - tcx) < viewport_width:
+            tcw = tcw - tcx
+            texture_width = tcw * texture_width
+        elif viewport_width < texture_width:
+            tcw = (viewport_width / texture_width) * tcw
+            texture_width = viewport_width
+
+        if viewport_height < texture_height:
+            tch = (viewport_height / texture_height) * tch
+            texture_height = viewport_height
+
+        # cropping
+        if y > maxy:
+            viewport_height = (maxy - y + line_height)
+            tch = (viewport_height / line_height) * original_height
+            tcy = original_height - tch
+            texture_height = viewport_height
+        if y - line_height < miny:
+            diff = miny - (y - line_height)
+            y += diff
+            viewport_height = line_height - diff
+            tch = (viewport_height / line_height) * original_height
+            texture_height = viewport_height
+
+        if tcw < 0:
+            # nothing to show
+            return y
+
+        top_left_corner = tcx, tcy + tch
+        top_right_corner = tcx + tcw, tcy + tch
+        bottom_right_corner = tcx + tcw, tcy
+        bottom_left_corner = tcx, tcy
+
+        texcoords = (
+            top_left_corner
+            + top_right_corner
+            + bottom_right_corner
+            + bottom_left_corner
+        )
+
+        # Horizontal alignment
+        xoffset = 0
+        if not base_dir:
+            base_dir = self._resolved_base_dir = Label.find_base_direction(value)  # noqa
+            if base_dir and halign == 'auto':
+                auto_halign_r = 'rtl' in base_dir
+        if halign == 'center':
+            xoffset = int((viewport_width - texture_width) / 2.)
+        elif halign == 'right' or auto_halign_r:
+            xoffset = max(0, int(viewport_width - texture_width))
+
+        # add rectangle
+        rect = rects[line_num]
+        rect.pos = int(xoffset + x), int(y - line_height)
+        rect.size = texture_width, texture_height
+        rect.texture = texture
+        rect.tex_coords = texcoords
+        # useful to debug rectangle sizes
+        # self.canvas.add(Color(0, .5, 0, .5, mode='rgba'))
+        # self.canvas.add(Rectangle(pos=rect.pos, size=rect.size))
+        # self.canvas.add(Color())
+        self.canvas.add(rect)
+
+        return y
+
     def _update_graphics_selection(self):
         if not self._selection:
             return
-        self.canvas.remove_group('selection')
-        dy = self.line_height + self.line_spacing
+
+        # local references to avoid dot lookups later
+        padding_left, padding_top, padding_right, padding_bottom = self.padding
         rects = self._lines_rects
-        padding_top = self.padding[1]
-        padding_bottom = self.padding[3]
-        _top = self.top
-        y = _top - padding_top + self.scroll_y
-        miny = self.y + padding_bottom
-        maxy = _top - padding_top
-        draw_selection = self._draw_selection
-        a, b = self._selection_from, self._selection_to
-        if a > b:
-            a, b = b, a
-        get_cursor_from_index = self.get_cursor_from_index
-        s1c, s1r = get_cursor_from_index(a)
-        s2c, s2r = get_cursor_from_index(b)
-        s2r += 1
-        # pass only the selection lines[]
-        # passing all the lines can get slow when dealing with a lot of text
-        y -= s1r * dy
-        _lines = self._lines
-        _get_text_width = self._get_text_width
+        label_cached = self._label_cached
+        lines = self._lines
         tab_width = self.tab_width
-        _label_cached = self._label_cached
-        width = self.width
-        padding_left = self.padding[0]
-        padding_right = self.padding[2]
-        x = self.x
+        top = self.top
+        get_text_width = self._get_text_width
+        get_cursor_from_index = self.get_cursor_from_index
+        draw_selection = self._draw_selection
         canvas_add = self.canvas.add
         selection_color = self.selection_color
-        for line_num, value in enumerate(_lines[s1r:s2r], start=s1r):
-            if miny <= y <= maxy + dy:
-                r = rects[line_num]
-                draw_selection(r.pos, r.size, line_num, (s1c, s1r),
-                               (s2c, s2r - 1), _lines, _get_text_width,
-                               tab_width, _label_cached, width,
-                               padding_left, padding_right, x,
-                               canvas_add, selection_color)
+
+        # selection borders
+        a, b = sorted((self._selection_from, self._selection_to))
+        selection_start_col, selection_start_row = get_cursor_from_index(a)
+        selection_end_col, selection_end_row = get_cursor_from_index(b)
+
+        dy = self.line_height + self.line_spacing
+        x = self.x
+        y = top - padding_top + self.scroll_y - selection_start_row * dy
+        width = self.width
+
+        miny = self.y + padding_bottom
+        maxy = top - padding_top + dy
+
+        self.canvas.remove_group('selection')
+        first_visible_line = math.floor(self.scroll_y / dy)
+        last_visible_line = math.ceil((self.scroll_y + maxy - miny) / dy)
+        width_minus_padding = width - (padding_right + padding_left)
+
+        for line_num, rect in enumerate(
+            islice(
+                rects,
+                max(selection_start_row, first_visible_line),
+                min(selection_end_row + 1, last_visible_line),
+            ),
+            start=selection_start_row
+        ):
+            draw_selection(
+                rect.pos,
+                rect.size,
+                line_num,
+                (selection_start_col, selection_start_row),
+                (selection_end_col, selection_end_row),
+                lines,
+                get_text_width,
+                tab_width,
+                label_cached,
+                width_minus_padding,
+                padding_left,
+                padding_right,
+                x,
+                canvas_add,
+                selection_color
+            )
             y -= dy
         self._position_handles('both')
 
-    def _draw_selection(self, *largs):
-        pos, size, line_num, (s1c, s1r), (s2c, s2r),\
-            _lines, _get_text_width, tab_width, _label_cached, width,\
-            padding_left, padding_right, x, canvas_add, selection_color = largs
+    def _draw_selection(
+        self,
+        pos,
+        size,
+        line_num,
+        selection_start,
+        selection_end,
+        lines,
+        get_text_width,
+        tab_width,
+        label_cached,
+        width_minus_padding,
+        padding_left,
+        padding_right,
+        x,
+        canvas_add,
+        selection_color
+    ):
+        selection_start_col, selection_start_row = selection_start
+        selection_end_col, selection_end_row = selection_end
+
         # Draw the current selection on the widget.
-        if line_num < s1r or line_num > s2r:
+        if not selection_start_row <= line_num <= selection_end_row:
             return
         x, y = pos
         w, h = size
-        x1 = x
-        x2 = x + w
-        if line_num == s1r:
-            lines = _lines[line_num]
-            x1 -= self.scroll_x
-            x1 += _get_text_width(lines[:s1c], tab_width, _label_cached)
-        if line_num == s2r:
-            lines = _lines[line_num]
-            x2 = (x - self.scroll_x) + _get_text_width(lines[:s2c],
-                                                       tab_width,
-                                                       _label_cached)
-        width_minus_padding = width - (padding_right + padding_left)
-        maxx = x + width_minus_padding
-        if x1 > maxx:
+        beg = x
+        end = x + w
+
+        if line_num == selection_start_row:
+            line = lines[line_num]
+            beg -= self.scroll_x
+            beg += get_text_width(
+                line[:selection_start_col],
+                tab_width,
+                label_cached
+            )
+
+        if line_num == selection_end_row:
+            line = lines[line_num]
+            end = (x - self.scroll_x) + get_text_width(
+                line[:selection_end_col],
+                tab_width,
+                label_cached
+            )
+
+        beg = boundary(beg, x, x + width_minus_padding)
+        end = boundary(end, x, x + width_minus_padding)
+        if beg == end:
             return
-        x1 = max(x1, x)
-        x2 = min(x2, x + width_minus_padding)
+
         canvas_add(Color(*selection_color, group='selection'))
-        canvas_add(Rectangle(
-            pos=(x1, pos[1]), size=(x2 - x1, size[1]), group='selection'))
+        canvas_add(
+            Rectangle(
+                pos=(beg, y),
+                size=(end - beg, h),
+                group='selection'
+            )
+        )
 
     def on_size(self, instance, value):
         # if the size change, we might do invalid scrolling / text split
@@ -2211,12 +2445,21 @@ def _get_cursor_pos(self):
         auto_halign_r = halign == 'auto' and base_dir and 'rtl' in base_dir
         if halign == 'center':
             row_width = self._get_row_width(self.cursor_row)
-            x = left + int((viewport_width - row_width) / 2) \
-                     + cursor_offset - self.scroll_x
+            x = (
+                left
+                + (viewport_width - row_width) // 2
+                + cursor_offset
+                - self.scroll_x
+            )
         elif halign == 'right' or auto_halign_r:
             row_width = self._get_row_width(self.cursor_row)
-            x = left + viewport_width - row_width \
-                     + cursor_offset - self.scroll_x
+            x = (
+                left
+                + viewport_width
+                - row_width
+                + cursor_offset
+                - self.scroll_x
+            )
         else:
             x = left + cursor_offset - self.scroll_x
 
@@ -2254,15 +2497,18 @@ def _get_line_options(self):
                 'anchor_y': 'top',
                 'padding_x': 0,
                 'padding_y': 0,
-                'padding': (0, 0)}
+                'padding': (0, 0)
+            }
             self._label_cached = Label(**kw)
         return self._line_options
 
     def _create_line_label(self, text, hint=False):
         # Create a label from a text, using line options
         ntext = text.replace(u'\n', u'').replace(u'\t', u' ' * self.tab_width)
+
         if self.password and not hint:  # Don't replace hint_text with *
             ntext = self.password_mask * len(ntext)
+
         kw = self._get_line_options()
         cid = '%s\0%s' % (ntext, str(kw))
         texture = Cache_get('textinput.label', cid)
@@ -2270,7 +2516,7 @@ def _create_line_label(self, text, hint=False):
         if texture is None:
             # FIXME right now, we can't render very long line...
             # if we move on "VBO" version as fallback, we won't need to
-            # do this. try to found the maximum text we can handle
+            # do this. try to find the maximum text we can handle
             label = None
             label_len = len(ntext)
             ld = None
@@ -2286,16 +2532,17 @@ def _create_line_label(self, text, hint=False):
                     label = Label(text=ntext[:label_len], **kw)
                     label.refresh()
                     if ld is not None and ld > 2:
-                        ld = int(ld / 2)
+                        ld //= 2
                         label_len += ld
                     else:
                         break
+
                 except:
                     # exception happen when we tried to render the text
                     # reduce it...
                     if ld is None:
                         ld = len(ntext)
-                    ld = int(ld / 2)
+                    ld //= 2
                     if ld < 2 and label_len:
                         label_len -= 1
                     label_len -= ld
@@ -2322,13 +2569,15 @@ def _tokenize(self, text):
         yield text[oldindex:]
 
     def _split_smart(self, text):
-        # Do a "smart" split. If autowidth or autosize is set,
-        # we are not doing smart split, just a split on line break.
-        # Otherwise, we are trying to split as soon as possible, to prevent
-        # overflow on the widget.
+        """
+        Do a "smart" split. If not multiline, or if wrap is set,
+        we are not doing smart split, just a split on line break.
+        Otherwise, we are trying to split as soon as possible, to prevent
+        overflow on the widget.
+        """
 
         # depend of the options, split the text on line, or word
-        if not self.multiline:
+        if not self.multiline or not self.do_wrap:
             lines = text.split(u'\n')
             lines_flags = [0] + [FL_IS_LINEBREAK] * (len(lines) - 1)
             return lines, lines_flags
@@ -2395,10 +2644,13 @@ def _key_down(self, key, repeat=False):
         displayed_str, internal_str, internal_action, scale = key
 
         # handle deletion
-        if (self._selection and
-                internal_action in (None, 'del', 'backspace', 'enter')):
-            if internal_action != 'enter' or self.multiline:
-                self.delete_selection()
+        if (
+            self._selection
+            and internal_action in (None, 'del', 'backspace', 'enter')
+            and (internal_action != 'enter' or self.multiline)
+        ):
+            self.delete_selection()
+
         elif internal_action == 'del':
             # Move cursor one char to the right. If that was successful,
             # do a backspace (effectively deleting char right of cursor)
@@ -2406,35 +2658,45 @@ def _key_down(self, key, repeat=False):
             self.do_cursor_movement('cursor_right')
             if cursor != self.cursor:
                 self.do_backspace(mode='del')
+
         elif internal_action == 'backspace':
             self.do_backspace()
 
         # handle action keys and text insertion
         if internal_action is None:
             self.insert_text(displayed_str)
+
         elif internal_action in ('shift', 'shift_L', 'shift_R'):
             if not self._selection:
                 self._selection_from = self._selection_to = self.cursor_index()
                 self._selection = True
             self._selection_finished = False
+
         elif internal_action == 'ctrl_L':
             self._ctrl_l = True
+
         elif internal_action == 'ctrl_R':
             self._ctrl_r = True
+
         elif internal_action == 'alt_L':
             self._alt_l = True
+
         elif internal_action == 'alt_R':
             self._alt_r = True
+
         elif internal_action.startswith('cursor_'):
             cc, cr = self.cursor
-            self.do_cursor_movement(internal_action,
-                                    self._ctrl_l or self._ctrl_r,
-                                    self._alt_l or self._alt_r)
+            self.do_cursor_movement(
+                internal_action,
+                self._ctrl_l or self._ctrl_r,
+                self._alt_l or self._alt_r
+            )
             if self._selection and not self._selection_finished:
                 self._selection_to = self.cursor_index()
                 self._update_selection()
             else:
                 self.cancel_selection()
+
         elif internal_action == 'enter':
             if self.multiline:
                 self.insert_text(u'\n')
@@ -2442,6 +2704,7 @@ def _key_down(self, key, repeat=False):
                 self.dispatch('on_text_validate')
                 if self.text_validate_unfocus:
                     self.focus = False
+
         elif internal_action == 'escape':
             self.focus = False
 
@@ -2460,32 +2723,27 @@ def _key_up(self, key, repeat=False):
             self._alt_r = False
 
     def keyboard_on_key_down(self, window, keycode, text, modifiers):
-        # Keycodes on OS X:
-        ctrl, cmd = 64, 1024
-        key, key_str = keycode
+        key, _ = keycode
         win = EventLoop.window
 
         # This allows *either* ctrl *or* cmd, but not both.
         modifiers = set(modifiers) - {'capslock', 'numlock'}
-        is_shortcut = (modifiers == {'ctrl'} or (
-            _is_osx and modifiers == {'meta'}))
-        is_interesting_key = key in (list(self.interesting_keys.keys()) + [27])
-
-        if not self.write_tab and super(TextInput,
-            self).keyboard_on_key_down(window, keycode, text, modifiers):
-            return True
+        is_shortcut = (
+            modifiers == {'ctrl'}
+            or _is_osx and modifiers == {'meta'}
+        )
+        is_interesting_key = key in self.interesting_keys.keys()
 
-        if not self._editable:
-            # duplicated but faster testing for non-editable keys
-            if text and not is_interesting_key:
-                if is_shortcut and key == ord('c'):
-                    self.copy()
-            elif key == 27:
-                self.focus = False
+        if (
+            not self.write_tab
+            and super().keyboard_on_key_down(window, keycode, text, modifiers)
+        ):
             return True
 
-        if text and not is_interesting_key:
+        if text and is_shortcut and not is_interesting_key:
+            self._handle_shortcut(key)
 
+        elif self._editable and text and not is_interesting_key:
             self._hide_handles(win)
             self._hide_cut_copy_paste(win)
             win.remove_widget(self._handle_middle)
@@ -2509,52 +2767,11 @@ def keyboard_on_key_down(self, window, keycode, text, modifiers):
 
             _command = self._command
             if _command and first_char == 2:
-                from_undo = True
-                _command, data = _command.split(':')
-                self._command = ''
-                if self._selection:
-                    self.delete_selection()
-                if _command == 'DEL':
-                    count = int(data)
-                    if not count:
-                        self.delete_selection(from_undo=True)
-                    end = self.cursor_index()
-                    self._selection_from = max(end - count, 0)
-                    self._selection_to = end
-                    self._selection = True
-                    self.delete_selection(from_undo=True)
-                    return
-                elif _command == 'INSERT':
-                    self.insert_text(data, from_undo)
-                elif _command == 'INSERTN':
-                    from_undo = False
-                    self.insert_text(data, from_undo)
-                elif _command == 'SELWORD':
-                    self.dispatch('on_double_tap')
-                elif _command == 'SEL':
-                    if data == '0':
-                        Clock.schedule_once(lambda dt: self.cancel_selection())
-                elif _command == 'CURCOL':
-                    self.cursor = int(data), self.cursor_row
+                self._handle_command(_command)
                 return
 
-            if is_shortcut:
-                if key == ord('x'):  # cut selection
-                    self._cut(self.selection_text)
-                elif key == ord('c'):  # copy selection
-                    self.copy()
-                elif key == ord('v'):  # paste clipboard content
-                    self.paste()
-                elif key == ord('a'):  # select all
-                    self.select_all()
-                elif key == ord('z'):  # undo
-                    self.do_undo()
-                elif key == ord('r'):  # redo
-                    self.do_redo()
             else:
-                is_sdl2 = (EventLoop.window.__class__.__module__ ==
-                           'kivy.core.window.window_sdl2')
-                if is_sdl2:
+                if EventLoop.window.managed_textinput:
                     # we expect to get managed key input via on_textinput
                     return
                 if self._selection:
@@ -2571,6 +2788,7 @@ def keyboard_on_key_down(self, window, keycode, text, modifiers):
             self.focus = False
             return True
         elif key == 9:  # tab
+            self.delete_selection()
             self.insert_text(u'\t')
             return True
 
@@ -2579,8 +2797,57 @@ def keyboard_on_key_down(self, window, keycode, text, modifiers):
             key = (None, None, k, 1)
             self._key_down(key)
 
+    def _handle_command(self, command):
+        from_undo = True
+        command, data = command.split(':')
+        self._command = ''
+        if self._selection:
+            self.delete_selection()
+        if command == 'DEL':
+            count = int(data)
+            if not count:
+                self.delete_selection(from_undo=True)
+            end = self.cursor_index()
+            self._selection_from = max(end - count, 0)
+            self._selection_to = end
+            self._selection = True
+            self.delete_selection(from_undo=True)
+            return
+        elif command == 'INSERT':
+            self.insert_text(data, from_undo)
+        elif command == 'INSERTN':
+            from_undo = False
+            self.insert_text(data, from_undo)
+        elif command == 'SELWORD':
+            self.dispatch('on_double_tap')
+        elif command == 'SEL':
+            if data == '0':
+                Clock.schedule_once(lambda dt: self.cancel_selection())
+        elif command == 'CURCOL':
+            self.cursor = int(data), self.cursor_row
+
+    def _handle_shortcut(self, key):
+        # actions that can be done in readonly
+        if key == ord('a'):  # select all
+            self.select_all()
+        elif key == ord('c'):  # copy selection
+            self.copy()
+
+        if not self._editable:
+            return
+
+        # actions that can be done only if editable
+        if key == ord('x'):  # cut selection
+            self._cut(self.selection_text)
+        elif key == ord('v'):  # paste clipboard content
+            self.paste()
+        elif key == ord('z'):  # undo
+            self.do_undo()
+        elif key == ord('r'):  # redo
+            self.do_redo()
+
     def keyboard_on_key_up(self, window, keycode):
-        key, key_str = keycode
+        key = keycode[0]
         k = self.interesting_keys.get(key)
         if k:
             key = (None, None, k, 1)
@@ -2702,6 +2969,18 @@ def _refresh_hint_text(self):
     defaults to True.
     '''
 
+    do_wrap = BooleanProperty(True)
+    '''If True, and the text is multiline, then lines larger than the width of
+    the widget will wrap around to the next line, avoiding the need for
+    horizontal scrolling. Disabling this option ensure one line is always
+    displayed as one line.
+
+    :attr:`do_wrap` is a :class:`~kivy.properties.BooleanProperty` and defaults
+    to True.
+
+    versionadded:: 2.1.0
+    '''
+
     password = BooleanProperty(False)
     '''If True, the widget will display its characters as the character
     set in :attr:`password_mask`.
@@ -2749,6 +3028,16 @@ def _set_cursor(self, pos):
 
         # adjust scrollview to ensure that the cursor will be always inside our
         # viewport.
+        self._adjust_viewport(cc, cr)
+
+        if self._cursor == cursor:
+            return
+
+        self._cursor = cursor
+        return True
+
+    @triggered(timeout=-1)
+    def _adjust_viewport(self, cc, cr):
         padding_left = self.padding[0]
         padding_right = self.padding[2]
         viewport_width = self.width - padding_left - padding_right
@@ -2756,30 +3045,25 @@ def _set_cursor(self, pos):
         offset = self.cursor_offset()
 
         # if offset is outside the current bounds, readjust
-        if offset > viewport_width + sx:
+        if offset - sx >= viewport_width:
             self.scroll_x = offset - viewport_width
-        if offset < sx:
+        elif offset < sx + 1:
             self.scroll_x = offset
 
         # do the same for Y
         # this algo try to center the cursor as much as possible
         dy = self.line_height + self.line_spacing
         offsety = cr * dy
-        sy = self.scroll_y
+
         padding_top = self.padding[1]
         padding_bottom = self.padding[3]
         viewport_height = self.height - padding_top - padding_bottom - dy
-        if offsety > viewport_height + sy:
-            sy = offsety - viewport_height
-        if offsety < sy:
-            sy = offsety
-        self.scroll_y = sy
 
-        if self._cursor == cursor:
-            return
-
-        self._cursor = cursor
-        return True
+        sy = self.scroll_y
+        if offsety > viewport_height + sy:
+            self.scroll_y = offsety - viewport_height
+        elif offsety < sy:
+            self.scroll_y = offsety
 
     cursor = AliasProperty(_get_cursor, _set_cursor)
     '''Tuple of (col, row) values indicating the current cursor position.
@@ -3216,9 +3500,11 @@ def _set_text(self, text):
     defaults to None.
     '''
 
-    base_direction = OptionProperty(None,
-                     options=['ltr', 'rtl', 'weak_rtl', 'weak_ltr', None],
-                     allownone=True)
+    base_direction = OptionProperty(
+        None,
+        options=['ltr', 'rtl', 'weak_rtl', 'weak_ltr', None],
+        allownone=True
+    )
     '''Base direction of text, this impacts horizontal alignment when
     :attr:`halign` is `auto` (the default). Available options are: None,
     "ltr" (left to right), "rtl" (right to left) plus "weak_ltr" and
@@ -3318,15 +3604,20 @@ def _get_hint_text(self):
     '''
 
     def _get_min_height(self):
-        return (len(self._lines) * (self.line_height + self.line_spacing) +
-                self.padding[1] + self.padding[3])
-
-    minimum_height = AliasProperty(_get_min_height,
-                                   bind=('_lines', 'line_spacing', 'padding',
-                                         'font_size', 'font_name', 'password',
-                                         'font_context', 'hint_text',
-                                         'line_height'),
-                                   cache=True)
+        return (
+            len(self._lines) * (self.line_height + self.line_spacing)
+            + self.padding[1]
+            + self.padding[3]
+        )
+
+    minimum_height = AliasProperty(
+        _get_min_height,
+        bind=(
+            '_lines', 'line_spacing', 'padding', 'font_size', 'font_name',
+            'password', 'font_context', 'hint_text', 'line_height'
+        ),
+        cache=True
+    )
     '''Minimum height of the content inside the TextInput.
 
     .. versionadded:: 1.8.0
@@ -3424,22 +3715,83 @@ def on_handle_image_right(self, instance, value):
 
 
 if __name__ == '__main__':
+    from textwrap import dedent
     from kivy.app import App
     from kivy.uix.boxlayout import BoxLayout
     from kivy.lang import Builder
 
+    KV = dedent(r'''
+    #:set font_size '20dp'
+
+    BoxLayout:
+        orientation: 'vertical'
+        padding: '20dp'
+        spacing: '10dp'
+        TextInput:
+            font_size: font_size
+            size_hint_y: None
+            height: self.minimum_height
+            multiline: False
+            text: 'monoline'
+
+        TextInput:
+            size_hint_y: None
+            font_size: font_size
+            height: self.minimum_height
+            multiline: False
+            password: True
+            password_mask: '•'
+            text: 'password'
+
+        TextInput:
+            font_size: font_size
+            size_hint_y: None
+            height: self.minimum_height
+            multiline: False
+            readonly: True
+            text: 'readonly'
+
+        TextInput:
+            font_size: font_size
+            size_hint_y: None
+            height: self.minimum_height
+            multiline: False
+            disabled: True
+            text: 'disabled'
+
+        TextInput:
+            font_size: font_size
+            hint_text: 'normal with hint text'
+
+        TextInput:
+            font_size: font_size
+            text: 'default'
+
+        TextInput:
+            font_size: font_size
+            text: 'bubble & handles'
+            use_bubble: True
+            use_handles: True
+
+        TextInput:
+            font_size: font_size
+            text: 'no wrap'
+            do_wrap: False
+
+        TextInput:
+            font_size: font_size
+            text: 'multiline\nreadonly'
+            disabled: app.time % 5 < 2.5
+    ''')
+
     class TextInputApp(App):
+        time = NumericProperty()
+
         def build(self):
-            root = BoxLayout(orientation='vertical')
-            textinput = TextInput(
-                multiline=True, use_bubble=True, use_handles=True
-            )
-            root.add_widget(textinput)
-            textinput2 = TextInput(
-                multiline=False, text='monoline textinput',
-                size_hint=(1, None), height=30
-            )
-            root.add_widget(textinput2)
-            return root
+            Clock.schedule_interval(self.update_time, 0)
+            return Builder.load_string(KV)
+
+        def update_time(self, dt):
+            self.time += dt
 
     TextInputApp().run()


Test patch: