Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[widget Audit] toga.NumberInput #1946

Merged
merged 31 commits into from
Jun 2, 2023
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0f20ed8
Update on_change handler to include programmatic changes.
freakboy3742 May 18, 2023
a206a76
Update docs and dummy API for NumberInput.
freakboy3742 May 19, 2023
fe86965
Ported tests to pytest, and updated Dummy to use source-of-truth..
freakboy3742 May 19, 2023
01d2104
Tweaked the numberinput example.
freakboy3742 May 21, 2023
b6ce26a
Add testbed tests for cocoa, at 100% coverage.
freakboy3742 May 21, 2023
f4b161e
Revert addition of clear() method.
freakboy3742 May 22, 2023
82549a8
iOS to 100% coverage.
freakboy3742 May 22, 2023
59415bb
Add changenote.
freakboy3742 May 22, 2023
5be2183
Remove walrus syntax for 3.7 compatibility.
freakboy3742 May 22, 2023
8c1935f
Ensure value is always quantized.
freakboy3742 May 23, 2023
0db5d31
GTK NumberInput to 100% coverage.
freakboy3742 May 23, 2023
ccf6091
Reworked vertical alignment tests to allow for CENTER alignment on Te…
freakboy3742 May 23, 2023
f5a6935
Modify NumberInput tests to better accomodate GTK oddities.
freakboy3742 May 23, 2023
05d97ac
Correct min/max clipping behavior.
freakboy3742 May 23, 2023
fca159c
Enable future annotations.
freakboy3742 May 26, 2023
9c8676f
Flag a compatibility method as being unreachable by coverage.
freakboy3742 May 30, 2023
1986e79
Correct merge error.
freakboy3742 May 30, 2023
ca4e5f0
Remove duplicate method
mhsmith May 30, 2023
9a50367
Apply suggestions from code review
freakboy3742 May 31, 2023
6d89389
Document clipping behavior.
freakboy3742 May 31, 2023
1be9dc5
Update screenshot.
freakboy3742 May 31, 2023
40052bc
Added more tests of min/max validation, checks for 0 != None, and ref…
freakboy3742 May 31, 2023
16d5757
Factor out quantization code, and apply quantization on validation bo…
freakboy3742 May 31, 2023
e4ada1f
Ensure that min/max values are quantized to the step on a step change.
freakboy3742 May 31, 2023
7d58646
Clarify clipping behavior.
freakboy3742 May 31, 2023
c7dc6fa
Switch to ROUND_HALF_UP mode
mhsmith May 31, 2023
c9d135b
Further rounding fixes
mhsmith May 31, 2023
a54ab42
Fix tests on GTK
mhsmith May 31, 2023
441f0be
Winforms at 100% coverage
mhsmith May 31, 2023
dab740a
Android at 100% coverage
mhsmith Jun 1, 2023
4141589
Use subclassing overrides rather than a speculative atttribute lookup…
freakboy3742 Jun 1, 2023
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/1946.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The NumberInput widget now has 100% test coverage, and complete API documentation.
185 changes: 107 additions & 78 deletions cocoa/src/toga_cocoa/widgets/numberinput.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import sys
from decimal import Decimal, InvalidOperation

from rubicon.objc import SEL, objc_method, objc_property
from travertino.size import at_least

from toga.colors import TRANSPARENT
from toga.widgets.numberinput import _clean_decimal_str
from toga_cocoa.colors import native_color
from toga_cocoa.libs import (
SEL,
NSLayoutAttributeBottom,
NSLayoutAttributeCenterY,
NSLayoutAttributeLeft,
Expand All @@ -17,12 +19,11 @@
NSTextAlignment,
NSTextField,
NSTextFieldSquareBezel,
objc_method,
objc_property,
NSTextView,
NSView,
)

from .base import Widget
from .box import TogaView


class TogaStepper(NSStepper):
Expand All @@ -31,53 +32,63 @@ class TogaStepper(NSStepper):

@objc_method
def onChange_(self, stepper) -> None:
self.interface.value = Decimal(stepper.floatValue).quantize(self.interface.step)
if self.interface.on_change:
self.interface.on_change(self.interface)
# Stepper has increased/decreased
self.interface.value = Decimal(stepper.floatValue)

