test-kivy-app/kivy_venv/lib/python3.11/site-packages/kivy/uix/textinput.py
2024-09-15 15:12:16 +03:00

4050 lines
136 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'''
Text Input
==========
.. versionadded:: 1.0.4
.. image:: images/textinput-mono.jpg
.. image:: images/textinput-multi.jpg
The :class:`TextInput` widget provides a box for editable plain text.
Unicode, multiline, cursor navigation, selection and clipboard features
are supported.
The :class:`TextInput` uses two different coordinate systems:
* (x, y) - coordinates in pixels, mostly used for rendering on screen.
* (col, row) - cursor index in characters / lines, used for selection
and cursor movement.
Usage example
-------------
To create a multiline :class:`TextInput` (the 'enter' key adds a new line)::
from kivy.uix.textinput import TextInput
textinput = TextInput(text='Hello world')
To create a singleline :class:`TextInput`, set the :class:`TextInput.multiline`
property to False (the 'enter' key will defocus the TextInput and emit an
:meth:`TextInput.on_text_validate` event)::
def on_enter(instance, value):
print('User pressed enter in', instance)
textinput = TextInput(text='Hello world', multiline=False)
textinput.bind(on_text_validate=on_enter)
The textinput's text is stored in its :attr:`TextInput.text` property. To run a
callback when the text changes::
def on_text(instance, value):
print('The widget', instance, 'have:', value)
textinput = TextInput()
textinput.bind(text=on_text)
You can set the :class:`focus <kivy.uix.behaviors.FocusBehavior>` to a
Textinput, meaning that the input box will be highlighted and keyboard focus
will be requested::
textinput = TextInput(focus=True)
The textinput is defocused if the 'escape' key is pressed, or if another
widget requests the keyboard. You can bind a callback to the focus property to
get notified of focus changes::
def on_focus(instance, value):
if value:
print('User focused', instance)
else:
print('User defocused', instance)
textinput = TextInput()
textinput.bind(focus=on_focus)
See :class:`~kivy.uix.behaviors.FocusBehavior`, from which the
:class:`TextInput` inherits, for more details.
Selection
---------
The selection is automatically updated when the cursor position changes.
You can get the currently selected text from the
:attr:`TextInput.selection_text` property.
Handles
-------
One can enable :attr:`TextInput.use_handles` property to enable or disable
the usage of selection handles. This property is True by default on mobiles.
Selection Handles uses the class :class:`Selector` as the base class for
the selection handles. You can customize the color for selection handles
like so ::
<Selector>
color: 0, 1, 0, 1
# or <Textinput_instance>.selection_color or app.selection_color
TextInput instantiates the selection handles and stores it in the following
properties. :attr:`TextInput._handle_middle`,
:attr:`TextInput._handle_left`, :attr:`TextInput._handle_right`.
You should set the selection template before the Instantiating TextInput,
so as to get the selection handles to take the changes you set to apply.
Filtering
---------
You can control which text can be added to the :class:`TextInput` by
overwriting :meth:`TextInput.insert_text`. Every string that is typed, pasted
or inserted by any other means into the :class:`TextInput` is passed through
this function. By overwriting it you can reject or change unwanted characters.
For example, to write only in capitalized characters::
class CapitalInput(TextInput):
def insert_text(self, substring, from_undo=False):
s = substring.upper()
return super().insert_text(s, from_undo=from_undo)
Or to only allow floats (0 - 9 and a single period)::
class FloatInput(TextInput):
pat = re.compile('[^0-9]')
def insert_text(self, substring, from_undo=False):
pat = self.pat
if '.' in self.text:
s = re.sub(pat, '', substring)
else:
s = '.'.join(
re.sub(pat, '', s)
for s in substring.split('.', 1)
)
return super().insert_text(s, from_undo=from_undo)
Default shortcuts
-----------------
=============== ========================================================
Shortcuts Description
--------------- --------------------------------------------------------
Left Move cursor to left
Right Move cursor to right
Up Move cursor to up
Down Move cursor to down
Home Move cursor at the beginning of the line
End Move cursor at the end of the line
PageUp Move cursor to 3 lines before
PageDown Move cursor to 3 lines after
Backspace Delete the selection or character before the cursor
Del Delete the selection of character after the cursor
Shift + <dir> Start a text selection. Dir can be Up, Down, Left or
Right
Control + c Copy selection
Control + x Cut selection
Control + v Paste clipboard content
Control + a Select all the content
Control + z undo
Control + r redo
=============== ========================================================
.. note::
To enable Emacs-style keyboard shortcuts, you can use
:class:`~kivy.uix.behaviors.emacs.EmacsBehavior`.
'''
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
from kivy.cache import Cache
from kivy.clock import Clock
from kivy.config import Config
from kivy.core.window import Window
from kivy.metrics import inch
from kivy.utils import boundary, platform
from kivy.uix.behaviors import FocusBehavior
from kivy.core.text import Label, DEFAULT_FONT
from kivy.graphics import Color, Rectangle, PushMatrix, PopMatrix, Callback
from kivy.graphics.context_instructions import Transform
from kivy.graphics.texture import Texture
from kivy.uix.widget import Widget
from kivy.uix.bubble import Bubble
from kivy.uix.behaviors import ButtonBehavior
from kivy.uix.image import Image
from kivy.properties import StringProperty, NumericProperty, \
BooleanProperty, AliasProperty, OptionProperty, \
ListProperty, ObjectProperty, VariableListProperty, ColorProperty, \
BoundedNumericProperty
__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
Cache_remove = Cache.remove
Cache_register('textinput.label', timeout=60.)
Cache_register('textinput.width', timeout=60.)
FL_IS_LINEBREAK = 0x01
FL_IS_WORDBREAK = 0x02
FL_IS_NEWLINE = FL_IS_LINEBREAK | FL_IS_WORDBREAK
# late binding
Clipboard = None
CutBuffer = None
MarkupLabel = None
_platform = platform
# for reloading, we need to keep a list of textinput to retrigger the rendering
_textinput_list = []
# cache the result
_is_osx = sys.platform == 'darwin'
# When we are generating documentation, Config doesn't exist
_is_desktop = False
_scroll_timeout = _scroll_distance = 0
if Config:
_is_desktop = Config.getboolean('kivy', 'desktop')
_scroll_timeout = Config.getint('widgets', 'scroll_timeout')
_scroll_distance = '{}sp'.format(Config.getint('widgets',
'scroll_distance'))
# register an observer to clear the textinput cache when OpenGL will reload
if 'KIVY_DOC' not in environ:
def _textinput_clear_cache(*l):
Cache_remove('textinput.label')
Cache_remove('textinput.width')
for wr in _textinput_list[:]:
textinput = wr()
if textinput is None:
_textinput_list.remove(wr)
else:
textinput._trigger_refresh_text()
textinput._refresh_hint_text()
from kivy.graphics.context import get_context
get_context().add_reload_observer(_textinput_clear_cache, True)
class Selector(ButtonBehavior, Image):
'''Default template for the selection Handles
In order to customize the look of the Selection Handles,
you should adjust its template like so ::
<Selector>
color: 1, 0, 1, 1
'''
window = ObjectProperty()
target = ObjectProperty()
matrix = ObjectProperty()
def __init__(self, **kwargs):
super().__init__(**kwargs)
self.always_release = True
self.matrix = self.target.get_window_matrix()
with self.canvas.before:
Callback(self.update_transform)
PushMatrix()
self.transform = Transform()
with self.canvas.after:
PopMatrix()
def update_transform(self, cb):
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]
)
def on_touch_down(self, touch):
if self.parent is not EventLoop.window:
return
try:
touch.push()
self.transform_touch(touch)
self._touch_diff = self.top - touch.y
if self.collide_point(*touch.pos):
FocusBehavior.ignored_touch.append(touch)
return super().on_touch_down(touch)
finally:
touch.pop()
class TextInputCutCopyPaste(Bubble):
# Internal class used for showing the little bubble popup when
# copy/cut/paste happen.
textinput = ObjectProperty(None)
''' Holds a reference to the TextInput this Bubble belongs to.
'''
but_cut = ObjectProperty(None)
but_copy = ObjectProperty(None)
but_paste = ObjectProperty(None)
but_selectall = ObjectProperty(None)
matrix = ObjectProperty(None)
_check_parent_ev = None
def __init__(self, **kwargs):
self.mode = 'normal'
super().__init__(**kwargs)
self._check_parent_ev = Clock.schedule_interval(self._check_parent, .5)
self.matrix = self.textinput.get_window_matrix()
with self.canvas.before:
Callback(self.update_transform)
PushMatrix()
self.transform = Transform()
with self.canvas.after:
PopMatrix()
def update_transform(self, cb):
m = self.textinput.get_window_matrix()
if self.matrix != m:
self.matrix = m
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])
def on_touch_down(self, touch):
try:
touch.push()
self.transform_touch(touch)
if self.collide_point(*touch.pos):
FocusBehavior.ignored_touch.append(touch)
return super().on_touch_down(touch)
finally:
touch.pop()
def on_touch_up(self, touch):
try:
touch.push()
self.transform_touch(touch)
for child in self.content.children:
if ref(child) in touch.grab_list:
touch.grab_current = child
break
return super().on_touch_up(touch)
finally:
touch.pop()
def on_textinput(self, instance, value):
global Clipboard
if value and not Clipboard and not _is_desktop:
value._ensure_clipboard()
def _check_parent(self, dt):
# this is a prevention to get the Bubble staying on the screen, if the
# attached textinput is not on the screen anymore.
parent = self.textinput
while parent is not None:
if parent == parent.parent:
break
parent = parent.parent
if parent is None:
self._check_parent_ev.cancel()
if self.textinput:
self.textinput._hide_cut_copy_paste()
def on_parent(self, instance, value):
parent = self.textinput
mode = self.mode
if parent:
self.content.clear_widgets()
if mode == 'paste':
# show only paste on long touch
self.but_selectall.opacity = 1
widget_list = [self.but_selectall, ]
if not parent.readonly:
widget_list.append(self.but_paste)
elif parent.readonly:
# show only copy for read only text input
widget_list = (self.but_copy, )
else:
# normal mode
widget_list = (self.but_cut, self.but_copy, self.but_paste)
for widget in widget_list:
self.content.add_widget(widget)
def do(self, action):
textinput = self.textinput
if action == 'cut':
textinput._cut(textinput.selection_text)
elif action == 'copy':
textinput.copy()
elif action == 'paste':
textinput.paste()
elif action == 'selectall':
textinput.select_all()
self.mode = ''
anim = Animation(opacity=0, d=.333)
anim.bind(on_complete=lambda *args:
self.on_parent(self, self.parent))
anim.start(self.but_selectall)
return
self.hide()
def hide(self):
parent = self.parent
if not parent:
return
anim = Animation(opacity=0, d=.225)
anim.bind(on_complete=lambda *args: parent.remove_widget(self))
anim.start(self)
class TextInput(FocusBehavior, Widget):
'''TextInput class. See module documentation for more information.
:Events:
`on_text_validate`
Fired only in multiline=False mode when the user hits 'enter'.
This will also unfocus the textinput.
`on_double_tap`
Fired when a double tap happens in the text input. The default
behavior selects the text around the cursor position. More info at
:meth:`on_double_tap`.
`on_triple_tap`
Fired when a triple tap happens in the text input. The default
behavior selects the line around the cursor position. More info at
:meth:`on_triple_tap`.
`on_quad_touch`
Fired when four fingers are touching the text input. The default
behavior selects the whole text. More info at
:meth:`on_quad_touch`.
.. warning::
When changing a :class:`TextInput` property that requires re-drawing,
e.g. modifying the :attr:`text`, the updates occur on the next
clock cycle and not instantly. This might cause any changes to the
:class:`TextInput` that occur between the modification and the next
cycle to be ignored, or to use previous values. For example, after
a update to the :attr:`text`, changing the cursor in the same clock
frame will move it using the previous text and will likely end up in an
incorrect position. The solution is to schedule any updates to occur
on the next clock cycle using
:meth:`~kivy.clock.ClockBase.schedule_once`.
.. Note::
Selection is cancelled when TextInput is focused. If you need to
show selection when TextInput is focused, you should delay
(use Clock.schedule) the call to the functions for selecting
text (select_all, select_text).
.. versionchanged:: 1.10.0
`background_disabled_active` has been removed.
.. versionchanged:: 1.9.0
:class:`TextInput` now inherits from
:class:`~kivy.uix.behaviors.FocusBehavior`.
:attr:`~kivy.uix.behaviors.FocusBehavior.keyboard_mode`,
:meth:`~kivy.uix.behaviors.FocusBehavior.show_keyboard`,
:meth:`~kivy.uix.behaviors.FocusBehavior.hide_keyboard`,
:meth:`~kivy.uix.behaviors.FocusBehavior.focus`,
and :attr:`~kivy.uix.behaviors.FocusBehavior.input_type`
have been removed since they are now inherited
from :class:`~kivy.uix.behaviors.FocusBehavior`.
.. versionchanged:: 1.7.0
`on_double_tap`, `on_triple_tap` and `on_quad_touch` events added.
.. versionchanged:: 2.1.0
:attr:`~kivy.uix.behaviors.FocusBehavior.keyboard_suggestions`
is now inherited from :class:`~kivy.uix.behaviors.FocusBehavior`.
'''
__events__ = ('on_text_validate', 'on_double_tap', 'on_triple_tap',
'on_quad_touch')
_resolved_base_dir = None
def __init__(self, **kwargs):
self._update_graphics_ev = Clock.create_trigger(
self._update_graphics, -1)
self.is_focusable = kwargs.get('is_focusable', True)
self._cursor = [0, 0]
self._selection = False
self._selection_finished = True
self._selection_touch = None
self.selection_text = u''
self._selection_from = None
self._selection_to = None
self._selection_callback = None
self._handle_left = None
self._handle_right = None
self._handle_middle = None
self._bubble = None
self._lines_flags = []
self._lines_labels = []
self._lines_rects = []
self._hint_text_flags = []
self._hint_text_labels = []
self._hint_text_rects = []
self._label_cached = None
self._line_options = None
self._keyboard_mode = Config.get('kivy', 'keyboard_mode')
self._command_mode = False
self._command = ''
self.reset_undo()
self._touch_count = 0
self._ctrl_l = False
self._ctrl_r = False
self._alt_l = False
self._alt_r = False
self._refresh_text_from_property_ev = None
self._long_touch_ev = None
self._do_blink_cursor_ev = Clock.create_trigger(
self._do_blink_cursor, .5, interval=True)
self._refresh_line_options_ev = None
self._scroll_distance_x = 0
self._scroll_distance_y = 0
self._enable_scroll = True
self._have_scrolled = False
# [from; to) range of lines being partially or fully rendered
# in TextInput's viewport
self._visible_lines_range = 0, 0
self.interesting_keys = {
8: 'backspace',
13: 'enter',
127: 'del',
271: 'enter',
273: 'cursor_up',
274: 'cursor_down',
275: 'cursor_right',
276: 'cursor_left',
278: 'cursor_home',
279: 'cursor_end',
280: 'cursor_pgup',
281: 'cursor_pgdown',
303: 'shift_L',
304: 'shift_R',
305: 'ctrl_L',
306: 'ctrl_R',
308: 'alt_L',
307: 'alt_R'
}
super().__init__(**kwargs)
fbind = self.fbind
refresh_line_options = self._trigger_refresh_line_options
update_text_options = self._update_text_options
trigger_update_graphics = self._trigger_update_graphics
fbind('font_size', refresh_line_options)
fbind('font_name', refresh_line_options)
fbind('font_context', refresh_line_options)
fbind('font_family', refresh_line_options)
fbind('base_direction', refresh_line_options)
fbind('text_language', refresh_line_options)
def handle_readonly(instance, value):
if value and (not _is_desktop or not self.allow_copy):
self.is_focusable = False
if (not (value or self.disabled) or _is_desktop and
self._keyboard_mode == 'system'):
self._editable = True
else:
self._editable = False
fbind('padding', update_text_options)
fbind('tab_width', update_text_options)
fbind('font_size', update_text_options)
fbind('font_name', update_text_options)
fbind('size', update_text_options)
fbind('password', update_text_options)
fbind('password_mask', update_text_options)
fbind('pos', trigger_update_graphics)
fbind('halign', trigger_update_graphics)
fbind('readonly', handle_readonly)
fbind('focus', self._on_textinput_focused)
handle_readonly(self, self.readonly)
handles = self._trigger_position_handles = Clock.create_trigger(
self._position_handles)
self._trigger_show_handles = Clock.create_trigger(
self._show_handles, .05)
self._trigger_cursor_reset = Clock.create_trigger(
self._reset_cursor_blink)
self._trigger_update_cutbuffer = Clock.create_trigger(
self._update_cutbuffer)
refresh_line_options()
self._trigger_refresh_text()
fbind('pos', handles)
fbind('size', handles)
# when the gl context is reloaded, trigger the text rendering again.
_textinput_list.append(ref(self, TextInput._reload_remove_observer))
if platform == 'linux':
self._ensure_clipboard()
def on_text_validate(self):
pass
def cursor_index(self, cursor=None):
'''Return the cursor index in the text/value.
'''
if not cursor:
cursor = self.cursor
try:
lines = self._lines
if not lines:
return 0
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 flags[cursor_row] & FL_IS_LINEBREAK:
index += 1
return index
except IndexError:
return 0
def cursor_offset(self):
'''Get the cursor x offset on the current line.
'''
offset = 0
row = int(self.cursor_row)
col = int(self.cursor_col)
lines = self._lines
if col and row < len(lines):
offset = self._get_text_width(
lines[row][:col],
self.tab_width,
self._label_cached
)
return offset
def get_cursor_from_index(self, index):
'''Return the (col, row) of the cursor from text index.
'''
index = boundary(index, 0, len(self.text))
if index <= 0:
return 0, 0
flags = self._lines_flags
lines = self._lines
if not lines:
return 0, 0
i = 0
for row, line in enumerate(lines):
count = i + len(line)
if flags[row] & FL_IS_LINEBREAK:
count += 1
i += 1
if count >= index:
return index - i, row
i = count
return int(index), int(row)
def select_text(self, start, end):
''' Select a portion of text displayed in this TextInput.
.. versionadded:: 1.4.0
:Parameters:
`start`
Index of textinput.text from where to start selection
`end`
Index of textinput.text till which the selection should be
displayed
'''
if end < start:
raise Exception('end must be superior to start')
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()
def select_all(self):
''' Select all of the text displayed in this TextInput.
.. versionadded:: 1.4.0
'''
self.select_text(0, len(self.text))
re_indent = re.compile(r'^(\s*|)')
def _auto_indent(self, substring):
index = self.cursor_index()
if index > 0:
_text = self.text
line_start = _text.rfind('\n', 0, index)
if line_start > -1:
line = _text[line_start + 1:index]
indent = self.re_indent.match(line).group()
substring += indent
return substring
def insert_text(self, substring, from_undo=False):
'''Insert new text at the current cursor position. Override this
function in order to pre-process text for input validation.
'''
_lines = self._lines
_lines_flags = self._lines_flags
if self.readonly or not substring or not self._lines:
return
if isinstance(substring, bytes):
substring = substring.decode('utf8')
if self.replace_crlf:
substring = substring.replace(u'\r\n', u'\n')
self._hide_handles(EventLoop.window)
if not from_undo and self.multiline and self.auto_indent \
and substring == u'\n':
substring = self._auto_indent(substring)
mode = self.input_filter
if mode not in (None, 'int', 'float'):
substring = mode(substring, from_undo)
if not substring:
return
col, row = self.cursor
cindex = self.cursor_index()
text = _lines[row]
len_str = len(substring)
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):
return
elif mode == 'float':
if not re.match(self._insert_float_pat, new_text):
return
self._set_line_text(row, new_text)
if len_str > 1 or substring == u'\n' or\
(substring == u' ' and _lines_flags[row] != FL_IS_LINEBREAK) or\
(row + 1 < len(_lines) and
_lines_flags[row + 1] != FL_IS_LINEBREAK) or\
(self._get_text_width(
new_text,
self.tab_width,
self._label_cached) > (self.width - self.padding[0] -
self.padding[2])):
# Avoid refreshing text on every keystroke.
# Allows for faster typing of text when the amount of text in
# TextInput gets large.
(
start, finish, lines, lines_flags, 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, lines_flags, len_lines
)
self.cursor = self.get_cursor_from_index(cindex + len_str)
# handle undo and redo
self._set_unredo_insert(cindex, cindex + len_str, substring, from_undo)
def _get_line_from_cursor(self, start, new_text, lines=None,
lines_flags=None):
# get current paragraph from cursor position
if lines is None:
lines = self._lines
if lines_flags is None:
lines_flags = self._lines_flags
finish = start
_next = start + 1
if start > 0 and lines_flags[start] != FL_IS_LINEBREAK:
start -= 1
new_text = lines[start] + new_text
i = _next
for i in range(_next, len(lines_flags)):
if lines_flags[i] == FL_IS_LINEBREAK:
finish = i - 1
break
else:
finish = i
new_text = new_text + u''.join(lines[_next:finish + 1])
lines, lines_flags = self._split_smart(new_text)
len_lines = max(1, len(lines))
return start, finish, lines, lines_flags, len_lines
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)
})
# reset redo when undo is appended to
self._redo = []
def reset_undo(self):
'''Reset undo and redo lists from memory.
.. versionadded:: 1.3.0
'''
self._redo = self._undo = []
def do_redo(self):
'''Do redo operation.
.. versionadded:: 1.3.0
This action re-does any command that has been un-done by
do_undo/ctrl+z. This function is automatically called when
`ctrl+r` keys are pressed.
'''
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':
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'])
self.do_backspace(from_undo=True)
elif undo_type == 'shiftln':
direction, rows, cursor = x_item['redo_command'][1:]
self._shift_lines(direction, rows, cursor, True)
else:
# delsel
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(cindex)
self._undo.append(x_item)
except IndexError:
# reached at top of undo list
pass
def do_undo(self):
'''Do undo operation.
.. versionadded:: 1.3.0
This action un-does any edits that have been made since the last
call to reset_undo().
This function is automatically called when `ctrl+z` keys are pressed.
'''
try:
x_item = self._undo.pop()
undo_type = x_item['undo_command'][0]
self.cursor = self.get_cursor_from_index(x_item['undo_command'][1])
if undo_type == 'insert':
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':
substring = x_item['undo_command'][2][0]
mode = x_item['undo_command'][3]
self.insert_text(substring, True)
if mode == 'del':
self.cursor = self.get_cursor_from_index(
self.cursor_index() - 1)
elif undo_type == 'shiftln':
direction, rows, cursor = x_item['undo_command'][1:]
self._shift_lines(direction, rows, cursor, True)
else:
# delsel
substring = x_item['undo_command'][2:][0]
self.insert_text(substring, True)
self._redo.append(x_item)
self.scroll_x = self.get_max_scroll_x()
except IndexError:
# reached at top of undo list
pass
def do_backspace(self, from_undo=False, mode='bkspc'):
'''Do backspace operation from the current cursor position.
This action might do several things:
- removing the current selection if available.
- removing the previous char and move the cursor back.
- do nothing, if we are at the start.
'''
# IME system handles its own backspaces
if self.readonly or self._ime_composition:
return
col, row = self.cursor
_lines = self._lines
_lines_flags = self._lines_flags
text = _lines[row]
cursor_index = self.cursor_index()
if col == 0 and row == 0:
return
start = row
if col == 0:
if _lines_flags[row] == FL_IS_LINEBREAK:
substring = u'\n'
new_text = _lines[row - 1] + text
else:
substring = _lines[row - 1][-1] if len(_lines[row - 1]) > 0 \
else u''
new_text = _lines[row - 1][:-1] + text
self._set_line_text(row - 1, new_text)
self._delete_line(row)
start = row - 1
else:
# 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 = (
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(
'insert' if col == 0 else 'del', start, finish,
lines, lineflags, len_lines
)
self.cursor = self.get_cursor_from_index(cursor_index - 1)
# handle undo and redo
self._set_unredo_bkspc(
cursor_index,
cursor_index - 1,
substring, from_undo, mode)
self.scroll_x = self.get_max_scroll_x()
def _set_unredo_bkspc(self, ol_index, new_index, substring, from_undo,
mode):
# handle undo and redo for backspace
if from_undo:
return
self._undo.append({
'undo_command': ('bkspc', new_index, substring, mode),
'redo_command': ol_index})
# reset redo when undo is appended to
self._redo = []
_re_whitespace = re.compile(r'\s+')
def _move_cursor_word_left(self, index=None):
pos = index or self.cursor_index()
if pos == 0:
return self.cursor
lines = self._lines
col, row = self.get_cursor_from_index(pos)
if col == 0:
row -= 1
col = len(lines[row])
while True:
matches = list(self._re_whitespace.finditer(lines[row], 0, col))
if not matches:
if col == 0:
if row == 0:
return 0, 0
row -= 1
col = len(lines[row])
continue
return 0, row
match = matches[-1]
mpos = match.end()
if mpos == col:
if len(matches) > 1:
match = matches[-2]
mpos = match.end()
else:
if match.start() == 0:
if row == 0:
return 0, 0
row -= 1
col = len(lines[row])
continue
return 0, row
col = mpos
return col, row
def _move_cursor_word_right(self, index=None):
pos = index or self.cursor_index()
col, row = self.get_cursor_from_index(pos)
lines = self._lines
mrow = len(lines) - 1
if row == mrow and col == len(lines[row]):
return col, row
if col == len(lines[row]):
row += 1
col = 0
while True:
matches = list(self._re_whitespace.finditer(lines[row], col))
if not matches:
if col == len(lines[row]):
if row == mrow:
return col, row
row += 1
col = 0
continue
return len(lines[row]), row
match = matches[0]
mpos = match.start()
if mpos == col:
if len(matches) > 1:
match = matches[1]
mpos = match.start()
else:
if match.end() == len(lines[row]):
if row == mrow:
return col, row
row += 1
col = 0
continue
return len(lines[row]), row
col = mpos
return col, row
def _expand_range(self, ifrom, ito=None):
if ito is None:
ito = ifrom
rfrom = self.get_cursor_from_index(ifrom)[1]
rtcol, rto = self.get_cursor_from_index(ito)
rfrom, rto = self._expand_rows(rfrom, rto + 1 if rtcol else rto)
return (self.cursor_index((0, rfrom)),
self.cursor_index((0, rto)))
def _expand_rows(self, rfrom, rto=None):
if rto is None or rto == rfrom:
rto = rfrom + 1
lines = self._lines
flags = list(reversed(self._lines_flags))
while rfrom > 0 and not (flags[rfrom - 1] & FL_IS_NEWLINE):
rfrom -= 1
rmax = len(lines) - 1
while 0 < rto < rmax and not (flags[rto - 1] & FL_IS_NEWLINE):
rto += 1
return max(0, rfrom), min(rmax, rto)
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
rects = self._lines_rects
orig_cursor = self.cursor
sel = None
if old_cursor is not None:
self.cursor = old_cursor
if not rows:
sindex = self.selection_from
eindex = self.selection_to
if (sindex or eindex) and sindex != eindex:
sindex, eindex = tuple(sorted((sindex, eindex)))
sindex, eindex = self._expand_range(sindex, eindex)
else:
sindex, eindex = self._expand_range(self.cursor_index())
srow = self.get_cursor_from_index(sindex)[1]
erow = self.get_cursor_from_index(eindex)[1]
sel = sindex, eindex
if direction < 0 and srow > 0:
psrow, perow = self._expand_rows(srow - 1)
rows = ((srow, erow), (psrow, perow))
elif direction > 0 and erow < len(lines) - 1:
psrow, perow = self._expand_rows(erow)
rows = ((srow, erow), (psrow, perow))
else:
(srow, erow), (psrow, perow) = rows
if direction < 0:
m1srow, m1erow = psrow, perow
m2srow, m2erow = srow, erow
cdiff = psrow - perow
xdiff = srow - erow
else:
m1srow, m1erow = srow, erow
m2srow, m2erow = psrow, perow
cdiff = perow - psrow
xdiff = erow - srow
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))
)
self.cursor = self.cursor_col, self.cursor_row + cdiff
if not from_undo:
undo_rows = ((srow + cdiff, erow + cdiff),
(psrow - xdiff, perow - xdiff))
self._undo.append({
'undo_command': ('shiftln', direction * -1, undo_rows,
self.cursor),
'redo_command': ('shiftln', direction, rows, orig_cursor),
})
self._redo = []
if sel:
def cb(dt):
self.select_text(*sel)
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 :
- cursor_left: move the cursor to the left
- cursor_right: move the cursor to the right
- cursor_up: move the cursor on the previous line
- cursor_down: move the cursor on the next line
- cursor_home: move the cursor at the start of the current line
- cursor_end: move the cursor at the end of current line
- cursor_pgup: move one "page" before
- cursor_pgdown: move one "page" after
In addition, the behavior of certain actions can be modified:
- control + cursor_left: move the cursor one word to the left
- control + cursor_right: move the cursor one word to the right
- control + cursor_up: scroll up one line
- control + cursor_down: scroll down one line
- control + cursor_home: go to beginning of text
- control + cursor_end: go to end of text
- alt + cursor_up: shift line(s) up
- alt + cursor_down: shift line(s) down
.. versionchanged:: 1.9.1
'''
if not self._lines:
return
col, row = self.cursor
if action == 'cursor_up':
result = self._move_cursor_up(col, row, control, alt)
if result:
col, row = result
else:
return
elif action == 'cursor_down':
result = self._move_cursor_down(col, row, control, alt)
if result:
col, row = result
else:
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 - self.pgmove_speed)
col = min(len(self._lines[row]), col)
elif action == 'cursor_pgdown':
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'
):
current_selection_to = self._selection_to
while self._selection_from != current_selection_to:
current_selection_to -= 1
if col:
col -= 1
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'
):
current_selection_to = self._selection_to
while self._selection_from != current_selection_to:
current_selection_to += 1
if len(self._lines[row]) > col:
col += 1
else:
row += 1
col = 0
elif action == 'cursor_left':
if not self.password and control:
col, row = self._move_cursor_word_left()
else:
if col == 0:
if row:
row -= 1
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()
else:
if col == len(self._lines[row]):
if row < len(self._lines) - 1:
col = 0
row += 1
else:
col, row = col + 1, row
dont_move_cursor = control and action in ['cursor_up', 'cursor_down']
if dont_move_cursor:
self._trigger_update_graphics()
else:
self.cursor = col, row
def get_cursor_from_xy(self, x, y):
'''Return the (col, row) of the cursor from an (x, y) position.
'''
padding_left, padding_top, padding_right, padding_bottom = self.padding
lines = self._lines
dy = self.line_height + self.line_spacing
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 - padding_right
xoff = max(
0, int((viewport_width - self._get_row_width(cursor_y)) / 2)
)
elif halign == 'right' or auto_halign_r:
viewport_width = self.width - padding_left - padding_right
xoff = max(
0, int(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
else:
cursor_x = len(lines[cursor_y])
return cursor_x, cursor_y
def get_max_scroll_x(self):
'''
Return how many pixels it needs to scroll to the right
to reveal the remaining content of a text that extends
beyond the visible width of a TextInput
'''
minimum_width = self._get_row_width(0) + self.padding[0] + \
self.padding[2]
max_scroll_x = max(0, minimum_width - self.width)
return max_scroll_x
#
# Selection control
#
def cancel_selection(self):
'''Cancel current selection (if any).
'''
self._selection_from = self._selection_to = self.cursor_index()
self._selection = False
self._selection_finished = True
self._selection_touch = None
self.selection_text = u''
self._trigger_update_graphics()
def delete_selection(self, from_undo=False):
'''Delete the current text selection (if any).
'''
if self.readonly:
return
self._hide_handles(EventLoop.window)
scroll_x = self.scroll_x
scroll_y = self.scroll_y
cc, cr = self.cursor
if not self._selection:
return
text = self.text
a, b = sorted((self._selection_from, self._selection_to))
start = self.get_cursor_from_index(a)
finish = self.get_cursor_from_index(b)
cur_line = self._lines[start[1]][:start[0]] +\
self._lines[finish[1]][finish[0]:]
self._set_line_text(start[1], cur_line)
start_del, finish_del, lines, lines_flags, len_lines = \
self._get_line_from_cursor(start[1], cur_line,
lines=(self._lines[:(start[1] + 1)] +
self._lines[(finish[1] + 1):]),
lines_flags=(
self._lines_flags[:(start[1] + 1)] +
self._lines_flags[(finish[1] + 1):])
)
self._refresh_text_from_property('del', start_del,
finish_del + (finish[1] - start[1]),
lines, lines_flags, len_lines)
self.scroll_x = scroll_x
self.scroll_y = scroll_y
# handle undo and redo for delete selection
if text[a:b]:
self._set_unredo_delsel(a, b, text[a:b], from_undo)
self.cancel_selection()
self.cursor = self.get_cursor_from_index(a)
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', a, substring),
'redo_command': (a, b)})
# reset redo when undo is appended to
self._redo = []
def _update_selection(self, finished=False):
'''Update selection text and order of from/to if finished is True.
Can be called multiple times until finished is True.
'''
a, b = int(self._selection_from), int(self._selection_to)
if a > b:
a, b = b, a
self._selection_finished = finished
_selection_text = self.text[a:b]
self.selection_text = ("" if not self.allow_copy else
((self.password_mask * (b - a)) if
self.password else _selection_text))
if not finished:
self._selection = True
else:
self._selection = bool(len(_selection_text))
self._selection_touch = None
if a == 0:
# update graphics only on new line
# allows smoother scrolling, noticeably
# faster when dealing with large text.
self._update_graphics_selection()
# self._trigger_update_graphics()
#
# Touch control
#
def long_touch(self, dt):
self._long_touch_ev = None
if self._selection_to == self._selection_from:
pos = self.to_local(*self._touch_down.pos, relative=False)
self._show_cut_copy_paste(
pos, EventLoop.window, mode='paste')
def cancel_long_touch_event(self):
# schedule long touch for paste
if self._long_touch_ev is not None:
self._long_touch_ev.cancel()
self._long_touch_ev = None
def _select_word(self, delimiters=u' .,:;!?\'"<>()[]{}'):
cindex = self.cursor_index()
col = self.cursor_col
line = self._lines[self.cursor_row]
start = max(0, len(line[:col]) -
max(line[:col].rfind(s) for s in delimiters) - 1)
end = min((line[col:].find(s) if line[col:].find(s) > -1
else (len(line) - col)) for s in delimiters)
Clock.schedule_once(lambda dt: self.select_text(cindex - start,
cindex + end))
def on_double_tap(self):
'''This event is dispatched when a double tap happens
inside TextInput. The default behavior is to select the
word around the current cursor position. Override this to provide
different behavior. Alternatively, you can bind to this
event to provide additional functionality.
'''
self._select_word()
def on_triple_tap(self):
'''This event is dispatched when a triple tap happens
inside TextInput. The default behavior is to select the
line around current cursor position. Override this to provide
different behavior. Alternatively, you can bind to this
event to provide additional functionality.
'''
ci = self.cursor_index()
sindex, eindex = self._expand_range(ci)
Clock.schedule_once(lambda dt: self.select_text(sindex, eindex))
def on_quad_touch(self):
'''This event is dispatched when four fingers are touching
inside TextInput. The default behavior is to select all text.
Override this to provide different behavior. Alternatively,
you can bind to this event to provide additional functionality.
'''
Clock.schedule_once(lambda dt: self.select_all())
def on_touch_down(self, touch):
if self.disabled:
return
touch_pos = touch.pos
if not self.collide_point(*touch_pos):
return False
if super().on_touch_down(touch):
return True
if self.focus:
self._trigger_cursor_reset()
# Check for scroll wheel
if 'button' in touch.profile and touch.button.startswith('scroll'):
# TODO: implement 'scrollleft' and 'scrollright'
scroll_type = touch.button[6:]
if scroll_type == 'down':
if self.multiline:
if self.scroll_y > 0:
self.scroll_y = max(0,
self.scroll_y - self.line_height *
self.lines_to_scroll)
self._trigger_update_graphics()
else:
if self.scroll_x > 0:
self.scroll_x = max(0, self.scroll_x -
self.line_height)
self._trigger_update_graphics()
if scroll_type == 'up':
if self.multiline:
max_scroll_y = max(0, self.minimum_height - self.height)
if self.scroll_y < max_scroll_y:
self.scroll_y = min(max_scroll_y,
self.scroll_y + self.line_height *
self.lines_to_scroll)
self._trigger_update_graphics()
else:
max_scroll_x = self.get_max_scroll_x()
if self.scroll_x < max_scroll_x:
self.scroll_x = min(max_scroll_x, self.scroll_x +
self.line_height)
self._trigger_update_graphics()
return True
touch.grab(self)
self._touch_count += 1
if touch.is_double_tap:
self.dispatch('on_double_tap')
if touch.is_triple_tap:
self.dispatch('on_triple_tap')
if self._touch_count == 4:
self.dispatch('on_quad_touch')
# stores the touch for later use
self._touch_down = touch
# Is a new touch_down, so previous scroll states needs to be reset
self._enable_scroll = True
self._have_scrolled = False
self._scroll_distance_x = 0
self._scroll_distance_y = 0
self._hide_cut_copy_paste(EventLoop.window)
# schedule long touch for paste
self._long_touch_ev = Clock.schedule_once(self.long_touch, .5)
self.cursor = self.get_cursor_from_xy(*touch_pos)
if not self.scroll_from_swipe:
self._cancel_update_selection(self._touch_down)
if CutBuffer and 'button' in touch.profile and \
touch.button == 'middle':
self.insert_text(CutBuffer.get_cutbuffer())
return True
return True
# cancel/update existing selection after a single tap
def _cancel_update_selection(self, touch):
if not self._selection_touch:
self.cancel_selection()
self._selection_touch = touch
self._selection_from = self._selection_to = self.cursor_index()
self._update_selection()
def on_touch_move(self, touch):
if touch.grab_current is not self:
return
if not self.focus:
touch.ungrab(self)
if self._selection_touch is touch:
self._selection_touch = None
return False
if self.scroll_from_swipe:
self.scroll_text_from_swipe(touch)
if not self._have_scrolled and self._selection_touch is touch:
self.cursor = self.get_cursor_from_xy(touch.x, touch.y)
self._selection_to = self.cursor_index()
self._update_selection()
return True
def on_touch_up(self, touch):
if touch.grab_current is not self:
return
touch.ungrab(self)
self._touch_count -= 1
self.cancel_long_touch_event()
if not self.focus:
return False
# types of touch that will have higher priority in being recognized,
# compared to single tap
prioritized_touch_types = (
touch.is_double_tap
or touch.is_triple_tap
or self._touch_count == 4
)
if not self._have_scrolled and not prioritized_touch_types:
# Is a single tap and did not scrolled.
# Selection needs to be canceled.
self._cancel_update_selection(self._touch_down)
# show Bubble
win = EventLoop.window
if self._selection_to != self._selection_from:
self._show_cut_copy_paste(touch.pos, win)
if self._selection_touch is touch:
self._selection_to = self.cursor_index()
self._update_selection(True)
if self.use_handles and self._selection_to == self._selection_from:
self._hide_handles()
handle_middle = self._handle_middle
if handle_middle is None:
self._handle_middle = handle_middle = Selector(
source=self.handle_image_middle,
window=win,
target=self,
size_hint=(None, None),
size=('45dp', '45dp'))
handle_middle.bind(on_press=self._handle_pressed,
on_touch_move=self._handle_move,
on_release=self._handle_released)
if not self._handle_middle.parent and self.text:
EventLoop.window.add_widget(handle_middle, canvas='after')
self._position_handles(mode='middle')
return True
def scroll_text_from_swipe(self, touch):
_scroll_timeout = (touch.time_update - touch.time_start) * 1000
self._scroll_distance_x += abs(touch.dx)
self._scroll_distance_y += abs(touch.dy)
if not self._have_scrolled:
# To be considered a scroll, touch should travel more than
# scroll_distance in less than the scroll_timeout since touch_down
if not (
_scroll_timeout <= self.scroll_timeout
and (
(self._scroll_distance_x >= self.scroll_distance)
or (self._scroll_distance_y >= self.scroll_distance)
)
):
# Distance isn't enough (yet) to consider it as a scroll
if _scroll_timeout <= self.scroll_timeout:
# Timeout is not reached, scroll is still enabled.
return False
else:
self._enable_scroll = False
self._cancel_update_selection(self._touch_down)
return False
# We have a scroll!
self._have_scrolled = True
self.cancel_long_touch_event()
if self.multiline:
max_scroll_y = max(0, self.minimum_height - self.height)
self.scroll_y = min(
max(0, self.scroll_y + touch.dy),
max_scroll_y
)
else:
max_scroll_x = self.get_max_scroll_x()
self.scroll_x = min(
max(0, self.scroll_x - touch.dx),
max_scroll_x
)
self._trigger_update_graphics()
self._position_handles()
return True
def _handle_pressed(self, instance):
self._hide_cut_copy_paste()
from_, to_ = self._selection_from, self.selection_to
if from_ > to_:
self._selection_from, self._selection_to = to_, from_
def _handle_released(self, instance):
if self._selection_from == self.selection_to:
return
self._update_selection()
self._show_cut_copy_paste(
(
self.x + instance.right
if instance is self._handle_left
else self.x + instance.x,
self.y + instance.top + self.line_height
),
EventLoop.window
)
def _handle_move(self, instance, touch):
if touch.grab_current != instance:
return
get_cursor = self.get_cursor_from_xy
handle_right = self._handle_right
handle_left = self._handle_left
handle_middle = self._handle_middle
try:
touch.push()
touch.apply_transform_2d(self.to_widget)
x, y = touch.pos
finally:
touch.pop()
cursor = get_cursor(
x,
y + instance._touch_diff + (self.line_height / 2)
)
self.cursor = cursor
if instance != touch.grab_current:
return
if instance == handle_middle:
self._position_handles(mode='middle')
return
cindex = self.cursor_index()
if instance == handle_left:
self._selection_from = cindex
elif instance == handle_right:
self._selection_to = cindex
self._update_selection()
self._trigger_update_graphics()
self._trigger_position_handles()
def _position_handles(self, *args, **kwargs):
if not self.text:
return
mode = kwargs.get('mode', 'both')
lh = self.line_height
handle_middle = self._handle_middle
if handle_middle:
hp_mid = self.cursor_pos
pos = self.to_local(*hp_mid, relative=True)
handle_middle.x = pos[0] - handle_middle.width / 2
handle_middle.top = max(self.padding[3],
min(self.height - self.padding[1],
pos[1] - lh))
if mode[0] == 'm':
return
group = self.canvas.get_group('selection')
if not group:
return
EventLoop.window.remove_widget(self._handle_middle)
handle_left = self._handle_left
if not handle_left:
return
hp_left = group[2].pos
handle_left.pos = self.to_local(*hp_left, relative=True)
handle_left.x -= handle_left.width
handle_left.y -= handle_left.height
handle_right = self._handle_right
last_rect = group[-1]
hp_right = last_rect.pos[0], last_rect.pos[1]
x, y = self.to_local(*hp_right, relative=True)
handle_right.x = x + last_rect.size[0]
handle_right.y = y - handle_right.height
def _hide_handles(self, win=None):
win = win or EventLoop.window
if win is None:
return
win.remove_widget(self._handle_right)
win.remove_widget(self._handle_left)
win.remove_widget(self._handle_middle)
def _show_handles(self, dt):
if not self.use_handles or not self.text:
return
win = EventLoop.window
handle_right = self._handle_right
handle_left = self._handle_left
if self._handle_left is None:
self._handle_left = handle_left = Selector(
source=self.handle_image_left,
target=self,
window=win,
size_hint=(None, None),
size=('45dp', '45dp'))
handle_left.bind(on_press=self._handle_pressed,
on_touch_move=self._handle_move,
on_release=self._handle_released)
self._handle_right = handle_right = Selector(
source=self.handle_image_right,
target=self,
window=win,
size_hint=(None, None),
size=('45dp', '45dp'))
handle_right.bind(on_press=self._handle_pressed,
on_touch_move=self._handle_move,
on_release=self._handle_released)
else:
if self._handle_left.parent:
self._position_handles()
return
if not self.parent:
return
self._trigger_position_handles()
if self.selection_from != self.selection_to:
self._handle_left.opacity = self._handle_right.opacity = 0
win.add_widget(self._handle_left, canvas='after')
win.add_widget(self._handle_right, canvas='after')
anim = Animation(opacity=1, d=.4)
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"""
if not self.use_bubble:
return
bubble = self._bubble
if bubble is None:
self._bubble = bubble = TextInputCutCopyPaste(textinput=self)
self.fbind('parent', self._show_cut_copy_paste, pos, win, True)
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:
return
if parent_changed:
return
# Search the position from the touch to the window
lh, ls = self.line_height, self.line_spacing
x, y = pos
t_pos = (x, y) if pos_in_window else self.to_window(x, y)
bubble_size = bubble.size
bubble_hw = bubble_size[0] / 2.
win_size = win.size
bubble_pos = (t_pos[0], t_pos[1] + inch(.25))
if (bubble_pos[0] - bubble_hw) < 0:
# bubble beyond left of window
if bubble_pos[1] > (win_size[1] - bubble_size[1]):
# bubble above window height
bubble_pos = (bubble_hw, (t_pos[1]) - (lh + ls + inch(.25)))
bubble.arrow_pos = 'top_left'
else:
bubble_pos = (bubble_hw, bubble_pos[1])
bubble.arrow_pos = 'bottom_left'
elif (bubble_pos[0] + bubble_hw) > win_size[0]:
# 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.arrow_pos = 'top_right'
else:
bubble_pos = (win_size[0] - bubble_hw, bubble_pos[1])
bubble.arrow_pos = 'bottom_right'
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.arrow_pos = 'top_mid'
else:
bubble.arrow_pos = 'bottom_mid'
bubble_pos = self.to_widget(*bubble_pos, relative=True)
bubble.center_x = bubble_pos[0]
if bubble.arrow_pos[0] == 't':
bubble.top = bubble_pos[1]
else:
bubble.y = bubble_pos[1]
bubble.mode = mode
Animation.cancel_all(bubble)
bubble.opacity = 0
win.add_widget(bubble, canvas='after')
Animation(opacity=1, d=.225).start(bubble)
def _hide_cut_copy_paste(self, win=None):
bubble = self._bubble
if not bubble:
return
bubble.hide()
#
# Private
#
@staticmethod
def _reload_remove_observer(wr):
"""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'
):
self._trigger_cursor_reset()
self._editable = True
else:
self._editable = False
else:
self._do_blink_cursor_ev.cancel()
self._hide_handles(win)
def _ensure_clipboard(self):
global Clipboard, CutBuffer
if not Clipboard:
from kivy.core.clipboard import Clipboard, CutBuffer
def cut(self):
''' Copy current selection to clipboard then delete it from TextInput.
.. versionadded:: 1.8.0
'''
self._cut(self.selection_text)
def _cut(self, data):
self._ensure_clipboard()
Clipboard.copy(data)
self.delete_selection()
def copy(self, data=''):
''' Copy the value provided in argument `data` into current clipboard.
If data is not of type string it will be converted to string.
If no data is provided then current selection if present is copied.
.. versionadded:: 1.8.0
'''
self._ensure_clipboard()
if data:
return Clipboard.copy(data)
if self.selection_text:
return Clipboard.copy(self.selection_text)
def paste(self):
''' Insert text from system :class:`~kivy.core.clipboard.Clipboard`
into the :class:`~kivy.uix.textinput.TextInput` at current cursor
position.
.. versionadded:: 1.8.0
'''
self._ensure_clipboard()
data = Clipboard.paste()
self.delete_selection()
if not self.multiline:
data = data.replace('\n', ' ')
self.insert_text(data)
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"""
kw = self._get_line_options()
try:
cid = u'{}\0{}\0{}'.format(text, self.password, kw)
except UnicodeDecodeError:
cid = '{}\0{}\0{}'.format(text, self.password, kw)
width = Cache_get('textinput.width', cid)
if width:
return width
if not _label_cached:
_label_cached = self._label_cached
text = text.replace('\t', ' ' * tab_width)
if not self.password:
width = _label_cached.get_extents(text)[0]
else:
width = _label_cached.get_extents(
self.password_mask * len(text))[0]
Cache_append('textinput.width', cid, width)
return width
def on_cursor_blink(self, instance, value):
"""trigger blink event reset to switch blinking while focused"""
self._reset_cursor_blink()
def _do_blink_cursor(self, dt):
if not self.cursor_blink:
# ignore event if not triggered,
# stop if cursor_blink value changed right now
if self._do_blink_cursor_ev.is_triggered:
self._do_blink_cursor_ev.cancel()
# don't blink, make cursor visible
self._cursor_blink = False
return
# Callback for blinking the cursor.
self._cursor_blink = not self._cursor_blink
def _reset_cursor_blink(self, *args):
self._do_blink_cursor_ev.cancel()
self._cursor_blink = False
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.
"""
if self.focus:
self._trigger_cursor_reset()
self._trigger_update_graphics()
def _delete_line(self, idx):
"""Delete current line, and fix cursor position"""
assert idx < len(self._lines)
self._lines_flags.pop(idx)
self._lines_labels.pop(idx)
self._lines.pop(idx)
self.cursor = self.cursor
def _set_line_text(self, line_num, text):
"""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
def _trigger_refresh_line_options(self, *largs):
if self._refresh_line_options_ev is not None:
self._refresh_line_options_ev.cancel()
else:
self._refresh_line_options_ev = Clock.create_trigger(
self._refresh_line_options, 0)
self._refresh_line_options_ev()
def _refresh_line_options(self, *largs):
self._line_options = None
self._get_line_options()
self._refresh_text_from_property()
self._refresh_hint_text()
self.cursor = self.get_cursor_from_index(len(self.text))
def _trigger_refresh_text(self, *largs):
if len(largs) and largs[0] == self:
largs = ()
if self._refresh_text_from_property_ev is not None:
self._refresh_text_from_property_ev.cancel()
self._refresh_text_from_property_ev = Clock.schedule_once(
lambda dt: self._refresh_text_from_property(*largs))
def _update_text_options(self, *largs):
Cache_remove('textinput.width')
self._trigger_refresh_text()
def _refresh_text_from_trigger(self, dt, *largs):
self._refresh_text_from_property(*largs)
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.
"""
mode = 'all'
if len(largs) > 1:
mode, start, finish, _lines, _lines_flags, len_lines = largs
# start = max(0, start)
cursor = None
else:
cursor = self.cursor_index()
_lines, self._lines_flags = self._split_smart(text)
_lines_labels = []
_line_rects = []
_create_label = self._create_line_label
for x in _lines:
lbl = _create_label(x)
_lines_labels.append(lbl)
_line_rects.append(Rectangle(size=lbl.size))
if mode == 'all':
self._lines_labels = _lines_labels
self._lines_rects = _line_rects
self._lines[:] = _lines
elif mode == 'del':
if finish > start:
self._insert_lines(start, finish + 1, len_lines,
_lines_flags, _lines, _lines_labels,
_line_rects)
elif mode == 'insert':
self._insert_lines(start, finish + 1, len_lines, _lines_flags,
_lines, _lines_labels, _line_rects)
min_line_ht = self._label_cached.get_extents('_')[1]
# with markup texture can be of height `1`
self.line_height = max(_lines_labels[0].height, min_line_ht)
# self.line_spacing = 2
# 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
)
# if we back to a new line, reset the scroll, otherwise, the effect is
# ugly
if self.cursor_row != row:
self.scroll_x = 0
# with the new text don't forget to update graphics again
self._trigger_update_graphics()
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])
if len_lines:
# if not inserting at first line then
if start:
# make sure line flags restored for first line
# _split_smart assumes first line to be not a new line
_lines_flags[0] = self_lines_flags[start]
_lins_flags.extend(_lines_flags)
_lins_flags.extend(self_lines_flags[finish:])
self._lines_flags = _lins_flags
_lins_lbls = []
_lins_lbls.extend(self._lines_labels[:start])
if len_lines:
_lins_lbls.extend(_lines_labels)
_lins_lbls.extend(self._lines_labels[finish:])
self._lines_labels = _lins_lbls
_lins_rcts = []
_lins_rcts.extend(self._lines_rects[:start])
if len_lines:
_lins_rcts.extend(_line_rects)
_lins_rcts.extend(self._lines_rects[finish:])
self._lines_rects = _lins_rcts
_lins = []
_lins.extend(self._lines[:start])
if len_lines:
_lins.extend(_lines)
_lins.extend(self._lines[finish:])
self._lines[:] = _lins
def _trigger_update_graphics(self, *largs):
self._update_graphics_ev.cancel()
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, 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()
line_height = self.line_height
dy = line_height + self.line_spacing
# adjust view if the cursor is going outside the bounds
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)
):
rects = self._hint_text_rects
labels = self._hint_text_labels
lines = self._hint_text_lines
else:
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 + scroll_y
miny = self.y + padding_bottom
maxy = self.top - padding_top
halign = self.halign
base_dir = self.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
y = self._draw_line(
value,
line_num,
labels[line_num],
viewport_pos,
line_height,
miny,
maxy,
x,
y,
base_dir,
halign,
rects,
auto_halign_r,
)
elif y <= miny:
line_num -= 1
break
y -= dy
if fst_visible_ln is not None:
self._visible_lines_range = (fst_visible_ln, line_num + 1)
else:
self._visible_lines_range = 0, 0
self._update_graphics_selection()
def _draw_line(
self,
value,
line_num,
texture,
viewport_pos,
line_height,
miny,
maxy,
x,
y,
base_dir,
halign,
rects,
auto_halign_r,
):
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
# local references to avoid dot lookups later
padding_left, padding_top, padding_right, padding_bottom = self.padding
rects = self._lines_rects
label_cached = self._label_cached
lines = self._lines
tab_width = self.tab_width
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
# 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 - 1),
),
start=max(selection_start_row, first_visible_line)
):
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
)
self._position_handles('both')
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 not selection_start_row <= line_num <= selection_end_row:
return
x, y = pos
w, h = size
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
canvas_add(Color(*selection_color, 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
# size the text maybe be put after size_hint have been resolved.
self._trigger_refresh_text()
self._refresh_hint_text()
self.scroll_x = self.scroll_y = 0
def _get_row_width(self, row):
# Get the pixel width of the given row.
_labels = self._lines_labels
if row < len(_labels):
return _labels[row].width
return 0
def _get_cursor_pos(self):
# return the current cursor x/y from the row/col
dy = self.line_height + self.line_spacing
padding_left = self.padding[0]
padding_top = self.padding[1]
padding_right = self.padding[2]
left = self.x + padding_left
top = self.top - padding_top
y = top + self.scroll_y
y -= self.cursor_row * dy
# Horizontal alignment
halign = self.halign
viewport_width = self.width - padding_left - padding_right
cursor_offset = self.cursor_offset()
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':
row_width = self._get_row_width(self.cursor_row)
x = (
left
+ max(0, (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
+ max(0, viewport_width - row_width)
+ cursor_offset
- self.scroll_x
)
else:
x = left + cursor_offset - self.scroll_x
return x, y
def _get_cursor_visual_height(self):
# Return the height of the cursor's visible part
_, cy = map(int, self.cursor_pos)
max_y = self.top - self.padding[1]
min_y = self.y + self.padding[3]
lh = self.line_height
if cy > max_y:
return lh - min(lh, cy - max_y)
else:
return min(lh, max(0, cy - min_y))
def _get_cursor_visual_pos(self):
# Return the position of the cursor's top visible point
cx, cy = map(int, self.cursor_pos)
max_y = self.top - self.padding[3]
return [cx, min(max_y, cy)]
def _get_line_options(self):
# Get or create line options, to be used for Label creation
if self._line_options is None:
self._line_options = kw = {
'font_size': self.font_size,
'font_name': self.font_name,
'font_context': self.font_context,
'font_family': self.font_family,
'text_language': self.text_language,
'base_direction': self.base_direction,
'anchor_x': 'left',
'anchor_y': 'top',
'padding_x': 0,
'padding_y': 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)
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 find the maximum text we can handle
label = None
label_len = len(ntext)
ld = None
# check for blank line
if not ntext:
texture = Texture.create(size=(1, 1))
Cache_append('textinput.label', cid, texture)
return texture
while True:
try:
label = Label(text=ntext[:label_len], **kw)
label.refresh()
if ld is not None and 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 //= 2
if ld < 2 and label_len:
label_len -= 1
label_len -= ld
continue
# ok, we found it.
texture = label.texture
Cache_append('textinput.label', cid, texture)
return texture
_tokenize_delimiters = u' .,:;!?\r\t'
def _tokenize(self, text):
# Tokenize a text string from some delimiters
if text is None:
return
delimiters = self._tokenize_delimiters
old_index = 0
prev_char = ''
for index, char in enumerate(text):
if char not in delimiters:
if char != u'\n':
if index > 0 and (prev_char in delimiters):
if old_index < index:
yield text[old_index:index]
old_index = index
else:
if old_index < index:
yield text[old_index:index]
yield text[index:index + 1]
old_index = index + 1
prev_char = char
yield text[old_index:]
def _split_smart(self, text):
"""
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 or not self.do_wrap:
lines = text.split(u'\n')
lines_flags = [0] + [FL_IS_LINEBREAK] * (len(lines) - 1)
return lines, lines_flags
# no autosize, do wordwrap.
x = flags = 0
line = []
lines = []
lines_flags = []
_join = u''.join
lines_append, lines_flags_append = lines.append, lines_flags.append
padding_left = self.padding[0]
padding_right = self.padding[2]
width = self.width - padding_left - padding_right
text_width = self._get_text_width
_tab_width, _label_cached = self.tab_width, self._label_cached
# try to add each word on current line.
words_widths = {}
for word in self._tokenize(text):
is_newline = (word == u'\n')
try:
w = words_widths[word]
except KeyError:
w = text_width(word, _tab_width, _label_cached)
words_widths[word] = w
# if we have more than the width, or if it's a newline,
# push the current line, and create a new one
if (x + w > width and line) or is_newline:
lines_append(_join(line))
lines_flags_append(flags)
flags = 0
line = []
x = 0
if is_newline:
flags |= FL_IS_LINEBREAK
elif width >= 1 and w > width:
while w > width:
split_width = split_pos = 0
# split the word
for c in word:
try:
cw = words_widths[c]
except KeyError:
cw = text_width(c, _tab_width, _label_cached)
words_widths[c] = cw
if split_width + cw > width:
break
split_width += cw
split_pos += 1
if split_width == split_pos == 0:
# can't fit the word in, give up
break
lines_append(word[:split_pos])
lines_flags_append(flags)
flags = FL_IS_WORDBREAK
word = word[split_pos:]
w -= split_width
x = w
line.append(word)
else:
x += w
line.append(word)
if line or flags & FL_IS_LINEBREAK:
lines_append(_join(line))
lines_flags_append(flags)
return lines, lines_flags
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')
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)
cursor = self.cursor
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
)
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')
else:
self.dispatch('on_text_validate')
if self.text_validate_unfocus:
self.focus = False
elif internal_action == 'escape':
self.focus = False
def _key_up(self, key, repeat=False):
displayed_str, internal_str, internal_action, scale = key
if internal_action in ('shift', 'shift_L', 'shift_R'):
if self._selection:
self._update_selection(True)
elif internal_action == 'ctrl_L':
self._ctrl_l = False
elif internal_action == 'ctrl_R':
self._ctrl_r = False
elif internal_action == 'alt_L':
self._alt_l = False
elif internal_action == 'alt_R':
self._alt_r = False
def keyboard_on_key_down(self, window, keycode, text, modifiers):
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 self.interesting_keys.keys()
if (
not self.write_tab
and super().keyboard_on_key_down(window, keycode, text, modifiers)
):
return True
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)
# check for command modes
# we use \x01INFO\x02 to get info from IME on mobiles
# pygame seems to pass \x01 as the unicode for ctrl+a
# checking for modifiers ensures conflict resolution.
first_char = ord(text[0])
if not modifiers and first_char == 1:
self._command_mode = True
self._command = ''
if not modifiers and first_char == 2:
self._command_mode = False
self._command = self._command[1:]
if self._command_mode:
self._command += text
return
_command = self._command
if _command and first_char == 2:
self._handle_command(_command)
return
else:
if EventLoop.window.managed_textinput:
# we expect to get managed key input via on_textinput
return
if self._selection:
self.delete_selection()
self.insert_text(text)
# self._recalc_size()
return
if is_interesting_key:
self._hide_cut_copy_paste(win)
self._hide_handles(win)
if key == 27: # escape
self.focus = False
return True
elif key == 9: # tab
self.delete_selection()
self.insert_text(u'\t')
return True
k = self.interesting_keys.get(key)
if k:
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 = keycode[0]
k = self.interesting_keys.get(key)
if k:
key = (None, None, k, 1)
self._key_up(key)
def keyboard_on_textinput(self, window, text):
if self._selection:
self.delete_selection()
self.insert_text(text, False)
# current IME composition in progress by the IME system, or '' if nothing
_ime_composition = StringProperty('')
# cursor position of last IME event
_ime_cursor = ListProperty(None, allownone=True)
def _bind_keyboard(self):
super()._bind_keyboard()
Window.bind(on_textedit=self.window_on_textedit)
def _unbind_keyboard(self):
super()._unbind_keyboard()
Window.unbind(on_textedit=self.window_on_textedit)
def window_on_textedit(self, window, ime_input):
text_lines = self._lines or ['']
if self._ime_composition:
pcc, pcr = self._ime_cursor
text = text_lines[pcr]
len_ime = len(self._ime_composition)
if text[pcc - len_ime:pcc] == self._ime_composition: # always?
remove_old_ime_text = text[:pcc - len_ime] + text[pcc:]
ci = self.cursor_index()
self._refresh_text_from_property(
"insert",
*self._get_line_from_cursor(pcr, remove_old_ime_text)
)
self.cursor = self.get_cursor_from_index(ci - len_ime)
if ime_input:
if self._selection:
self.delete_selection()
cc, cr = self.cursor
text = text_lines[cr]
new_text = text[:cc] + ime_input + text[cc:]
self._refresh_text_from_property(
"insert", *self._get_line_from_cursor(cr, new_text)
)
self.cursor = self.get_cursor_from_index(
self.cursor_index() + len(ime_input)
)
self._ime_composition = ime_input
self._ime_cursor = self.cursor
def on__hint_text(self, instance, value):
self._refresh_hint_text()
def _refresh_hint_text(self):
_lines, self._hint_text_flags = self._split_smart(self.hint_text)
_hint_text_labels = []
_hint_text_rects = []
_create_label = self._create_line_label
for x in _lines:
lbl = _create_label(x, hint=True)
_hint_text_labels.append(lbl)
_hint_text_rects.append(Rectangle(size=lbl.size))
self._hint_text_lines[:] = _lines
self._hint_text_labels = _hint_text_labels
self._hint_text_rects = _hint_text_rects
# Remember to update graphics
self._trigger_update_graphics()
#
# Properties
#
_lines = ListProperty([])
_hint_text_lines = ListProperty([])
_editable = BooleanProperty(True)
_insert_int_pat = re.compile(u'^-?[0-9]*$')
_insert_float_pat = re.compile(u'^-?[0-9]*\\.?[0-9]*$')
_cursor_blink = BooleanProperty(False)
_cursor_visual_pos = AliasProperty(
_get_cursor_visual_pos, None, bind=['cursor_pos']
)
_cursor_visual_height = AliasProperty(
_get_cursor_visual_height, None, bind=['cursor_pos']
)
readonly = BooleanProperty(False)
'''If True, the user will not be able to change the content of a textinput.
.. versionadded:: 1.3.0
:attr:`readonly` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
text_validate_unfocus = BooleanProperty(True)
'''If True, the :meth:`TextInput.on_text_validate` event will unfocus the
widget, therefore make it stop listening to the keyboard. When disabled,
the :meth:`TextInput.on_text_validate` event can be fired multiple times
as the result of TextInput keeping the focus enabled.
.. versionadded:: 1.10.1
:attr:`text_validate_unfocus` is
a :class:`~kivy.properties.BooleanProperty` and defaults to True.
'''
multiline = BooleanProperty(True)
'''If True, the widget will be able show multiple lines of text. If False,
the "enter" keypress will defocus the textinput instead of adding a new
line.
:attr:`multiline` is a :class:`~kivy.properties.BooleanProperty` and
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`.
.. versionadded:: 1.2.0
:attr:`password` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
password_mask = StringProperty('*')
'''Sets the character used to mask the text when :attr:`password` is True.
.. versionadded:: 1.10.0
:attr:`password_mask` is a :class:`~kivy.properties.StringProperty` and
defaults to `'*'`.
'''
cursor_blink = BooleanProperty(True)
'''This property is used to set whether the graphic cursor should blink
or not.
.. versionchanged:: 1.10.1
`cursor_blink` has been refactored to enable switching the blinking
on/off and the previous behavior has been moved to a private
`_cursor_blink` property. The previous default value `False` has been
changed to `True`.
:attr:`cursor_blink` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
def _get_cursor(self):
return self._cursor
def _set_cursor(self, pos):
if not self._lines:
self._trigger_refresh_text()
return
l = self._lines
cr = boundary(pos[1], 0, len(l) - 1)
cc = boundary(pos[0], 0, len(l[cr]))
cursor = cc, cr
# 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
sx = self.scroll_x
base_dir = self.base_direction or self._resolved_base_dir
auto_halign_r = (
self.halign == 'auto'
and base_dir
and 'rtl' in base_dir
)
offset = self.cursor_offset()
row_width = self._get_row_width(self.cursor_row)
# if offset is outside the current bounds, readjust
if offset - sx >= viewport_width:
self.scroll_x = offset - viewport_width
elif offset < sx + 1:
self.scroll_x = offset
# Avoid right/center horizontal alignment issues if the viewport is at
# the end of the line, if not multiline.
viewport_scroll_x = row_width - viewport_width
if (
not self.multiline
and offset >= viewport_scroll_x
and self.scroll_x >= viewport_scroll_x
and (
self.halign == "center"
or self.halign == "right"
or auto_halign_r
)
):
self.scroll_x = max(0, viewport_scroll_x)
# 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
padding_top = self.padding[1]
padding_bottom = self.padding[3]
viewport_height = self.height - padding_top - padding_bottom - dy
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.
You can set a new (col, row) if you want to move the cursor. The scrolling
area will be automatically updated to ensure that the cursor is
visible inside the viewport.
:attr:`cursor` is an :class:`~kivy.properties.AliasProperty`.
'''
def _get_cursor_col(self):
return self._cursor[0]
cursor_col = AliasProperty(_get_cursor_col, None, bind=('cursor', ))
'''Current column of the cursor.
:attr:`cursor_col` is an :class:`~kivy.properties.AliasProperty` to
cursor[0], read-only.
'''
def _get_cursor_row(self):
return self._cursor[1]
cursor_row = AliasProperty(_get_cursor_row, None, bind=('cursor', ))
'''Current row of the cursor.
:attr:`cursor_row` is an :class:`~kivy.properties.AliasProperty` to
cursor[1], read-only.
'''
cursor_pos = AliasProperty(_get_cursor_pos, None,
bind=('cursor', 'padding', 'pos', 'size',
'focus', 'scroll_x', 'scroll_y',
'line_height', 'line_spacing'),
cache=False)
'''Current position of the cursor, in (x, y).
:attr:`cursor_pos` is an :class:`~kivy.properties.AliasProperty`,
read-only.
'''
cursor_color = ColorProperty([1, 0, 0, 1])
'''Current color of the cursor, in (r, g, b, a) format.
.. versionadded:: 1.9.0
:attr:`cursor_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [1, 0, 0, 1].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
cursor_width = NumericProperty('1sp')
'''Current width of the cursor.
.. versionadded:: 1.10.0
:attr:`cursor_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to '1sp'.
'''
line_height = NumericProperty(1)
'''Height of a line. This property is automatically computed from the
:attr:`font_name`, :attr:`font_size`. Changing the line_height will have
no impact.
.. note::
:attr:`line_height` is the height of a single line of text.
Use :attr:`minimum_height`, which also includes padding, to
get the height required to display the text properly.
:attr:`line_height` is a :class:`~kivy.properties.NumericProperty`,
read-only.
'''
tab_width = NumericProperty(4)
'''By default, each tab will be replaced by four spaces on the text
input widget. You can set a lower or higher value.
:attr:`tab_width` is a :class:`~kivy.properties.NumericProperty` and
defaults to 4.
'''
padding_x = VariableListProperty([0, 0], length=2, deprecated=True)
'''Horizontal padding of the text: [padding_left, padding_right].
padding_x also accepts a one argument form [padding_horizontal].
:attr:`padding_x` is a :class:`~kivy.properties.VariableListProperty` and
defaults to [0, 0]. This might be changed by the current theme.
.. deprecated:: 1.7.0
Use :attr:`padding` instead.
'''
def on_padding_x(self, instance, value):
self.padding[0] = value[0]
self.padding[2] = value[1]
padding_y = VariableListProperty([0, 0], length=2, deprecated=True)
'''Vertical padding of the text: [padding_top, padding_bottom].
padding_y also accepts a one argument form [padding_vertical].
:attr:`padding_y` is a :class:`~kivy.properties.VariableListProperty` and
defaults to [0, 0]. This might be changed by the current theme.
.. deprecated:: 1.7.0
Use :attr:`padding` instead.
'''
def on_padding_y(self, instance, value):
self.padding[1] = value[0]
self.padding[3] = value[1]
padding = VariableListProperty([6, 6, 6, 6])
'''Padding of the text: [padding_left, padding_top, padding_right,
padding_bottom].
padding also accepts a two argument form [padding_horizontal,
padding_vertical] and a one argument form [padding].
.. versionchanged:: 1.7.0
Replaced AliasProperty with VariableListProperty.
:attr:`padding` is a :class:`~kivy.properties.VariableListProperty` and
defaults to [6, 6, 6, 6].
'''
halign = OptionProperty('auto', options=['left', 'center', 'right',
'auto'])
'''Horizontal alignment of the text.
:attr:`halign` is an :class:`~kivy.properties.OptionProperty` and
defaults to 'auto'. Available options are : auto, left, center and right.
Auto will attempt to autodetect horizontal alignment for RTL text (Pango
only), otherwise it behaves like `left`.
.. versionadded:: 1.10.1
'''
scroll_x = NumericProperty(0)
'''X scrolling value of the viewport. The scrolling is automatically
updated when the cursor is moved or text changed. If there is no
user input, the scroll_x and scroll_y properties may be changed.
:attr:`scroll_x` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
scroll_y = NumericProperty(0)
'''Y scrolling value of the viewport. See :attr:`scroll_x` for more
information.
:attr:`scroll_y` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
selection_color = ColorProperty([0.1843, 0.6549, 0.8313, .5])
'''Current color of the selection, in (r, g, b, a) format.
.. warning::
The color should always have an "alpha" component less than 1
since the selection is drawn after the text.
:attr:`selection_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [0.1843, 0.6549, 0.8313, .5].
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
border = ListProperty([4, 4, 4, 4])
'''Border used for :class:`~kivy.graphics.vertex_instructions.BorderImage`
graphics instruction. Used with :attr:`background_normal` and
:attr:`background_active`. Can be used for a custom background.
.. versionadded:: 1.4.1
It must be a list of four values: (bottom, right, top, left). Read the
BorderImage instruction for more information about how to use it.
:attr:`border` is a :class:`~kivy.properties.ListProperty` and defaults
to (4, 4, 4, 4).
'''
background_normal = StringProperty(
'atlas://data/images/defaulttheme/textinput')
'''Background image of the TextInput when it's not in focus.
.. versionadded:: 1.4.1
:attr:`background_normal` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/textinput'.
'''
background_disabled_normal = StringProperty(
'atlas://data/images/defaulttheme/textinput_disabled')
'''Background image of the TextInput when disabled.
.. versionadded:: 1.8.0
:attr:`background_disabled_normal` is a
:class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/textinput_disabled'.
'''
background_active = StringProperty(
'atlas://data/images/defaulttheme/textinput_active')
'''Background image of the TextInput when it's in focus.
.. versionadded:: 1.4.1
:attr:`background_active` is a
:class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/textinput_active'.
'''
background_color = ColorProperty([1, 1, 1, 1])
'''Current color of the background, in (r, g, b, a) format.
.. versionadded:: 1.2.0
:attr:`background_color` is a :class:`~kivy.properties.ColorProperty`
and defaults to [1, 1, 1, 1] (white).
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
foreground_color = ColorProperty([0, 0, 0, 1])
'''Current color of the foreground, in (r, g, b, a) format.
.. versionadded:: 1.2.0
:attr:`foreground_color` is a :class:`~kivy.properties.ColorProperty`
and defaults to [0, 0, 0, 1] (black).
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
disabled_foreground_color = ColorProperty([0, 0, 0, .5])
'''Current color of the foreground when disabled, in (r, g, b, a) format.
.. versionadded:: 1.8.0
:attr:`disabled_foreground_color` is a
:class:`~kivy.properties.ColorProperty` and
defaults to [0, 0, 0, 5] (50% transparent black).
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
use_bubble = BooleanProperty(not _is_desktop)
'''Indicates whether the cut/copy/paste bubble is used.
.. versionadded:: 1.7.0
:attr:`use_bubble` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True on mobile OS's, False on desktop OS's.
'''
use_handles = BooleanProperty(not _is_desktop)
'''Indicates whether the selection handles are displayed.
.. versionadded:: 1.8.0
:attr:`use_handles` is a :class:`~kivy.properties.BooleanProperty`
and defaults to True on mobile OS's, False on desktop OS's.
'''
scroll_from_swipe = BooleanProperty(not _is_desktop)
'''Allow to scroll the text using swipe gesture according to
:attr:`scroll_timeout` and :attr:`scroll_distance`.
.. versionadded:: 2.1.0
:attr:`scroll_from_swipe` is a BooleanProperty and defaults to True on
mobile OSs, False on desktop OSs.
'''
scroll_distance = NumericProperty(_scroll_distance)
'''Minimum distance to move before change from scroll to selection mode, in
pixels.
It is advisable that you base this value on the dpi of your target device's
screen.
.. versionadded:: 2.1.0
:attr:`scroll_distance` is a NumericProperty and defaults to 20 pixels.
'''
scroll_timeout = NumericProperty(_scroll_timeout)
'''Timeout allowed to trigger the :attr:`scroll_distance`, in milliseconds.
If the user has not moved :attr:`scroll_distance` within the timeout, the
scrolling will be disabled, and the selection mode will start.
.. versionadded:: 2.1.0
:attr:`scroll_timeout` is a NumericProperty and defaults to 250
milliseconds.
'''
def get_sel_from(self):
return self._selection_from
selection_from = AliasProperty(get_sel_from, None)
'''If a selection is in progress or complete, this property will represent
the cursor index where the selection started.
.. versionchanged:: 1.4.0
:attr:`selection_from` is an :class:`~kivy.properties.AliasProperty`
and defaults to None, readonly.
'''
def get_sel_to(self):
return self._selection_to
selection_to = AliasProperty(get_sel_to, None)
'''If a selection is in progress or complete, this property will represent
the cursor index where the selection started.
.. versionchanged:: 1.4.0
:attr:`selection_to` is an :class:`~kivy.properties.AliasProperty` and
defaults to None, readonly.
'''
selection_text = StringProperty(u'')
'''Current content selection.
:attr:`selection_text` is a :class:`~kivy.properties.StringProperty`
and defaults to '', readonly.
'''
def on_selection_text(self, instance, value):
if value:
if self.use_handles:
self._trigger_show_handles()
if CutBuffer and not self.password:
self._trigger_update_cutbuffer()
def _get_text(self):
flags = self._lines_flags
lines = self._lines
len_lines = len(lines)
less_flags = len(flags) < len_lines
if less_flags:
flags.append(1)
text = ''.join(
('\n' if (flags[i] & FL_IS_LINEBREAK) else '') + lines[i]
for i in range(len_lines)
)
if less_flags:
flags.pop()
return text
def _set_text(self, text):
if isinstance(text, bytes):
text = text.decode('utf8')
if self.replace_crlf:
text = text.replace(u'\r\n', u'\n')
if self.text != text:
self._refresh_text(text)
self.cursor = self.get_cursor_from_index(len(text))
text = AliasProperty(_get_text, _set_text, bind=('_lines',), cache=True)
'''Text of the widget.
Creation of a simple hello world::
widget = TextInput(text='Hello world')
If you want to create the widget with an unicode string, use::
widget = TextInput(text=u'My unicode string')
:attr:`text` is an :class:`~kivy.properties.AliasProperty`.
'''
font_name = StringProperty(DEFAULT_FONT)
'''Filename of the font to use. The path can be absolute or relative.
Relative paths are resolved by the :func:`~kivy.resources.resource_find`
function.
.. warning::
Depending on your text provider, the font file may be ignored. However,
you can mostly use this without problems.
If the font used lacks the glyphs for the particular language/symbols
you are using, you will see '[]' blank box characters instead of the
actual glyphs. The solution is to use a font that has the glyphs you
need to display. For example, to display |unicodechar|, use a font like
freesans.ttf that has the glyph.
.. |unicodechar| image:: images/unicode-char.png
:attr:`font_name` is a :class:`~kivy.properties.StringProperty` and
defaults to 'Roboto'. This value is taken
from :class:`~kivy.config.Config`.
'''
font_size = NumericProperty('15sp')
'''Font size of the text in pixels.
:attr:`font_size` is a :class:`~kivy.properties.NumericProperty` and
defaults to 15 :attr:`~kivy.metrics.sp`.
'''
font_context = StringProperty(None, allownone=True)
'''Font context. `None` means the font is used in isolation, so you are
guaranteed to be drawing with the TTF file resolved by :attr:`font_name`.
Specifying a value here will load the font file into a named context,
enabling fallback between all fonts in the same context. If a font
context is set, you are not guaranteed that rendering will actually use
the specified TTF file for all glyphs (Pango will pick the one it
thinks is best).
If Kivy is linked against a system-wide installation of FontConfig,
you can load the system fonts by specifying a font context starting
with the special string `system://`. This will load the system
fontconfig configuration, and add your application-specific fonts on
top of it (this imposes a significant risk of family name collision,
Pango may not use your custom font file, but pick one from the system)
.. note::
This feature requires the Pango text provider.
.. versionadded:: 1.10.1
:attr:`font_context` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
'''
font_family = StringProperty(None, allownone=True)
'''Font family, this is only applicable when using :attr:`font_context`
option. The specified font family will be requested, but note that it may
not be available, or there could be multiple fonts registered with the
same family. The value can be a family name (string) available in the
font context (for example a system font in a `system://` context, or a
custom font file added using :class:`kivy.core.text.FontContextManager`).
If set to `None`, font selection is controlled by the :attr:`font_name`
setting.
.. note::
If using :attr:`font_name` to reference a custom font file, you
should leave this as `None`. The family name is managed automatically
in this case.
.. note::
This feature requires the Pango text provider.
.. versionadded:: 1.10.1
:attr:`font_family` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
'''
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
"weak_rtl".
.. note::
This feature requires the Pango text provider.
.. note::
Weak modes are currently not implemented in Kivy text layout, and
have the same effect as setting strong mode.
.. versionadded:: 1.10.1
:attr:`base_direction` is an :class:`~kivy.properties.OptionProperty` and
defaults to None (autodetect RTL if possible, otherwise LTR).
'''
text_language = StringProperty(None, allownone=True)
'''Language of the text, if None Pango will determine it from locale.
This is an RFC-3066 format language tag (as a string), for example
"en_US", "zh_CN", "fr" or "ja". This can impact font selection, metrics
and rendering. For example, the same bytes of text can look different
for `ur` and `ar` languages, though both use Arabic script.
.. note::
This feature requires the Pango text provider.
.. versionadded:: 1.10.1
:attr:`text_language` is a :class:`~kivy.properties.StringProperty` and
defaults to None.
'''
_hint_text = StringProperty('')
def _set_hint_text(self, value):
if isinstance(value, bytes):
value = value.decode('utf8')
self._hint_text = value
def _get_hint_text(self):
return self._hint_text
hint_text = AliasProperty(
_get_hint_text, _set_hint_text, bind=('_hint_text', ))
'''Hint text of the widget, shown if text is ''.
.. versionadded:: 1.6.0
.. versionchanged:: 1.10.0
The property is now an AliasProperty and byte values are decoded to
strings. The hint text will stay visible when the widget is focused.
:attr:`hint_text` a :class:`~kivy.properties.AliasProperty` and defaults
to ''.
'''
hint_text_color = ColorProperty([0.5, 0.5, 0.5, 1.0])
'''Current color of the hint_text text, in (r, g, b, a) format.
.. versionadded:: 1.6.0
:attr:`hint_text_color` is a :class:`~kivy.properties.ColorProperty` and
defaults to [0.5, 0.5, 0.5, 1.0] (grey).
.. versionchanged:: 2.0.0
Changed from :class:`~kivy.properties.ListProperty` to
:class:`~kivy.properties.ColorProperty`.
'''
auto_indent = BooleanProperty(False)
'''Automatically indent multiline text.
.. versionadded:: 1.7.0
:attr:`auto_indent` is a :class:`~kivy.properties.BooleanProperty` and
defaults to False.
'''
replace_crlf = BooleanProperty(True)
'''Automatically replace CRLF with LF.
.. versionadded:: 1.9.1
:attr:`replace_crlf` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
allow_copy = BooleanProperty(True)
'''Decides whether to allow copying the text.
.. versionadded:: 1.8.0
:attr:`allow_copy` is a :class:`~kivy.properties.BooleanProperty` and
defaults to True.
'''
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
)
'''Minimum height of the content inside the TextInput.
.. versionadded:: 1.8.0
:attr:`minimum_height` is a readonly
:class:`~kivy.properties.AliasProperty`.
.. warning::
:attr:`minimum_width` is calculated based on :attr:`width` therefore
code like this will lead to an infinite loop::
<FancyTextInput>:
height: self.minimum_height
width: self.height
'''
line_spacing = NumericProperty(0)
'''Space taken up between the lines.
.. versionadded:: 1.8.0
:attr:`line_spacing` is a :class:`~kivy.properties.NumericProperty` and
defaults to 0.
'''
lines_to_scroll = BoundedNumericProperty(3, min=1)
'''Set how many lines will be scrolled at once when using the mouse scroll
wheel.
.. versionadded:: 2.2.0
:attr:`lines_to_scroll is a
:class:`~kivy.properties.BoundedNumericProperty` and defaults to 3, the
minimum is 1.
'''
input_filter = ObjectProperty(None, allownone=True)
''' Filters the input according to the specified mode, if not None. If
None, no filtering is applied.
.. versionadded:: 1.9.0
:attr:`input_filter` is an :class:`~kivy.properties.ObjectProperty` and
defaults to `None`. Can be one of `None`, `'int'` (string), or `'float'`
(string), or a callable. If it is `'int'`, it will only accept numbers.
If it is `'float'` it will also accept a single period. Finally, if it is
a callable it will be called with two parameters; the string to be added
and a bool indicating whether the string is a result of undo (True). The
callable should return a new substring that will be used instead.
'''
handle_image_middle = StringProperty(
'atlas://data/images/defaulttheme/selector_middle')
'''Image used to display the middle handle on the TextInput for cursor
positioning.
.. versionadded:: 1.8.0
:attr:`handle_image_middle` is a :class:`~kivy.properties.StringProperty`
and defaults to 'atlas://data/images/defaulttheme/selector_middle'.
'''
def on_handle_image_middle(self, instance, value):
if self._handle_middle:
self._handle_middle.source = value
handle_image_left = StringProperty(
'atlas://data/images/defaulttheme/selector_left')
'''Image used to display the Left handle on the TextInput for selection.
.. versionadded:: 1.8.0
:attr:`handle_image_left` is a :class:`~kivy.properties.StringProperty` and
defaults to 'atlas://data/images/defaulttheme/selector_left'.
'''
def on_handle_image_left(self, instance, value):
if self._handle_left:
self._handle_left.source = value
handle_image_right = StringProperty(
'atlas://data/images/defaulttheme/selector_right')
'''Image used to display the Right handle on the TextInput for selection.
.. versionadded:: 1.8.0
:attr:`handle_image_right` is a
:class:`~kivy.properties.StringProperty` and defaults to
'atlas://data/images/defaulttheme/selector_right'.
'''
def on_handle_image_right(self, instance, value):
if self._handle_right:
self._handle_right.source = value
write_tab = BooleanProperty(True)
'''Whether the tab key should move focus to the next widget or if it should
enter a tab in the :class:`TextInput`. If `True` a tab will be written,
otherwise, focus will move to the next widget.
.. versionadded:: 1.9.0
:attr:`write_tab` is a :class:`~kivy.properties.BooleanProperty` and
defaults to `True`.
'''
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):
Clock.schedule_interval(self.update_time, 0)
return Builder.load_string(KV)
def update_time(self, dt):
self.time += dt
TextInputApp().run()