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.OptionContainer #1996

Merged
merged 20 commits into from
Aug 10, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
932efb0
Update docs and core API for OptionContainer.
freakboy3742 Jun 19, 2023
58bdf2e
Core tests converted to Pytest, with 100% coverage.
freakboy3742 Jun 19, 2023
ec6176d
Cocoa implementation to 100% coverage.
freakboy3742 Jun 20, 2023
f8eb53f
GTK optioncontainer coverage to 100%.
freakboy3742 Jun 20, 2023
fbdf363
Introduce a small explicit delay to work around intermittent test fai…
freakboy3742 Jun 20, 2023
4792170
Merge branch 'audit-splitcontainer' into audit-optioncontainer
freakboy3742 Jun 20, 2023
b6d05f9
Lower the horizontal limit that identifies full width.
freakboy3742 Jun 20, 2023
53abedd
Merge branch 'audit-splitcontainer' into audit-optioncontainer
freakboy3742 Jun 22, 2023
49b81b2
Merge branch 'audit-splitcontainer' into audit-optioncontainer
freakboy3742 Jun 26, 2023
15624b5
Merge branch 'audit-splitcontainer' into audit-optioncontainer
freakboy3742 Jun 26, 2023
00b46fb
Merge branch 'main' into audit-optioncontainer
freakboy3742 Jul 17, 2023
eac7feb
Merge branch 'main' into audit-optioncontainer
freakboy3742 Jul 26, 2023
a46e14c
Merge branch 'main' into audit-optioncontainer
freakboy3742 Jul 26, 2023
7747857
Merge branch 'main' into audit-optioncontainer
freakboy3742 Aug 1, 2023
76e8887
Correct some spelling errors.
freakboy3742 Aug 1, 2023
02f7533
Merge branch 'main' into audit-optioncontainer
freakboy3742 Aug 3, 2023
125ddf2
Documentation fixes
mhsmith Aug 9, 2023
d4b6209
Winforms OptionContainer to 100%
mhsmith Aug 9, 2023
7f222a5
Add implementation of tab_enabled for cocoa.
freakboy3742 Aug 9, 2023
fc22290
Add protection against future removal of a private method.
freakboy3742 Aug 9, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/1996.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The OptionContainer widget now has 100% test coverage, and complete API documentation.
1 change: 1 addition & 0 deletions changes/1996.removal.1.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ability to increment and decrement the current OptionContainer tab was removed. Instead of `container.current_tab += 1`, use `container.current_tab = container.current_tab.index + 1`
1 change: 1 addition & 0 deletions changes/1996.removal.2.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
``OptionContainer.add()``, ``OptionContainer.remove()`` and ``OptionContainer.insert()`` have been removed, due to being ambiguous with base widget methods of the same name. Use the ``OptionContainer.content.append()``, ``OptionContainer.content.remove()`` and ``OptionContainer.content.insert()`` APIs instead.
1 change: 1 addition & 0 deletions changes/1996.removal.3.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The ``on_select`` handler for OptionContainer no longer receives the ``option`` argument providing the selected tab. Use ``current_tab`` to obtain the currently selected tab.
125 changes: 75 additions & 50 deletions cocoa/src/toga_cocoa/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
@@ -1,100 +1,117 @@
from rubicon.objc import objc_method
import warnings

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

from toga_cocoa.container import Container
from toga_cocoa.libs import NSObject, NSTabView, NSTabViewItem
from toga_cocoa.container import Container, MinimumContainer
from toga_cocoa.libs import NSTabView, NSTabViewItem

from ..libs import objc_property
from .base import Widget


class TogaTabViewDelegate(NSObject):
class TogaTabView(NSTabView):
interface = objc_property(object, weak=True)
impl = objc_property(object, weak=True)

@objc_method
def tabView_shouldSelectTabViewItem_(self, view, item) -> bool:
return view.indexOfTabViewItem(item) not in self.impl._disabled_tabs

@objc_method
def tabView_didSelectTabViewItem_(self, view, item) -> None:
# If the widget is part of a visible layout, and a resize event has
# occurred while the tab wasn't visible, the layout of *this* tab won't
# reflect the new available size. Refresh the layout.
if self.interface.window:
self.interface.refresh()
# Refresh the layout of the newly selected tab.
index = view.indexOfTabViewItem(view.selectedTabViewItem)
container = self.impl.sub_containers[index]
container.content.interface.refresh()

# Trigger any selection handler
if self.interface.on_select:
index = view.indexOfTabViewItem(view.selectedTabViewItem)
self.interface.on_select(
self.interface, option=self.interface.content[index]
)
# Notify of the change in selection.
self.interface.on_select(None)

@objc_method
def refreshContent(self) -> None:
# Refresh all the subcontainer layouts
for container in self.impl.sub_containers:
container.content.interface.refresh()


class OptionContainer(Widget):
def create(self):
self.native = NSTabView.alloc().init()
self.delegate = TogaTabViewDelegate.alloc().init()
self.delegate.interface = self.interface
self.delegate.impl = self
self.native.delegate = self.delegate
self.native = TogaTabView.alloc().init()
self.native.interface = self.interface
self.native.impl = self
self.native.delegate = self.native

# Cocoa doesn't provide an explicit (public) API for tracking
# tab enabled/disabled status; it's handled by the delegate returning
# if a specific tab should be enabled/disabled. Keep the set of
# currently disabled tabs for reference purposes.
self._disabled_tabs = set()
self.sub_containers = []

# Add the layout constraints
self.add_constraints()