@objc_method
def controlTextDidChange_(self, notification) -> None:
value = str(self.impl.native_input.stringValue)
try:
value = str(self.impl.input.stringValue)
# Try to convert to a decimal. If the value isn't a number,
# this will raise InvalidOperation
Decimal(value)
# We set the input widget's value to the literal text input
# This preserves the display of "123.", which Decimal will
# convert to "123"
self.interface.value = value
if self.interface.on_change:
self.interface.on_change(self.interface)
except InvalidOperation:
# If the string value isn't valid, reset the widget
# to the widget's stored value. This will update the
# display, removing any invalid values from view.
self.impl.set_value(self.interface.value)
# If the string value isn't valid, remove any characters that
# would make it invalid.
self.impl.native_input.stringValue = _clean_decimal_str(value)

self.interface.on_change(self.interface)


class TogaNumberInput(NSTextField):
interface = objc_property(object, weak=True)
impl = objc_property(object, weak=True)

@objc_method
def textDidEndEditing_(self, notification) -> None:
# Loss of focus; ensure that the displayed value
# matches the clipped, normalized decimal value
self.impl.set_value(self.interface.value)


class NumberInput(Widget):
def create(self):
self.native = TogaView.alloc().init()
self.native = NSView.alloc().init()

self.native_input = TogaNumberInput.new()
self.native_input.interface = self.interface
self.native_input.impl = self
self.native_input.bezeled = True
self.native_input.bezelStyle = NSTextFieldSquareBezel
self.native_input.translatesAutoresizingMaskIntoConstraints = False
self.native_input.selectable = True

self.input = NSTextField.new()
self.input.bezeled = True
self.input.bezelStyle = NSTextFieldSquareBezel
self.input.translatesAutoresizingMaskIntoConstraints = False
self.native_stepper = TogaStepper.alloc().init()
self.native_stepper.interface = self.interface
self.native_stepper.impl = self
self.native_stepper.translatesAutoresizingMaskIntoConstraints = False

self.stepper = TogaStepper.alloc().init()
self.stepper.interface = self.interface
self.stepper.impl = self
self.stepper.translatesAutoresizingMaskIntoConstraints = False
self.native_stepper.target = self.native_stepper
self.native_stepper.action = SEL("onChange:")

self.stepper.target = self.stepper
self.stepper.action = SEL("onChange:")
self.native_stepper.valueWraps = False

self.stepper.controller = self.input
self.input.delegate = self.stepper
self.native_stepper.controller = self.native_input
self.native_input.delegate = self.native_stepper

# Add the input and stepper to the constraining box.
self.native.addSubview(self.input)
self.native.addSubview(self.stepper)
self.native.addSubview(self.native_input)
self.native.addSubview(self.native_stepper)

