#!/usr/bin/env python
# -*- coding: utf-8 -*-
#
# text.py
#
# Copyright 2016 notna <notna@apparat.org>
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston,
# MA 02110-1301, USA.
#
#
__all__ = [
"Label",
"TextInput",
"TextInputBackground",
"CustomTextInputBackground",
"PasswordInput",
]
import time
import pyglet
from pyglet.gl import *
from pyglet.window import key
from ..util import default_property
try:
import pyperclip
HAVE_PYPERCLIP = True
except ImportError:
HAVE_PYPERCLIP = False
from typing import Optional, Any, TYPE_CHECKING
if TYPE_CHECKING:
from . import SubMenu
import peng3d
from .widgets import Background, Widget, mouse_aabb, DEFER_BG
from .button import ButtonBackground
from ..util.types import *
from .. import util
[docs]class Label(Widget):
"""
Simple widget that can display any single-line non-formatted string.
This widget does not use any background by default.
The default font color is chosen to work on the default background color and may need to be changed if the background color is changed.
"""
def __init__(
self,
name: Optional[str],
submenu: "SubMenu",
window: Any = None,
peng: Any = None,
*,
pos: DynPosition,
size: DynSize = None,
bg=None,
label="Label",
font_size=None,
font=None,
font_color=None,
multiline=False,
label_cls=pyglet.text.Label,
anchor_x="center",
anchor_y="center",
label_layer=1,
):
super(Label, self).__init__(
name, submenu, window, peng, pos=pos, size=size, bg=bg
)
self.font = font
self.font_size = font_size
self.font_color = font_color
if label_cls == pyglet.text.HTMLLabel:
self._label = label_cls(
str(label),
# Font is changed later
x=0,
y=0,
batch=self.submenu.batch2d,
anchor_x=anchor_x,
anchor_y=anchor_y,
group=pyglet.graphics.OrderedGroup(label_layer),
width=self.size[0],
height=self.size[1],
multiline=multiline,
)
self._label.font_name = self.font
self._label.font_size = self.font_size
self._label.font_color = self.font_color
else:
self._label = label_cls(
str(label),
font_name=self.font,
font_size=self.font_size,
color=self.font_color,
x=0,
y=0,
batch=self.submenu.batch2d,
anchor_x=anchor_x,
anchor_y=anchor_y,
group=pyglet.graphics.OrderedGroup(label_layer),
width=self.size[0],
height=self.size[1],
multiline=multiline,
)
if getattr(label, "_dynamic", False):
def f():
self.label = str(label)
self.peng.i18n.addAction("setlang", f)
self.redraw()
[docs] def on_redraw(self, dt=None):
super(Label, self).on_redraw()
self.redraw_label()
[docs] def redraw_label(self):
"""
Re-draws the text by calculating its position.
Currently, the text will always be centered on the position of the label.
"""
# Convenience variables
sx, sy = self.size
x, y = self.pos
# Label position
self._label.font_name = self.font
self._label.font_size = self.font_size
self._label.font_color = self.font_color
self._label.x = int(x + sx / 2.0)
self._label.y = int(y + sy / 2.0)
self._label.width = self.size[0]
self._label.height = self.size[1]
self._label._update() # Needed to prevent the label from drifting to the top-left after resizing by odd amounts
@property
def label(self):
"""
Property for accessing the text of the label.
"""
return self._label.text
@label.setter
def label(self, label):
self._label.text = str(label)
[docs]class TextInputBackground(ButtonBackground):
"""
Background for the :py:class:`TextInput` Widget.
This background uses the button drawing routines and adds a cursor.
"""
vlist_cursor_layer = 10
def __init__(self, *args, **kwargs):
self.stime = 0
super(TextInputBackground, self).__init__(*args, **kwargs)
[docs] def init_bg(self):
super(TextInputBackground, self).init_bg()
self.vlist_cursor = self.submenu.batch2d.add(
2,
GL_LINES,
pyglet.graphics.OrderedGroup(self.vlist_cursor_layer),
"v2f",
"c3B",
)
self.reg_vlist(self.vlist_cursor)
[docs] def redraw_bg(self):
super(TextInputBackground, self).redraw_bg()
sx, sy, x, y, bx, by = self.getPosSize()
# TODO: make this less hacky
otext = self.widget._text.text
self.widget._text.text = self.widget._text.text[: self.widget.cursor_pos]
tw = (
self.widget._text.content_width + 2
if len(self.widget._text.text) != 0
else 0
)
self.widget._text.text = otext
v = x + tw + bx, y + by, x + tw + bx, y + sy - by
self.vlist_cursor.vertices = v
bg, o, i, s, h = self.getColors()
# TODO: add compat with arbitrary backgrounds, will not work on material
c = (
s * 2
if (self.stime - time.time()) % 1 > 0.5 or not self.widget.focussed
else [0, 0, 0] * 2
)
self.vlist_cursor.colors = c
@property
def pressed(self):
return self.change_on_press and self.widget.focussed
[docs]class CustomTextInputBackground(Background):
"""
Background for the :py:class:`TextInput` Widget.
This background adds a cursor on top of another background.
"""
vlist_cursor_layer = 10
on_color = [255, 255, 255, 200]
off_color = [0, 0, 0, 0]
interval = 1.0
def __init__(self, widget, cls=ButtonBackground, *args, **kwargs):
self.stime = 0
super().__init__(widget)
self.parent = cls(widget, *args, **kwargs)
[docs] def init_bg(self):
self.parent.init_bg()
self.vlist_cursor = self.submenu.batch2d.add(
2,
GL_LINES,
pyglet.graphics.OrderedGroup(self.vlist_cursor_layer),
"v2f",
"c4B",
)
self.reg_vlist(self.vlist_cursor)
[docs] def redraw_bg(self):
self.parent.redraw_bg()
sx, sy, x, y, bx, by = self.getPosSize()
# TODO: make this less hacky
otext = self.widget._text.text
self.widget._text.text = self.widget._text.text[: self.widget.cursor_pos]
tw = (
self.widget._text.content_width + 2
if len(self.widget._text.text) != 0
else 0
)
self.widget._text.text = otext
v = x + tw + bx, y + by, x + tw + bx, y + sy - by
self.vlist_cursor.vertices = v
c = (
self.off_color * 2
if (self.stime - time.time()) % self.interval > 0.5
or not self.widget.focussed
else self.on_color * 2
)
self.vlist_cursor.colors = c
def getPosSize(self):
sx, sy = self.widget.size
x, y = self.widget.pos
bx, by = self.widget.border
return sx, sy, x, y, bx, by
@property
def border(self):
return self.widget.style.border
def __del__(self):
del self.parent
super().__del__()
[docs]class TextInput(Widget):
"""
Basic Textual Input widget.
By default, this widget uses :py:class:`TextInputBackground` as its Background class.
The optional default text will only be displayed if the text is empty.
The ``allow_overflow`` flag determines if the text entered can be longer than the size of the :py:class:`TextInput`\\ .
The ``allow_copypaste`` flag controls whether or not the user can copy and paste the contents of the text box.
By default, copying and pasting is allowed. This flag can also be set to ``"force"`` to force a crash
with an appropriate error message if the :py:mod:`pyperclip` module is not available.
Currently, only copying, pasting and cutting the whole text box is supported, as there is no mechanism for text selection yet.
The key combinations used by this widget can be configured in the config via the :confval:`controls.keybinds.common.*` config values.
``parent_bgcls`` may be used to override the background used. Note that the cursor will still be rendered.
Additional parameters required by the custom background should be passed as keyword arguments.
Note that arguments already used by TextInput are not passed down. This may cause issues
with ButtonBackground and some other classes.
``allow_returnkey`` determines whether pressing the return key inserts a ``\\r`` character or not.
Note that the ``send_form`` action of the submenu may still be sent, even if this is set to true.
"""
IS_CLICKABLE = True
def __init__(
self,
name: Optional[str],
submenu: "SubMenu",
window: Any = None,
peng: Any = None,
*args,
pos: DynPosition,
size: DynSize = None,
bg=None,
text="",
default="",
border=[4, 4],
borderstyle=None,
font_size=None,
font=None,
font_color=None,
font_color_default=[62, 67, 73, 200],
allow_overflow=False,
allow_copypaste=True,
min_size=None,
parent_bgcls=None,
allow_returnkey=False,
**kwargs,
):
if allow_copypaste == "force" and not HAVE_PYPERCLIP:
raise ValueError(
"%s with name %s requires Clipboard support, but Pyperclip is not installed"
% (self.__class__.__name__, name)
)
super(TextInput, self).__init__(
name,
submenu,
window,
peng,
pos=pos,
size=size,
bg=util.default(bg, DEFER_BG),
min_size=min_size,
)
self.font = font
self.font_size = font_size
self.font_color = font_color
self.borderstyle = borderstyle
if parent_bgcls is None and bg is None:
# Standard background
self.setBackground(TextInputBackground(self, border, self.borderstyle))
elif parent_bgcls is not None and bg is None:
# Semi-customized background with parent class
self.style.border = border
self.setBackground(
CustomTextInputBackground(self, cls=parent_bgcls, *args, **kwargs)
)
self.allow_returnkey = allow_returnkey
self.focussed = False
self.allow_overflow = allow_overflow
self.allow_copypaste = (
True if allow_copypaste is True or allow_copypaste == "force" else False
)
self.force_copypaste = True if allow_copypaste == "force" else False
self._text = pyglet.text.Label(
str(text),
font_name=self.font,
font_size=self.font_size,
color=self.font_color,
x=0,
y=0,
batch=None, # self.submenu.batch2d,
anchor_x="left",
anchor_y="center",
width=self.size[0],
height=self.size[1],
)
self._default = pyglet.text.Label(
str(default),
font_name=self.font,
font_size=self.font_size,
color=font_color_default,
x=0,
y=0,
batch=None,
anchor_x="left",
anchor_y="center",
width=self.size[0],
height=self.size[1],
)
self.cursor_pos = len(text)
self.focussed = False
if getattr(text, "_dynamic", False):
def f():
self.text = str(text)
self.peng.i18n.addAction("setlang", f)
if getattr(default, "_dynamic", False):
def f():
self.default = str(default)
self.peng.i18n.addAction("setlang", f)
self.peng.i18n.addAction("setlang", self.redraw) # for dynamic size
self.peng.registerEventHandler("on_text", self.on_text)
self.peng.registerEventHandler("on_text_motion", self.on_text_motion)
if self.allow_copypaste:
self.peng.keybinds.add(
self.window.cfg["controls.keybinds.common.copy"],
"peng3d:gui.widget.textinput.%s.copy" % self.name,
self.on_copy,
)
self.peng.keybinds.add(
self.window.cfg["controls.keybinds.common.paste"],
"peng3d:gui.widget.textinput.%s.paste" % self.name,
self.on_paste,
)
self.peng.keybinds.add(
self.window.cfg["controls.keybinds.common.cut"],
"peng3d:gui.widget.textinput.%s.cut" % self.name,
self.on_cut,
)
self.redraw()
pyglet.clock.schedule_interval(lambda dt: self.redraw(), 1.0 / 2.0)
[docs] def on_redraw(self):
super(TextInput, self).on_redraw()
self.redraw_label()
[docs] def redraw_label(self):
"""
Re-draws the label by calculating its position.
Currently, the label will always be centered on the position of the label.
"""
# Convenience variables
sx, sy = self.size
x, y = self.pos
# Label position
x = x + self.bg.border[0]
y = y + sy / 2.0
w = self.size[0] - self.bg.border[0] * 2
h = self.size[1] - self.bg.border[1] * 2
self._text.x, self._text.y = x, y
self._text.width, self._text.height = w, h
self._default.x, self._default.y = x, y
self._default.width, self._default.height = w, h
self._text._update() # Needed to prevent the label from drifting to the top-left after resizing by odd amounts
self._default._update()
[docs] def draw(self):
super(TextInput, self).draw()
if self._text.text == "":
self._default.draw()
else:
self._text.draw()
draw.__noautodoc__ = True
def on_text(self, text):
if not (self.focussed and self.clickable):
return
if not self.allow_returnkey and ("\n" in text or "\r" in text):
return
otext = self.text
t = self.text
t = t[: self.cursor_pos] + text + t[self.cursor_pos :]
self.text = t
self.cursor_pos += len(self.text) - len(otext)
self.cursor_pos = min(self.cursor_pos, len(self.text))
self.redraw()
def on_text_motion(self, motion):
if not (self.focussed and self.clickable):
return
if motion == key.MOTION_BACKSPACE:
l = list(self.text)
try:
# Deletes the character in front of the cursor
del l[self.cursor_pos - 1]
except Exception:
return
self.text = "".join(l)
self.cursor_pos -= 1
self.cursor_pos = max(self.cursor_pos, 0)
self.redraw()
elif motion == key.MOTION_DELETE:
l = list(self.text)
try:
# Deletes the character after the cursor
del l[self.cursor_pos]
except Exception:
return
self.text = "".join(l)
self.redraw()
elif motion == key.MOTION_LEFT:
# Moves the cursor to the left one character
self.cursor_pos -= 1
self.cursor_pos = max(self.cursor_pos, 0)
self.redraw()
elif motion == key.MOTION_RIGHT:
# Moves the cursor to the right one character
self.cursor_pos += 1
self.cursor_pos = min(self.cursor_pos, len(self.text))
self.redraw()
elif (
motion == key.MOTION_BEGINNING_OF_LINE
or motion == key.MOTION_BEGINNING_OF_FILE
):
# Moves the cursor to the beginning
self.cursor_pos = 0
self.redraw()
elif motion == key.MOTION_END_OF_LINE or motion == key.MOTION_END_OF_FILE:
# Moves the cursor to the end
self.cursor_pos = len(self.text)
self.redraw()
def on_mouse_press(self, x, y, button, modifiers):
if not self.clickable:
return
elif mouse_aabb([x, y], self.size, self.pos):
if button == pyglet.window.mouse.LEFT:
self.doAction("press")
self.pressed = True
self.focussed = True
self.bg.stime = time.time()
elif button == pyglet.window.mouse.RIGHT:
self.doAction("context")
self.redraw()
else:
self.focussed = False
self.redraw()
def on_copy(self, symbol, modifiers, release):
if release:
return
try:
pyperclip.copy(self.text)
except Exception:
if self.force_copypaste:
raise
def on_paste(self, symbol, modifiers, release):
if release:
return
try:
t = pyperclip.paste()
# Tries to copy as much as possible, until it does not fit
if not self.allow_overflow:
# TODO: implement more efficient algorithm
self.text = ""
for c in t:
self.text += c
else:
self.text = t
except Exception:
if self.force_copypaste:
raise
def on_cut(self, symbol, modifiers, release):
if release:
return
try:
pyperclip.copy(self.text)
self.text = ""
except Exception:
if self.force_copypaste:
raise
@property
def text(self):
"""
Property for accessing the text.
"""
return self._text.text
@text.setter
def text(self, text):
text = str(text)
otext = self._text.text
self._text.text = text
self._text._update()
# Reverts the change if
# 1. the text length has NOT increased
# 2. and allow_overflow is False
# 3. and the text is wider than the widget size minus the border size
# 4. and the text is not empty
if (
len(otext) < len(text)
and not self.allow_overflow
and self.size[0] - self.bg.border[0] * 2 <= self._text.content_width
if len(self.text) != 0
else 0
):
self._text.text = otext
self._text._update()
self.doAction("textchange")
self.redraw() # necessary for size/pos that depends on label size
def getContentSize(self):
l = [
max(self._text.content_width, self._default.content_width),
max(self._text.content_height, self._default.content_height),
]
b = self.bg.border # TODO: make this work with borderless backgrounds
return [l[0] + b[0] * 2, l[1] + b[1] * 2]
@property
def default(self):
"""
Property for accessing the default text.
"""
return self._default.text
@default.setter
def default(self, default):
self._default.text = str(default)
borderstyle = default_property("style")