def add_content(self, index, text, widget):
"""Adds a new option to the option container.
def set_bounds(self, x, y, width, height):
super().set_bounds(x, y, width, height)

Args:
index: The index in the tab list where the tab should be added.
text (str): The text for the option container
widget: The widget or widget tree that belongs to the text.
"""
container = Container(content=widget)
# Setting the bounds changes the constraints, but that doesn't mean
# the constraints have been fully applied. Schedule a refresh to be done
# as soon as possible in the future
self.native.performSelector(
SEL("refreshContent"), withObject=None, afterDelay=0
)

def add_content(self, index, text, widget):
# Establish the minimum layout
widget.interface.style.layout(widget.interface, MinimumContainer())
min_width = widget.interface.layout.width
min_height = widget.interface.layout.height

# Create the container for the widget
container = Container()
container.content = widget
container.min_width = min_width
container.min_height = min_height
self.sub_containers.insert(index, container)

# Create a NSTabViewItem for the content
item = NSTabViewItem.alloc().init()
item.label = text

# Turn the autoresizing mask on the widget widget
# into constraints. This makes the widget fill the
# available space inside the OptionContainer.
container.native.translatesAutoresizingMaskIntoConstraints = True

item.view = container.native
self.native.insertTabViewItem(item, atIndex=index)

def remove_content(self, index):
tabview = self.native.tabViewItemAtIndex(index)
if tabview == self.native.selectedTabViewItem:
# Don't allow removal of a selected tab
raise self.interface.OptionException(
"Currently selected option cannot be removed"
)

self.native.removeTabViewItem(tabview)

def set_on_select(self, handler):
pass
sub_container = self.sub_containers[index]
sub_container.content = None
del self.sub_containers[index]

def set_option_enabled(self, index, enabled):
tabview = self.native.tabViewItemAtIndex(index)
if enabled:
try:
self._disabled_tabs.remove(index)
except KeyError:
# Enabling a tab that wasn't previously disabled
pass
else:
if tabview == self.native.selectedTabViewItem:
# Don't allow disable a selected tab
raise self.interface.OptionException(
"Currently selected option cannot be disabled"
)

self._disabled_tabs.add(index)
tabview._setTabEnabled(enabled)

# This is an undocumented method, but it disables the button for the item. As an
# extra safety mechanism, the delegate will prevent the item from being selected
# by returning False for tabView:shouldSelectTabViewItem: if the item is in the
# disabled tab set. We catch the AttributeError and raise a warning in case the
# private method is ever fully deprecated; if this happens, the tab still won't
# be selectable (because of the delegate), but it won't be *visually* disabled,
# the code won't crash.
try:
tabview._setTabEnabled(enabled)
except AttributeError: # pragma: no cover
warnings.warn("Private Cocoa method _setTabEnabled: has been removed!")

def is_option_enabled(self, index):
return index not in self._disabled_tabs
Expand All @@ -114,5 +131,13 @@ def set_current_tab_index(self, current_tab_index):
self.native.selectTabViewItemAtIndex(current_tab_index)

def rehint(self):
self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH)
self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT)
# The optionContainer must be at least the size of it's largest content,
# with a hard minimum to prevent absurdly small optioncontainers.
min_width = self.interface._MIN_WIDTH
min_height = self.interface._MIN_HEIGHT
for sub_container in self.sub_containers:
min_width = max(min_width, sub_container.min_width)
min_height = max(min_height, sub_container.min_height)

self.interface.intrinsic.width = at_least(min_width)
self.interface.intrinsic.height = at_least(min_height)
49 changes: 49 additions & 0 deletions cocoa/tests_backend/widgets/optioncontainer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from rubicon.objc import SEL, send_message

from toga_cocoa.libs import NSTabView

from .base import SimpleProbe


class OptionContainerProbe(SimpleProbe):
native_class = NSTabView
disabled_tab_selectable = False

# 2023-06-20: This makes no sense, but here we are. If you render an NSTabView with
# a size constraint of (300, 200), and then ask for the frame size of the native
# widget, you get (314, 216).
#
# If you draw the widget at the origin of a window, the widget reports a frame
# origin of (-7, -6).
#
# If you draw an NSTabView the full size of a 640x480 window, the box containing the
# widget is 640x452, but the widget reports a frame of 654x458 @ (-7, -6).
#
# If you set the NSTabView to be 300x200, then draw a 300 px box below and a 200px
# box beside the NSTabView to act as rulers, the rulers are the same size as the
# NSTabView.
#
# I can't find any way to reverse engineer the magic left=7, right=7, top=6,
# bottom=10 offsets from other properties of the NSTabView. So, we'll hard code them
# and hope for the best.
LEFT_OFFSET = 7
RIGHT_OFFSET = 7
TOP_OFFSET = 6
BOTTOM_OFFSET = 10

@property
def width(self):
return self.native.frame.size.width - self.LEFT_OFFSET - self.RIGHT_OFFSET

@property
def height(self):
return self.native.frame.size.height - self.TOP_OFFSET - self.BOTTOM_OFFSET

def select_tab(self, index):
self.native.selectTabViewItemAtIndex(index)

def tab_enabled(self, index):
# _isTabEnabled() is a hidden method, so the naming messes with Rubicon's
# property lookup mechanism. Invoke it by passing the message directly.
item = self.native.tabViewItemAtIndex(index)
return send_message(item, SEL("_isTabEnabled"), restype=bool, argtypes=[])
5 changes: 0 additions & 5 deletions core/src/toga/widgets/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -266,16 +266,11 @@ def refresh(self) -> None:
# defer the refresh call to the root node.
self._root.refresh()
else:
self.refresh_sublayouts()
# We can't compute a layout until we have a viewport
if self._impl.viewport:
super().refresh(self._impl.viewport)
self._impl.viewport.refreshed()

def refresh_sublayouts(self) -> None:
for child in self.children:
child.refresh_sublayouts()

def focus(self) -> None:
"""Give this widget the input focus.

Expand Down