# Add constraints to lay out the input and stepper.
# Stepper is always top right corner.
Expand All @@ -86,7 +97,7 @@ def create(self):
self.native,
NSLayoutAttributeTop,
NSLayoutRelationEqual,
self.stepper,
self.native_stepper,
NSLayoutAttributeTop,
1.0,
0,
Expand All @@ -97,7 +108,7 @@ def create(self):
self.native,
NSLayoutAttributeRight,
NSLayoutRelationEqual,
self.stepper,
self.native_stepper,
NSLayoutAttributeRight,
1.0,
0,
Expand All @@ -110,7 +121,7 @@ def create(self):
self.native,
NSLayoutAttributeBottom,
NSLayoutRelationEqual,
self.stepper,
self.native_stepper,
NSLayoutAttributeBottom,
1.0,
0,
Expand All @@ -120,10 +131,10 @@ def create(self):
# Input is always left, centred vertically on the stepper
self.native.addConstraint(
NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_(
self.stepper,
self.native_stepper,
NSLayoutAttributeCenterY,
NSLayoutRelationEqual,
self.input,
self.native_input,
NSLayoutAttributeCenterY,
1.0,
0,
Expand All @@ -134,7 +145,7 @@ def create(self):
self.native,
NSLayoutAttributeLeft,
NSLayoutRelationEqual,
self.input,
self.native_input,
NSLayoutAttributeLeft,
1.0,
0,
Expand All @@ -144,10 +155,10 @@ def create(self):
# Stepper and input meet in the middle with a small gap
self.native.addConstraint(
NSLayoutConstraint.constraintWithItem_attribute_relatedBy_toItem_attribute_multiplier_constant_(
self.stepper,
self.native_stepper,
NSLayoutAttributeLeft,
NSLayoutRelationEqual,
self.input,
self.native_input,
NSLayoutAttributeRight,
1.0,
2,
Expand All @@ -158,65 +169,83 @@ def create(self):
self.add_constraints()

def set_color(self, color):
self.input.textColor = native_color(color)
self.native_input.textColor = native_color(color)

def set_readonly(self, value):
# Even if it's not editable, it's still selectable.
self.input.editable = not value
self.input.selectable = True
def set_background_color(self, color):
if color is TRANSPARENT:
# The text view needs to be made transparent *and* non-bezeled
self.native_input.drawsBackground = False
self.native_input.bezeled = False
else:
self.native_input.drawsBackground = True
self.native_input.bezeled = True
self.native_input.backgroundColor = native_color(color)

def has_focus(self):
# When the NSTextField gets focus, a field editor is created, and that editor
# has the original widget as the delegate. The first responder is the Field Editor.
return isinstance(self.native.window.firstResponder, NSTextView) and (
self.native.window.firstResponder.delegate == self.native_input
)

def focus(self):
if not self.has_focus():
self.interface.window._impl.native.makeFirstResponder(self.native_input)

def set_placeholder(self, value):
self.input.cell.placeholderString = value
def get_readonly(self):
return not self.native_input.isEditable()

def set_readonly(self, value):
self.native_input.editable = not value

def set_step(self, step):
self.stepper.increment = self.interface.step
self.native_stepper.increment = step

def set_min_value(self, value):
if self.interface.min_value is None:
self.stepper.minValue = -sys.maxsize
if value is None:
self.native_stepper.minValue = -sys.float_info.max
else:
self.stepper.minValue = value
self.native_stepper.minValue = float(value)

def set_max_value(self, value):
if self.interface.max_value is None:
self.stepper.maxValue = sys.maxsize
if value is None:
self.native_stepper.maxValue = sys.float_info.max
else:
self.stepper.maxValue = value
self.native_stepper.maxValue = float(value)

def set_alignment(self, value):
self.input.alignment = NSTextAlignment(value)
self.native_input.alignment = NSTextAlignment(value)

def set_font(self, font):
if font:
self.input.font = font._impl.native
self.native_input.font = font._impl.native

def get_value(self):
try:
raw = str(self.native_input.stringValue)
return Decimal(raw).quantize(self.interface.step)
mhsmith marked this conversation as resolved.
Show resolved Hide resolved
except InvalidOperation:
return None

def set_value(self, value):
if self.interface.value is None:
self.stepper.floatValue = 0
self.input.stringValue = ""
if value is None:
self.native_stepper.floatValue = 0.0
self.native_input.stringValue = ""
else:
self.stepper.floatValue = float(self.interface.value)
# We use the *literal* input value here, not the value
# stored in the interface, because we want to display
# what the user has actually input, not the interpreted
# Decimal value. Any invalid input value should result
# in the interface to a value of None, so this branch
# should only execute if we know the raw value can be
# converted to a Decimal.
self.input.stringValue = value
self.native_stepper.floatValue = float(value)
self.native_input.stringValue = str(value)
self.interface.on_change(None)

def get_enabled(self):
return self.native_input.isEnabled

def set_enabled(self, value):
self.input.enabled = value
self.stepper.enabled = value
self.native_input.enabled = value
self.native_stepper.enabled = value

def rehint(self):
# Height of a text input is known and fixed.
stepper_size = self.input.intrinsicContentSize()
input_size = self.input.intrinsicContentSize()
input_size = self.native_input.intrinsicContentSize()
stepper_size = self.native_stepper.intrinsicContentSize()

self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH)
self.interface.intrinsic.height = max(input_size.height, stepper_size.height)

def set_on_change(self, handler):
# No special handling required
pass
15 changes: 15 additions & 0 deletions cocoa/tests_backend/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,3 +212,18 @@ async def type_character(self, char):
keyCode=key_code,
),
)

async def mouse_event(self, event_type, location):
await self.post_event(
NSEvent.mouseEventWithType(
event_type,
location=location,
modifierFlags=0,
timestamp=0,
windowNumber=self.native.window.windowNumber,
context=None,
eventNumber=0,
clickCount=1,
pressure=1.0 if event_type == NSEventType.LeftMouseDown else 0.0,
),
)
3 changes: 2 additions & 1 deletion cocoa/tests_backend/widgets/multilinetextinput.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from toga.colors import TRANSPARENT
from toga_cocoa.libs import NSScrollView
from toga_cocoa.libs import NSScrollView, NSTextView

from .base import SimpleProbe
from .properties import toga_alignment, toga_color, toga_font
Expand All @@ -11,6 +11,7 @@ class MultilineTextInputProbe(SimpleProbe):
def __init__(self, widget):
super().__init__(widget)
self.native_text = widget._impl.native_text
assert isinstance(self.native_text, NSTextView)

@property
def value(self):
Expand Down
Loading