Repository: kivy/kivy

Issue:
Faster text input insertion and deletion
Using the perf test from here https://github.com/kivy/kivy/pull/661. This is the difference I get.
## With master branch

```
=====================
Test: load_large_text
=====================
loading uix/textinput.py....
putting text in textinput
mem usage after test
44 MB
------------------------------------------
Loaded 1602 lines 1.22284793854 secs
------------------------------------------
=====================
Test: stress_insert
=====================
[INFO   ] [Clipboard   ] using <pygame> as clipboard provider
Done!
pasted 1602 lines 9.0 times
mem usage after test
120 MB
total lines in text input: 17612
--------------------------------------
total time elapsed: 27.5229020119
--------------------------------------
=====================
Test: stress_del
=====================
Done!
deleted 210 characters 9 times
mem usage after test
159 MB
total lines in text input: 17553
--------------------------------------
total time elapsed: 75.9813170433
--------------------------------------
=====================
Test: stress_selection
=====================
Done!
mem usage after test
159 MB
--------------------------------------
total time elapsed: 0
--------------------------------------
===================
All Tests Completed
===================
```
## with this branch

```
=====================
Test: load_large_text
=====================
loading uix/textinput.py....
putting text in textinput
mem usage after test
45 MB
------------------------------------------
Loaded 1699 lines 0.942888975143 secs
------------------------------------------
=====================
Test: stress_insert
=====================
[INFO   ] [Clipboard   ] using <pygame> as clipboard provider
Done!
pasted 1699 lines 8.0 times
mem usage after test
89 MB
total lines in text input: 16981
--------------------------------------
total time elapsed: 3.90121006966
--------------------------------------
=====================
Test: stress_del
=====================
Done!
deleted 210 characters 9 times
mem usage after test
91 MB
total lines in text input: 16922
--------------------------------------
total time elapsed: 2.36115598679
--------------------------------------
=====================
Test: stress_selection
=====================
Done!
mem usage after test
91 MB
--------------------------------------
total time elapsed: 0
--------------------------------------
===================
All Tests Completed
===================
```



Discussion:
It look ok in the first read, but i'm hesitant about the side effect of some part.
Right now, i'll not merge it for 1.4.0, but we could do a 1.4.1 for it.

The side effect is when the font_size change, the textwidth cache is not cleared, and dunno about gl reloading for labels if they stay on the cache. If you work on window, doing a resizing is working ?

If i understand, the main optim is to cache the text width calculation right? And i think that would be possible to cache directly within the core Label class. But then, the cache always need to check the font-name/size/bold/italic attribute etc, to see if we already have the size of the text.

Then, i imagine a new approach:

``` python
from kivy.core.text import Label
# create a fake and empty label with the right attribute as the textinput would use
ref_label = Label(...)
# then use the ext_extents()
ref_label.get_extents(...)
```

In the `__init__` of the LabelBase, we could create the "identifiant" of the label (font_name, font_size, ...)
And then, add a cache decorator in the get_extents() implem in pygame and pil.

What do you think ?

+1 for 1.4.1 or even 1.5, I'd rather get it right  than rush it,
 I haven't checked on windows, just about to do it and yes the label_width cache should be updated on font_size.
Text width calculation is a major part of optimisation but not the main optim, The main optimisation is in refreshing only the lines that are affected by the edit(insert/delete) instead of all the lines in refresh_text.

Your approach is better and would improve perf for text used elsewhere not just in TextInput.
I'll try to implement it. 

Removing the cache for text width all to-gather this is the result I get.

```
=====================
Test: stress_insert
=====================
[INFO   ] [Clipboard   ] using <pygame> as clipboard provider
Done!
pasted 1692 lines 8.0 times
mem usage after test
88 MB
total lines in text input: 16911
--------------------------------------
total time elapsed: 7.85320210457
--------------------------------------
```

Kind of expected doubling of time in some cases and reduced mem usage of roughly 1 M.
Moving the caching to get extents (we already use _label_cached in TextInput and self.font_id in core label so I only edited get_extents in text_pygame.py to test the difference)::

