Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/3299.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The web and Textual backends now provide a NumberInput widget.
12 changes: 8 additions & 4 deletions docs/reference/api/widgets/numberinput.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,17 @@ A text input that is limited to numeric input.
:align: center
:width: 300px

.. group-tab:: Web |no|
.. group-tab:: Web

Not supported
.. figure:: /reference/images/numberinput-web.png
:align: center
:width: 300px

.. group-tab:: Textual |no|
.. group-tab:: Textual

Not supported
.. figure:: /reference/images/numberinput-textual.png
:align: center
:width: 300px

Usage
-----
Expand Down
Binary file added docs/reference/images/numberinput-textual.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/reference/images/numberinput-web.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 3 additions & 2 deletions textual/src/toga_textual/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
from .widgets.label import Label

# from .widgets.multilinetextinput import MultilineTextInput
# from .widgets.numberinput import NumberInput
from .widgets.numberinput import NumberInput

# from .widgets.optioncontainer import OptionContainer
# from .widgets.passwordinput import PasswordInput
# from .widgets.progressbar import ProgressBar
Expand Down Expand Up @@ -69,7 +70,7 @@ def not_implemented(feature):
# "ImageView",
"Label",
# "MultilineTextInput",
# "NumberInput",
"NumberInput",
# "OptionContainer",
# "PasswordInput",
# "ProgressBar",
Expand Down
93 changes: 93 additions & 0 deletions textual/src/toga_textual/widgets/numberinput.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
from travertino.size import at_least

from textual.validation import Number
from textual.widgets import Input as TextualInput

from .base import Widget


class TogaInput(TextualInput):
def __init__(self, impl):
super().__init__()
self.interface = impl.interface
self.impl = impl

def on_input_changed(self, event: TextualInput.Changed) -> None:
self.interface.on_change()

def on_input_blurred(self, event: TextualInput.Blurred):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blurred definitely looks like the right feature here (side note... why they didn't call this "focus" I don't know, but whatever). However, it looks like it wasn't added until textual 2.0, so we need to bump the minimum Textual version in the pyproject.toml to accomodate this. There's no problem doing a version bump, as long as there's no other consequences - in my very quick testing, Textual 2.0 has some weird "on app close" logic that seemed to get in the way of actually exiting the app; Textual 3.0 works, but outputs:

focus was removed
Unmount() >>> TogaApp(title='Demo NumberInput', classes={'-dark-mode'}, pseudo_classes={'dark', 'focus'}) method=None

to the console on exit, which suggests there's some sort of new exit handling required.

There's also a small code re-use issue here. The clipping logic is almost identical to the clipping logic in the core. It would be preferable to avoid duplicating that logic is possible; it would be preferable to factor out a "_clipped_decimal" utility method in the interface that does all the value normalisation, and then using that method in both the value setter and the on_input_blurred implementation for Textual.

I suspect that might also reveal a couple of other cleaning issues (handling of - -> None rather than 0; and step clipping).

if self.impl.get_value() == "-":
self.impl.set_value("0")

if self.impl.get_value() is not None and self.impl.min is not None:
if float(self.impl.get_value()) < self.impl.min:
self.impl.set_value(str(self.impl.min))

if self.impl.get_value() is not None and self.impl.max is not None:
if float(self.impl.get_value()) > self.impl.max:
self.impl.set_value(str(self.impl.max))


class NumberInput(Widget):
def create(self):
self.native = TogaInput(self)
self.native.type = "number"
self.min = None
self.max = None

def get_readonly(self):
return self.native.disabled

def set_readonly(self, value):
self.native.disabled = value

def get_placeholder(self):
return self.native.placeholder

def set_placeholder(self, value):
self.native.placeholder = value

def get_value(self):
if self.native.value == "" or self.native.value is None:
return None
else:
if self.native.value != "-":
return float(self.native.value)
else:
return self.native.value

def set_value(self, value):
try:
if value is None:
self.native.value = ""
else:
self.native.value = str(value)
except AttributeError:
self.native.value = ""

def set_step(self, step):
pass

def set_min_value(self, value):
self.min = value
self.native.validators = [
Number(minimum=self.min),
]

def set_max_value(self, value):
self.max = value
self.native.validators = [
Number(maximum=self.max),
]

@property
def width_adjustment(self):
return 2

@property
def height_adjustment(self):
return 2

def rehint(self):
self.interface.intrinsic.width = at_least(10)
self.interface.intrinsic.height = 3
5 changes: 3 additions & 2 deletions web/src/toga_web/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@
from .widgets.label import Label

# from .widgets.multilinetextinput import MultilineTextInput
# from .widgets.numberinput import NumberInput
from .widgets.numberinput import NumberInput

# from .widgets.optioncontainer import OptionContainer
from .widgets.passwordinput import PasswordInput
from .widgets.progressbar import ProgressBar
Expand Down Expand Up @@ -66,7 +67,7 @@ def not_implemented(feature):
# 'ImageView',
"Label",
# 'MultilineTextInput',
# 'NumberInput',
"NumberInput",
# 'OptionContainer',
"PasswordInput",
"ProgressBar",
Expand Down
50 changes: 50 additions & 0 deletions web/src/toga_web/widgets/numberinput.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from .base import Widget


class NumberInput(Widget):
def create(self):
self._return_listener = None
self.native = self._create_native_widget("sl-input")
self.native.type = "number"
self.native.value = None
self.native.onblur = self.lost_focus
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a convention of clearly naming native event handlers based on their native naming - so in this case, lost_focus() should be dom_onblur(). That makes it easier for us to follow when/why certain methods are being invoked.


def lost_focus(self, event):
print(self.native.value == "-")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like stray debugging code?

Suggested change
print(self.native.value == "-")

if self.native.value == "":
self.native.value = None

if self.native.value is not None and self.native.min is not None:
if float(self.native.value) < self.native.min:
self.native.value = self.native.min

if self.native.value is not None and self.native.max is not None:
if float(self.native.value) > self.native.max:
self.native.value = self.native.max

def get_readonly(self, value):
return self.native.readOnly

def set_readonly(self, value):
self.native.readOnly = value

def set_step(self, step):
self.native.step = step

def set_min_value(self, value):
self.native.min = value

def set_max_value(self, value):
self.native.max = value

def get_value(self):
if self.native.value == "" or self.native.value is None:
return None
else:
return float(self.native.value)

def set_value(self, value):
self.native.value = value

def set_text_align(self, value):
pass