```
def get_extents(self, text):
        uid = ''.join((text, '_', self.fontid))
        width = Cache_get('core_label.width', uid)
        if not width:
            width = self._get_font().size(text)
            Cache_append('core_label.width', uid, width)
        return width
```

This is  the difference

```
=====================
Test: stress_insert
=====================
[INFO   ] [Clipboard   ] using <pygame> as clipboard provider
Done!
pasted 1692 lines 8.0 times
mem usage after test
91 MB
total lines in text input: 16911
--------------------------------------
total time elapsed: 8.62396907806
--------------------------------------
```

Not only is there a increase in memory usage, the time used also increases, which negates the purpose of using the cache.

text_width Cache is now reloaded when font_size/name/padding... change. This has been now  tested on Windows 7 (vm) and it works after resize, although the text in the textinput tends to scroll to top, but that's a septate issue.


Resolving patch:
diff --git a/kivy/uix/textinput.py b/kivy/uix/textinput.py
--- a/kivy/uix/textinput.py
+++ b/kivy/uix/textinput.py
@@ -340,33 +340,53 @@ def insert_text(self, substring, from_undo=False):
         insert_at_end = True if text[cc:] == '' else False
         new_text = text[:cc] + substring + text[cc:]
         self._set_line_text(cr, new_text)
-        if len_str > 1 or substring == '\n':
+
+        wrap = (self._get_text_width(new_text,
+                                            self.tab_width,
+                                            self._label_cached) > self.width)
+        if len_str > 1 or substring == '\n' or wrap:
             # Avoid refreshing text on every keystroke.
             # Allows for faster typing of text when the amount of text in
             # TextInput gets large.
-            start = cr
-            lines, lineflags = self._split_smart(new_text)
-            len_lines = len(lines)
-            finish = cr + (len_lines - 1)
+
+            start, finish, lines,\
+                lineflags, len_lines = self._get_line_from_cursor(cr, new_text)
             self._trigger_refresh_text('insert', start, finish, lines,
                 lineflags, len_lines)
-        # reset cursor
-        self.cursor = cursor = self.get_cursor_from_index(ci + len_str)
+
+        self.cursor = self.get_cursor_from_index(ci + len_str)
         # handle undo and redo
-        self._set_unredo_insert(cc, cr, ci, sci, substring, cursor, from_undo)
+        self._set_unredo_insert(ci, ci + len_str, substring, from_undo)
+
+    def _get_line_from_cursor(self, start, new_text):
+        finish = start
+        lines = self._lines
+        linesflags = self._lines_flags
+        if not linesflags[start]:
+            start -= 1
+            new_text = ''.join((lines[start], new_text))
+        try:
+            while not linesflags[finish + 1]:
+                new_text = ''.join((new_text, lines[finish + 1]))
+                finish += 1
+        except IndexError:
+            pass
+        lines, lineflags = self._split_smart(new_text)
+        len_lines = len(lines) - 1
+        return start, finish, lines, lineflags, len_lines
 
-    def _set_unredo_insert(self, cc, cr, ci, sci, substring, cursor, from_undo):
+    def _set_unredo_insert(self, ci, sci, substring, from_undo):
         # handle undo and redo
         if from_undo:
             return
-        count = substring.count('\n')
-        if substring == '\n':
-            cursor = 0, cursor[1] + 1
-        elif count > 0:
-            cursor = cursor[0], cursor[1] + count
-
-        self._undo.append({'undo_command': ('insert', cursor, ci, sci()),
-            'redo_command': (cc, cr, substring)})
+        #count = substring.count('\n')
+        #if substring == '\n':
+        #    cursor = 0, cursor[1] + 1
+        #elif count > 0:
+        #    cursor = cursor[0], cursor[1] + count
+
+        self._undo.append({'undo_command': ('insert', ci, sci),
+            'redo_command': (ci, substring)})
         # reset redo when undo is appended to
         self._redo = []
 
@@ -389,23 +409,23 @@ def do_redo(self):
         try:
             x_item = self._redo.pop()
             undo_type = x_item['undo_command'][0]
+            _get_cusror_from_index = self.get_cursor_from_index
 
             if undo_type == 'insert':
-                cc, cr, substring = x_item['redo_command']
-                self.cursor = cc, cr
+                ci, substring = x_item['redo_command']
+                self.cursor = _get_cusror_from_index(ci)
                 self.insert_text(substring, True)
             elif undo_type == 'bkspc':
-                cc, cr = x_item['redo_command']
-                self.cursor = cc, cr
+                self.cursor = _get_cusror_from_index(x_item['redo_command'])
                 self.do_backspace(True)
             else:
                 # delsel
-                ci, sci, cc, cr = x_item['redo_command']
+                ci, sci = x_item['redo_command']
                 self._selection_from = ci
                 self._selection_to = sci
                 self._selection = True
                 self.delete_selection(True)
-                self.cursor = (cc, cr)
+                self.cursor = _get_cusror_from_index(ci)
             self._undo.append(x_item)
         except IndexError:
             # reached at top of undo list
@@ -423,10 +443,10 @@ def do_undo(self):
         try:
             x_item = self._undo.pop()
             undo_type = x_item['undo_command'][0]
-            self.cursor = x_item['undo_command'][1]
+            self.cursor = self.get_cursor_from_index(x_item['undo_command'][1])
 
             if undo_type == 'insert':
-                ci, sci = x_item['undo_command'][2:]
+                ci, sci = x_item['undo_command'][1:]
                 self._selection_from = ci
                 self._selection_to = sci
                 self._selection = True
@@ -443,7 +463,7 @@ def do_undo(self):
             # reached at top of undo list
             pass
 
-    def do_backspace(self, from_undo=False):
+    def do_backspace(self, from_undo=False, mode='bkspc'):
         '''Do backspace operation from the current cursor position.
         This action might do several things:
 
@@ -459,33 +479,39 @@ def do_backspace(self, from_undo=False):
         cursor_index = self.cursor_index()
         if cc == 0 and cr == 0:
             return
+        _lines_flags = self._lines_flags
         if cc == 0:
             text_last_line = self._lines[cr - 1]
+            substring = '\n' if _lines_flags[cr] else ' '
             self._set_line_text(cr - 1, text_last_line + text)
             self._delete_line(cr)
-            substring = '\n'
+            new_text = ''
         else:
             #ch = text[cc-1]
             substring = text[cc - 1]
             new_text = text[:cc - 1] + text[cc:]
             self._set_line_text(cr, new_text)
 
-        # refresh_text seems to be unnecessary here
-        # plus removing it leads to a large improvement in editing text
-        # where large..ish text is involved.
-        #self._refresh_text_from_property()
-        self.cursor = cursor = self.get_cursor_from_index(cursor_index - 1)
+            if not self._lines_flags[cr]:
+                # refresh just the current line instead of the whole text
+                start, finish, lines, lineflags, len_lines =\
+                    self._get_line_from_cursor(cr, new_text)
+                self._trigger_refresh_text('del', start, finish, lines,
+                                            lineflags, len_lines)
+
+        self.cursor = self.get_cursor_from_index(cursor_index - 1)
         # handle undo and redo
-        self._set_undo_redo_bkspc(cc, cr, cursor, substring, from_undo)
+        self._set_undo_redo_bkspc(cursor_index,
+                                        cursor_index - 1,
+                                        substring, from_undo)
 
-    def _set_undo_redo_bkspc(self, cc, cr, cursor, substring, from_undo):
+    def _set_undo_redo_bkspc(self, ol_index, new_index, substring, from_undo):
         # handle undo and redo for backspace
         if from_undo:
             return
-
         self._undo.append({
-            'undo_command': ('bkspc', cursor, substring),
-            'redo_command': (cc, cr)})
+            'undo_command': ('bkspc', new_index, substring),
+            'redo_command': ol_index})
         #reset redo when undo is appended to
         self._redo = []
 
@@ -585,22 +611,25 @@ def delete_selection(self, from_undo=False):
             self._lines[finish[1]][finish[0]:]
         lines, lineflags = self._split_smart(cur_line)
         len_lines = len(lines)
-        self._refresh_text(self.text, 'del', start[1], finish[1], lines,
-            lineflags, len_lines)
+        if start[1] == finish[1]:
+            self._set_line_text(start[1], cur_line)
+        else:
+            self._refresh_text(self.text, 'del', start[1], finish[1], lines,
+                lineflags, len_lines)
         self.scroll_x = scrl_x
         self.scroll_y = scrl_y
         # handle undo and redo for delete selecttion
-        self._set_unredo_delsel(cc, cr, a, b, cursor, v[a:b], from_undo)
+        self._set_unredo_delsel(a, b, v[a:b], from_undo)
         self.cancel_selection()
 
-    def _set_unredo_delsel(self, cc, cr, ci, sci, cursor, substring, from_undo):
+    def _set_unredo_delsel(self, a, b, substring, from_undo):
         # handle undo and redo for backspace
         if from_undo:
             return
 
         self._undo.append({
-            'undo_command': ('delsel', cursor, substring),
-            'redo_command': (ci, sci, cc, cr)})
+            'undo_command': ('delsel', a, substring),
+            'redo_command': (a, b)})
         # reset redo when undo is appended to
         self._redo = []
 
@@ -897,10 +926,8 @@ def _refresh_line_options(self, *largs):
     def _trigger_refresh_text(self, *largs):
         if len(largs) and largs[0] == self:
             largs = ()
-        Clock.unschedule(
-            lambda *args: self._refresh_text_from_property(*largs))
-        Clock.schedule_once(
-            lambda *args: self._refresh_text_from_property(*largs))
+        Clock.unschedule(lambda dt: self._refresh_text_from_property(*largs))
+        Clock.schedule_once(lambda dt: self._refresh_text_from_property(*largs))
 
     def _update_text_options(self, *largs):
         Cache_remove('textinput.width')
@@ -913,8 +940,9 @@ 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.
         mode = 'all'
-        if len(largs):
+        if len(largs) > 1:
             mode, start, finish, _lines, _lines_flags, len_lines = largs
+            start = max(0, start)
         else:
             _lines, self._lines_flags = self._split_smart(text)
         _lines_labels = []
@@ -933,11 +961,17 @@ def _refresh_text(self, text, *largs):
             self._lines_labels = _lines_labels
             self._lines_rects = _line_rects
         elif mode == 'del':
-            self._insert_lines(start, finish + 1, len_lines, _lines_flags,
-                _lines, _lines_labels, _line_rects)
+            self._insert_lines(start,
+                                finish if start == finish else (finish + 1),
+                                len_lines, _lines_flags,
+                                _lines, _lines_labels, _line_rects)
         elif mode == 'insert':
-            self._insert_lines(start, start + 1, len_lines, _lines_flags,
-                _lines, _lines_labels, _line_rects)
+            self._insert_lines(start,
+                                finish if (start == finish and not len_lines)
+                                        else
+                                (finish + 1),
+                                len_lines, _lines_flags,
+                                _lines, _lines_labels, _line_rects)
 
         line_label = _lines_labels[0]
         if line_label is None:
@@ -958,16 +992,17 @@ def _refresh_text(self, text, *largs):
 
     def _insert_lines(self, start, finish, len_lines, _lines_flags, _lines,
         _lines_labels, _line_rects):
+            self_lines_flags = self._lines_flags
             _lins_flags = []
-            _lins_flags.extend(self._lines_flags[:start])
+            _lins_flags.extend(self_lines_flags[:start])
             if len_lines:
                 # if not inserting at first line then
                 if start:
-                    # make sure new line is set in line flags cause
+                    # make sure line flags restored for first line
                     # _split_smart assumes first line to be not a new line
-                    _lines_flags[0] = 1
+                    _lines_flags[0] = self_lines_flags[start]
                 _lins_flags.extend(_lines_flags)
-            _lins_flags.extend(self._lines_flags[finish:])
+            _lins_flags.extend(self_lines_flags[finish:])
             self._lines_flags = _lins_flags
 
             _lins = []
@@ -1311,7 +1346,7 @@ def _key_down(self, key, repeat=False):
             cursor = self.cursor
             self.do_cursor_movement('cursor_right')
             if cursor != self.cursor:
-                self.do_backspace()
+                self.do_backspace(mode='del')
         elif internal_action == 'backspace':
             self.do_backspace()
         elif internal_action == 'enter':


Test patch: