From 88d8b298461d591bd568e4f0bbfad883ba42a1f5 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 23 Jun 2023 14:26:17 +0800 Subject: [PATCH 01/40] Update docstrings and port Table tests to pytest. --- core/src/toga/sources/accessors.py | 63 ++- core/src/toga/widgets/table.py | 362 +++++++------ core/tests/sources/test_accessors.py | 161 +++--- core/tests/test_deprecated_factory.py | 8 - core/tests/widgets/test_table.py | 734 +++++++++++++++++++------- dummy/src/toga_dummy/widgets/table.py | 29 +- 6 files changed, 882 insertions(+), 475 deletions(-) diff --git a/core/src/toga/sources/accessors.py b/core/src/toga/sources/accessors.py index 2349aa6179..cb6d49f91b 100644 --- a/core/src/toga/sources/accessors.py +++ b/core/src/toga/sources/accessors.py @@ -4,53 +4,61 @@ WHITESPACE = re.compile(r"\s+") -def to_accessor(heading): +def to_accessor(heading: str) -> str: """Convert a human-readable heading into a data attribute accessor. - This won't be infallible; for ambiguous cases, you'll need to manually - specify the accessors. + This is done by: + + 1. converting the heading to lower case; + 2. Removing any character that can't be used in a Python identifier + 3. Replacing all whitespace with " " + 4. Prepending ``_`` if the first character is a digit. Examples: - 'Heading 1' -> 'heading_1' - 'Heading - Title' -> 'heading_title' - 'Heading!' -> 'heading' - Args: - heading (``str``): The column heading. + * 'Heading 1' -> 'heading_1' + * 'Heading - Title' -> 'heading_title' + * 'Heading!' -> 'heading' + * '1 Heading' -> '_1_heading' + * '你好' -> '你好' - Returns: - the accessor derived from the heading. + :param heading: The column heading. + :returns: The accessor derived from the heading. + :raises ValueError: If the heading cannot be converted into an accessor. """ value = WHITESPACE.sub( " ", NON_ACCESSOR_CHARS.sub("", heading.lower()), ).replace(" ", "_") - if len(value) == 0 or value[0].isdigit(): + try: + if value[0].isdigit(): + value = f"_{value}" + except IndexError: raise ValueError( - f"Unable to automatically generate accessor from heading '{heading}'." + f"Unable to automatically generate accessor from heading {heading!r}." ) return value -def build_accessors(headings, accessors): +def build_accessors( + headings: list[str], + accessors: list[str | None] | dict[str, str] | None, +) -> list[str]: """Convert a list of headings (with accessor overrides) to a finalised list of accessors. - Args: - headings: a list of strings to be used as headings - accessors: the accessor overrides. Can be: - - A list, same length as headings. Each entry is - a string providing the override name for the accessor, - or None, indicating the default accessor should be used. - - A dictionary from the heading names to the accessor. If - a heading name isn't present in the dictionary, the default - accessor will be used - - Otherwise, a final list of ready-to-use accessors. - - Returns: - A finalised list of accessors. + :param headings: The list of headings. + :param accessors: The list of accessor overrides. Can be specified as: + + * A list the same length as headings. Each entry in the list is a a string that + is the override name for the accessor, or :any:`None` if the default accessor + for the heading at that index should be used. + * A dictionary mapping heading names to accessor names. If a heading name isn't + present in the dictionary, the default accessor will be used. + + :returns: The final list of accessors. """ if accessors: if isinstance(accessors, dict): @@ -68,7 +76,4 @@ def build_accessors(headings, accessors): else: result = [to_accessor(h) for h in headings] - if len(result) != len(set(result)): - raise ValueError("Data accessors are not unique.") - return result diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 77e01daf9a..990a4b39aa 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -1,106 +1,123 @@ +from __future__ import annotations + import warnings from toga.handlers import wrapped_handler -from toga.sources import ListSource +from toga.sources import ListSource, Row from toga.sources.accessors import build_accessors, to_accessor from .base import Widget class Table(Widget): - """A Table Widget allows the display of data in the form of columns and rows. - - Args: - headings (``list`` of ``str``): The list of headings for the table. - id (str): An identifier for this widget. - data (``list`` of ``tuple``): The data to be displayed on the table. - accessors: A list of methods, same length as ``headings``, that describes - how to extract the data value for each column from the row. (Optional) - style (:obj:`Style`): An optional style object. - If no style is provided` then a new one will be created for the widget. - on_select (``callable``): A function to be invoked on selecting a row of the table. - on_double_click (``callable``): A function to be invoked on double clicking a row of - the table. - missing_value (``str`` or ``None``): value for replacing a missing value - in the data source. (Default: None). When 'None', a warning message - will be shown. - - Examples: - >>> headings = ['Head 1', 'Head 2', 'Head 3'] - >>> data = [] - >>> table = Table(headings, data=data) - - Data can be in several forms. - A list of dictionaries, where the keys match the heading names: - - >>> data = [{'head_1': 'value 1', 'head_2': 'value 2', 'head_3': 'value3'}), - >>> {'head_1': 'value 1', 'head_2': 'value 2', 'head_3': 'value3'}] - - A list of lists. These will be mapped to the headings in order: - - >>> data = [('value 1', 'value 2', 'value3'), - >>> ('value 1', 'value 2', 'value3')] - - A list of values. This is only accepted if there is a single heading. - - >>> data = ['item 1', 'item 2', 'item 3'] - """ - def __init__( self, - headings, + headings: list[str] | None = None, id=None, style=None, - data=None, - accessors=None, - multiple_select=False, - on_select=None, - on_double_click=None, - missing_value=None, - factory=None, # DEPRECATED! + data: list | ListSource | None = None, + accessors: list[str] | None = None, + multiple_select: bool = False, + on_select: callable | None = None, + on_activate: callable | None = None, + missing_value: str = "", + on_double_click=None, # DEPRECATED ): + """Create a new Selection widget. + + Inherits from :class:`~toga.widgets.base.Widget`. + + :param headings: The list of headings for the table. A value of :any:`None` + can be used to specify a table without headings. Individual headings cannot + include newline characters; any text after a newline will be ignored + :param id: The ID for the widget. + :param style: A style object. If no style is provided, a default style will be + applied to the widget. + :param data: The data to be displayed on the table. Can be a list of values or a + ListSource. See the definition of the :attr:`data` property for details on + how data can be specified and used. + :param accessors: A list of names, with same length as :attr:`headings`, that + describes the attributes of the data source that will be used to populate + each column. If unspecified, accessors will be automatically derived from + the table headings. + :param multiple_select: Does the table allow multiple selection? + :param on_select: Initial :any:`on_select` handler. + :param on_activate: Initial :any:`on_activate` handler. + :param missing_value: The string that will be used to populate a cell when a + data source doesn't provided a value for a given attribute. + :param on_double_click: **DEPRECATED**; use :attr:`on_activate`. + """ super().__init__(id=id, style=style) + ###################################################################### - # 2022-09: Backwards compatibility + # 2023-06: Backwards compatibility ###################################################################### - # factory no longer used - if factory: - warnings.warn("The factory argument is no longer used.", DeprecationWarning) + if on_double_click: + if on_activate: + raise ValueError("Cannot specify both on_double_click and on_activate") + else: + warnings.warn( + "Table.on_double_click has been renamed Table.on_activate.", + DeprecationWarning, + ) + on_activate = on_double_click ###################################################################### # End backwards compatibility. ###################################################################### - self.headings = headings[:] - self._accessors = build_accessors(self.headings, accessors) - self._multiple_select = multiple_select - self._on_select = None - self._on_double_click = None - self._data = None - if missing_value is None: - print( - "WARNING: Using empty string for missing value in data. " - "Define a 'missing_value' on the table to silence this message" + if headings is not None: + self._headings = [heading.split("\n")[0] for heading in headings] + self._accessors = build_accessors(self._headings, accessors) + elif accessors is not None: + self._headings = None + self._accessors = accessors + else: + raise ValueError( + "Cannot create a table without either headings or accessors" ) + + self._multiple_select = multiple_select + self.on_select = None + self.on_activate = None + self._missing_value = missing_value or "" self._impl = self.factory.Table(interface=self) self.data = data self.on_select = on_select - self.on_double_click = on_double_click + self.on_activate = on_activate @property - def data(self): - """The data source of the widget. It accepts table data in the form of - ``list``, ``tuple``, or :obj:`ListSource` + def data(self) -> ListSource: + """The data to display in the table, as a ListSource. + + When specifying data: + + * A ListSource will be used as-is + + * A value of None is turned into an empty ListSource. + + * A list or tuple of values will be converted into a ListSource. Each item in + the list will be converted into a Row object. + + * If the item in the list is a dictionary, the keys of the dictionary will + become the attributes of the Row. + + * All other values will be converted into a Row with attributes matching the + ``accessors`` provided at time of construction (or the ``accessors`` that + were derived from the ``headings`` that were provided at construction). + + If the value is a string, or any other a non-iterable object, the Row will + have a single attribute matching the first accessor. - Returns: - Returns a (:obj:`ListSource`). + If the value is a list, tuple, or any other iterable, values in the iterable + will be mapped in order to the accessors. """ return self._data @data.setter - def data(self, data): + def data(self, data: list | ListSource | None): if data is None: self._data = ListSource(accessors=self._accessors, data=[]) elif isinstance(data, (list, tuple)): @@ -112,36 +129,39 @@ def data(self, data): self._impl.change_source(source=self._data) @property - def multiple_select(self): + def multiple_select(self) -> bool: """Does the table allow multiple rows to be selected?""" return self._multiple_select @property - def selection(self): + def selection(self) -> list[Row] | Row | None: """The current selection of the table. - A value of None indicates no selection. + If multiple selection is enabled, returns a list of Row objects from the data + source matching the current selection. An empty list is returned if no rows are + selected. - If the table allows multiple selection, returns a list of selected data nodes. - Otherwise, returns a single data node. - - The value of a column of the selection can be accessed with - selection.accessor_name (for single selection) and with - selection[x].accessor_name (for multiple selection) + If multiple selection is *not* enabled, returns the selected Row object, or + :any:`None` if no row is currently selected. """ - return self._impl.get_selection() + try: + selection = self._impl.get_selection() + return [self.data[index] for index in selection] + except TypeError: + try: + return self.data[selection] + except TypeError: + return None def scroll_to_top(self): """Scroll the view so that the top of the list (first row) is visible.""" self.scroll_to_row(0) - def scroll_to_row(self, row): + def scroll_to_row(self, row: int): """Scroll the view so that the specified row index is visible. - Args: - row: The index of the row to make visible. Negative values refer - to the nth last row (-1 is the last row, -2 second last, - and so on) + :param row: The index of the row to make visible. Negative values refer to the + nth last row (-1 is the last row, -2 second last, and so on). """ if row >= 0: self._impl.scroll_to_row(row) @@ -153,100 +173,130 @@ def scroll_to_bottom(self): self.scroll_to_row(-1) @property - def on_select(self): - """The callback function that is invoked when a row of the table is - selected. The provided callback function has to accept two arguments - table (:obj:`Table`) and row (``Row`` or ``None``). - - The value of a column of row can be accessed with row.accessor_name - - Returns: - (``callable``) The callback function. - """ + def on_select(self) -> callable: + """The callback function that is invoked when a row of the table is selected.""" return self._on_select @on_select.setter - def on_select(self, handler): - """Set the function to be executed on node selection. - - :param handler: callback function - :type handler: ``callable`` - """ + def on_select(self, handler: callable): self._on_select = wrapped_handler(self, handler) - self._impl.set_on_select(self._on_select) @property - def on_double_click(self): - """The callback function that is invoked when a row of the table is - double clicked. The provided callback function has to accept two - arguments table (:obj:`Table`) and row (``Row`` or ``None``). - - The value of a column of row can be accessed with row.accessor_name - - Returns: - (``callable``) The callback function. - """ - return self._on_double_click - - @on_double_click.setter - def on_double_click(self, handler): - """Set the function to be executed on node double click. - - :param handler: callback function - :type handler: ``callable`` + def on_activate(self) -> callable: + """The callback function that is invoked when a row of the table is activated, + usually with a double click or similar action.""" + return self._on_activate + + @on_activate.setter + def on_activate(self, handler): + self._on_activate = wrapped_handler(self, handler) + + def add_column(self, heading: str, accessor: str | None = None): + """**DEPRECATED**: use :meth:`~toga.Table.append_column`""" + self.insert_column(len(self._accessors), heading, accessor=accessor) + + def append_column(self, heading: str, accessor: str | None = None): + """Append a column to the end of the table. + + :param heading: The heading for the new column. + :param accessor: The accessor to use on the data source when populating + the table. If not specified, an accessor will be derived from the + heading. """ - self._on_double_click = wrapped_handler(self, handler) - self._impl.set_on_double_click(self._on_double_click) + self.insert_column(len(self._accessors), heading, accessor=accessor) - def add_column(self, heading, accessor=None): - """Add a new column to the table. - - :param heading: title of the column - :type heading: ``string`` - :param accessor: accessor of this new column - :type heading: ``string`` + def insert_column( + self, + index: int, + heading: str | None, + accessor: str | None = None, + ): + """Insert an additional column into the table. + + :param index: The index at which to insert the column, or the accessor of the + column before which the column should be inserted. + :param heading: The heading for the new column. If the table doesn't have + headings, the value will be ignored. + :param accessor: The accessor to use on the data source when populating the + table. If not specified, an accessor will be derived from the heading. An + accessor *must* be specified if the table doesn't have headings. """ - - if not accessor: + if self._headings is None: + if accessor is None: + raise ValueError("Must specify an accessor on a table without headings") + heading = None + elif not accessor: accessor = to_accessor(heading) - if accessor in self._accessors: - raise ValueError(f'Accessor "{accessor}" is already in use') + if isinstance(index, str): + index = self._accessors.index(index) + else: + # Re-interpret negative indicies, and clip indicies outside valid range. + if index < 0: + index = max(len(self._accessors) + index, 0) + else: + index = min(len(self._accessors), index) - self.headings.append(heading) - self._accessors.append(accessor) + if self._headings: + self._headings.insert(index, heading) + self._accessors.insert(index, accessor) - self._impl.add_column(heading, accessor) + self._impl.insert_column(index, heading, accessor) - def remove_column(self, column): + def remove_column(self, column: int | str): """Remove a table column. - :param column: accessor or position (>0) - :type column: ``string`` - :type column: ``int`` + :param column: The index of the column to remove, or the accessor of the column + to remove. """ - if isinstance(column, str): # Column is a string; use as-is - accessor = column + index = self._accessors.index(column) else: - try: - accessor = self._accessors[column] - except IndexError: - # Column specified as an integer, but the integer is out of range. - raise ValueError(f"Column {column} does not exist") - except TypeError: - # Column specified as something other than int or str - raise ValueError("Column must be an integer or string") + if column < 0: + index = len(self._accessors) + column + else: + index = column - try: - # Remove column - self._impl.remove_column(accessor) - del self.headings[self._accessors.index(accessor)] - self._accessors.remove(accessor) - except KeyError: - raise ValueError(f'Invalid column: "{column}"') + # Remove column + del self._headings[index] + del self._accessors[index] + self._impl.remove_column(index) + + @property + def headings(self) -> list[str]: + """The column headings for the table""" + return self._headings + + @property + def accessors(self) -> list[str]: + """The accessors used to populate the table""" + return self._accessors @property - def missing_value(self): + def missing_value(self) -> str: + """The value that will be used when a data row doesn't provide an value for an + attribute. + """ return self._missing_value + + ###################################################################### + # 2023-06: Backwards compatibility + ###################################################################### + + @property + def on_double_click(self): + """**DEPRECATED**: Use ``on_activate``""" + warnings.warn( + "Table.on_double_click has been renamed Table.on_activate.", + DeprecationWarning, + ) + return self.on_activate + + @on_double_click.setter + def on_double_click(self, handler): + warnings.warn( + "Table.on_double_click has been renamed Table.on_activate.", + DeprecationWarning, + ) + self.on_activate = handler diff --git a/core/tests/sources/test_accessors.py b/core/tests/sources/test_accessors.py index 7fc689f103..1d8b25a80e 100644 --- a/core/tests/sources/test_accessors.py +++ b/core/tests/sources/test_accessors.py @@ -1,92 +1,95 @@ -from unittest import TestCase +import pytest from toga.sources.accessors import build_accessors, to_accessor -class ToAccessorTests(TestCase): - def test_simple(self): - "Simple cases can be converted" - self.assertEqual(to_accessor("hello"), "hello") - self.assertEqual(to_accessor("Hello"), "hello") - self.assertEqual(to_accessor("Hello1"), "hello1") - self.assertEqual(to_accessor("Hello 1"), "hello_1") - self.assertEqual(to_accessor("Hello world"), "hello_world") - self.assertEqual(to_accessor("Hello World"), "hello_world") - self.assertEqual(to_accessor("Hello World 1"), "hello_world_1") - self.assertEqual(to_accessor("Hello World 1!"), "hello_world_1") - self.assertEqual(to_accessor("Hello!$@# World!^&*("), "hello_world") +@pytest.mark.parametrize( + "heading, accessor", + [ + ("hello", "hello"), + ("hello", "hello"), + ("Hello", "hello"), + ("Hello1", "hello1"), + ("Hello 1", "hello_1"), + ("1 Hello", "_1_hello"), + ("Hello world", "hello_world"), + ("Hello World", "hello_world"), + ("Hello World 1", "hello_world_1"), + ("Hello World 1!", "hello_world_1"), + ("1 Hello World", "_1_hello_world"), + ("1 Hello World!", "_1_hello_world"), + ("Hello!$@# World!^&*(", "hello_world"), + (" ", "_"), + # Multiple whitespace characters are collapsed + ("Hello - World", "hello_world"), + (" ", "_"), + ], +) +def test_to_accessor(heading, accessor): + "Headings can be converted into accessors" - def test_whitespace_duplicates(self): - "Multiple whitespace characters are collapsed, after other substitutions have been performed" - self.assertEqual(to_accessor("Hello - World"), "hello_world") + assert to_accessor(heading) == accessor - def test_digit_first(self): - "A digit-first name can't be automatically generated" - with self.assertRaises(ValueError): - to_accessor("101 Dalmations") - def test_symbols_only(self): - "A symbol-only name can't be automatically generated" - with self.assertRaises(ValueError): - to_accessor("$*(!&*@&^*&^!") +@pytest.mark.parametrize("heading", ["$*(!&*@&^*&^!", ""]) +def test_to_accessor_failures(heading): + with pytest.raises( + ValueError, + match=r"Unable to automatically generate accessor from heading '.*'", + ): + to_accessor(heading) -class BuildAccessorsTests(TestCase): - def test_autoconvert(self): - "If no accessors are provided, the headings are autoconverted" - self.assertEqual( - build_accessors( - headings=["First Col", "Second Col", "Third Col"], accessors=None - ), +@pytest.mark.parametrize( + "headings, overrides, accessors", + [ + # No overrides + ( + ["First Col", "Second Col", "Third Col"], + None, ["first_col", "second_col", "third_col"], - ) - - def test_autoconvert_failure(self): - "If no accessors are provided, all the headings must be autoconvertable" - with self.assertRaises(ValueError): - build_accessors( - headings=["1st Col", "Second Col", "Third Col"], accessors=None - ) - - def test_unique_accessors(self): - "The final accessors must be unique" - with self.assertRaises(ValueError): - build_accessors(headings=["Col 1", "Col - 1", "Third Col"], accessors=None) - - def test_accessor_list_mismatch(self): - "The number of headings must match the number of accessors if specified as a list" - with self.assertRaises(ValueError): - build_accessors( - headings=["First Col", "Second Col", "Third Col"], - accessors=["first", "second"], - ) - - def test_manual_accessor_list(self): - "Accessors can be completely manually specified" - self.assertEqual( - build_accessors( - headings=["First Col", "Second Col", "Third Col"], - accessors=["first", "second", "third"], - ), + ), + # Explicitly provided accessors + ( + ["First Col", "Second Col", "Third Col"], + ["first", "second", "third"], ["first", "second", "third"], - ) + ), + # Override some accessors + ( + ["First Col", "Second Col", "Third Col"], + ["first", "second", None], + ["first", "second", "third_col"], + ), + # Override some accessors using dictionary + ( + ["First Col", "Second Col", "Third Col"], + {"First Col": "first", "Second Col": "second"}, + ["first", "second", "third_col"], + ), + ], +) +def test_build_accessors(headings, overrides, accessors): + "Accessors can be constructed from headings with overrides" + assert build_accessors(headings, overrides) == accessors - def test_partial_accessor_list(self): - "None is interpreted as a default on an accessor override list" - self.assertEqual( - build_accessors( - headings=["First Col", "2nd Col", "Third Col"], - accessors=[None, "second", None], - ), - ["first_col", "second", "third_col"], - ) - def test_accessor_dict(self): - "Accessor overrides can be specified as a dictionary" - self.assertEqual( - build_accessors( - headings=["First Col", "2nd Col", "Third Col"], - accessors={"2nd Col": "second"}, - ), - ["first_col", "second", "third_col"], - ) +@pytest.mark.parametrize( + "headings, overrides, error", + [ + ( + ["First Col", "Second Col", "Third Col"], + ["first", "second"], + r"Number of accessors must match number of headings", + ), + ( + ["!!", "Second Col", "Third Col"], + None, + r"Unable to automatically generate accessor from heading '!!'", + ), + ], +) +def test_build_accessor_failure(headings, overrides, error): + "If an accessor list can't be build, an error is raised" + with pytest.raises(ValueError, match=error): + build_accessors(headings, overrides) diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index c5fcca97b7..0e95756325 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -98,14 +98,6 @@ def test_split_container_created(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_table_created(self): - with self.assertWarns(DeprecationWarning): - widget = toga.Table( - headings=["Test"], missing_value="", factory=self.factory - ) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_tree_created(self): with self.assertWarns(DeprecationWarning): widget = toga.Tree(headings=["Test"], factory=self.factory) diff --git a/core/tests/widgets/test_table.py b/core/tests/widgets/test_table.py index b1f8d19e6c..6d818f2742 100644 --- a/core/tests/widgets/test_table.py +++ b/core/tests/widgets/test_table.py @@ -1,191 +1,545 @@ +from unittest.mock import Mock + +import pytest + import toga -from toga.sources import ListSource, Source -from toga_dummy.utils import TestCase - - -class CustomSource(Source): - pass - - -class TableTests(TestCase): - def setUp(self): - super().setUp() - - self.headings = ["Heading 1", "Heading 2", "Heading 3"] - - def select_handler(widget, row): - pass - - def double_click_handler(widget, row): - pass - - self.on_select = select_handler - self.on_double_click = double_click_handler - - self.table = toga.Table( - self.headings, - on_select=self.on_select, - on_double_click=self.on_double_click, - ) - - def test_widget_created(self): - self.assertEqual(self.table._impl.interface, self.table) - self.assertActionPerformed(self.table, "create Table") - - self.assertEqual(self.table.headings, self.headings) - self.assertIsInstance(self.table.data, ListSource) - - def test_list_of_lists_data_source(self): - self.table.data = [ - ["a1", "b1", "c1"], - ["a2", "b2", "c2"], - ] - - self.assertIsInstance(self.table.data, ListSource) - - def test_custom_data_source(self): - data_source = CustomSource() - self.table.data = data_source - self.assertIs(self.table.data, data_source) - - def test_nothing_selected(self): - self.assertEqual(self.table.selection, None) - - def test_scroll_to_row(self): - self.table.data = [ - ["a1", "b1", "c1"], - ["a2", "b2", "c2"], - ["a3", "b3", "c3"], - ["a4", "b3", "c4"], - ] - self.table.scroll_to_row(2) - self.assertValueSet(self.table, "scroll to", 2) - - def test_scroll_to_top(self): - self.table.data = [ - ["a1", "b1", "c1"], - ["a2", "b2", "c2"], - ["a3", "b3", "c3"], - ["a4", "b3", "c4"], - ] - self.table.scroll_to_top() - self.assertValueSet(self.table, "scroll to", 0) - - def test_scroll_to_bottom(self): - self.table.data = [ - ["a1", "b1", "c1"], - ["a2", "b2", "c2"], - ["a3", "b3", "c3"], - ["a4", "b3", "c4"], - ] - self.table.scroll_to_bottom() - self.assertValueSet(self.table, "scroll to", len(self.table.data) - 1) - - def test_multiple_select(self): - self.assertEqual(self.table.multiple_select, False) - secondtable = toga.Table( - self.headings, - multiple_select=True, - ) - self.assertEqual(secondtable.multiple_select, True) - - def test_on_select(self): - def dummy_handler(widget, row): - pass - - self.assertValueSet(self.table, "on_select", self.table.on_select) - - on_select = self.table.on_select - self.assertEqual(on_select._raw, self.on_select) - - self.table.on_select = dummy_handler - on_select = self.table.on_select - self.assertEqual(on_select._raw, dummy_handler) - - def test_on_double_click(self): - def dummy_handler(widget, row): - pass - - self.assertValueSet(self.table, "on_double_click", self.table.on_double_click) - - on_double_click = self.table.on_double_click - self.assertEqual(on_double_click._raw, self.on_double_click) - - self.table.on_double_click = dummy_handler - on_double_click = self.table.on_double_click - self.assertEqual(on_double_click._raw, dummy_handler) - - def test_add_column(self): - new_heading = "Heading 4" - dummy_data = [ - ["a1", "b1", "c1"], - ["a2", "b2", "c2"], - ["a3", "b3", "c3"], - ["a4", "b3", "c4"], - ] - self.table.data = dummy_data - - expecting_headings = self.headings + [new_heading] - self.table.add_column(new_heading) - - self.assertEqual(self.table.headings, expecting_headings) - - def test_add_columns_accessor_in_use(self): - new_heading = "Heading 4" - accessor = "heading_2" - - with self.assertRaises(ValueError): - self.table.add_column(new_heading, accessor) - - def test_remove_column_by_accessor(self): - remove = "heading_2" - dummy_data = [ - ["a1", "b1", "c1"], - ] - self.table.data = dummy_data - - expecting_accessors = [h for h in self.table._accessors if h != remove] - self.table.remove_column(remove) - self.assertEqual(self.table._accessors, expecting_accessors) - - def test_remove_column_by_position(self): - remove = 2 - dummy_data = [ - ["a1", "b1", "c1"], - ] - self.table.data = dummy_data - - heading = self.table.headings[remove] - expecting_headings = [h for h in self.table.headings if h != heading] - self.table.remove_column(remove) - self.assertEqual(self.table.headings, expecting_headings) - - def test_remove_column_invalid_name(self): - dummy_data = [ - ["a1", "b1", "c1"], - ] - self.table.data = dummy_data - - # Remove a column that doesn't exist - with self.assertRaises(ValueError): - self.table.remove_column("Not a column") - - def test_remove_column_invalid_index(self): - dummy_data = [ - ["a1", "b1", "c1"], - ] - self.table.data = dummy_data - - # Remove a column using an index that doesn't exist - with self.assertRaises(ValueError): - self.table.remove_column(42) - - def test_remove_column_invalid_type(self): - dummy_data = [ - ["a1", "b1", "c1"], - ] - self.table.data = dummy_data - - # Remove a column using a data type that isn't valid - with self.assertRaises(ValueError): - self.table.remove_column(3.14159) +from toga.sources import ListSource +from toga_dummy.utils import ( + assert_action_performed, + assert_action_performed_with, +) + + +@pytest.fixture +def on_select_handler(): + return Mock() + + +@pytest.fixture +def on_activate_handler(): + return Mock() + + +@pytest.fixture +def source(): + return ListSource( + accessors=["key", "value"], + data=[ + {"key": "first", "value": 111, "other": "aaa"}, + {"key": "second", "value": 222, "other": "bbb"}, + {"key": "third", "value": 333, "other": "ccc"}, + ], + ) + + +@pytest.fixture +def table(source, on_select_handler, on_activate_handler): + return toga.Table( + ["Title", "Value"], + accessors=["key", "value"], + data=source, + on_select=on_select_handler, + on_activate=on_activate_handler, + ) + + +def test_table_created(): + "An minimal Table can be created" + table = toga.Table(["First", "Second"]) + assert table._impl.interface == table + assert_action_performed(table, "create Table") + + assert len(table.data) == 0 + assert table.headings == ["First", "Second"] + assert table.accessors == ["first", "second"] + assert not table.multiple_select + assert table.missing_value == "" + assert table.on_select._raw is None + assert table.on_activate._raw is None + + +def test_create_with_values(source, on_select_handler, on_activate_handler): + "A Table can be created with initial values" + table = toga.Table( + ["First", "Second"], + data=source, + accessors=["primus", "secondus"], + multiple_select=True, + on_select=on_select_handler, + on_activate=on_activate_handler, + missing_value="Boo!", + ) + assert table._impl.interface == table + assert_action_performed(table, "create Table") + + assert len(table.data) == 3 + assert table.headings == ["First", "Second"] + assert table.accessors == ["primus", "secondus"] + assert table.multiple_select + assert table.missing_value == "Boo!" + assert table.on_select._raw == on_select_handler + assert table.on_activate._raw == on_activate_handler + + +def test_create_with_acessor_overrides(): + "A Table can partially override accessors" + table = toga.Table( + ["First", "Second"], + accessors={"First": "override"}, + ) + assert table._impl.interface == table + assert_action_performed(table, "create Table") + + assert len(table.data) == 0 + assert table.headings == ["First", "Second"] + assert table.accessors == ["override", "second"] + + +def test_create_no_headings(): + "A Table can be created with no headings" + table = toga.Table( + headings=None, + accessors=["primus", "secondus"], + ) + assert table._impl.interface == table + assert_action_performed(table, "create Table") + + assert len(table.data) == 0 + assert table.headings is None + assert table.accessors == ["primus", "secondus"] + + +def test_create_headings_required(): + "A Table requires either headingscan be created with no headings" + with pytest.raises( + ValueError, + match=r"Cannot create a table without either headings or accessors", + ): + toga.Table() + + +def test_set_data_list(table, on_select_handler): + "Data can be set from a list of lists" + + # The selection hasn't changed yet. + on_select_handler.assert_not_called() + + # Change the data + table.data = [ + ["Alice", 123, "extra1"], + ["Bob", 234, "extra2"], + ["Charlie", 345, "extra3"], + ] + + # This triggered the select handler + on_select_handler.assert_called_once_with(table) + + # A ListSource has been constructed + assert isinstance(table.data, ListSource) + assert len(table.data) == 3 + + # The accessors are mapped in order. + assert table.data[1].key == "Bob" + assert table.data[1].value == 234 + + +def test_set_data_tuple(table, on_select_handler): + "Data can be set from a list of tuples" + + # The selection hasn't changed yet. + on_select_handler.assert_not_called() + + # Change the data + table.data = [ + ("Alice", 123, "extra1"), + ("Bob", 234, "extra2"), + ("Charlie", 345, "extra3"), + ] + + # This triggered the select handler + on_select_handler.assert_called_once_with(table) + + # A ListSource has been constructed + assert isinstance(table.data, ListSource) + assert len(table.data) == 3 + + # The accessors are mapped in order. + assert table.data[1].key == "Bob" + assert table.data[1].value == 234 + + +def test_set_data_dict(table, on_select_handler): + "Data can be set from a list of dicts" + + # The selection hasn't changed yet. + on_select_handler.assert_not_called() + + # Change the data + table.data = [ + {"key": "Alice", "value": 123, "extra": "extra1"}, + {"key": "Bob", "value": 234, "extra": "extra2"}, + {"key": "Charlie", "value": 345, "extra": "extra3"}, + ] + + # This triggered the select handler + on_select_handler.assert_called_once_with(table) + + # A ListSource has been constructed + assert isinstance(table.data, ListSource) + assert len(table.data) == 3 + + # The accessors are all available + assert table.data[1].key == "Bob" + assert table.data[1].value == 234 + assert table.data[1].extra == "extra2" + + +def test_set_data_other(table, on_select_handler): + "Data can be set from a list of values" + + # The selection hasn't changed yet. + on_select_handler.assert_not_called() + + # Change the data + table.data = [ + "Alice", + 1234, + "other", + ] + + # This triggered the select handler + on_select_handler.assert_called_once_with(table) + + # A ListSource has been constructed + assert isinstance(table.data, ListSource) + assert len(table.data) == 3 + + # The values are mapped to the first accessor. + assert table.data[0].key == "Alice" + assert table.data[1].key == 1234 + assert table.data[2].key == "other" + + +def test_single_selection(table, on_select_handler): + "The current selection can be retrieved" + # Selection is initially empty + assert table.selection is None + on_select_handler.assert_not_called() + + # Select an item + table._impl.simulate_selection(1) + + # Selection returns a single row + assert table.selection == table.data[1] + + # Selection handler was triggered + on_select_handler.assert_called_once_with(table) + + +def test_multiple_selection(source, on_select_handler): + "A multi-select table can have the selection retrieved" + table = toga.Table( + ["Title", "Value"], + data=source, + multiple_select=True, + on_select=on_select_handler, + ) + # Selection is initially empty + assert table.selection == [] + on_select_handler.assert_not_called() + + # Select an item + table._impl.simulate_selection([0, 2]) + + # Selection returns a list of rows + assert table.selection == [table.data[0], table.data[2]] + + # Selection handler was triggered + on_select_handler.assert_called_once_with(table) + + +def test_scroll_to_top(table): + "A table can be scrolled to the top" + table.scroll_to_top() + + assert_action_performed_with(table, "scroll to row", row=0) + + +def test_scroll_to_row(table): + "A table can be scrolled to a specific row" + table.scroll_to_row(1) + + assert_action_performed_with(table, "scroll to row", row=1) + + +def test_scroll_to_row_negative(table): + "A table can be scrolled to a specific row with a negative index" + table.scroll_to_row(-1) + + assert_action_performed_with(table, "scroll to row", row=2) + + +def test_scroll_to_bottom(table): + "A table can be scrolled to the top" + table.scroll_to_bottom() + + assert_action_performed_with(table, "scroll to row", row=2) + + +def test_insert_column_accessor(table): + """A column can be inserted at an accessor""" + table.insert_column("value", "New Column", accessor="extra") + + # The column was added + assert_action_performed_with( + table, + "insert column", + index=1, + heading="New Column", + accessor="extra", + ) + assert table.headings == ["Title", "New Column", "Value"] + assert table.accessors == ["key", "extra", "value"] + + +def test_insert_column_unknown_accessor(table): + """If the insertion index accessor is unknown, an error is raised""" + with pytest.raises(ValueError, match=r"'unknown' is not in list"): + table.insert_column("unknown", "New Column", accessor="extra") + + +def test_insert_column_index(table): + """A column can be inserted""" + + table.insert_column(1, "New Column", accessor="extra") + + # The column was added + assert_action_performed_with( + table, + "insert column", + index=1, + heading="New Column", + accessor="extra", + ) + assert table.headings == ["Title", "New Column", "Value"] + assert table.accessors == ["key", "extra", "value"] + + +def test_insert_column_big_index(table): + """A column can be inserted at an index bigger than the number of columns""" + + table.insert_column(100, "New Column", accessor="extra") + + # The column was added + assert_action_performed_with( + table, + "insert column", + index=2, + heading="New Column", + accessor="extra", + ) + assert table.headings == ["Title", "Value", "New Column"] + assert table.accessors == ["key", "value", "extra"] + + +def test_insert_column_negative_index(table): + """A column can be inserted at a negative index""" + + table.insert_column(-2, "New Column", accessor="extra") + + # The column was added + assert_action_performed_with( + table, + "insert column", + index=0, + heading="New Column", + accessor="extra", + ) + assert table.headings == ["New Column", "Title", "Value"] + assert table.accessors == ["extra", "key", "value"] + + +def test_insert_column_big_negative_index(table): + """A column can be inserted at a negative index larger than the number of columns""" + + table.insert_column(-100, "New Column", accessor="extra") + + # The column was added + assert_action_performed_with( + table, + "insert column", + index=0, + heading="New Column", + accessor="extra", + ) + assert table.headings == ["New Column", "Title", "Value"] + assert table.accessors == ["extra", "key", "value"] + + +def test_insert_column_no_accessor(table): + """A column can be inserted with a default accessor""" + + table.insert_column(1, "New Column") + + # The column was added + assert_action_performed_with( + table, + "insert column", + index=1, + heading="New Column", + accessor="new_column", + ) + assert table.headings == ["Title", "New Column", "Value"] + assert table.accessors == ["key", "new_column", "value"] + + +def test_insert_column_no_headings(source): + """A column can be inserted into a table with no headings""" + table = toga.Table(headings=None, accessors=["key", "value"], data=source) + + table.insert_column(1, "New Column", accessor="extra") + + # The column was added + assert_action_performed_with( + table, + "insert column", + index=1, + heading=None, + accessor="extra", + ) + assert table.headings is None + assert table.accessors == ["key", "extra", "value"] + + +def test_insert_column_no_headings_missing_accessor(source): + """An accessor is mandatory when adding a column to a table with no headings""" + table = toga.Table(headings=None, accessors=["key", "value"], data=source) + + with pytest.raises( + ValueError, + match=r"Must specify an accessor on a table without headings", + ): + table.insert_column(1, "New Column") + + +def test_append_column(table): + """A column can be appended""" + table.append_column("New Column", accessor="extra") + + # The column was added + assert_action_performed_with( + table, + "insert column", + index=2, + heading="New Column", + accessor="extra", + ) + assert table.headings == ["Title", "Value", "New Column"] + assert table.accessors == ["key", "value", "extra"] + + +def test_remove_column_accessor(table): + "A column can be removed by accessor" + + table.remove_column("value") + + # The column was removed + assert_action_performed_with( + table, + "remove column", + index=1, + ) + assert table.headings == ["Title"] + assert table.accessors == ["key"] + + +def test_remove_column_unknown_accessor(table): + "If the column named for removal doesn't exist, an error is raised" + with pytest.raises(ValueError, match=r"'unknown' is not in list"): + table.remove_column("unknown") + + +def test_remove_column_invalid_index(table): + "If the index specified doesn't exist, an error is raised" + with pytest.raises(IndexError, match=r"list assignment index out of range"): + table.remove_column(100) + + +def test_remove_column_index(table): + "A column can be removed by index" + + table.remove_column(1) + + # The column was removed + assert_action_performed_with( + table, + "remove column", + index=1, + ) + assert table.headings == ["Title"] + assert table.accessors == ["key"] + + +def test_remove_column_negative_index(table): + "A column can be removed by index" + + table.remove_column(-2) + + # The column was removed + assert_action_performed_with( + table, + "remove column", + index=0, + ) + assert table.headings == ["Value"] + assert table.accessors == ["value"] + + +def test_deprecated_names(on_activate_handler): + "Deprecated names still work" + + # Can't specify both on_double_click and on_activate + with pytest.raises( + ValueError, + match=r"Cannot specify both on_double_click and on_activate", + ): + toga.Table(["First", "Second"], on_double_click=Mock(), on_activate=Mock()) + + # on_double_click is redirected at construction + with pytest.warns( + DeprecationWarning, + match="Table.on_double_click has been renamed Table.on_activate", + ): + table = toga.Table(["First", "Second"], on_double_click=on_activate_handler) + + # on_double_click accessor is redirected to on_activate + with pytest.warns( + DeprecationWarning, + match="Table.on_double_click has been renamed Table.on_activate", + ): + assert table.on_double_click._raw == on_activate_handler + + assert table.on_activate._raw == on_activate_handler + + # on_double_click mutator is redirected to on_activate + new_handler = Mock() + with pytest.warns( + DeprecationWarning, + match="Table.on_double_click has been renamed Table.on_activate", + ): + table.on_double_click = new_handler + + assert table.on_activate._raw == new_handler + + # add_column redirects to insert + table.add_column("New Column", "new_accessor") + + assert_action_performed_with( + table, + "insert column", + index=2, + heading="New Column", + accessor="new_accessor", + ) + assert table.headings == ["First", "Second", "New Column"] + assert table.accessors == ["first", "second", "new_accessor"] diff --git a/dummy/src/toga_dummy/widgets/table.py b/dummy/src/toga_dummy/widgets/table.py index f31806358f..a4deef6284 100644 --- a/dummy/src/toga_dummy/widgets/table.py +++ b/dummy/src/toga_dummy/widgets/table.py @@ -1,12 +1,15 @@ +from ..utils import not_required from .base import Widget +@not_required # Testbed coverage is complete for this widget. class Table(Widget): def create(self): self._action("create Table") def change_source(self, source): self._action("change source", source=source) + self.interface.on_select(None) def insert(self, index, item): self._action("insert row", index=index, item=item) @@ -21,20 +24,20 @@ def clear(self): self._action("clear") def get_selection(self): - self._action("get selection") - return None - - def set_on_select(self, handler): - self._set_value("on_select", handler) - - def set_on_double_click(self, handler): - self._set_value("on_double_click", handler) + return self._get_value( + "selection", + [] if self.interface.multiple_select else None, + ) def scroll_to_row(self, row): - self._set_value("scroll to", row) + self._action("scroll to row", row=row) + + def insert_column(self, index, heading, accessor): + self._action("insert column", index=index, heading=heading, accessor=accessor) - def add_column(self, heading, accessor): - self._action("add column", heading=heading, accessor=accessor) + def remove_column(self, index): + self._action("remove column", index=index) - def remove_column(self, accessor): - self._action("remove column", accessor=accessor) + def simulate_selection(self, selection): + self._set_value("selection", selection) + self.interface.on_select(None) From de52103d76c1f8ffd0ddfe4969336bcd75ee021b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 23 Jun 2023 14:55:24 +0800 Subject: [PATCH 02/40] Add docs for Table. --- core/src/toga/sources/accessors.py | 4 +- docs/reference/api/index.rst | 2 +- docs/reference/api/widgets/table.rst | 103 +++++++++++++++----- docs/reference/data/widgets_by_platform.csv | 2 +- docs/reference/images/Table.jpeg | Bin 90327 -> 0 bytes docs/reference/images/Table.png | Bin 0 -> 73001 bytes docs/reference/images/Table1.jpeg | Bin 14374 -> 0 bytes 7 files changed, 82 insertions(+), 29 deletions(-) delete mode 100644 docs/reference/images/Table.jpeg create mode 100644 docs/reference/images/Table.png delete mode 100644 docs/reference/images/Table1.jpeg diff --git a/core/src/toga/sources/accessors.py b/core/src/toga/sources/accessors.py index cb6d49f91b..c6e2571906 100644 --- a/core/src/toga/sources/accessors.py +++ b/core/src/toga/sources/accessors.py @@ -9,9 +9,9 @@ def to_accessor(heading: str) -> str: This is done by: - 1. converting the heading to lower case; + 1. Converting the heading to lower case; 2. Removing any character that can't be used in a Python identifier - 3. Replacing all whitespace with " " + 3. Replacing all whitespace with "_" 4. Prepending ``_`` if the first character is a digit. Examples: diff --git a/docs/reference/api/index.rst b/docs/reference/api/index.rst index 0cc1dbad10..b846ddb34f 100644 --- a/docs/reference/api/index.rst +++ b/docs/reference/api/index.rst @@ -44,7 +44,7 @@ General widgets horizontal line, and the selected value is shown as a draggable marker. :doc:`Switch ` A clickable button with two stable states: True (on, checked); and False (off, unchecked). The button has a text label. - :doc:`Table ` Table of data + :doc:`Table ` A widget for displaying columns of tabular data. :doc:`TextInput ` A widget for the display and editing of a single line of text. :doc:`TimeInput ` A widget to select a clock time :doc:`Tree ` Tree of data diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index dc1cd3e433..d0fff77ecb 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -1,6 +1,12 @@ Table ===== +A widget for displaying columns of tabular data. + +.. figure:: /reference/images/Table.png + :width: 300px + :align: center + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,48 +14,95 @@ Table :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!^(Table|Component)$)'} -The table widget is a widget for displaying tabular data. It can be instantiated with the list of headings and then data rows -can be added. - -.. figure:: /reference/images/Table1.jpeg - :align: center - Usage ----- +A Table uses a :class:`~toga.sources.ListSource` to manage the data being displayed. +options. If ``data`` is not specified as a ListSource, it will be converted into a +ListSource at runtime. + +The simplest instantiation of a Table is to use a list of lists (or list of tuples), +containing the items to display in the table. When creating the table, you must also +specify the headings to use on the table; those headings will be converted into +accessors on the Row data objects created for the table data. In this example, +we will display a table of 2 columns, with 3 initial rows of data: + .. code-block:: python import toga - table = toga.Table(['Heading 1', 'Heading 2']) + table = toga.Table( + headings=["Name", "Age"], + data=[ + ("Arthur Dent", 42), + ("Ford Prefect", 37), + ("Tricia McMillan", 38), + ] + ) - # Append to end of table - table.data.append('Value 1', 'Value 2') + # Get the details of the first item in the data: + print(f"{table.data[0].name} is age {table.data[0].age}") - # Insert to row 2 - table.data.insert(2, 'Value 1', 'Value 2') + # Append new data to the table + table.data.append(("Zaphod Beeblebrox", 47)) - Examples: - >>> headings = ['Head 1', 'Head 2', 'Head 3'] - >>> data = [] - >>> table = Table(headings, data=data) +You can also specify data for a Table using a list of dictionaries. This allows to to +store data in the data source that won't be displayed in the table. It also allows you +to control the display order of columns independent of the storage of that data. - Data can be in several forms. A list of dictionaries, where the keys match - the heading names: +.. code-block:: python - >>> data = [{'head_1': 'value 1', 'head_2': 'value 2', 'head_3': 'value3'}), - >>> {'head_1': 'value 1', 'head_2': 'value 2', 'head_3': 'value3'}] + import toga - A list of lists. These will be mapped to the headings in order: + table = toga.Table( + headings=["Name", "Age"], + data=[ + {"name": "Arthur Dent", "age": 42, "planet": "Earth"}, + {"name", "Ford Prefect", "age": 37, "planet": "Betelgeuse Five"}, + {"name": "Tricia McMillan", "age": 38, "plaent": "Earth"}, + ] + ) - >>> data = [('value 1', 'value 2', 'value3'), - >>> ('value 1', 'value 2', 'value3')] + # Get the details of the first item in the data: + print(f"{table.data[0].name}, who is age {table.data[0].age}, is from {table.data[0].planet}") - A list of values. This is only accepted if there is a single heading. +The attribute names used on each row of data (called "accessors") are created automatically from +the name of the headings that you provide. This is done by: - >>> data = ['item 1', 'item 2', 'item 3'] - """ +1. Converting the heading to lower case; +2. Removing any character that can't be used in a Python identifier; +3. Replacing all whitespace with "_"; +4. Prepending ``_`` if the first character is a digit. + +If you want to use different accessors to the ones that are automatically generated, you +can override them by providing an ``accessors`` argument. This can be either: + +* A list of the same size as the list of headings, specifying the accessors for each + heading. A value of :any:`None` will fall back to the default generated accessor; or +* A dictionary mapping heading names to accessor names. + +In this example, the table will use "Name" as the visible header, but internally, the +attribute "character" will be used: + +.. code-block:: python + + import toga + table = toga.Table( + headings=["Name", "Age"], + accessors={"Name", 'character'}, + data=[ + {"character": "Arthur Dent", "age": 42, "planet": "Earth"}, + {"character", "Ford Prefect", "age": 37, "planet": "Betelgeuse Five"}, + {"name": "Tricia McMillan", "age": 38, "plaent": "Earth"}, + ] + ) + + # Get the details of the first item in the data: + print(f"{table.data[0].character}, who is age {table.data[0].age}, is from {table.data[0].planet}") + +You can also create a table *without* a heading row. However, if you do this, you *must* +specify accessors. Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 4e1a640e29..ae0a7a0802 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -17,7 +17,7 @@ ProgressBar,General Widget,:class:`~toga.ProgressBar`,Progress Bar,|y|,|y|,|y|,| Selection,General Widget,:class:`~toga.Selection`,A widget to select a single option from a list of alternatives.,|y|,|y|,|y|,|y|,|y|, Slider,General Widget,:class:`~toga.Slider`,Slider,|y|,|y|,|y|,|y|,|y|, Switch,General Widget,:class:`~toga.Switch`,Switch,|y|,|y|,|y|,|y|,|y|,|b| -Table,General Widget,:class:`~toga.Table`,Table of data,|b|,|b|,|b|,,|b|, +Table,General Widget,:class:`~toga.Table`,A widget for displaying columns of tabular data.,|b|,|b|,|b|,,|b|, TextInput,General Widget,:class:`~toga.TextInput`,A widget for the display and editing of a single line of text.,|y|,|y|,|y|,|y|,|y|,|b| TimeInput,General Widget,:class:`~toga.TimeInput`,A widget to select a clock time,,,|y|,,|y|, Tree,General Widget,:class:`~toga.Tree`,Tree of data,|b|,|b|,|b|,,, diff --git a/docs/reference/images/Table.jpeg b/docs/reference/images/Table.jpeg deleted file mode 100644 index 21eeca3ab5d55fd2428ed968ebdf78cf3c14b3d7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 90327 zcmeFZ2UwHaw>KE1gNStLDj;2ys#2f4{l&o%202_nx`WGtbO3cf(FzlDDk2_g-uLR@oeVJ;H-dni`oH zffyJ-py$93=x7m?X$bT70D;WSK?)!chz-QVAOd0rj(}4@`V6A~er&`b2V(r!=f^;x zcyAEX|2W42c>l8jcl*~h|Mt$983$qoKH&vA?Det#{p@3=uNnXScpCT)WMFgE!~}S^ zxgY56?jPg@4L;fdUAhUpLxDdYo>n`50d(n#i8<4s&jZ@Oj%EIJcydxaWi}nu$h7g8 z{lzx0_ph3}->dco-OY7>@ctk^o)I4F78Xx(s+>IL64t%)-jX&cO+s z(0mefjDeBy7!xBi^Pi7lcm#Y8V&Y-uJ#*23h0p3P>)8N))h8Km*rcvBbPHII5v0}b z1;(&*2nwAN7Lhq8D<`j@uA!-Q>9Y3KYlcR~*G)`qZ0+nFZaF&LcXRjf^z!xz3JwVk zgM~*teHI%RpOBc8nU$TBoA=^nesM`@S$RceRdr(%s<{RIuC=YFx37O-@War@@rlW) z>6x#y*u|ygmDRQN?;D%M{evGrNr&WLzyIWm0mS%kYyq$T#@XNa;sN+N#>B+P#QG;+ z49CL$WSobI`OHNYUIQ!Ey8(P>RiCi&U&(mW(9JHTW=#;d7dXZtD6PIIL;RDqe{uGo zV=U(X7H9v?*#E)T9Ox>rJpXLRfM3RA$AGC~1R4{-0??RQnE%yS{zqf|S7ZCLvHyEJ z0&eoJI~+U41pKnIFthyocmL^wqj_MV${b-q#~B%b#l*-10)yzEqfbyTz9r7|rt0)N zE!0#c*C2J^c>&GQCWR~vHnYYNKSs$nT}6&SugmL>Ku@k7ff8!LF+zDFVcUzol}?(o zcUrnKL_v$W-U*jY~ejIgc%yM29(MnNi~!t zyzbCA0vVV)AAyd=AAxuvBGl8gH(eKC2s#IX7(@!12}uWc7YP$SXHpDlhIs?a7g2m~lww$I`XX1m!IeXQ-u@4%XoSlY( z7}ZDl;UP{&s~?2*esjDtB3YV!wE8u`0VRa~Jm+LM@*o>RXVy0)Z+(B13h>F<3;Dr6 zg(1kniTr;Z(kPoH3|Z+2MCFU`#+jd-`d>PlHnfb<$jYmB$i;>rv3cm$LptK zPAJ^nyNWfo!@ahg&yz-PhqF@URJh<@Z0wIf@82AO`mFv+oWO4IP1d--Qux12y6d_j z2s>MkMBb6P#@BY_0?VA-IiUv^dUHV$kPfh_BTHIvcwd5 zC{zopME82ItZL5JajQtVwC_WosVa>B?H<;MH*Gx7^;K01E+=RD@aGp;?Dne?m8Mg} zGX>Z33V+ZZJZku>p#0aR_>mqFNBTy~gL@#^$-aXaVO>XPxtFU-#{KaSwtGeU*!8G; z?jgD3mu-#4o)4cn!*-daqA*Yaedv<&+ZT;vqm$Mx!!xh0jXvmcOU24d!G@3Djc0v_9NWXz zkXC3f!1qREEvDdJ2)@DMmip~n&4yl$sY%Mk&P>kWj6Uy-HX{axGhzOB*gs`E=jP8H zfn-uJ0@5e+_mif#3XVYMvf-DSzQ7j^V7~O2d^DYzn|}6xp&2=x{uF^m#2kTq_Q>+_ zPj|qcU~%(0^0DEmcXAZUH82|e| z|L1o2|Jm_DW>9(|$D(7EA5jmAeH*NAv}Y7qvpN0tFo`7FC} zDA{ebMebxLWWQyit1!Yg&zh?O771|{*H$@8hZ!VsHw~fQ{V{qV)+(E_gn5IT#g5}{ zlKIMRxzv*@%qr)@h3(&E^9<@H;mCXh(M99!F+#5eS&d+3O79~~XM6ZMgY_d3O5aQm zhADF|WpdEhw{dE(JQ+a@Rz@6ZRa#SqDp^_Zd~wIiMm#Q}(6w6lB?Kp($rsav+=-_+ zQ{~8JNOFAN?|x)Y#`(fZUkAAVR9Sn`ki?nwY9q&-54Ya7g5-8w9Vv2TDN-^y765?7 zSv_46#KG6xt6q{#xb7nm zC-uS15y-2P)A%H8w2u_}JG!Fs*Fi%Oc2X`@H}`o@!4;&uNWy~Ls&3hH|J4d= zvrd0=Bt-qycy;x}_-Ei^&@)I$hdR!<5v|dd1xqu4LHY5cu@*CFFIYTl9qj(m1VoiTr3?Jfus+`>LhjuY<11;3_Io zMQjh3`fmLa@+(q;{u#_fKj#e79v%>3Qgfz)CwDZ`xvw$~`wGOaCh@nxmRn$7N=;2` zEku;=%fBB$#CI`M6$lmmIG!lj7xg#LC5)i6lamj6A$Ww8@W8gN?M41ojtAC8R{Urp z-xG&4*SVKkMAOnPo6^^)pANn)^C<*1(uJg!kNE)FQjo`VJL>I5UyAN^n%TAzqVia% zEnQX*Sd{Ak!-|yCAS!TD6&ESC(Byz?!lY^YJ)Q8BJ8Lix0$|#1!D3=Cm~dk+c54}| zwZk&~^Yy_Ylp>6Ir-_LyEV39?X&HI9EYProBTEeeNODk;*OQD~l8m!Yt)9P~JTxxa zJ{NiU9BY6_W89+h+rd18Q^uFSRVX9I#T8A*e<+`yyY^+LxT&!z@iZF!MDS8^k_Jbj zh#W_CHOLMGVqp#*<{4GUaW6rfFDM%xX*vR-Yf<*syRBDawXLvX*q>o7N1%{}wst)w zNO6+li?4FUNuYn)m#CgcpbH2J8?X$U99HwFf_c3WX*m>iSPBqj>uQn$th=x^)J22D~wZ2)s zbU93zs;s`#neRRKdDK$vS1Mt&Tl5GtCq!q*P)wu0ewq7}f7qAK>k32nj$au06e=N@ zllsgoWy$dR?j2D}Vt|YBcKk9zjE>Eh-2sp`^D#nU*(dGKCHP9}nMITpJ%ekiK1sGS zO}rG}iOVGJT$YKCpr_D%ad6vY>#Z-yOS?n#AOxx?XGCz*0>89Ok z67=-&kj^yG_!K<~ZgS|BoHbkgPPQX3Ua{o%0(<8Y=Aq*Qy|POKG} zNzk$SGC8NZOHt=JE?|s@Kdek0J~YvZ;K=c=uqgcr#1A@Vw6q=2CN)TM@Db=N{BE5u zNiLbrRv}Cj%fCd(c9ra~6dY8^!%%GFC|5q0{)*cuoh99Y!wlkwmWPoeJFGqEE@c|v z8s5RNQSUU0nZ%Ck&o?_Ew$ zRHHf&xqB6jru^#-S2nojcRF8XF}`bZy!tTr>;2)^DN?U?ZE{Cv;3wf8&?j(yPr_}w z2H}U6Bmt?xreS%aRMp&9Ja(#*lhatdeIO+xmf?Pg7lC!SKV! ztp8!VnPFL8`Gt5H86zFJNt2)c0jmzw)q13}S9CPI`_2x&nXE!6$4&`enpi@_+S&cV z0m{7zVtfq3JEV|vp`clKcht5A`I}#+iN07**YQYN@x@3|%L(P=08$LbXO4PHiB%?u z!cL6GLwYdj`I>o)N1$YQA{7}0`v;+hN^n5jj%^c>*Fr1+AzU!!wbgm7YaxhIj+mtrYVR`u2pna z$VZi#U)^(R-q)&KR$p#hNs0C;tvJ*vJER_g&i^n&#GoMrNUP+cnUj7c=ESZ;U#i>9 zs>#^#J2!0V-lO-gy<8V%SP`LZAd~3F;N3cy`{3F5{uT*6vH(63%Dv?j^6dA6u!h?% zCW>?4KFH*FsD4@sRVv{%6(9TKn!75yX=uMxo7bF)Ah~S zjXxxh#^|$f&txdS2_ek*k@o(yoF!JXHg{Bt`$wW$F5he2BT%EB-e~iq^a0$DlK_l= ze;NEsp<{yNa|FtiF9yVUl%758y77k?`YY8B2ySpnQW>*=?48wnYOnL)(jvgZjCbP-{ zkl4Y-5EO{JPejw0l;DIDmUPpS?PyIPtP`6~XBavH4cUE!Ni8G0B@??8r>U$2+<>-Z zg2w3a#`1;=@3z?H`Gum-yGiJWSEWoCJkt!18QyNZuvsBdWr3CbCeq}+o96u2+u8n= zn6`ZR|0;cxWB;!hrvE16e_k~IiP6wll;k=Bc>`!zs7rx7gOBgEu^f<~=^feo9I)8jlcLO4!^+<5qD*2ux2%ZH`LP)AB`uD{5O@n7YwCWja{2#$esQ~ipm8pQRKU_HLRXlcKEqa1jpv$HF%1^=QMookpI z^e(o~=i93gq|F9sTK!GVH@#k;_>W}kzs&ys&&W6$r!Rs% zkiX96tI#@*KpQujsTBGcgz%7chi8$SEQjnvlFw;vmr&Gwz=F|dU{c0ueEp#g}ohRLK#X^lN151D9&KAFARBwzs$32X;CPEcRP+Rda2uMA zX=)`KQxv@=n|>aqV0=}QL`*klASY0r7J`jm@ZRnzCgIh^!@^8gb~V$LrHsSNVvh-{ zgFPh4$}@unmUCvkh_@mj7X-ly^A%1qBuj>SF2R$WB>OqJ`CQ9iOdr~8 zi0Gkm559@UeY71G;-JJGH*R zs?T#YYbZu)`OeqfD?WyZW(WOs2NmBU+NrGg9N5rk= z+^TzRb)+oj9})hDlCRpiCQzuf!?jRR(Fbv-rleEz?~ICo6)Al1Zog>xs4KK)R=}5} zb0`vwD0s;MaqIq{DkpldDcbc}bp~QkMVZbO1*ro~V|k;*io_JvHyJWkqEQfvT9ggh znW6;m9>bv4pTWImcXZWiqV5w$UdHWupertBdPDS_aTiel3q?F`b z5I;b8KGlT`p;*AX-%B>Ayyu=>w`m~?rF5Oz%>UfF)T)j=w?$F2%oPov=BzSWNzGSu zbS$|iE_mxJXne3;xA;VHPn8bE-ygc`D^rG9pU=wEq}k8FW-Dv$b4ezbjzHo2{WTj% zRC|i`Y;%1I8Hz{m0G<&3ahJ9zyT%b1+#f1FWXiPdBKqfj7tWwd)VL7#=7LKP1qqD_hQb-tzFd@*c4UIedp zHz74ka+RUC_}8F3x@v1+;04j%whMYAY!Q986Z01wr`TQJ)QLeZhsWRxDhl+c-z z^SMsidf|Fno|@VX!c``>y^MwynI3$gfh^?`ay5(2u6zWN{|zwu3_dd&3;$8%LoeUB zt7uxz%4zMt)2+2Rce#2zRo7qPq?3Fz0MGgW2eagl;v_lHKk3L}ZZe=HsQfkq2!dJq z>=hO4a4(KKzE4@A*X8X*^LLgKTG8)Eu?Ol`ep=4%GL_ywb(d#Rb^Pf&knd0ZE^k`n zpYdAyS4?9?ANOq7K(`TMx$wk+iE3lq1v6bYgNLt)UFO*4-Uj9|9D>aP~kmet2fHn$4JKy?+qjLc4GmDc$D2(uKu&x0mQYI7D zWYO3-v8(ROR&V%jN@hwXPVt%v$)x$z4H-|i6{Fg2i;EDZ=rKtG06?*O?`IHf`i#+^ z5sjo~BI|NkABMw`jI{e=RIG%8oahbVygq%XJxLmZN)u5J(vYNJNZxtJG0|UDv=?2< zusJ$MtmHO;b(yY`3;Yx}5e!jwwXRW)jk)Za&R@xA9Z=(SMOP_Ryw0JK@}aqXwa=E% zVm#mvBY-{$y*ilPjp^_L+nm-m4;(*jq+7%qQFR>J(`;fG54|R z;^4z8O4dep_lk@iV`43h$|ZO!J5yejSO-3A&e`bUI&eXvb{Fz!^O?6tUS+S#unNzb zzJiChqgFKp#MQ?f>k%F8q1P!TboL`a2AE^Xv&3Q2=V>th6)={__gk1J2%+sCn#A)3 z{%wU_4dt5=f!ZbNS{Fd=-AcB76W4m=pJa$@YK_1NrkyvwV+f8IvbR@nSB0f|=TI z*}#LBZ_a{-r7G7h%fRN=>=}Qk(Al3b9YP4V(#i5Jb&bN(nUo%%q!O7I8#8#2PNpxr zy-!L5f88g-&%&ugyAuANnCr-M!PTL5a<-j0PKqu+)72~%{C>?nvYZB!r7NiX90zoc zND2_CC-rp^iecx?qxYbqE0CfL-!Bw51;z18Zna@Jt#?V;ARFE z2N&F1G6j4p_*9o9*_RYZL?lFMRJqa`eli5PFOsRV(2vR;Dx%sfXPJ0g9tym8u6Q6s z=W?Z0V?4Q8fxwfn5*54*74`bfwce}e-~#72taiQXfxT_xIvg%Gb2iNUd57vTnFnXw zLq4zE0V~1XwhGd*O?0+8U~W{2Vegp>99xK$FW?Ujo{lMC)7B7*DqHZ0M>_Nq z%z%!_j zu)QO9YK`|Kw|`RZdw~fzp`zqg)&Sge-8LAdHcP>51 z^A0}kZ~ff>JPd?6?=PmO>H{L$eO_0HxYdIpSfvJaORy0zaelQLBQif|Tz3eQxthZQ z0|ggbX}@b3LVMhPl_w0I^>^e)%wfn{O?c$?C_#tL-e2||+;G@ecy$TgJ7VcF6~d+B zG8r8H!{ijIhPh838o+PeW~{3k{qxDU{>PVYvQ{JxkHb=w=5ALx(rVD2p(qE}A5M*J zq5j|3-y~HDUFpp7YezN}w>3tz5DTQr(-Bcn=(CtD?tx$ViRBhWP;x)lcBh1<{G zBg?YVvXe(?ietXNTdRA`b?XI9WbErQXBBP6#9^DJmnw(D6mI%;&XIC*aNlB*>qUQqlv$x!4FZ|v}BA2^cfC?OvCsdf#MfVx#%pZ^i?V=cbL>9*R*nN+g~iT z-y(&3M0&NHl{!4Ta$^+huJ|UtiA}14Ex9=;aI%~h zp~)5JNWY$o%UhT?wmK-SsB78R7owrT_JF<-gq>;oE(N1*$dcuiOSt{IlO9mDmu zNe%XU9}!YJc?Y*Gp1TWlWQU}@Jz$@bV$o3#BSkVWfBsh8IB2+P(MEq2k&vD&#FLBH25 z59rUUV;PJM`jz>O%&*QO<96oC7Ix?7b8yZloQ4bce4}!Q$0U`h=V-})hU_qsU1y>v z!JN9P>PYb@J6~g>Mfv%?bCk$iB{Hr1=Zef=nu=n-c#PXG`wiy*&P-CcD{!jP_!`Qr z*yXvN+uC6Sdj9aPA-~N(GWh=k9560(KLe&)j@#?r* zH>fw69-2lRK7C8(0vaVb$P z>%C7V`}$dn7K~_8wl^Jf+0O_6+F8vN@C?1a>S60~@3o*bJ#)-@>D3t)R9uD&?~LJG z7&_EJx3x@XOBn}uU0*|PPXQ)33YdTg^g#r_I(I6#$+)LZr$<78FrBU$>sdK_+x1tS z=fnOoA%k6Em2-g){rz(tdmb16@tWaBUXSJ115zMah}gcQvLi~U>3wQSMD!=t*6TV{ za<;c(P|_16usY`t`QE3W4x5PGV_W8W_3j=6vRtn2Q7JO=hwlnb%{6s%#-V;Ks(K{Y z51emrIi)P*7JWX-_6YPe9F9-Ji0ErW=wpbJbk18YumU@bNFfo((AqRNw6@Q8uI5>` zw5N@9%={{c$UdmO_2E?eE(Jrfq{YJBM!))AA!@~9-1eBUWJObAT!UtN^{v_AhosR* z+DiUkx^)|k{RI2$t}9Pp;`xJJBEc- zEdis@xjKQNQjRhsBg2V#fOEv8#AlCbcb-d73K6&OweZf4xjs>MeyQo&JqFmgKx$f; z{I^+J78y>UyhpH8`O2so;gmq3oAw_LwV*NW>zY`7ry+vK1sexL&C6m4sF)%^1EH}_vu{ZOF%5L{8Y?ZcILZ) zhb8^NZ)FJjCmnt?`FCBpo3jZ2i`#3Xe}?`ALU2ONNzyqXlq-Kqx0__jZ+Nd|yemht z;iT-e|BpyfN+<8Qx#rj>kth$D%q8c!f;ey84gG6Zti9gynv}0A+>$zb`Lw8QUam=rm2#W0VoO>f1yDP7fNZ5>2sb64B0v~_WD+rY z49?u8zge|dsMBXze6`dk;(V)A>BAD^2X3M=nOB-8T z!mRmNXoomt23VN5eD}1re&(F*vMeC4jI~g3QTH>PuSC1Soz)%oM=5U6GZ2CmZmxpP zQ3IfqZaECTz*QSX)_6@-9#;-Ymn3icX)Bu^j&vqnzT1R~d1w_k_u^sYm-e=gH$G-; zXBCl!DCWR*));&~m|zQz2h<9NEC=p0gQVfO^tF4#C7)7#+H9RX$~>!P`^51@oPmGb zT(!SGzy8Z3&i3V%5w=0rh>u)~LAXqy(qU$fY9EDe^vKyUw++KInS8{#>p&NVDx~Yj zr)p%O-)FASYQUZtzUXmn+;;qHauIfY$vE!EyO27|tG0QgEpJ~9G<@E@X&9$k4#bu( zd_cZYB%daqp@_h{Ekd}-mlT&-dkXnTt$CK@)N&Z3>0~nmC?xC=E;kJ^S6*x2*}%;D zU1~w|_*>!z`sX|X4w~UtBnd8nrb|zU19^Z!V=@m#r8K_+uo7twB|SxqZ=1jRpDkLc zbr5-e;!0%;Z}q^)e&R~PyNk8Y&U+AoeR~%Qjirm*nyqgpIqc3Twv)3Fy&o4c%X1a# z1b5gRTlF$mQs&@~W)7V!7usBqPr>;1BhcHhJ~&zK7a-lPQF`yDn=uKBm986s5UkVt z-wG}p(>AUyef}dKp1;sdJpJhHD~qz|?@C~g8u9}&kXK{ElcB)td*EsylGLqp1oA|% zmv!zXAPk88he^$oiT0129STg}MJ5*AVD&2Txah2yX)SOwus@N$vejsNUra-OVLxi9 zD|H|+*>`BpQmUvFfzGx@`3Yvt%PZFW!p zRKBL$v~tW{2*26lC@=N$&@NS2ndrvV0V`WxS zH|=>dqu(+-m4^(sB=9M*hg|5IiMjp!t!Y}Sx_x3mv)h$oce^`5PZKFOqkuw|m`(Dm z{z)n)*_&9=i{prrDu-Erv`m2T*Vn0UbwX|u*0%R~zrPgah?5J6{#m(Xw->tNir~FKnvVukc~|CEQCo>oYf|7I2F>JAUxukJMdS zE+Fu47IZhfRfLc2C6S1 zZ@Y#PM4!YopMOhNLy=jDD*X`-@k;rkzGsz!@re~1#b#c73J0pj>!yv@wQ}!$?RQVD z5(8_YB80rvo4;ND9SSLW4j3g} zFup;8g;oN_KYs}CHph5BLla)P3csBMFDg0P!JCpt6pWItx}|;EAvLYOIJ2;~W_srM zwrX&7=PMuWHf`_A=ZEH9H#atGx91Vn=W+}$+t~iJ2~FNTY%Z!Xw;ONMQ=xN&1GLr9 zk}&uzAqoC*xOZ!FGR=7a?3FougFr(GB$X{g4whcgyYAA9^G3Ld1?aMLWub_7->u z9Cb()$|6mS_eHCbYfdD464s4d=M~67JvX;V>hFf03Y1ZHUskS{0v2B$#{Z>TYK}$xqBT1c zV*#g9(B9MZ+amNN%wMAFyE(TUBb7}1LQ|HZ#2UydIU_Nq%5V%mA_OEh+v&3Vz(QeW zAlEF9lBL$ELJ}%u1&4kx=Oz$}d(WLLaU4YmOAP5ehue|uJ^W(M(JTGEvN zSuQq--|gom@ew2TNENrD>g-p=oo;K{=r$X_8mHf~(e3xjMn~nouS+=U(Lct{6vUW0 z*H;i%iIinr0vMkFCfvp)L4eSy%HsUJ15zZd6y+iz9A#JLsJEhh9$99SMt27p zD?VV1N>oK%F-kHNt3&T-`2j=o6A;vo^iiPL9djRZ+$Rry4DO-RdG+?Ru3z=7VQ=rq zY8PXs4D6hAyJ$p*U^fqGy8=AZm}YkFF3!FedsPotbn~A zm>>V+Ns;Q9;LS`HNuZ*m+W=6$31Adr2QZk-Vg$hLOeVo-CC-ali5>E<`-{E4pyNuW zM>4nD(!$UB7JPpr5$H0Uqxs9)jIo^wZhXAD_c+7vi5i6qH%mN}<1<^dc9 z9npI*0feshtUC%#vz*NhO`y?sr)bm9f_IDB5bG2Qgx&)o*p31qvD*XV4tchqOM831 z!_l;p|4#-0~*~qdE*{mSOC9I=C@Om&x?FxCaiKBR0q4hW6|B7F=!VRJ)_; zXB^+{QJ2;&0}D*czUj;(UQ9g}vN-GY6|m|r0iW_F!$0Y_jr&I9qj|}lgk=cXa(U|- z=1Yfpg^RaSYEw$>G&)0F&FIlfv80{vwKZR2Z)t}JOxQ=KjORGot7SzTd^G7Fsn2Y# zt7%I5A#14ctxbMbhMHk*ZRP5))|FqS4rc%Zu?L>$LAnN@g;F|XmiE31EuJVNR4giw z_UbvRn}l#Gwx3zL$e+m`QWM-3rVLf&IsQOY`U+?4^G21eJu2_0Y3mdm--shvo1c}; zH0w0(;W-oa_;`K@+4@oI=s2e0=grp&DGcwA4y6|qGc;xyNZ$r34@R+>5awtVc()O7m?cTL363Rc zi!JaI*ZW2j_0M)CU&U}SwM3xaVmCRk)7BnneLEx@+Ok)bIxT}qV zg*2~LxM*~3Q2{#Kk?E2I|Cw&+to< zpFzkHE9{=h>o}e7YgsokT=AT~NCg8*7XRJn*YzzoJGYhGT7?>~ir#qF7i(}gWtkNj z^lPeZtIo>KCPc8Yc_&0%BsYoXmeu5^s3@f!R&lA#((uvGhzfD%eLolJ`9tv2J+d+o zBFm$5yrclBux>g1Gq9)2kezguK195$C~B;(URSJ$a{`s%?>A%Jh;B*suiKT*6hH@w zJyv)g^)^1{I{ghCp8;Wndm|nLb~ip=og4df=@UjsL(dvJXf?j)F}^fDcVj|kqE3)Y z?SZw)`mf_@IIlqQL+Zzikcw)Q=wc%0jXQ{5yI1a!9Ub*G-@fYcR`LYnp|s9 zwPQ$x%7vdT1L0rB5crqU~EE@hNnY1g?ih9^9K`DPQOLPvzY8k-zvHN zitF==-1_8Xgit~Uc&YGm2>dwCc~djZw3gIZI+m&Z_`7MQ&VdP|rX2DUG9ED8Hed$W zA)Uh)zyaqziJWQfB&ftbpjxUVNcSyJ;=@y_>Ul}xCg*~6y%+*gU0!_Q@B!R1xD=DtU-j6pt5=K=Z-$Z^G> zqPrzqSbO#!g9#3M@kML^i35JtxIey7-3hy9V=Rd3ZIQ(P|GQuS!BNNP#qAlu9W+yHLG&P)$_Pp zfXqEoy;47C`=Q(5{+Hv2q}QCbwl0Xc_XmlV&z{7a7T1 zm-KjG$y?woLmTh*6+G#3@$&WQ;)Wk8JTQ2_cHkymxC=e$Czj*5<8D$S9m4P>M>=c` z132z@1d!$u8XU!n(mC!@?nDnCf%wjEuMrU4g=W3E3nEFJz$UPg$-4TocL!yx!KEm( znEA_tPSrPqDmUsTZ+DsWg9prr>^0UQ5iTkPofs)nV-nIk+#3#RZOA#7+7zGqRDO^( zqhIhaG%u1qtYEgQv0h z(Ez1@ywlH)-;Uu=8t8 zp9eSQJ;C@|#u7%oi^({1v+s5F0qf4D!o}~<&(m}A*~hOBt9+abJ8vK`r=-4A_NjJh zJae<46S?K>_vqft)tA$$OIdB1e)_43t_yjPa|oMFC%NLc>i2(U(CfQ?0a3s4FL4;o>tI6@uH>++u zIa!gZjS71X^fCBfKtGvA5uo>hQKLP82dK3Jq1=x$AZ&i6YLj18Gh4_!Kn>-sG|sJRfAM3+i^mSxk{IB@WstWJDnIL@B$eN@7eTmX zNtQ_~D1(jmC%-XXgo=>)Y+I8CDNu7qAztTs3*xm#CZ61RRdqi<&aUy@Tjq7c4_z2}=X`#{Y3TyKNzt3fsx$-Vs*pa;S5_6+!vv0xUF)kwI z#hFcqPL;2ayBl>l$QkkwMFVh}n1TF442kLhYzFhCnOU^~5jRyYtZr^JPeWJ!XPnJc zbMp0dA+2*PCJbkNW4+J1J(mtR+g5X3GH(V5xx(4u-Ry9DGrXxjnZoUZEs7@q74z>V zb+wjM7+qW!AYXgKy~QpLhH&1PFtwn(kkHC0x|Q^)%PZzyBFT8CgaS-sw=vw!lB~c| z1rT!&AjTOMWI`?!>NA&^xkd#1L=7GCkD=X>>Rx__&@cyXF^;HrXMZI_6Q@3;xxLJ} z0QN+DouPB216Q*ESHFS*+6iz`ne_;1c?92X1k?R?2osFca_aOH@*Ji;K{_u_D>TrA z=L*^5-dIyOkCAc@G}ka=BE?=fNq$8de<&eGQ0ZZdGWH|Z4CL#lwPE6vWd(o#{Mm`Y22-x2 zc<%2>LsZ$4{8jHxnXZQkBLUz*s-e9E7G1SuBVe@!YpeloGopJXIe#Ei7EY3r@pG1( zzHN2`X!qi8-lN{q;7(kmk~3z~i1;Gc@BPQLgebHYaQQ0t6QF7Xf?Z#fjf1-*$+D8q zfZCoY_N7bp?!-?dY|G2C`Ojxz^pT2}8k}?X6s6bgpU&DJa8&CsearMT?X=5VtdiYx zEuQ|ZWet5<76$deBmJzcoNC1Fss>=#sdGPkPS9qg`CL0T&FgoD_JURusPLsd@}*un zM)3L?l9P_TD2;_EX^+YHm3l0)1X#F ze}D+U@$HZ%*%)0uifWVs?Fm&DuM#hEJa?KyISN`bLekO7epJ5Ev&p60w;nX2SU5g% z#^c`SLGV;+s6~!;@O{(d4x2FlZ{s<6>iN#DLxvBEvu*q|$%PKqsA_Y^S;t@#*CGA3 z`L4Q_h3|kBUe)-gYL$ZuMAV+90NCGb4@i2j-(N0(vt8R+Is(NFfW=`E*U{laAtj1K z4;vnwNy(hm>kCN#_5DM@bHzi8*KN%$oh_N4iGsI0tkY6dYilnUuvSXNC>97?lvZKx zpLeXgM=|jg+V@tgV0XGuS!f8`o(cZpyH)^EBLnC>rV8GDH3TRmFQBtMBCsxE-fz7? zED7zTR({v#qv&EJ_b3XiuWU{8KxiKKXUa-U>5T<^j*EN2FCQCiAq*!RG0ZfP8FI`LG+Bfj_DnVhrUnIV}-*--U(sIf7nU4K}yaZ9VAS_7HMd1ikdM#DZ zcCB-Q)J03h;L9P6@l?TNCxDMSI-mtPP!KHRHR4{JG8J6uB@$QXUAn2Q+d}B(H1Q6I z`j8=&eEqAgk#&#bGAwW9`QcQdLS}`FagKd^B0E)P%d&7um6Fog($F+_y^0l@*sca{9<;_%|h?L8Rgs35}H?h)t~ z0iDQa6T`jZYm#VI;1#0fy<#P1^(*M3%VY7RplLpuO*V^*YLI=EhwAwBzK?wenB)M>2t%^eW#EBn`{+uXmX z{z3~q*f&u}3oiMFsy-w>K5N+q>|c@miU2l0gMrI6X)Vz@QLX?^CpiECEgZ~`s!}Dh zS~@@%Rn&Rs|MlweaZ858GOOf-WhIN77nr6)?ki-AYqh^|(r(|}I)4j?IbRiu-MBr? zmm+S|LPEJF)|4@S8plP>A9im4(DkKr>4V_iymayVU3E(;G@aWsYeL(O6f^Q3Ne^Hy z0?6dRy+&il*>p`75h>B?j}!u{mlpQduKV42_jNI&BT>dN^sU0&1p}6L%vZ}rR;m~< zE=*};_a!J<7X2RjWZ@LK9Y5#du?uB}?H~3DI)xObXs5&(6_L8@dG}#fL-}SQ*UL(ocK zzAkfi!<3Oo$N5JjZ^_Sj@6(^b2?m%Xz=aUNBmhD6*=S`lf|Nuo&klKHIk;|0R9+$F zPpw1;zF=~%MV^VdQtkf2^bn->E3q{c?u;NnaeaFL#Df#A!@0;|q@8im0g^Bz zb$WlUa*zzwDR@XD8=ZmcGwe&`>(gC3;aGCwlly{k52PtR0fS$&{Axpb_>^>vfZjd= zEkcu2VVX=|bft)Luq;c|+hntAJhMX~<)i9aTE|B84RX+d8P%vj^qveYU@Gd*H5Nq{ z2L?TJ@<*zg>qM7&^t7=B^c=ysd?WW5iL_&z{BWbfv2kO%QO#(gntdhteCegMPBHU{ z;6>LmnGu$fVg9=6u-k9poM!Vm+b!*(7kU?E6;Se*olM$W_DbxdCN_~C=pWa~YIM$U zN)&8x5DN@bj2JLgmr?Jc?1)WEEw8W^n3X5A!bR{2*K1O(`M(YAue)VVedhE3e&_Ca z#?D4FN;-uldI+dFd4{=oUUY}ieFlUY!}TQ}K4-OTGkvR-#o z1G1=5@a_wAX5cx|aQw>!F`|CeYkI$vak{<~dG98|ifEjsffN6pUbC65G@>vlRcQWk zpe*?U7iEY&W9Z&RT6*+Akshs5{~Y~u8+R)F0rHp=Rh^s+n}J}9#2YbBZQ@4mx8~|R zLWk~~^B4KG4a#HJy#p(lPKuwpbTbzn7!Vk?nbl~r<+b4S(+~Zndf&Rbw(it{98%U7 z*!`cU@WVtAadXU2;FH%sNumFFE*#hp{I>%1zkBe9rvG>s<3INx|JhDSV7JrXi2k+u zuXHp}tC0(C0RE!~QiLi(rVusMp@L)<=TjOkr@gVyW{k{@m|LItGM#{0Bc$XWaumt;3uQ>-0OTLxW;w$G~)vzc|Sck*FF(U zF3)F^v0tv#rlIRpCUF!(lOoAbX$W zyHr6HoT0Nf5w4Q#36?vFM930-$2F?py ze5soonsVpeO3*=u%AXcv}EM#!l%+CoN2WGETq4JgE=Hk&w-!1*umYCr4 zsOK8z9Ie~~e)1OI-ELWd=?(M>5a!6{B(X(&!-)CA5 zHhw3~#qfWVX&yH^xKjHODXlXloO8QI^(ncVB28vp936y`wS7q?#EL=p_}fI;9^7$Q zge2qa=0&CAp7EuRBlY#QQ{8Mt6HTThRjzN(cseHF_-f3FE-|VAFhEb>cSqBJ33*V5 zN>2cv)Nr|PLcBVw?QBxH@w}IbT^U=Qp;)V>#S{O~)$m&S!~HMi;SXf2?&etjN+A1@ z{8vgM_Xw2fIcGiAr0jbQCqyCz!r;wbRVK>8V_gn)^Eu6}YY-reu(3C54}_+&mJuA} z6~dR z5h``geUX%Os*PZotG{IVE4*qV%r*LFOITK*eV_>(z_lny@7^3MolS@0NB;~UwH`_A z)d7qUU7YGejLmxrU{4n6waK)GP@GrprP7YZjBF-0Gy}RA|5Bwr?^%h%1iGV!&6v5C_x zJWrvD>=#v=nCOSw^(9?RNu<+PJ|*EL-rdc}-+29v(DB&;wJP2venv)g_ea#sY>?xF z`w8*q&qTIZKXao0e3g~crdRyC%XVQkU#_^}A4k~~SGecEK$UqO29JPq2d5|Dn)4Fe z2!ruIM_jo$rhhoPEEtov5>|87^R}OVx@Z0TL)4Jw>$#ZsOI;Y`2$HNgx_#G=68)#G zq{~Ntdi(y|w1&%>J$2o}vXInE$?e<)B!|Qu-q~{F_oIL%jXRr%l4K zq=SZuXRoU#Z3~)zSQbOC;W9%pIiqy|BEw$A4{jF_wE84b@q;`5dW_v&LM_gn#y8zE z?~3_v@%p6+giCu%2Vc8p@8JPDW*#LmUx~hH762JW4j|`*`Vl~3H~E~ywgw>fYhx<) zmdRb0VM*drgZ>=C6Xpadwq}zGajP{&!Pl>j|GE}1je6UkVI@Sc5GV+vk!0yrDm$RB zJBF0}f_#R=3ytmfT@b`L{W?e;=SAf5*DRY<%QlVpR)1pjw@Q`$_2yTF<@;OI88BIG z$lyG~LI}1%4(5hh%yJ@a=zR}>o^C2{-0f9VPRQUJPx=}abGi+F;^pxh0;7k!6om~W zMhEbT7yXNel)D_zTFssCB+CR~RcZDTv} ztd;^){Q$s_1j7`bh8$mg1=*V_vN9{)T#&iT)!bT&*+TPvicj0Y*Ed+J37>oFmn)lM zXF#e+j8}1*9^1tG9jub>&^|%jx2MqaZW$Ck#5cQP@{5+E#__9mv)wR1mjThdz7uWT z4bhT16Kt;y7<>~FERb$)H$dU-U%dGB=vK?-t!-$`+-Y_A zq6ssYZo**0Mo5su{Cj;N9Vj0AGSZx?GIrR0YMN6Ll~TT(BzJ?^(EYelU)BRIHj6}S z)4bJP2UG+gQXNN0n6>vO3uNvGYjESplu{xjxX~AkttPUCv+*QRXKBGZm*|Hdnsf+D zg`3 zPo+GhpUv)E?19-@-%C`a4~E;n=NfXwViUH6t=;r_IL?1bf%GA%>`N`6R@gg078wAK z$DI|RC6(i?e=~4|N)pj8;rX?-4aA4{y31n?CS20)HNI@!O!K=Q{N~K7`A2NbhhE(K z;`Gx4Aj#wd<8c`|pP~iL%%v_9nb8r zN^iIM>|>KnL1lkCiu3|be^4wdD5Nbw@J2+perdrANRL}UsP?{DM3|>M;{4H47^ll@WsH5qXooC{19(>=1_fd(mZ-Htt&Cs+cN+U`7(QX zM6{0ZpqgdXenK?QOvt{SheP*9oVnnf1U?a|gPea3sdzX)>6QKj?A3afhT zWG_c~t(W#|zDSp=*x0i27R@`dr{6?=4y~CkF?eQ7wp+)jt#zg9w>mUk5e&Ym;ScXr zo|`tYpB~^oJzT6~;LyF>m2rrwA05jOl@#~Ez-18MK=J*n|36{G{Qg_Pe;1AtA)BjU zIrL*`DBOHLjvq3g<4f+txRFPl^Cq$w))tQy{LSEY>&!{|1Sx`|oAL8C%TMhp5!d>0 zARze{wdjF?KT1^F3Ua|HG_P#Dk2%j`eytZ)xNE#Ygn%mhd?AXUgFHu{g>r@R08<`- z_mxmDkhcl@Tyr|a)cDi&tZpLi%GlJ`HfO$`sFGTA;d4t6waxXBYYZWog6F~;G}nr& zu}YwXAzSV=t8YN7-P?Um-}w&AyF9=Dsy9w(V&+L~tHZni8QfFci;h zkS)46=YjYm^cS?CQI7TkEoAdZop-dk(3M3}duOE%YjYqaR2%SKKTbtk_qP2IWWH$b zH@A__e4Kc^MX-9c1uTwqbPFmDdN#S^L(qV3q%q=~LxbJS)ZyFeLD%KeTrLboQuV!j z)$XM9=da*ow1R0^x}C__fa_|`X8a1D3Anyf z^kHDe(K*2QI}r+Rpbyu_cj3;EPvMpL#ap?aidH<&Hwxi7DcWRcy|d!rY7;CiT`c8x z+L7-lh(Urmm%*Q-+KN87BL#DrI&7NDcw(1H@AIqM9|Ves?;uC~_UV7?68?#n;W&YX z1jr5He}r<-qJcN1AUkqCFPo2O=(XAj6o4kVL7sb~;Tp{F2McoYE^*uV3p`OmdLLQN z2;FdXxk*;U)vQw4ZZ4{HLZ@Pp^SRJZU0qrBBqX`CXzkjs^!>i(lv5bz61&?myuqne zMt^KiT#{SvaSl&KW*5Zn(9TH{7(9RU_F~XNj_l+u42U zKebu<v zQ+fBxS8v3i{N&5oCw@QMKApM0n{ec#?cnO_QoZbpHMTW;UmDyE81No zw+8X9*PdSryw{p^!iGA0{SHs_9(V>W$QHI{w%=YUZh|o=katHmny7TJ9;aH3`E1L{ z?%Ru2r)wx5_21;GtV@*4@^;xtTCKf0pUyy>I?b=3{}OtnMCm>SA9YL5fD?HEzhF= zaca&>Svk!;U=&k;CKEOx$UQiww z3EogXTM~xGYoCGCUHT+Zqz zj+_UdC*Xtdf);HKE75;`r2L!u>_0<6^e|wHh9=)QPKqQ%P&up@t*PvMBvV?tl3)zv zeB!B{nb&>2z=|8}D;Uly(b0EKG_Pw7&}P_?1`8+N@~ z&+}8SiLR8y$hfev)K02b$MUbFa-4SgPJ4%($3g|*5V-s~#1|aF0`)8qS_NizV+dPP zEAJaSOfx2Ty-E&0hV#M-7#O`R$#xia)3r!=;1>UFHjBp?(XZA}TQLL+;I78ur$~11 zEXprxWj_^3&N|TF{E3YoFK=jK{Z$i9{hdSpj|u+ozfLbhn^4AfmvKrBlLI~pgLdb# zrc=j!x(&vBJ_p)0Z`9G4tP_DYltvJqzr&>hxug#NKvs}7n!Twq9w6Md}UXN=uX zQlLBHOTo2z;6zr%aR)mX=x&(>hQpMBe`KS!ttP0W5Y0EqYD$k zPjjHnaBSixSg)?8JfwMoSom_#E=d~%T;Y+3PR;YQCb1fmmujBG*A0u2XsaFj zV4>*#sm&t7L~ieOjrLn?Y=+mB+jc5@jOuNk7U$z##?aoj`YNk=J{v)yrXF>-ZkIoO z{5M0IZ*%%gZ9!VUndz=oTI(=!82S2d25-oZ!cM2lp9yI%HLnHpF0$gOoxn2BUAVG@~VK1#&B1yQbeUV~-;E2vFxdiI;U7H`d@vaNo5p9cH@%Jl!cuwve zH>D4RX|^7wUwK-%dGLps+?lJ9g!i7erWq776FpU6)tEuY>eL2Vr)}+#+FL!OUm1s@ zfn-^1i15B&#R33VZJVTUkO+`qL`m+_q$a1%R znz=os)YOmB_-x;;4(AgtrDbt1_on*mcvE)@-GeY(WqWV~t3!zzinP)S+2_fgi!mC`9jJ4zm>2E!u654J8KUbq zr^+Vo)g_olPMTf9hGMSZiYD< zjqLpl(DCs8>sVm8O>tB~)JyZ@LTV3vK;dRS<>~uDPY?gLbCJln6>>3XK$Z=E!E&rEUqK)(6%DgeUGM%fPusW zGXKFEMKGWHfowz8h#tIRZ+Q$mcp6(gl-r~h-a%rilhq3Q(Bf^6OluIZ>GVw+YfK^m z8en)W&`3m*VH7>;eRA7}4say3L95ctPdj{${v`%f1P-w-2&tOfpd{tL);Hkm=v#{8Qwu^x@rM-26P0EUDY!^U|&m zUrA9xw5MYuk?u1pc8I-)#%pVhK^83?cQ>nZFG^9EGcN~fn%Ba7;}4=I0%wu z!9BeUy-2G-&QF^}<7y(lG`#PM2!$=E3@^RL-wm|Fa*tQ81#+b^vD~nIkggm1sgKY3 zkz4EwfzYsE)3ybHf_&oAe^9i!npR{xcI8_#$raYBP7uxc^$;q61=WPBE zUSQ=bBBqdk@%kUNm&eN^TSGJvH7OO%+_3%KL9}q+$Eeli`YJC{NI4ezVspS1h#?}^ z|7n3A^6#=o>MFA3h9z=Q%w(##^6Pi1%yqCyDl5@0()*x0+KNz0Fc53JhrI=^8r+C) zb4)l-C_iHSZwA)JtM!5H(5QQM_CZP3+}4lzev~=um5*=kd?O7$ymRmFH643~I%mu9 zw5pDhZCB=m!){w-%k_?7(z`%?pFYOl!U=wkf~M8^f0$0c7GY>!KH1T${xf<*ZRn6`c( z98{y;DhlcaHbJh=3*~|4u^%!lvlq9`aOba?eb){sGV)ctT4^bN>25S&Gq)c%O`T2q zWImZZozgtMEov&4S(98gi;W%{A1>rJe?OX~v96mkS6Ew&UEM03G6`;I@FBGz%9|q3t<7GdRW=LSkiU3r_7%=lCr!*vGG4CN3UL=vk>pD;BSxuXFLRmw z*#)7nB3q;Ya0wxcUmSI(#nCTK1esAB_PDDuQEK2WoQmlT<9R@{`?q<^8RIFApHyvLEfp;sP$+{!e|uJ2cg zj`U#X)woh)kLEl3VWBC1GrUKU)wU?4;IT$Qg0L>xR`k_4UTRij5Ntfmj*{OuSiJh@ zw>?-Vxkf9R2bTW|cV~9~seyF97o2l~`{J&0?2olfzxYnv6{Itge0i06p^L;yF6QoH zomg6UsoX4!F{^o6P#h9c(&t+Dq-v67qCijNFFl3MQn4}DGaM-p^nR0^T@dmC#`|%` z8mvfK7P%ROp|y zw9tnfo^;jk+looj63!>8I`42Zv+~VnxD@~TdNxc}+i#Iji8iosuTC;qfxm7yNYNX8 zP4q-3&>y3!xI54QD7=?TdxM;hM6$#4!_4R9qcJf9G0J8wV!a`PCk#mGmQ`Po0YZk} z1FmvIKT_Uix>#=x;9pRj>9c|uZYtLRI8wqb=mSW8AP|~BwK_2qnrbj&rE)2*MP5!q zb+yrB3ky}0U`w$ZTc@?7^?J(8ziwQ;S4=xb6$0-JMs%W%;k;P+=o52Hp)9c6(7WVV zKJ7(*Z`G~5km|rV+do=l27DMZMD#@pPUkArzYgo`3pT+6fbs+gYQqD7+Cp8cAgGJf zO!jnN4D-`_X>afKc6!zmJLhip?UTM=;ee0Kr_!6Bb)^+@uX8d~iq{v21#t3=jaes` z_#5Zt_6B%IfA_Q%cP})TXuXzVc~;n!H*D#o91NloTsl*oKCX2bON(Aex(zCp9~D_6 zj^go5VHDla%nuD-Cjl>mx;@L_cLY5=e-T9+RIarJn}XYL&@N0rjsw`MX zoJv`O#IV~s1flP72l^pB^b2)}c0f`zbc-=H@kTpaC!gx0_T#n&QWj0gC~AGNF=<1s z#B&OR9Xr^rLyQ)xYWt`?whkO(Gxcmf@*J9}0eB2qDTGu)%TyNxSLsp(qORCRcfCpMsR;4cBvyc33WS zTXN9Gr9!iY5X&=r^`Q-(J~w}Ta;UW%Sv9m|&O9O=?Cl?R!cO&>rzx4$#2%VPYtzhx2X)3dewaAFBw`p(A2K@$l#v#t&P9O zkM}LaCKR9vr)I(Ua5alktsBX`i^y;;rc&rkIli4sEw1!_p<>T15znzSU)!8(42_=5 zoxnkT>mj{%ZSHvUnKcMw8tLh~Gu^aDdioTCnNpzk@OPzO;m_^_b~r0p8ITrJ z7~7S|Z%~ZK-pgxF%kbHbP8shW-#!gLI}zE|K(}FA<~(HPID{P_+cbFytKrm}_d)rF zag8)-oRn|h;pyt?e&Lo6+=uyP^u-5nq;z>NGZ%f3dd9%OJTn$k?9_}chVwNg!e^TJ zQUSHI?!|`@iSMy>o-?JM^#)@T($W(FdQ%_@eY^J0qtJiT=AAMaDigMkHLoc*indA8C6?zBYRxQjdL-Q15?r7#PprIMbQ zxX(E`-lR-EU8~DiGfSQp!a$diy-=eknuj^KY~;i?{hSu|P{=v?&%uEI2RyJ|4cx?nZ0Z~#9;~4I)DT+y9^~w-r&`1pSf5Zo z#k4kE^~wmJ)oag$k9YWT*&_9BM)m6&_z!1h3fCJ$MTUH~VcVBdKH>$&4Od9i_(5KY zoVVf8(uu@__)ty)x`h|lj*j9juZUQnof?ACbWNL5?$=;2SS^t%L*chYkM&#tPu##P}{L)&b9RPwLro1WmGlP59ZAc!FO({ z>#A&z*Y7+QGQK5PH+x&~SHrwu7y2a>!`L#w8j2*dRNPuLIUQ6oC|}*DGxtb8=V3UH z2#d&L;Xe|!g~}4@KdY5W9`_i^D8!pR8D0(uK#bd!ZWm{kXd5OTZIC@Sk0!RA$BRI{ z;KM(x7aae0eHyhOREzcjH;;kTK&V_e5-6gE`_sQdcmSUL8g;IblPjjkq-^FFN#)?F zXkI!z_njj7o1ths7OQe*D0nC|@@3xOUm~bc)Njus5pzIgz@li9skkgCSzHv;jwC!j zzz@qLd@lY@mAZBLncscaxG;k6#tWTOnO@FI#)WO&rjvLWNBY#jzP+-JLSFHLqn=OS z$sO;3UCL%K3eQB*Mz+|5T2i2)KxrS&P5&y${V<2P8WAc846(&SokslMon5-UJI?}F zee+4@&f`Coi{)jKbaL$dc$6-3T|fG}zvL(D)q7il?kme8 zNYtGOy^%e~$GUn8I&nX~DjSLnhJGHn8kDO0=FZ^NP(iW;YX?*S*&+y;uO%W|%$g<1 zQ0s9zL#PeGweUrwVfh-kFi%av=5P-y9&v3Rrux%7S_<#2Y+pLLB*?&*AcDw;a2EPG zR&XJabim1GME-S_(Q1r-A(V|6i;knZFJ);N)m9BB+H*yFJj}U%Xe@BYI#Rf#TO={! z&d$d~tpaoVN#mEx?vf*E)tk9^(U9%0DbfbAqNP?%Jze)=Z;ZZxYn#W5)z3|o+O98) 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

yCqt5wH2=$$Wm$7-WmsVwA+#M}f6h2V?#g~O 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�x-Xoawy)4=lQuxIMU>5mEXlntSGlj=yDk>Ee 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 A5dZ)H literal 0 HcmV?d00001 diff --git a/docs/reference/images/Table1.jpeg b/docs/reference/images/Table1.jpeg deleted file mode 100644 index 5ec4ad7e3b9b906b030ceb4804ecaa9e0cb531a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14374 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 PtzKGOUaV5YF!=uf24a~8 From e6137c4e983fd3a089c06b761345e554a955209b Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Fri, 23 Jun 2023 15:01:17 +0800 Subject: [PATCH 03/40] Add changenotes. --- changes/2011.feature.1.rst | 1 + changes/2011.feature.2.rst | 1 + changes/2011.removal.1.rst | 1 + changes/2011.removal.2.rst | 1 + changes/2011.removal.3.rst | 1 + 5 files changed, 5 insertions(+) create mode 100644 changes/2011.feature.1.rst create mode 100644 changes/2011.feature.2.rst create mode 100644 changes/2011.removal.1.rst create mode 100644 changes/2011.removal.2.rst create mode 100644 changes/2011.removal.3.rst diff --git a/changes/2011.feature.1.rst b/changes/2011.feature.1.rst new file mode 100644 index 0000000000..476813d4d7 --- /dev/null +++ b/changes/2011.feature.1.rst @@ -0,0 +1 @@ +The Table widget now has 100% test coverage and complete API documentation. diff --git a/changes/2011.feature.2.rst b/changes/2011.feature.2.rst new file mode 100644 index 0000000000..e48283fa2b --- /dev/null +++ b/changes/2011.feature.2.rst @@ -0,0 +1 @@ +Tables can now omit the header row by specifying ``headings=None`` and providing accessors. diff --git a/changes/2011.removal.1.rst b/changes/2011.removal.1.rst new file mode 100644 index 0000000000..28d434d518 --- /dev/null +++ b/changes/2011.removal.1.rst @@ -0,0 +1 @@ +``Table.on_double_click`` has been renamed ``Table.on_activate``. diff --git a/changes/2011.removal.2.rst b/changes/2011.removal.2.rst new file mode 100644 index 0000000000..e75e04e516 --- /dev/null +++ b/changes/2011.removal.2.rst @@ -0,0 +1 @@ +Tables now use an empty string for the default missing value on a Table. diff --git a/changes/2011.removal.3.rst b/changes/2011.removal.3.rst new file mode 100644 index 0000000000..0f8d7c80be --- /dev/null +++ b/changes/2011.removal.3.rst @@ -0,0 +1 @@ +``Table.add_column()`` has been deprecated in favor of ``Table.append_column()`` and ``Table.insert_column()`` From 0ddc6ab64860defee5e9fd9b44831610641683f7 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 24 Jun 2023 12:29:30 +0800 Subject: [PATCH 04/40] Add deletion by index to sources, and notifications for new attributes on Rows. --- core/src/toga/sources/list_source.py | 17 +++++++++++----- core/tests/sources/test_list_source.py | 26 +++++++++++++++++++++++++ core/tests/widgets/test_detailedlist.py | 6 +++--- 3 files changed, 41 insertions(+), 8 deletions(-) diff --git a/core/src/toga/sources/list_source.py b/core/src/toga/sources/list_source.py index 15dc54cbef..dd6612dc73 100644 --- a/core/src/toga/sources/list_source.py +++ b/core/src/toga/sources/list_source.py @@ -15,13 +15,16 @@ def __init__(self, **data): When any of the named attributes are modified, the source to which the row belongs will be notified. """ - self._attrs = list(data.keys()) self._source = None for name, value in data.items(): setattr(self, name, value) def __repr__(self): - descriptor = " ".join(f"{attr}={getattr(self, attr)!r}" for attr in self._attrs) + descriptor = " ".join( + f"{attr}={getattr(self, attr)!r}" + for attr in sorted(dir(self)) + if not attr.startswith("_") + ) return f"" ###################################################################### @@ -35,7 +38,7 @@ def __setattr__(self, attr: str, value): :param value: The new attribute value. """ super().__setattr__(attr, value) - if attr in self._attrs: + if not attr.startswith("_"): if self._source is not None: self._source.notify("change", item=self) @@ -74,6 +77,11 @@ def __len__(self) -> int: def __getitem__(self, index: int) -> Row: return self._data[index] + def __delitem__(self, index): + row = self._data[index] + del self._data[index] + self.notify("remove", index=index, item=row) + ###################################################################### # Factory methods for new rows ###################################################################### @@ -154,8 +162,7 @@ def remove(self, row: Row): :param row: The row to remove from the data source. """ i = self._data.index(row) - del self._data[i] - self.notify("remove", index=i, item=row) + del self[i] return row def index(self, row): diff --git a/core/tests/sources/test_list_source.py b/core/tests/sources/test_list_source.py index 4038bc103b..110d57bb9c 100644 --- a/core/tests/sources/test_list_source.py +++ b/core/tests/sources/test_list_source.py @@ -29,6 +29,13 @@ def test_row(): row.val1 = "new value" source.notify.assert_called_once_with("change", item=row) + source.notify.reset_mock() + + # An attribute that wasn't in the original attribute set + # still causes a change notification + row.val3 = "other value" + source.notify.assert_called_once_with("change", item=row) + source.notify.reset_mock() @pytest.mark.parametrize( @@ -331,6 +338,25 @@ def test_append_positional(source): listener.insert.assert_called_once_with(index=3, item=row) +def test_del(source): + "You can delete an item from a list source by index" + listener = Mock() + source.add_listener(listener) + + # Delete the second element + row = source[1] + del source[1] + + assert len(source) == 2 + assert source[0].val1 == "first" + assert source[0].val2 == 111 + + assert source[1].val1 == "third" + assert source[1].val2 == 333 + + listener.remove.assert_called_once_with(item=row, index=1) + + def test_remove(source): "You can remove an item from a list source" listener = Mock() diff --git a/core/tests/widgets/test_detailedlist.py b/core/tests/widgets/test_detailedlist.py index 70e31e12ec..b510dac616 100644 --- a/core/tests/widgets/test_detailedlist.py +++ b/core/tests/widgets/test_detailedlist.py @@ -28,7 +28,7 @@ def test_detailedlist_property(self): data=test_list, accessors=["icon", "label1", "label2"] ) for i in range(len(self.dlist.data)): - self.assertEqual(self.dlist.data[i]._attrs, listsource_list[i]._attrs) + self.assertEqual(self.dlist.data[i].icon, listsource_list[i].icon) test_tuple = ("ttest1", "ttest2", " ") self.dlist.data = test_tuple @@ -36,11 +36,11 @@ def test_detailedlist_property(self): data=test_tuple, accessors=["icon", "label1", "label2"] ) for i in range(len(self.dlist.data)): - self.assertEqual(self.dlist.data[i]._attrs, listsource_tuple[i]._attrs) + self.assertEqual(self.dlist.data[i].icon, listsource_tuple[i].icon) self.dlist.data = listsource_list for i in range(len(self.dlist.data)): - self.assertEqual(self.dlist.data[i]._attrs, listsource_list[i]._attrs) + self.assertEqual(self.dlist.data[i].icon, listsource_list[i].icon) def test_scroll_to_row(self): test_list = ["test1", "test2", "test3", " "] From e36ca8817c6f5ad94387b76fe35ba00a3be104ae Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 24 Jun 2023 13:08:09 +0800 Subject: [PATCH 05/40] Cocoa Table tested for all but icons and widget cells. --- cocoa/src/toga_cocoa/widgets/table.py | 160 +++++------- cocoa/tests_backend/widgets/base.py | 13 +- cocoa/tests_backend/widgets/table.py | 110 ++++++++ core/src/toga/widgets/table.py | 27 +- core/tests/widgets/test_table.py | 49 ++++ dummy/src/toga_dummy/widgets/table.py | 7 +- examples/table/table/app.py | 14 +- testbed/tests/widgets/properties.py | 14 +- testbed/tests/widgets/test_table.py | 360 ++++++++++++++++++++++++++ 9 files changed, 638 insertions(+), 116 deletions(-) create mode 100644 cocoa/tests_backend/widgets/table.py create mode 100644 testbed/tests/widgets/test_table.py diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index c533b1fa9c..0e680c06f6 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -45,27 +45,15 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): # for encoding an icon in a table cell. Otherwise, look # for an icon attribute. elif isinstance(value, tuple): - icon_iface, value = value + icon, value = value else: try: - icon_iface = value.icon + icon = value.icon except AttributeError: - icon_iface = None + icon = None except AttributeError: # The accessor doesn't exist in the data. Use the missing value. - try: - value = self.interface.missing_value - except ValueError as e: - # There is no explicit missing value. Warn the user. - message, value = e.args - print(message.format(row, col_identifier)) - icon_iface = None - - # If the value has an icon, get the _impl. - # Icons are deferred resources, so we provide the factory. - if icon_iface: - icon = icon_iface._impl - else: + value = self.interface.missing_value icon = None # creates a NSTableCellView from interface-builder template (does not exist) @@ -85,17 +73,14 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): tcv.setText(str(value)) if icon: - tcv.setImage(icon.native) + tcv.setImage(icon._impl.native) else: tcv.setImage(None) - # Keep track of last visible view for row - self.impl._view_for_row[data_row] = tcv - return tcv @objc_method - def tableView_pasteboardWriterForRow_(self, table, row) -> None: + def tableView_pasteboardWriterForRow_(self, table, row) -> None: # pragma: no cover # this seems to be required to prevent issue 21562075 in AppKit return None @@ -108,13 +93,7 @@ def selectionShouldChangeInTableView_(self, table) -> bool: @objc_method def tableViewSelectionDidChange_(self, notification) -> None: - if notification.object.selectedRow == -1: - selected = None - else: - selected = self.interface.data[notification.object.selectedRow] - - if self.interface.on_select: - self.interface.on_select(self.interface, row=selected) + self.interface.on_select(None) # 2021-09-04: Commented out this method because it appears to be a # source of significant slowdown when the table has a lot of data @@ -143,19 +122,14 @@ def tableViewSelectionDidChange_(self, notification) -> None: # target methods @objc_method def onDoubleClick_(self, sender) -> None: - if self.clickedRow == -1: - clicked = None - else: + if self.clickedRow != -1: clicked = self.interface.data[self.clickedRow] - if self.interface.on_double_click: - self.interface.on_double_click(self.interface, row=clicked) + self.interface.on_activate(None, row=clicked) class Table(Widget): def create(self): - self._view_for_row = dict() - # Create a table view, and put it in a scroll view. # The scroll view is the native, because it's the outer container. self.native = NSScrollView.alloc().init() @@ -164,117 +138,117 @@ def create(self): self.native.autohidesScrollers = False self.native.borderType = NSBezelBorder - self.table = TogaTable.alloc().init() - self.table.interface = self.interface - self.table.impl = self - self.table.columnAutoresizingStyle = NSTableViewColumnAutoresizingStyle.Uniform - self.table.usesAlternatingRowBackgroundColors = True - self.table.allowsMultipleSelection = self.interface.multiple_select + self.native_table = TogaTable.alloc().init() + self.native_table.interface = self.interface + self.native_table.impl = self + self.native_table.columnAutoresizingStyle = ( + NSTableViewColumnAutoresizingStyle.Uniform + ) + self.native_table.usesAlternatingRowBackgroundColors = True + self.native_table.allowsMultipleSelection = self.interface.multiple_select # Create columns for the table self.columns = [] - # Cocoa identifies columns by an accessor; to avoid repeated - # conversion from ObjC string to Python String, create the - # ObjC string once and cache it. - self.column_identifiers = {} - for heading, accessor in zip( - self.interface.headings, self.interface._accessors - ): - self._add_column(heading, accessor) - - self.table.delegate = self.table - self.table.dataSource = self.table - self.table.target = self.table - self.table.doubleAction = SEL("onDoubleClick:") + if self.interface.headings: + for index, (heading, accessor) in enumerate( + zip(self.interface.headings, self.interface.accessors) + ): + self._insert_column(index, heading, accessor) + else: + self.native_table.setHeaderView(None) + for index, accessor in enumerate(self.interface.accessors): + self._insert_column(index, None, accessor) + + self.native_table.delegate = self.native_table + self.native_table.dataSource = self.native_table + self.native_table.target = self.native_table + self.native_table.doubleAction = SEL("onDoubleClick:") # Embed the table view in the scroll view - self.native.documentView = self.table + self.native.documentView = self.native_table # Add the layout constraints self.add_constraints() def change_source(self, source): - self.table.reloadData() + self.native_table.reloadData() def insert(self, index, item): # set parent = None if inserting to the root item index_set = NSIndexSet.indexSetWithIndex(index) - self.table.insertRowsAtIndexes( + self.native_table.insertRowsAtIndexes( index_set, withAnimation=NSTableViewAnimation.EffectNone ) def change(self, item): - row_index = self.table.rowForView(self._view_for_row[item]) + row_index = self.interface.data.index(item) row_indexes = NSIndexSet.indexSetWithIndex(row_index) column_indexes = NSIndexSet.indexSetWithIndexesInRange( NSRange(0, len(self.columns)) ) - self.table.reloadDataForRowIndexes(row_indexes, columnIndexes=column_indexes) + + self.native_table.reloadDataForRowIndexes( + row_indexes, columnIndexes=column_indexes + ) def remove(self, index, item): indexes = NSIndexSet.indexSetWithIndex(index) - self.table.removeRowsAtIndexes( + self.native_table.removeRowsAtIndexes( indexes, withAnimation=NSTableViewAnimation.EffectNone ) def clear(self): - self._view_for_row.clear() - self.table.reloadData() + self.native_table.reloadData() def get_selection(self): if self.interface.multiple_select: selection = [] - current_index = self.table.selectedRowIndexes.firstIndex - for i in range(self.table.selectedRowIndexes.count): - selection.append(self.interface.data[current_index]) - current_index = self.table.selectedRowIndexes.indexGreaterThanIndex( - current_index + current_index = self.native_table.selectedRowIndexes.firstIndex + for i in range(self.native_table.selectedRowIndexes.count): + selection.append(current_index) + current_index = ( + self.native_table.selectedRowIndexes.indexGreaterThanIndex( + current_index + ) ) return selection else: - index = self.table.selectedRow + index = self.native_table.selectedRow if index != -1: - return self.interface.data[index] + return index else: return None - def set_on_select(self, handler): - pass - - def set_on_double_click(self, handler): - pass - def scroll_to_row(self, row): - self.table.scrollRowToVisible(row) + self.native_table.scrollRowToVisible(row) def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - def _add_column(self, heading, accessor): - column_identifier = at(accessor) - self.column_identifiers[accessor] = column_identifier - column = NSTableColumn.alloc().initWithIdentifier(column_identifier) + def _insert_column(self, index, heading, accessor): + column = NSTableColumn.alloc().initWithIdentifier(accessor) column.minWidth = 16 - self.table.addTableColumn(column) - self.columns.append(column) - column.headerCell.stringValue = heading + self.columns.insert(index, column) + self.native_table.addTableColumn(column) + if index != len(self.columns) - 1: + self.native_table.moveColumn(len(self.columns) - 1, toColumn=index) + + if heading is not None: + column.headerCell.stringValue = heading - def add_column(self, heading, accessor): - self._add_column(heading, accessor) - self.table.sizeToFit() + def insert_column(self, index, heading, accessor): + self._insert_column(index, heading, accessor) + self.native_table.sizeToFit() - def remove_column(self, accessor): - column_identifier = self.column_identifiers[accessor] - column = self.table.tableColumnWithIdentifier(column_identifier) - self.table.removeTableColumn(column) + def remove_column(self, index): + column = self.columns[index] + self.native_table.removeTableColumn(column) # delete column and identifier self.columns.remove(column) - del self.column_identifiers[accessor] - - self.table.sizeToFit() + self.native_table.sizeToFit() diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 83edbf7b1f..1e335e695e 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -166,17 +166,24 @@ async def type_character(self, char): ), ) - async def mouse_event(self, event_type, location, delay=None): + async def mouse_event( + self, + event_type, + location, + delay=None, + modifierFlags=0, + clickCount=1, + ): await self.post_event( NSEvent.mouseEventWithType( event_type, location=location, - modifierFlags=0, + modifierFlags=modifierFlags, timestamp=0, windowNumber=self.native.window.windowNumber, context=None, eventNumber=0, - clickCount=1, + clickCount=clickCount, pressure=1.0 if event_type == NSEventType.LeftMouseDown else 0.0, ), delay=delay, diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py new file mode 100644 index 0000000000..ca8ad7a85a --- /dev/null +++ b/cocoa/tests_backend/widgets/table.py @@ -0,0 +1,110 @@ +from rubicon.objc import NSPoint + +from toga_cocoa.libs import NSEventType, NSScrollView, NSTableView + +from .base import SimpleProbe +from .properties import toga_color + +NSEventModifierFlagCommand = 1 << 20 + + +class TableProbe(SimpleProbe): + native_class = NSScrollView + supports_cell_widgets = True + supports_cell_icons = True + + def __init__(self, widget): + super().__init__(widget) + self.native_table = widget._impl.native_table + assert isinstance(self.native_table, NSTableView) + + @property + def background_color(self): + if self.native.drawsBackground: + return toga_color(self.native.backgroundColor) + else: + return None + + @property + def row_count(self): + return int(self.native_table.numberOfRowsInTableView(self.native_table)) + + @property + def column_count(self): + return len(self.native_table.tableColumns) + + def assert_cell_content(self, row, col, value, icon=None): + view = self.native_table.tableView( + self.native_table, + viewForTableColumn=self.native_table.tableColumns[col], + row=row, + ) + assert str(view.textField.stringValue) == value + + if icon: + assert view.imageView.image == icon._impl.native + + @property + def max_scroll_position(self): + return int(self.native.documentView.bounds.size.height) - int( + self.native.contentView.bounds.size.height + ) + + @property + def scroll_position(self): + return int(self.native.contentView.bounds.origin.y) + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass + + @property + def header_height(self): + if self.native_table.headerView: + return self.native_table.headerView.frame.size.height + else: + return 0 + + def row_position(self, row): + # Pick a point half way across horizontally, and half way down the row, + # taking into account the size of the rows and the header + row_height = self.native_table.rowHeight + return self.native.convertPoint( + NSPoint( + self.width / 2, + row * row_height + self.header_height + row_height / 2, + ), + toView=None, + ) + + async def select_row(self, row, add=False): + point = self.row_position(row) + # Selection maintains an inner mouse event loop, so we can't + # use the "wait for another event" approach for the mouse events. + # Use a short delay instead. + await self.mouse_event( + NSEventType.LeftMouseDown, + point, + delay=0.1, + modifierFlags=NSEventModifierFlagCommand if add else 0, + ) + await self.mouse_event( + NSEventType.LeftMouseUp, + point, + delay=0.1, + modifierFlags=NSEventModifierFlagCommand if add else 0, + ) + + async def activate_row(self, row): + point = self.row_position(row) + # Selection maintains an inner mouse event loop, so we can't + # use the "wait for another event" approach for the mouse events. + # Use a short delay instead. + await self.mouse_event(NSEventType.LeftMouseDown, point, delay=0.1) + await self.mouse_event(NSEventType.LeftMouseUp, point, delay=0.1) + + # Second click, with a click count. + await self.mouse_event( + NSEventType.LeftMouseDown, point, delay=0.1, clickCount=2 + ) + await self.mouse_event(NSEventType.LeftMouseUp, point, delay=0.1, clickCount=2) diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 990a4b39aa..0cab5382d3 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -77,10 +77,12 @@ def __init__( ) self._multiple_select = multiple_select + self._missing_value = missing_value or "" + + # Prime some properties that need to exist before the table is created. self.on_select = None self.on_activate = None - - self._missing_value = missing_value or "" + self._data = None self._impl = self.factory.Table(interface=self) self.data = data @@ -88,6 +90,22 @@ def __init__( self.on_select = on_select self.on_activate = on_activate + @property + def enabled(self) -> bool: + """Is the widget currently enabled? i.e., can the user interact with the widget? + Table widgets cannot be disabled; this property will always return True; any + attempt to modify it will be ignored. + """ + return True + + @enabled.setter + def enabled(self, value): + pass + + def focus(self): + "No-op; Table cannot accept input focus" + pass + @property def data(self) -> ListSource: """The data to display in the table, as a ListSource. @@ -237,7 +255,7 @@ def insert_column( else: index = min(len(self._accessors), index) - if self._headings: + if self._headings is not None: self._headings.insert(index, heading) self._accessors.insert(index, accessor) @@ -259,7 +277,8 @@ def remove_column(self, column: int | str): index = column # Remove column - del self._headings[index] + if self._headings is not None: + del self._headings[index] del self._accessors[index] self._impl.remove_column(index) diff --git a/core/tests/widgets/test_table.py b/core/tests/widgets/test_table.py index 6d818f2742..8e5f742c61 100644 --- a/core/tests/widgets/test_table.py +++ b/core/tests/widgets/test_table.py @@ -5,6 +5,7 @@ import toga from toga.sources import ListSource from toga_dummy.utils import ( + assert_action_not_performed, assert_action_performed, assert_action_performed_with, ) @@ -118,6 +119,25 @@ def test_create_headings_required(): toga.Table() +def test_disable_no_op(table): + "Table doesn't have a disabled state" + # Enabled by default + assert table.enabled + + # Try to disable the widget + table.enabled = False + + # Still enabled. + assert table.enabled + + +def test_focus_noop(table): + "Focus is a no-op." + + table.focus() + assert_action_not_performed(table, "focus") + + def test_set_data_list(table, on_select_handler): "Data can be set from a list of lists" @@ -258,6 +278,16 @@ def test_multiple_selection(source, on_select_handler): on_select_handler.assert_called_once_with(table) +def test_activation(table, on_activate_handler): + "A row can be activated" + + # Activate an item + table._impl.simulate_activate(1) + + # Activate handler was triggered; the activated row is provided + on_activate_handler.assert_called_once_with(table, row=table.data[1]) + + def test_scroll_to_top(table): "A table can be scrolled to the top" table.scroll_to_top() @@ -495,6 +525,25 @@ def test_remove_column_negative_index(table): assert table.accessors == ["value"] +def test_remove_column_no_headings(table): + "A column can be removed when there are no headings" + table = toga.Table( + headings=None, + accessors=["primus", "secondus"], + ) + + table.remove_column(1) + + # The column was removed + assert_action_performed_with( + table, + "remove column", + index=1, + ) + assert table.headings is None + assert table.accessors == ["primus"] + + def test_deprecated_names(on_activate_handler): "Deprecated names still work" diff --git a/dummy/src/toga_dummy/widgets/table.py b/dummy/src/toga_dummy/widgets/table.py index a4deef6284..1e5d42b710 100644 --- a/dummy/src/toga_dummy/widgets/table.py +++ b/dummy/src/toga_dummy/widgets/table.py @@ -38,6 +38,9 @@ def insert_column(self, index, heading, accessor): def remove_column(self, index): self._action("remove column", index=index) - def simulate_selection(self, selection): - self._set_value("selection", selection) + def simulate_selection(self, row): + self._set_value("selection", row) self.interface.on_select(None) + + def simulate_activate(self, row): + self.interface.on_activate(None, row=self.interface.data[row]) diff --git a/examples/table/table/app.py b/examples/table/table/app.py index ca2a778094..9a983d500e 100644 --- a/examples/table/table/app.py +++ b/examples/table/table/app.py @@ -35,16 +35,16 @@ def on_select_handler2(self, widget, row, **kwargs): else: self.label_table2.text = "No row selected" - def on_double_click1(self, widget, row, **kwargs): + def on_activate1(self, widget, row, **kwargs): self.main_window.info_dialog( title="movie selection", - message=self.build_double_click_message(row=row, table_index=1), + message=self.build_activate_message(row=row, table_index=1), ) - def on_double_click2(self, widget, row, **kwargs): + def on_activate2(self, widget, row, **kwargs): self.main_window.info_dialog( title="movie selection", - message=self.build_double_click_message(row=row, table_index=2), + message=self.build_activate_message(row=row, table_index=2), ) # Button callback functions @@ -129,7 +129,7 @@ def startup(self): ), multiple_select=False, on_select=self.on_select_handler1, - on_double_click=self.on_double_click1, + on_activate=self.on_activate1, missing_value="Unknown", ) @@ -139,7 +139,7 @@ def startup(self): multiple_select=True, style=Pack(flex=1, padding_left=5), on_select=self.on_select_handler2, - on_double_click=self.on_double_click2, + on_activate=self.on_activate2, missing_value="?", ) @@ -196,7 +196,7 @@ def increase_fontsize(self, widget): self.table1._impl.set_font(font) @classmethod - def build_double_click_message(cls, row, table_index): + def build_activate_message(cls, row, table_index): adjective = random.choice( ["magnificent", "amazing", "awesome", "life-changing"] ) diff --git a/testbed/tests/widgets/properties.py b/testbed/tests/widgets/properties.py index 6267ac55a4..ce0c98566f 100644 --- a/testbed/tests/widgets/properties.py +++ b/testbed/tests/widgets/properties.py @@ -351,7 +351,7 @@ async def test_color(widget, probe): "The foreground color of a widget can be changed" for color in COLORS: widget.style.color = color - await probe.redraw("Widget text color should be %s" % color) + await probe.redraw("Widget foreground color should be %s" % color) assert_color(probe.color, color) @@ -362,12 +362,12 @@ async def test_color_reset(widget, probe): # Set the color to something different widget.style.color = RED - await probe.redraw("Widget text color should be RED") + await probe.redraw("Widget foreground color should be RED") assert_color(probe.color, named_color(RED)) # Reset the color, and check that it has been restored to the original del widget.style.color - await probe.redraw("Widget text color should be restored to the original") + await probe.redraw("Widget foreground color should be restored to the original") assert_color(probe.color, original) @@ -375,7 +375,7 @@ async def test_background_color(widget, probe): "The background color of a widget can be set" for color in COLORS: widget.style.background_color = color - await probe.redraw("Widget text background color should be %s" % color) + await probe.redraw("Widget background color should be %s" % color) if not getattr(probe, "background_supports_alpha", True): color.a = 1 assert_color(probe.background_color, color) @@ -388,13 +388,13 @@ async def test_background_color_reset(widget, probe): # Set the background color to something different widget.style.background_color = RED - await probe.redraw("Widget text background color should be RED") + await probe.redraw("Widget background background color should be RED") assert_color(probe.background_color, named_color(RED)) # Reset the background color, and check that it has been restored to the original del widget.style.background_color await probe.redraw( - message="Widget text background color should be restored to original" + message="Widget background background color should be restored to original" ) assert_color(probe.background_color, original) @@ -405,7 +405,7 @@ async def test_background_color_transparent(widget, probe): supports_alpha = getattr(probe, "background_supports_alpha", True) widget.style.background_color = TRANSPARENT - await probe.redraw("Widget text background color should be TRANSPARENT") + await probe.redraw("Widget background background color should be TRANSPARENT") assert_color(probe.background_color, TRANSPARENT if supports_alpha else original) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py new file mode 100644 index 0000000000..ec8d0339f5 --- /dev/null +++ b/testbed/tests/widgets/test_table.py @@ -0,0 +1,360 @@ +from unittest.mock import Mock + +import pytest + +import toga +from toga.sources import ListSource +from toga.style.pack import Pack + +from .probe import get_probe +from .properties import ( # noqa: F401 + test_background_color, + test_background_color_reset, + test_enable_noop, + test_flex_widget_size, + test_focus_noop, +) + + +@pytest.fixture +def on_select_handler(): + return Mock() + + +@pytest.fixture +def on_activate_handler(): + return Mock() + + +@pytest.fixture +def source(): + return ListSource( + accessors=["a", "b", "c", "d", "e"], + data=[ + {"a": f"A{i}", "b": f"B{i}", "c": f"C{i}", "d": f"D{i}", "e": f"E{i}"} + for i in range(0, 100) + ], + ) + + +@pytest.fixture +async def widget(source, on_select_handler, on_activate_handler): + return toga.Table( + ["A", "B", "C"], + data=source, + missing_value="MISSING!", + on_select=on_select_handler, + on_activate=on_activate_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +def headerless_widget(source, on_select_handler): + return toga.Table( + data=source, + missing_value="MISSING!", + accessors=["a", "b", "c"], + on_select=on_select_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +async def headerless_probe(main_window, headerless_widget): + old_content = main_window.content + + box = toga.Box(children=[headerless_widget]) + main_window.content = box + probe = get_probe(headerless_widget) + await probe.redraw("Constructing headerless Table probe") + probe.assert_container(box) + yield probe + + main_window.content = old_content + + +@pytest.fixture +def multiselect_widget(source, on_select_handler): + return toga.Table( + ["A", "B", "C"], + data=source, + multiple_select=True, + on_select=on_select_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +async def multiselect_probe(main_window, multiselect_widget): + old_content = main_window.content + + box = toga.Box(children=[multiselect_widget]) + main_window.content = box + probe = get_probe(multiselect_widget) + await probe.redraw("Constructing multiselect Table probe") + probe.assert_container(box) + yield probe + + main_window.content = old_content + + +async def test_scroll(widget, probe): + """The table can be scrolled""" + + # Store the initial position; it might be <0 for implementation reasons. + initial_position = probe.scroll_position + assert initial_position <= 0 + + # Scroll to the bottom of the table + widget.scroll_to_bottom() + await probe.wait_for_scroll_completion() + await probe.redraw("Table scrolled to bottom") + + assert probe.scroll_position == probe.max_scroll_position + + # Scroll to the middle of the table + widget.scroll_to_row(50) + await probe.wait_for_scroll_completion() + await probe.redraw("Table scrolled to mid row") + + # Row 50 should be visible. It could be at the top of the table, or the bottom of + # the table; we don't really care which - as long as it's roughly in the middle of + # the scroll range, call it a win. + assert probe.scroll_position == pytest.approx( + probe.max_scroll_position / 2, abs=250 + ) + + # Scroll to the top of the table + widget.scroll_to_top() + await probe.wait_for_scroll_completion() + await probe.redraw("Table scrolled to bottom") + assert probe.scroll_position == initial_position + + +async def test_select(widget, probe, source, on_select_handler): + """Rows can be selected""" + # Initial selection is empty + assert widget.selection is None + await probe.redraw("No row is selected") + on_select_handler.assert_not_called() + + # A single row can be selected + await probe.select_row(1) + await probe.redraw("Second row is selected") + assert widget.selection == source[1] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + # Trying to multi-select only does a single select + await probe.select_row(2, add=True) + await probe.redraw("Third row is selected") + assert widget.selection == source[2] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + +async def test_activate( + widget, + probe, + source, + on_select_handler, + on_activate_handler, +): + """Rows can be activated""" + + await probe.activate_row(1) + await probe.redraw("Second row is activated") + + # Activation selects the row. + assert widget.selection == source[1] + on_select_handler.assert_called_once_with(widget) + on_select_handler.reset_mock() + + on_activate_handler.assert_called_once_with(widget, row=source[1]) + on_activate_handler.reset_mock() + + # Double click somewhere not on the table + await probe.activate_row(-1) + await probe.redraw("Somewhere off the table is activated") + + on_activate_handler.assert_not_called() + on_activate_handler.reset_mock() + + +async def test_multiselect( + multiselect_widget, + multiselect_probe, + source, + on_select_handler, +): + """A table can be set up for multi-select""" + await multiselect_probe.redraw("No row is selected in multiselect table") + + # Initial selection is empty + assert multiselect_widget.selection == [] + on_select_handler.assert_not_called() + + # A single row can be selected + await multiselect_probe.select_row(1) + assert multiselect_widget.selection == [source[1]] + await multiselect_probe.redraw("One row is selected in multiselect table") + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + # A row can be added to the selection + await multiselect_probe.select_row(2, add=True) + await multiselect_probe.redraw("Two rows are selected in multiselect table") + assert multiselect_widget.selection == [source[1], source[2]] + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + # A row can be removed from the selection + await multiselect_probe.select_row(1, add=True) + await multiselect_probe.redraw("First row has been removed from the selection") + assert multiselect_widget.selection == [source[2]] + on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.reset_mock() + + +class MyData: + def __init__(self, value): + self.value = value + + def __str__(self): + return f"" + + +async def _row_change_test(widget, probe): + """Meta test for adding and removing data to the table""" + + # Change the data source for something smaller + widget.data = [{"a": f"A{i}", "b": i, "c": MyData(i)} for i in range(0, 5)] + await probe.redraw("Data source has been changed") + + assert probe.row_count == 5 + # All cell contents are strings + probe.assert_cell_content(4, 0, "A4") + probe.assert_cell_content(4, 1, "4") + probe.assert_cell_content(4, 2, "") + + # Append a row to the table + widget.data.append({"a": "AX", "b": "BX", "c": "CX"}) + await probe.redraw("Full row has been appended") + + assert probe.row_count == 6 + probe.assert_cell_content(4, 0, "A4") + probe.assert_cell_content(5, 0, "AX") + + # Insert a row into the middle of the table; + # Row is missing a B accessor + widget.data.insert(2, {"a": "AY", "c": "CY"}) + await probe.redraw("Partial row has been appended") + + assert probe.row_count == 7 + probe.assert_cell_content(2, 0, "AY") + probe.assert_cell_content(5, 0, "A4") + probe.assert_cell_content(6, 0, "AX") + + # Missing value has been populated + probe.assert_cell_content(2, 1, "MISSING!") + + # Change content on the partial row + widget.data[2].a = "ANEW" + widget.data[2].b = "BNEW" + await probe.redraw("Partial row has been updated") + + assert probe.row_count == 7 + probe.assert_cell_content(2, 0, "ANEW") + probe.assert_cell_content(5, 0, "A4") + probe.assert_cell_content(6, 0, "AX") + + # Missing value has the default empty string + probe.assert_cell_content(2, 1, "BNEW") + + # Delete a row + del widget.data[3] + await probe.redraw("Row has been removed") + assert probe.row_count == 6 + probe.assert_cell_content(2, 0, "ANEW") + probe.assert_cell_content(4, 0, "A4") + probe.assert_cell_content(5, 0, "AX") + + # Clear the table + widget.data.clear() + await probe.redraw("Data has been cleared") + assert probe.row_count == 0 + + +async def test_row_changes(widget, probe): + """Rows can be added and removed""" + # Header is visible + assert probe.header_height > 10 + await _row_change_test(widget, probe) + + +async def test_headerless_row_changes(headerless_widget, headerless_probe): + """Rows can be added and removed to a headerless table""" + # Header doesn't exist + assert headerless_probe.header_height == 0 + await _row_change_test(headerless_widget, headerless_probe) + + +async def _column_change_test(widget, probe): + """Meta test for adding and removing columns""" + # Initially 3 columns; Cell 0,2 contains C1 + assert probe.column_count == 3 + probe.assert_cell_content(0, 2, "C0") + + widget.append_column("E", accessor="e") + await probe.redraw("E column appended") + + # 4 columns; the new content on row 0 is "E1" + assert probe.column_count == 4 + probe.assert_cell_content(0, 2, "C0") + probe.assert_cell_content(0, 3, "E0") + + widget.insert_column(3, "D", accessor="d") + await probe.redraw("E column appended") + + # 5 columns; the new content on row 0 is "D1", between C1 and E1 + assert probe.column_count == 5 + probe.assert_cell_content(0, 2, "C0") + probe.assert_cell_content(0, 3, "D0") + probe.assert_cell_content(0, 4, "E0") + + widget.remove_column(2) + await probe.redraw("C column removed") + + # 4 columns; C1 has gone + assert probe.column_count == 4 + probe.assert_cell_content(0, 2, "D0") + probe.assert_cell_content(0, 3, "E0") + + +async def test_column_changes(widget, probe): + """Columns can be added and removed""" + await _column_change_test(widget, probe) + + +async def test_headerless_column_changes(headerless_widget, headerless_probe): + """Columns can be added and removed to a headerless table""" + await _column_change_test(headerless_widget, headerless_probe) + + +# async def test_crash(widget, probe): +# import random + +# for i in range(0, 1000): +# row = random.randint(0, 17) +# await probe.select_row(row) +# await probe.redraw(f"{i}: select {row}", delay=0.2) + + +# async def test_cell_widget(widget, probe): +# "A widget can be used as a cell value" + +# async def test_cell_icon(widget, probe): +# "A widget can be used as a cell value" +# # icon in table as tuple +# # icon in table as attribute/value From f63c715af980d878b304d13d06c106f9655f63d9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 25 Jun 2023 13:04:30 +0800 Subject: [PATCH 06/40] 100% coverage and testbed for icons. --- android/tests_backend/icons.py | 32 +++++ cocoa/tests_backend/icons.py | 32 +++++ core/src/toga/icons.py | 73 +++++------ core/tests/command/test_command.py | 4 +- core/tests/resources/blue.png | Bin 0 -> 863 bytes core/tests/resources/orange.bmp | Bin 0 -> 20874 bytes core/tests/resources/orange.png | Bin 0 -> 863 bytes core/tests/resources/red-32.png | Bin 0 -> 604 bytes core/tests/resources/red-72.png | Bin 0 -> 863 bytes core/tests/resources/red.png | Bin 0 -> 863 bytes core/tests/test_app.py | 11 +- core/tests/test_deprecated_factory.py | 7 -- core/tests/test_icon.py | 50 -------- core/tests/test_icons.py | 115 ++++++++++++++++++ docs/reference/api/resources/icons.rst | 43 +++---- docs/reference/data/widgets_by_platform.csv | 2 +- docs/spelling_wordlist | 9 +- dummy/src/toga_dummy/icons.py | 3 +- gtk/tests_backend/icons.py | 36 ++++++ iOS/src/toga_iOS/icons.py | 4 +- iOS/tests_backend/icons.py | 32 +++++ testbed/src/testbed/resources/icons/blue.png | Bin 0 -> 863 bytes .../src/testbed/resources/icons/green-32.png | Bin 0 -> 604 bytes .../src/testbed/resources/icons/green-72.png | Bin 0 -> 863 bytes .../src/testbed/resources/icons/green.icns | Bin 0 -> 84304 bytes testbed/src/testbed/resources/icons/green.ico | Bin 0 -> 30892 bytes testbed/src/testbed/resources/icons/green.png | Bin 0 -> 863 bytes .../src/testbed/resources/icons/orange.ico | Bin 0 -> 30922 bytes .../src/testbed/resources/icons/red-32.png | Bin 0 -> 604 bytes .../src/testbed/resources/icons/red-72.png | Bin 0 -> 863 bytes testbed/src/testbed/resources/icons/red.icns | Bin 0 -> 85426 bytes testbed/src/testbed/resources/icons/red.ico | Bin 0 -> 30802 bytes testbed/src/testbed/resources/icons/red.png | Bin 0 -> 863 bytes testbed/tests/test_icons.py | 28 +++++ winforms/tests_backend/icons.py | 32 +++++ 35 files changed, 376 insertions(+), 137 deletions(-) create mode 100644 android/tests_backend/icons.py create mode 100644 cocoa/tests_backend/icons.py create mode 100644 core/tests/resources/blue.png create mode 100644 core/tests/resources/orange.bmp create mode 100644 core/tests/resources/orange.png create mode 100644 core/tests/resources/red-32.png create mode 100644 core/tests/resources/red-72.png create mode 100644 core/tests/resources/red.png delete mode 100644 core/tests/test_icon.py create mode 100644 core/tests/test_icons.py create mode 100644 gtk/tests_backend/icons.py create mode 100644 iOS/tests_backend/icons.py create mode 100644 testbed/src/testbed/resources/icons/blue.png create mode 100644 testbed/src/testbed/resources/icons/green-32.png create mode 100644 testbed/src/testbed/resources/icons/green-72.png create mode 100644 testbed/src/testbed/resources/icons/green.icns create mode 100644 testbed/src/testbed/resources/icons/green.ico create mode 100644 testbed/src/testbed/resources/icons/green.png create mode 100644 testbed/src/testbed/resources/icons/orange.ico create mode 100644 testbed/src/testbed/resources/icons/red-32.png create mode 100644 testbed/src/testbed/resources/icons/red-72.png create mode 100644 testbed/src/testbed/resources/icons/red.icns create mode 100644 testbed/src/testbed/resources/icons/red.ico create mode 100644 testbed/src/testbed/resources/icons/red.png create mode 100644 testbed/tests/test_icons.py create mode 100644 winforms/tests_backend/icons.py diff --git a/android/tests_backend/icons.py b/android/tests_backend/icons.py new file mode 100644 index 0000000000..da3fcd3f96 --- /dev/null +++ b/android/tests_backend/icons.py @@ -0,0 +1,32 @@ +import pytest + +from .probe import BaseProbe + + +class IconProbe(BaseProbe): + # Android only supports 1 format, so the alternate is the same as the primary. + alternate_resource = "resources/icons/blue" + + def __init__(self, app, icon): + super().__init__() + self.app = app + self.icon = icon + # At least for now, there's no native object. + # assert isinstance(self.icon._impl.native, NSImage) + + def assert_icon_content(self, path): + if path == "resources/icons/green": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources" / "icons" / "green.png" + ) + elif path == "resources/icons/blue": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources" / "icons" / "blue.png" + ) + else: + pytest.fail("Unknown icon resource") + + def assert_default_icon_content(self): + assert self.icon._impl.path == self.app.paths.toga / "resources" / "toga.png" diff --git a/cocoa/tests_backend/icons.py b/cocoa/tests_backend/icons.py new file mode 100644 index 0000000000..dfa7aa64f1 --- /dev/null +++ b/cocoa/tests_backend/icons.py @@ -0,0 +1,32 @@ +import pytest + +from toga_cocoa.libs import NSImage + +from .probe import BaseProbe + + +class IconProbe(BaseProbe): + alternate_resource = "resources/icons/blue" + + def __init__(self, app, icon): + super().__init__() + self.app = app + self.icon = icon + assert isinstance(self.icon._impl.native, NSImage) + + def assert_icon_content(self, path): + if path == "resources/icons/green": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources" / "icons" / "green.icns" + ) + elif path == "resources/icons/blue": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources" / "icons" / "blue.png" + ) + else: + pytest.fail("Unkonwn icon resouce") + + def assert_default_icon_content(self): + assert self.icon._impl.path == self.app.paths.toga / "resources" / "toga.icns" diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py index 955a857a63..1e3851d5ed 100644 --- a/core/src/toga/icons.py +++ b/core/src/toga/icons.py @@ -1,5 +1,4 @@ -import os -import warnings +from pathlib import Path import toga from toga.platform import get_platform_factory @@ -27,14 +26,6 @@ def __get__(self, obj, owner): class Icon: - """A representation of an Icon image. - - :param path: The path to the icon file, relative to the application's - module directory. - :param system: Is this a system resource? Set to ``True`` if the icon is - one of the Toga-provided icons. Default is False. - """ - @cachedicon def TOGA_ICON(cls): return Icon("resources/toga", system=True) @@ -43,8 +34,23 @@ def TOGA_ICON(cls): def DEFAULT_ICON(cls): return Icon("resources/toga", system=True) - def __init__(self, path, system=False): - self.path = path + # System is + def __init__( + self, + path=None, + *, + system=False, # Deliberately undocumented; for internal use only + ): + """Create a new icon. + + :param path: Base filename for the icon. This can be specified as a string, or + as a :any:`pathlib.Path` object. The path can be an absolute file system + path, or a path relative to the module that defines your Toga application + class. This base filename should *not* contain an extension; a platform will + modify this base filename and add an extension to define the final icon + filename ( or filenames). If an extension is specified, it will be ignored. + """ + self.path = Path(path) self.system = system self.factory = get_platform_factory() @@ -72,43 +78,24 @@ def __init__(self, path, system=False): self._impl = self.factory.Icon(interface=self, path=full_path) except FileNotFoundError: - print( - "WARNING: Can't find icon {self.path}; falling back to default icon".format( - self=self - ) - ) - self._impl = Icon.DEFAULT_ICON._impl - - def bind(self, factory=None): - warnings.warn( - "Icons no longer need to be explicitly bound.", DeprecationWarning - ) - return self._impl + print(f"WARNING: Can't find icon {self.path}; falling back to default icon") + self._impl = self.DEFAULT_ICON._impl def _full_path(self, size, extensions, resource_path): - basename, file_extension = os.path.splitext(self.path) - - if not file_extension: - # If no extension is provided, look for one of the allowed - # icon types, in preferred format order. - for extension in extensions: - icon_path = resource_path / f"{basename}-{size}{extension}" - - if icon_path.exists(): - return icon_path - - # look for an icon file without a size in the filename - icon_path = resource_path / f"{basename}{extension}" + for extension in extensions: + if size: + icon_path = ( + resource_path + / self.path.parent + / f"{self.path.stem}-{size}{extension}" + ) if icon_path.exists(): return icon_path - elif file_extension.lower() in extensions: - # If an icon *is* provided, it must be one of the acceptable types - icon_path = resource_path / self.path + icon_path = ( + resource_path / self.path.parent / f"{self.path.stem}{extension}" + ) if icon_path.exists(): return icon_path - else: - # An icon has been specified, but it's not a valid format. - raise FileNotFoundError(f"{self.path} is not a valid icon") raise FileNotFoundError(f"Can't find icon {self.path}") diff --git a/core/tests/command/test_command.py b/core/tests/command/test_command.py index 797d65be24..0b43d2e30b 100644 --- a/core/tests/command/test_command.py +++ b/core/tests/command/test_command.py @@ -1,3 +1,5 @@ +from pathlib import Path + from tests.command.constants import COMMANDS_IN_ORDER, PARENT_GROUP1 from tests.utils import order_test @@ -40,7 +42,7 @@ def test_command_init_kargs(self): self.assertEqual(cmd.text, "test") self.assertEqual(cmd.shortcut, "t") self.assertEqual(cmd.tooltip, "test command") - self.assertEqual(cmd.icon.path, "icons/none.png") + self.assertEqual(cmd.icon.path, Path("icons/none.png")) self.assertEqual(cmd.group, grp) self.assertEqual(cmd.section, 1) self.assertEqual(cmd.order, 1) diff --git a/core/tests/resources/blue.png b/core/tests/resources/blue.png new file mode 100644 index 0000000000000000000000000000000000000000..782211bfa933049b5c8a0ba57cec97a848cb2faa GIT binary patch literal 863 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZKFm_=LCuX$FS>1BL$o|6g`_xhT*Hnk7Mg!3;cF zAx-Npzx%^tpxV>RXlrNw@PYVRG0sKO@84=KHe)qx3~gS2Us5Wjn;G9tIfOGcdmP8v#!tWkzaLE zEPt`h)5Z!A0^7o4VIkMdFbKY%sbWd)eo0jEcyC*rO>R^XW)zJ>As>2;zyOLa*wsgI=G2NT&B5xu1=DI`s-_G52 z9v@D#X9gzce=Kn1_Ew(pJ6~8@?T>WIr+$G&9FYozs<-!b?pSnNDq=Bf_FtXZ7n!cg zeQ)#ZuCkU;4OC{yThZ7Xx#QsZ43WJTR8~CPVXMAYc)tAgLT;9(wI&?fQxtF9YA@*4 z;7l|9#+wm&ec@)iKTLTmn(xRo%m78PYKdz^NlIc#s#S7PDv)9@GB7gMH89gPG7T{> zvNABVGBDLPFt9Q(xc$3k5sHS~{FKbJO57S&EjuCy)Sv;kp(HamwYVfPw*a@Eznh;P Q1nOb%boFyt=akR{0Bh|^ApigX literal 0 HcmV?d00001 diff --git a/core/tests/resources/orange.bmp b/core/tests/resources/orange.bmp new file mode 100644 index 0000000000000000000000000000000000000000..c5abad4e0b17009ec6697e3654ed5ca02e652ff2 GIT binary patch literal 20874 zcmeHPy>Hbp5Kq*C^iVeh9V(#?6rr-TRH6$-RTo$oKxJcKdj9|`LknW5ikQ10kjIvl znLmM6VucwMCMH1UuJ`;4e*yGhL~*nj z&0xqV93PXPj3W9&FS&L9@$;R}zi&SKbnVLG>mTvv+S=uv;qb=haCq_E!NK6}rLQyB zpT51Fd>=)#=@%c*Le51(E(2`?y#zV}ItKa)^mjz~jD8q~aWF1^Gv`BOr)df~3wo^p zp`NI>oRW|S@BNMotCoK1cxWDK%y0Abyz(#v)@rg;x2 zZc=6Aw1iv-*}i+gC5!VnkYU|eZr2fooCkTZ9nH$=`~47Py6Rf}QvTGh_{O0s{q0g6 zTm%X^fV_F%!ZM@73S}L*fFj3J$OTBtMnA37#g+dl$i0x$$EF93(Ok*j6yIf=kc@fJ zc&sTMT&J@-*2~6&khcPAYfQ1VQ;w*;F-Q=vB3#$(|#UvZ= zL#-xA$R{0abF$c|_sPbPI4*DBd_n2r$~B*hv~xb?3Kzxq(5RjXd8dbEM`2Vx?zN&(h`l%D4;(`HJz$jcImM`21pga`>E{;X~`;x-!;xf7~n3 zMcbgQd{z4>Z1L+FAGOD%YkJwzXBX<%M{b*QZL`7`*QiezA1yv#02}Vx_4XWGbN$EV zM_YdWFg{vrunj_dxPFz_FK(ap_~4#B?%(zJVE@i@cRfBhKJ3#vsT%j0x;Ve!`KiAA z(4KvLxGBzEjYsDh!rV{jI#0+xhDI_v&)_<}(D1Sb4hK(@))5g{PF%4%&W5fEir`i{;0yj zGxFs9b!F&KSU>k?9#sP~E<=6X-#c%N4@L~*Y@n^aZ?gU23P?U*ppYnb`Fu(-E@@vr z-;_q9__>=lMmy)@6k^>vUzyl0lzui`KA&5SBDF^$uDQBD->uN2evR)3PHkXy4*mas?%KqaKu2hn1o;Is@MwiJ zt-Jj04~v0nPcNgbo%zED;%mh?7fHW=tG(Ea)vz(NdHt2Yzb}3KIok-Ri80CB-9;;S z-<5el4tt5GuPgg=Hc2K?rIxAk8-dbIo-U3d5r@~#V2^5c5O8bX)}l1SiDQx4iooap z#HWaQzli=<-thh4J3rN^1BJHs0uR%KyAFJhY4_R}a@{J-5BuysLfZx@R`)`rID*RVT&r z7u!6oyzYvnENHx0del4Gh4cGy%M?U1TE+`+Xg$)#ya*J~Toy~!@}7J_fCJGB4p++FAK z;WT?@U~>M)0!MCd8jlK zHqY)VYYEjrWtO}ZjlGdO4xY~t*?U1{#ls!8>T8AP%U>_#W@%b$!m&L?@y4z8f^H4Y zG}CXq8Iji)Znpcwl((Y!j!eT0QD6kAmbgZgq$HN4S|t~y0x1R~10!Qy12bJC(+~qA zDsIUX5K?80>NoHH*#Ih!P-wb+`3 zm_ne?eoq(25R22TlW$8kDG0PoUp%FQDeCR-{TY#M>mEi zKXnteEicyXI>^2K^GEBnFJ;%+TUoD6IA^V6^sm_`!ZGaA+ZmzTJvOj?ouXLtY`La$ zih=S;o<_5lU((wvn-=pbyWHBag=bf3YgX%&FA9RJ1-A|~+MeiH^D#2%uKKADcUPvz zn(v=F$#n1ivP)rYOM|N8T#sAGbOrBlop*b>s%d29;l!ki(I*n`_~%8&$Uk%bd;g%5 z&+IS1>;r&aQ7v(eC`m~yNwrEYN(E93Mg~U4x&~&tMy4SKhE}GQRz^nJ1_o9J2F=Dt zHBmI==BH$)RpQq0DX(@aP=f~ChLX(O)Z&uF+ydNsmR84j0rfC=y85}Sb4q9e0MEwM Ak^lez literal 0 HcmV?d00001 diff --git a/core/tests/resources/red-72.png b/core/tests/resources/red-72.png new file mode 100644 index 0000000000000000000000000000000000000000..dace499b7243b5b93f5c99a817e842367f67f939 GIT binary patch literal 863 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZKFm_=LCu>HhRVLxV3LBLR))*hiSrH2foL&`*bAPKVe)b?>=jm%2UaALZ#l*xZ@n| zq-V{0+ZTQ#U(}>M!B<(cFI&#vRr#Zs=~K_EhikN++g@$n)xLAxGn;jNZjbz`lVbUc zZJt(M`R-E}ocbtHZf>yTtjmdKI;Vst0KL~qdjJ3c literal 0 HcmV?d00001 diff --git a/core/tests/resources/red.png b/core/tests/resources/red.png new file mode 100644 index 0000000000000000000000000000000000000000..dace499b7243b5b93f5c99a817e842367f67f939 GIT binary patch literal 863 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZKFm_=LCu>HhRVLxV3LBLR))*hiSrH2foL&`*bAPKVe)b?>=jm%2UaALZ#l*xZ@n| zq-V{0+ZTQ#U(}>M!B<(cFI&#vRr#Zs=~K_EhikN++g@$n)xLAxGn;jNZjbz`lVbUc zZJt(M`R-E}ocbtHZf>yTtjmdKI;Vst0KL~qdjJ3c literal 0 HcmV?d00001 diff --git a/core/tests/test_app.py b/core/tests/test_app.py index 990d058d3b..eb4cb5e493 100644 --- a/core/tests/test_app.py +++ b/core/tests/test_app.py @@ -1,3 +1,4 @@ +from pathlib import Path from unittest.mock import MagicMock import toga @@ -35,19 +36,13 @@ def test_app_name(self): def test_app_icon(self): # App icon will default to a name autodetected from the running module - self.assertEqual(self.app.icon.path, "resources/toga") - + self.assertEqual(self.app.icon.path, Path("resources/toga")) # This icon will be bound self.assertIsNotNone(self.app.icon._impl) - # Binding is a no op. - with self.assertWarns(DeprecationWarning): - self.app.icon.bind() - self.assertIsNotNone(self.app.icon._impl) - # Set the icon to a different resource self.app.icon = "other.icns" - self.assertEqual(self.app.icon.path, "other.icns") + self.assertEqual(self.app.icon.path, Path("other.icns")) # This icon name will *not* exist. The Impl will be the DEFAULT_ICON's impl self.assertEqual(self.app.icon._impl, toga.Icon.DEFAULT_ICON._impl) diff --git a/core/tests/test_deprecated_factory.py b/core/tests/test_deprecated_factory.py index 0e95756325..227497b3cf 100644 --- a/core/tests/test_deprecated_factory.py +++ b/core/tests/test_deprecated_factory.py @@ -55,13 +55,6 @@ def test_font(self): self.assertEqual(widget._impl.interface, widget) self.assertNotEqual(widget.factory, self.factory) - def test_icon(self): - widget = toga.Icon("resources/toga", system=True) - with self.assertWarns(DeprecationWarning): - widget.bind(factory=self.factory) - self.assertEqual(widget._impl.interface, widget) - self.assertNotEqual(widget.factory, self.factory) - def test_window(self): with self.assertWarns(DeprecationWarning): widget = toga.Window(factory=self.factory) diff --git a/core/tests/test_icon.py b/core/tests/test_icon.py deleted file mode 100644 index 7d90f9b264..0000000000 --- a/core/tests/test_icon.py +++ /dev/null @@ -1,50 +0,0 @@ -import unittest -from unittest.mock import MagicMock - -import toga - - -class TestIcon(unittest.TestCase): - def setUp(self): - # We need a test app to for icon loading to work - self.app = toga.App( - formal_name="Test App", - app_id="org.beeware.test-app", - ) - - self.factory = MagicMock() - self.factory.Icon = MagicMock( - return_value=MagicMock(spec=self.app.factory.Icon) - ) - - self.test_path = "Example.bmp" - self.icon = toga.Icon(self.test_path) - self.system_icon = toga.Icon(self.test_path, system=True) - - def test_icon_bind(self): - # Filename doesn't exist, so it binds to the default icon - self.assertEqual(self.icon._impl.interface, toga.Icon.DEFAULT_ICON) - - # Bind is a no-op - with self.assertWarns(DeprecationWarning): - self.icon.bind() - - # Icon hasn't changed. - self.assertEqual(self.icon._impl.interface, toga.Icon.DEFAULT_ICON) - self.assertEqual(self.icon.path, self.test_path) - - def test_icon_file(self): - """Validate filename property.""" - - # Validate file name/path for non-system icon - self.assertEqual(self.icon.path, self.test_path) - - # Test file name/path for system icon - self.assertEqual(self.system_icon.path, self.test_path) - - def test_TOGA_ICON(self): - """Validate TOGA_ICON.""" - # Get Tiberius object - tiberius = toga.Icon.TOGA_ICON - - self.assertEqual(tiberius.path, "resources/toga") diff --git a/core/tests/test_icons.py b/core/tests/test_icons.py new file mode 100644 index 0000000000..25885d90a9 --- /dev/null +++ b/core/tests/test_icons.py @@ -0,0 +1,115 @@ +from pathlib import Path + +import pytest + +import toga +from toga_dummy.icons import Icon as DummyIcon + +APP_RESOURCES = Path(__file__).parent / "resources" +TOGA_RESOURCES = Path(toga.__file__).parent / "resources" + + +class MyApp(toga.App): + pass + + +@pytest.fixture +def app(): + return MyApp("Icons Test", "org.beeware.toga.icons") + + +@pytest.mark.parametrize( + "path, system, sizes, extensions, final_paths", + [ + # Relative path + (Path("resources/red"), False, None, [".png"], APP_RESOURCES / "red.png"), + # Absolute path (points at a file in the system resource folder, but is declared as non-system) + ( + Path(__file__).parent.parent / "src" / "toga" / "resources" / "toga", + False, + None, + [".png"], + TOGA_RESOURCES / "toga.png", + ), + # PNG format + ("resources/red", False, None, [".png"], APP_RESOURCES / "red.png"), + # Explicitly specified as BMP, but ignored because platform wants PNG format + ("resources/red.bmp", False, None, [".png"], APP_RESOURCES / "red.png"), + # PNG format in multiple sizes + ( + "resources/red", + False, + [32, 72], + [".png"], + { + 32: APP_RESOURCES / "red-32.png", + 72: APP_RESOURCES / "red-72.png", + }, + ), + # PNG format in multiple sizes, but no individually sized PNGs available + ( + "resources/blue", + False, + [32, 72], + [".png"], + { + 32: APP_RESOURCES / "blue.png", + 72: APP_RESOURCES / "blue.png", + }, + ), + # Multiple formats, first option doesn't exist + ("resources/blue", False, None, [".bmp", ".png"], APP_RESOURCES / "blue.png"), + # Multiple formats, first match returned + ( + "resources/orange", + False, + None, + [".bmp", ".png"], + APP_RESOURCES / "orange.bmp", + ), + # Relative path, system resource + (Path("resources/toga"), True, None, [".png"], TOGA_RESOURCES / "toga.png"), + # Relative path as string, system resource + (Path("resources/toga"), True, None, [".png"], TOGA_RESOURCES / "toga.png"), + ], +) +def test_create(monkeypatch, app, path, system, sizes, extensions, final_paths): + "Icons can be created" + # Patch the dummy Icon class to evaluated different lookup strategies. + monkeypatch.setattr(DummyIcon, "SIZES", sizes) + monkeypatch.setattr(DummyIcon, "EXTENSIONS", extensions) + + icon = toga.Icon(path, system=system) + + # Icon is bound + assert icon._impl is not None + # impl/interface round trips + assert icon._impl.interface == icon + + # The icon's path is fully qualified + assert icon._impl.path == final_paths + + +def test_create_fallback(app): + "If a resource doesn't exist, a fallback icon is used." + icon = toga.Icon("resources/missing") + + assert icon._impl is not None + assert icon._impl.interface == toga.Icon.DEFAULT_ICON + + +@pytest.mark.parametrize( + "name, path", + [ + ("DEFAULT_ICON", "resources/toga"), + ("TOGA_ICON", "resources/toga"), + ], +) +def test_cached_icons(app, name, path): + "Default icons exist, and are cached" + + icon = getattr(toga.Icon, name) + assert icon.path == Path("resources/toga") + + # Retrieve the icon a second time; The same instance is returned. + assert id(getattr(toga.Icon, name)) == id(icon) diff --git a/docs/reference/api/resources/icons.rst b/docs/reference/api/resources/icons.rst index fad27e5918..6312970ec9 100644 --- a/docs/reference/api/resources/icons.rst +++ b/docs/reference/api/resources/icons.rst @@ -1,6 +1,8 @@ Icon ==== +A small, square image, used to provide easily identifiable visual context to a widget. + .. rst-class:: widget-support .. csv-filter:: Availability (:ref:`Key `) :header-rows: 1 @@ -8,30 +10,29 @@ Icon :included_cols: 4,5,6,7,8,9 :exclude: {0: '(?!(Icon|Component))'} - Usage ----- -An icon is a small, square image, used to decorate buttons and menu items. - -A Toga icon is a **late bound** resource - that is, it can be constructed -without an implementation. When it is assigned to an app, command, or other -role where an icon is required, it is bound to a factory, at which time -the implementation is created. - -The filename specified for an icon is interpreted as a path relative to the -module that defines your Toga application. The only exception to this is a -system icon, which is relative to the toga core module itself. - -An icon is **guaranteed** to have an implementation. If you specify a filename -that cannot be found, Toga will output a warning to the console, and load a -default icon. - -When an icon file is specified, you can optionally omit the extension. If an -extension is provided, that literal file will be loaded. If the platform -backend cannot support icons of the format specified, the default icon will -be used. If an extension is *not* provided, Toga will look for a file with the -one of the platform's allowed extensions. +The filename specified for an icon can be specified as an absolute path, or as a path +relative to the module that defines your Toga application. It should be specified +*without* an extension; the platform will determine an appropriate extension, and may +also modify the name of the icon to include a file size qualifier. + +The following formats are supported (in order of preference): +* **Android** - PNG +* **iOS** ICNS, PNG, BMP, ICO +* **macOS** - ICNS, PNG, PDF +* **GTK** - PNG, ICO, ICNS. 32px and 72px variants of each icon can be provided; +* **Windows** - ICO, PNG, BMP + +The first matching icon of the most specific size will be used. For example, on Windows, +specifying an icon of ``myicon`` will cause Toga to look for ``myicon.ico``, then +``myicon.png``, then ``myicon.bmp``. On GTK, Toga will look for ``myicon-72.png`` and +``myicon-32.png``, then ``myicon.png``, then ``myicon-32.ico``, and so on. + +An icon is **guaranteed** to have an implementation. If you specify a path and not +matching icon can be found, Toga will output a warning to the console, and load a +default "Tiberius the yak" icon. Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index ae0a7a0802..5056890d3a 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -31,5 +31,5 @@ App Paths,Resource,:class:`~toga.paths.Paths`,A mechanism for obtaining platform Font,Resource,:class:`~toga.Font`,Fonts,|b|,|b|,|b|,|b|,|b|, Command,Resource,:class:`~toga.Command`,Command,|b|,|b|,|b|,,|b|, Group,Resource,:class:`~toga.Group`,Command group,|b|,|b|,|b|,|b|,|b|, -Icon,Resource,:class:`~toga.Icon`,"An icon for buttons, menus, etc",|b|,|b|,|b|,|b|,|b|, +Icon,Resource,:class:`~toga.Icon`,"A small, square image, used to provide easily identifiable visual context to a widget.",|y|,|y|,|y|,|y|,|y|, Image,Resource,:class:`~toga.Image`,An image,|y|,|y|,|y|,|y|,|y|, diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 05a1116ab9..afd9e49146 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -42,10 +42,12 @@ Pango parameterization platformer pre +prepending programmatically -pytest +px Pygame PyScript +pytest Quickstart radiusx radiusy @@ -65,14 +67,14 @@ Subclasses substring substrings testbed -triaged -Triaging Todo toolbar toolbars Toolbars toolkits Towncrier +triaged +Triaging tvOS Ubuntu validator @@ -81,4 +83,5 @@ verbing viewport watchOS WebKit +whitespace Winforms diff --git a/dummy/src/toga_dummy/icons.py b/dummy/src/toga_dummy/icons.py index fd765e1dd4..98a5c66596 100644 --- a/dummy/src/toga_dummy/icons.py +++ b/dummy/src/toga_dummy/icons.py @@ -1,6 +1,7 @@ -from .utils import LoggedObject +from .utils import LoggedObject, not_required +@not_required # Testbed coverage is complete. class Icon(LoggedObject): EXTENSIONS = [".png", ".ico"] SIZES = None diff --git a/gtk/tests_backend/icons.py b/gtk/tests_backend/icons.py new file mode 100644 index 0000000000..1ff776df1e --- /dev/null +++ b/gtk/tests_backend/icons.py @@ -0,0 +1,36 @@ +import pytest + +from toga_gtk.libs import Gtk + +from .probe import BaseProbe + + +class IconProbe(BaseProbe): + alternate_resource = "resources/icons/orange" + + def __init__(self, app, icon): + super().__init__() + self.app = app + self.icon = icon + assert isinstance(self.icon._impl.native_32, Gtk.Image) + assert isinstance(self.icon._impl.native_72, Gtk.Image) + + def assert_icon_content(self, path): + if path == "resources/icons/green": + assert self.icon._impl.paths == { + 32: self.app.paths.app / "resources" / "icons" / "green-32.png", + 72: self.app.paths.app / "resources" / "icons" / "green-72.png", + } + elif path == "resources/icons/orange": + assert self.icon._impl.paths == { + 32: self.app.paths.app / "resources" / "icons" / "orange.ico", + 72: self.app.paths.app / "resources" / "icons" / "orange.ico", + } + else: + pytest.fail("Unknown icon resource") + + def assert_default_icon_content(self): + assert self.icon._impl.paths == { + 32: self.app.paths.toga / "resources" / "toga.png", + 72: self.app.paths.toga / "resources" / "toga.png", + } diff --git a/iOS/src/toga_iOS/icons.py b/iOS/src/toga_iOS/icons.py index 0826a9a91d..233a852b4b 100644 --- a/iOS/src/toga_iOS/icons.py +++ b/iOS/src/toga_iOS/icons.py @@ -7,5 +7,5 @@ class Icon: def __init__(self, interface, path): self.interface = interface - - self.native = UIImage.alloc().initWithContentsOfFile(str(path)) + self.path = path + self.native = UIImage.imageWithContentsOfFile(str(path)) diff --git a/iOS/tests_backend/icons.py b/iOS/tests_backend/icons.py new file mode 100644 index 0000000000..f042ed97b3 --- /dev/null +++ b/iOS/tests_backend/icons.py @@ -0,0 +1,32 @@ +import pytest + +from toga_iOS.libs import UIImage + +from .probe import BaseProbe + + +class IconProbe(BaseProbe): + alternate_resource = "resources/icons/blue" + + def __init__(self, app, icon): + super().__init__() + self.app = app + self.icon = icon + assert isinstance(self.icon._impl.native, UIImage) + + def assert_icon_content(self, path): + if path == "resources/icons/green": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources" / "icons" / "green.icns" + ) + elif path == "resources/icons/blue": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources" / "icons" / "blue.png" + ) + else: + pytest.fail("Unkonwn icon resouce") + + def assert_default_icon_content(self): + assert self.icon._impl.path == self.app.paths.toga / "resources" / "toga.icns" diff --git a/testbed/src/testbed/resources/icons/blue.png b/testbed/src/testbed/resources/icons/blue.png new file mode 100644 index 0000000000000000000000000000000000000000..782211bfa933049b5c8a0ba57cec97a848cb2faa GIT binary patch literal 863 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZKFm_=LCuX$FS>1BL$o|6g`_xhT*Hnk7Mg!3;cF zAx-Npzx%^tpxV>RXlrNw@PYVRG0sKO@84=KHe)qx3~gS2Us5Wjn;G9tIfOGcdmP8v#!tWkzaLE zEPt`h)5Z!A0^7o4VIkMdFbKY%sbWd)eo0jEcyC*rO>R^XW)zJ>As>2;zyOLa*wsgI=G2NT&B5xu1=DI`s-_G52 z9v@D#X9gzce=Kn1_Ew(pJ6~8@?T>WIr+$G&9FYozs<-!b?pSnNDq=Bf_FtXZ7n!cg zeQ)#ZuCkU;4OC{yThZ7Xx#QsZ43WJTR8~CPVXMAYc)tAgLT;9(wI&?fQxtF9YA@*4 z;7l|9#+wm&ec@)iKTLTmn(xRo%m78PYKdz^NlIc#s#S7PDv)9@GB7gMH89gPG7T{> zvNABVGBDLPFt9Q(xc$3k5sHS~{FKbJO57S&EjuCy)Sv;kp(HamwYVfPw*a@Eznh;P Q1nOb%boFyt=akR{0Bh|^ApigX literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/green-32.png b/testbed/src/testbed/resources/icons/green-32.png new file mode 100644 index 0000000000000000000000000000000000000000..d15f5dbc5fb5f4c6ca2b6c861c329cb0d3db77ff GIT binary patch literal 604 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7e6l0AZa85pY67#JE_7#My5g&JNk zFq9fFFuY1&V6d9Oz#v{QXIG#NP$DqEC&U#?5 zY2}t3K%xDfE{-7$BHP##e&(nDkeDXh!1CBhYwc8# zIgWnnCTd$=tlM>vd;8~))@fhLuCuqYUYT&tTF2;LvrmL$*r&HMLbrQtVEZ~nvF6!w zP3IH?<&!*(W-Y&@w^ueT=2dpNwP6d-uF}@5)+t{U1X&Ai9cZ*Y(X-}bWYS&rQy=cG zOpi6+KXsDn-uq>j!rGPwRmZs=w~*-y-r+j$_Hzm0Xkxq!^40jEr>+%yf-RLktY9Of9U8EVT^`tPBkD zzWsWOq9HdwB{QuOw+8vuPJuuT8gLs*GILXlOA>PnaO?5dm)r-`!{F)a=d#Wzp$PzO Cgvh)A literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/green-72.png b/testbed/src/testbed/resources/icons/green-72.png new file mode 100644 index 0000000000000000000000000000000000000000..fa414187c6094ca2c3d5232d5dd0dcc5ceceeda2 GIT binary patch literal 863 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZKFm_=LCuX@-UYL;wH(?|QUa3+M*Tk|4ie1|F@D zrgfL!{b4as?dfH-wKISCKzywj=OXF%Z?zYju^KjpHm|?(_xGibKW7^OH8Cc6ySr!w z@4GS&$YC$>^mS!_&L+tyrSRVM4mVJ`$?B^n|HPET=&doU7y<{zv`q| z{$iV_l~=y|)CH$LN|c)$EIF(5(8If#cdF;BA1=38_T8xencTC(y&jtTo^kxMJ#QxW zY=6vzw{Kl`-~Th^cIAxE@)I|RcAlGjqiV)(n|ZgogI-$RuAY%Pb(eyR(fl7AftR~@ z4)627d#${s{idOJPjXDv!48|Mqa9LJhda1-CAl3VHrx;NQH-a_!rb%*x9oxAHi zKAdLH3{1}dSm4O*tvus*zOb~~AL*1&{Q`?PA{7c%Z}02evFNr`#A4R$zdExoGF_GX z-sag|Wi6o^sLYbLqOmt}$HDU%B6}~Wta!M?R(-ATeEI8z+$>FNO*povDBif$UeK+< znP&QpHzV@;!p(MnnDSOM-;rsUAqtEj)e_f;l9a@fRIB8oR3OD*WME{hYhb2pWEx^% zXk}z(Wo)EvU|?lnFnwkBP81Ef`6-!cmAExL`(n%r)Sv;kp(HamwYVfPw*a@EjSq!x Q0rfC=y85}Sb4q9e0A`^zvH$=8 literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/green.icns b/testbed/src/testbed/resources/icons/green.icns new file mode 100644 index 0000000000000000000000000000000000000000..30f930c711f129206b607090c0f204bad8d72f9e GIT binary patch literal 84304 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 literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/green.ico b/testbed/src/testbed/resources/icons/green.ico new file mode 100644 index 0000000000000000000000000000000000000000..e16a1faafd590f4458cd1261edcaf0824dabce96 GIT binary patch literal 30892 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 Q0rfC=y85}Sb4q9e0A`^zvH$=8 literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/orange.ico b/testbed/src/testbed/resources/icons/orange.ico new file mode 100644 index 0000000000000000000000000000000000000000..9f712d70152ae519138f84ab8aeea2d8a679002a GIT binary patch literal 30922 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 F{{c%%M?C-l literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/red-32.png b/testbed/src/testbed/resources/icons/red-32.png new file mode 100644 index 0000000000000000000000000000000000000000..a6cf5e69b15930628a7713c3d3f4a717b5932f3a GIT binary patch literal 604 zcmeAS@N?(olHy`uVBq!ia0vp^3LwnE3?yBabR7e6l0AZa85pY67#JE_7#My5g&JNk zFq9fFFuY1&V6d9Oz#v{QXIG#NP$DqEC&U#<|ED7Q|9{@Y^VfieN|prq1v4-z#4Nb+ zo!!fO@gfeso3|Fm#{E$I`OAmb`36S?P!VI2x4Vl}Y5Ef*Acwug)7O>#Ih!P-wb+`3 zm_ne?eoq(25R22TlW$8kDG0PoUp%FQDeCR-{TY#M>mEi zKXnteEicyXI>^2K^GEBnFJ;%+TUoD6IA^V6^sm_`!ZGaA+ZmzTJvOj?ouXLtY`La$ zih=S;o<_5lU((wvn-=pbyWHBag=bf3YgX%&FA9RJ1-A|~+MeiH^D#2%uKKADcUPvz zn(v=F$#n1ivP)rYOM|N8T#sAGbOrBlop*b>s%d29;l!ki(I*n`_~%8&$Uk%bd;g%5 z&+IS1>;r&aQ7v(eC`m~yNwrEYN(E93Mg~U4x&~&tMy4SKhE}GQRz^nJ1_o9J2F=Dt zHBmI==BH$)RpQq0DX(@aP=f~ChLX(O)Z&uF+ydNsmR84j0rfC=y85}Sb4q9e0MEwM Ak^lez literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/red-72.png b/testbed/src/testbed/resources/icons/red-72.png new file mode 100644 index 0000000000000000000000000000000000000000..dace499b7243b5b93f5c99a817e842367f67f939 GIT binary patch literal 863 zcmeAS@N?(olHy`uVBq!ia0vp^9w5xY3?!3`olAj~Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZKFm_=LCu>HhRVLxV3LBLR))*hiSrH2foL&`*bAPKVe)b?>=jm%2UaALZ#l*xZ@n| zq-V{0+ZTQ#U(}>M!B<(cFI&#vRr#Zs=~K_EhikN++g@$n)xLAxGn;jNZjbz`lVbUc zZJt(M`R-E}ocbtHZf>yTtjmdKI;Vst0KL~qdjJ3c literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/red.icns b/testbed/src/testbed/resources/icons/red.icns new file mode 100644 index 0000000000000000000000000000000000000000..2c77f8c30319a30a8967b16197b9e365c975c2f3 GIT binary patch literal 85426 zcmeEuX&_YL+xMAcFk{OuTSj)WlqDo4vXgx$Bt>KwWtkb0RF*7JvXgx)BI~r-lE|7Y zQz={aeVzB%{?GHg&xiNR`>_u*X72kw*M05Rb^p#W&JJ$g08IC#v%~2#005_r(7&Wk zO~FP1006b7#zjNuFM98VAcNiuoIOXNKQM1Yb!DKWgJTl~>1-dm_b&Ed$}tk3WKWA#CO(diaxhz&BQ>P(1=7 z5T5q7$}4|4vCN!o@^%?18{vww)bOi6-o;?psZie-vPt{Q$!~3;_Z~AC+2hG%VectY zZ`CXsrk)jnlbVx?#>l)iNXxA=%N=2ES*Gz12>q}{T2QECL>Kbt$=lmeEdi(H<2Bu% z{e46w zby9{l_b#6$P+9t8%Km4Y1pN)#K95?L^C0?ivuIgQ{mG&hmI;M>yvE|ijg)mLIpf7E z+qABNKa0PE)-!1yKO2--oz_V%9}5(@Uxm#jYbm@m2lI?PC59JCb3z5q$f?<2|CDaX z=;~O$q!`0&GS3gudpAq}{7dTZtdG6_ieG9aq35(9R`5$csZ-ax0#OaD>v-3q=uH!_ zXVIofN82n8VB0pYth8WBpQ_Yy>1AGFQwR^zh~w(dZ;z_MI>!NhO~3+$QXDYj|DfF8 z3cPz}<@}{#PHk+e<~z?+Lqb3lFFH>WpVjjzV?#`WokmhL+cB$2aE7nUh3#jF7%ISz zHEA(Anw>bMJ%D#g>W0n^lP8j;g)TNc`-(h6IH4w0OR3v; zE*9CLTh>ikXuq;);b}eo^6V^cn2ISy9ugJ=Q^uw539Mn9MS_M=K$H-H#$EpK?@bsNXfI=lqYHy(rXF zy>zie*)9ai2a*85V(`E7!Cq*?fc<=6?Ui?J@7w=}d;kOfDYm#}=K($0X+grT5xng40 zKRvQl`*m`vq@-r;`Dp!kPyX6VIjv5(;Nh`G&?lK@;&p>$OJ6;v7F!VGskMJ zZwx-J(kw~*`()_UH*V*b90tEiwJs;t3EfuziT8Z`mXAVIeA(cWyrB20ui>Yt;JC3% zt;4aMn{6*RVig2#>;A-h$Vl;Id-sIxI#mb!=9?wSof^p;<=e>Q?F;tQ_c!)3|M}=g zHPz=Uslf)Q7!Ve@P96J2>qJ~&e09>>>%8oS69KL|F<7!SuhvVg{d~E6`R8_Iw>~#L zYQJ$iM1?fYfjb>mr|cCvhP^t3y}o-@=GygpB3D>h2Ix-1sCyjg-Rm2;-@f_eX!oLD z^U8Te`ts`+-JX%vf?0fv*Ve+e_ zr1hj8Mj_pHOC~;w;zSbUHavq=b@)t`t(OMp&q7uqTERrD+Cbe{Fw$!4mZS&QhM^M$ z{F*@e+X|D#^BhXm_;JPBTdQElwoUqv)WJTg*8@=JDe;NyDk z?Z+(to}O>~mUMhud~;Nj6!2aA9lOWgWxmW6(;29Z8l3?-R0O1N&~^sjP;`wd~ekK{@8 z$zS%0D@d82=AreKp>-rfHQVyt*5_<7m$&{?cH%JnE~Fq1JsYz903(c=wJ4tZ44O1u8%)Q*c> z?>GHZH2grzh7xz4I82BoGM1>HPOH=)mhn;r{{RI<)q*rUS~+|U9s z--FnG%2C!OLdFhxX|U~gjPg6C;C$+!ztS*UnL3M00|WIWktt;HOIsz1qXVH!lpvob zcw$SbH86t?h=c>Ie}7utc~e*#8WMdMIY8bJw{daiI2#gp`SsWmA$WJNHkr&y3*TUu zmRH{sOoe{jVYacrn>JYcjEn^35zeug>pNxP0>muYiu@YM??w-XpzYu#2}UOhwZ{iR z?DJ7_g-cp}eU2P6XcQU@9-Ob^=)R5+7WX|tvMz2Bx@0zzBSk9<+qxf=^Tbe_EWMaG z?)aIZ()=P1?fp>vd9MzELYbUJNZ#=?qXhwvHy!x&H-5iMAcOQZns>)id5UYA_G5`K za@`-VCO4T=z?Q#x9N(zv`Mj}UBx_^l0A1G9HqqwUyGw-N~ar zNUEH36|xU`EZ0iUFG@H{pAX07sCd?!JqHdrdnS(Eds>+RBG9N~u&rl7Zhy`!i2>xh z*JeqCv1kKOl8g2f56QZg!HmT*kTxj--`;T6Xirya`IsC04CbuRU>KfXuH3y^#Q550 zU^hp@8)M4s?CeWWfT`3WniL+3iyrZ$2g(@ zToZC^wby!&CJiHHto5g}Zi*CW)J@Rq+Z!e?Q$2wvb*y-|-jF4|i>+z2PSmiif}mTi zv1ftp_p#FD036lH4-%8qpYl~rwQ{Y%gIR++kjnbCc^fw`7>PL3_pQoWgxuy zNe+nLEq7TYhofjg+)i9PM=fRgbNevUrU66YcgVq7R&3gdHW>bb z&h|a@bNJus)s2ogjKc8PF-Yk#RJf=wev;Umtx1s?v{)?CC$NDXXi}7BG=(;chs!ID zQUec5jzxX7eiQ=0HxK6BCW+y5(EKvo?RD5EA4R-6{5BbSc_$J_d;DbC5G%eQ<6AqV zE<;pV8_ez|#gi*MaFoPFyJ8Z&iOQ>|Fc=~mP0%9878;c?vO~7w2H81*KjGITNkCc+ z9KMbOpBWB+VJ9sE*_IPb<^*I&_GV)7(r_&A7H1ZBCwFHnP|Xn21>mHRu3lsk%T1k9 z7IxC`Se{NfO1v-7x^j+8C0&Ie3~3Vw?Z>NWS5Jax(tH-xMT^C=kppgIS5Rn_E||D+ z%kDDylWXG+(r`sDGBgf=)AJF4vY5P?Bx+m_@{}xKt(5|x$iNK+=1WNMlRZLqN)Q>3K^VdKaNMH}Mz}b|SjGiL(92!` zG%ChVvx6r+hF}C)K`3^3kv?azafj?_UI(6&+U>KK@(P&CaXx3Ng1QaP$&tt=Dv$wt zY>)e&_kYG2-AgNh`~4~L^XJK@C~x=odrEqMr`u}qgIQnR@P8)&85n^xyLtUAP?vhY zD+vih1nQ*!S{erC`Hi7nvl08|9?@*`c?5Z_ykhAuASDZ%=;8(Ahs=8hZu)6M{1yqe z_|F2Nb@}`~{!94lyMbi$ze2Gof)Z!_1U&_lj^T_E9BqLOe$4oLN{J6v29UT9$mPKL z0NPH4%i0_ie^gLR;Cb7*YhVM1M@rE+@@+m9?61yK{2jSn3zCUr!0u-p+9(~3x(wEn zU}egyC}UJZ)PGg7e?kE;I{%8Q-u~#zKuaeF866Z4(g$%=Er6P8XqH8^i!Ltgy5h+} zPTE~KFO*y@!C`g4hsru0CdjsqBD{6dZI(b}>k`rfhsa?8js`%>AI09SL~%$u9r(S# zfG#ntsEPkTdh64qq9$f2->DF8$Zd0NSx`9w-#`W7x-|MxZdyg0WN3$b+&vI|Z9MeH z_lKm=_oOKX}xO<5pFSq4R9(kMvc+Tn}?-9e?$W1GT zWAeI{_S?!>Z5F&rdBVJcR|(R;K2kxdZR2Kp3lL+m;h)!+c@~Z;yN@@wJ@JN;VB*VR z7sgNdu;9mD!aTW`93R&%J4j+(!`$yViONHn=R7#dF}{8IiYpmGNsir)f)N4NwHxaq zmZ)lz0z}N^pUwA-pl!+!*6}qiV;=^eM2k82!Rq{9|Lj*QZL@~N{S9xBY27HUt(s?e zs7$^_ro;OIst%&K3>PA~bV?G;-(D3lsxbeT@L>BM0Jqd7d~5d;5Pox&j8K_C`nPKg z!`}y8fJG-G{yz3zJ3%~usk{z`|FofSrQ~}?8(^(%`h!HfuLXoQ ziG!bLncg-%<3~maOCW7`@p?BZ1PzNIU7r}GwHI2t}T=5_IOxzuMRFK zOxjKr;s${WdL`e;o+3x$!Pf>_e-Y=a-A;zw(O8RMR08KdU5jJ+GnEND-GyMb80HJVYi&3v<YoTB-qN>5OL>44QDn(C}i!x z=&KDT!qmMM!CfVI%icS$x1Z(HfvxoB0@u%*ytCa@H4i;)^D?!O#FORYr#U}E!I}1-b&@qwD+H2t2WtxXD1fC zEPDwtdi}wudBS23C9Vbex8&=_=aR1gf$S<)+;TmnaW5E#;IplP0&5hi%tZ=-xe)8( zU4HV1f-Um28Zr0_8|`tv1I4$dgua=C1p6bJNv1Ab60xVB4<^=ec)$1_xb*C@HkqB{ znb6%crB_aoL%IKN!KaPSRj&a;)762~y0i0s6nmA95Y?`JZ#VCTS}_c-)`CyFvDjNc zm3bKf*CG@?b?+%?7%Xa>@W+1gl%%==Q($l z)@g8e5$i$0k#dc4XW4)vEU>e^r!e+#A~a_wdEv%-zE-6eEEb+!Bf+9swdP>2P?PfJcB# z)2Grmc9F#t!0QXx;1@_UO6I@%rY@o|+u!5m!v!Z(1}%IZ%o~0E8+PkPxG8PY18miH zv3Ial=OAGBn-T8THRg!PMWgh=k@2;Xn zAv6Gg398H_&Q^O{*^mN0b&`;~Hi9CTw=@eisbClq!=vE7_|u=06FE4xqep4;?qi#u zaSd};EG0*}-p>_euU79L2P%KF1~e2<`@27$YHylPqP_x3e98?w&Pw{2u@-E%Bk;|rXi z5&eLOz-{L?-w+Fh8mjcPz&_7uskz6y8-d|y8t{bV^p#t|lK15y@45OC7t9uD8-nh! z+6n%i)d6f$e?GZ!<8wedMsbw8QyuT%;QQBVM=_wg71)gI=wMVRzFoUHzI5IKY>fSl z5owN|MU5Jsf7T8Oj39-54^A9=`j)t!!iWocxmrdRdhg;LzNZlDCBEU>sl9=7%tL3_ zl^U}I2#S6+MAw22uApOj4(acRYOU{Mqhs<|1HT8I|2$y#?JmN}!}YO(D(!tS#CB&; za;JV%j|IZ1%r#L#l$!^UjN9_qI^}@#W#CVL`w9ZUzzZI@&Y;)QKQbOpI?{LAJxd(Y zLpcSZAe?N~LyHEK2eh8v!;uvBW-mqZ z>NL+Qt<^Itv(@%&LNIDKF3I(xvD2DUZ}C)$Joae~xP^{gW0KCTn?kLs zWQl?mb)D*>bLlxfMjjqqC9y>b)`aeMZAE5pZQ2z(0{{z(j0qXw%^?!i+KS2E- zp#Bd~{|Bi51JwTk>i+=s|2Lp61>x)Y7odMe!2#q{7Oxi2D{DxJ)4nTgzn6}<3uxiPSk*Fkl~1S3hDy6dq!$(SrJI%;RyOoR<(NqRYM;W& z_9++3OFqakI(Y^Z8aZWmRi=m*PIa#Z(A=Ppr`5PmkBZpZ@^w>MUdpz0^BYa{p4~`o z(`+0LnBBl!TH8q*U;Q-fz(xv#hcK`2l#I;eKYUv7%EY!<-psdQO3`B2ERoVUXmV<6 zXI-IJuHf>xNx>`Q;SMU3s>zo|zO_@@1>cP^xtmtK#RXQzXA2c`H(gE7Iwm%J>5Wz@ zo$|e`?|bgtS|^4PPKz+Z_B&NOsC=QP)%RL+@o+a0nA$NpW@?kYY1zwT65UZCmMP-W z3i5|t}K0Zj>cMuY=3E2B#X9~e@wd*9e)$>zJHr-4Fg0%v{R79ZF z%B5a)ld7`!aUdy92is4g7nV)>^6uQ)Otjf~^_9>9Z)2g)4DE0^H$V&JaU&qNb7Mu<*{_h=PRs^N8=U4?=KqD`@*{ z{^jgD7n)V9RZ*q*O3V2)6HGQ|4yO8k2s#T=w$u&TZfZ5;=x;j{j27{XiDISlaqTTtR>S8P+@(#(`N4NGi1} z4mky+@#{hU#FPR|*3{aX5aKRB;R`ZQc_m=5+znCey-beFhgQCL;BoFaj4w!FQ_@5! ztL@8QGRZSOyx~|($$ED|)eV79%$4JTm!pry#pAG33s}g1BAHNXi&2?S!aD+KI)h1h<*Ha?S?s0w;tFAcv2rmKwG} zM-Wlxy~?L-eQiSm=rptlMh{k923eI7jCkmkZIn$)>)A(xSmTVZE#W| z9#0{<pKH>oq#(@k-`gPVK>YcC~BeK|6RWWPKW3@=W-dUL?8^y`Lh+kInkT6T}Kb&6qQ3jlXmw*2A)tI|M>ol~)J ztJ05gScZTQp?`bOl`Do9xUj^H=S?HI)3p_}TPnOBZXB&ak4vXR<4bqK>%`|3VNuI) ze@axw>Hdd-HSdDbrp)LmlJ)7(s5I=Ckqtd9igSyi9fswU60rM0aPL*yRw7*VPVH34 z^TM*@AR=3!Op{yO?213CzO(yb(icD~usvaYyl~SvbJtNK7W2M2c^*o(_^taUBiH>$ zPhYbtI#CU%10YwrL2WKe>8INTqb@eTl;E^zJAa(tjRbZL#`o_(mXWCgSB~d!Z{>|) z@=V`YC5$O$b3Ww3;(1|nDzhD{LUAVuYTHoY{^WWefml8{Y}Cs&w(({vS>ObL0)(=4 z4R(!BX|3VoR#I28{pU`&>`R8h`;P{=>Lrf#7o5%adNx8wZ9xr#lToZ!ydM|zR5Ox zpn8DCd4;J!yUFRE2^|{18Nh=J5;{!jEtJ~Yii{co$?DB!yNMHm3tQI%#d|)=o5VKK zLe)h74R-CkVmBddM8M!D0|9})>hOYPaCd64%6OE`qJL5hL`azt$SGzT1v3o zSQ)VQn~YnDH>YNc>3pA-oW!Cn!Rx~LLr+Xw7Sujj0d$X2=!qi*W(9@3L`2g=v6C@n z_~H?>+i3}97o;-0;i&nHuauK3Q<<11ze@z0oB#w0i?*^WEIDNqvSG1BBldoe4}XU< zT|9imwTUK6lUU+i3Nubtg8hNwxt#_|+^%g)~Es?%CwpBk)$-HI0D#sc7N z(vp>i_N|IKkuqyvu#Qwe#kWLsGR!NRd3nv{>GR+84%~3s`)Iq|&FYKDW_2kc93ti_ z$cpe&X(a~QC=|3+AHN#{fVy<^iZflVT>bG$nbDya8Su1Is;5;}D13jPgZYGup`e`{ zk%7%y@^edVgTTxm7J?{t+cf7?KFd4Ujdd}T;O0g~dp1?5RU{3}UJ}(pHzLd2TYVys z=tz~~F-)cm#x~N%u6s}N6i`42Ic$5K+;Bw39o8eYskO`E)XhtLw*BoCh=6l@*uEC0 z%9PCa4MCzm@{|Z{`SgBqdA0kD3##q38a8-ahV8nw$b1!JP9=Akv0#b7y28!bYrRGy zL#L9J0T}!WZMAyJHUnRd=xLwV$e^pBewU!|2G%MNb%lT+)Uh2d_fsJl*{Yz4AOua<+^&P;-4U)91fTwiGE07YFVneP0k%> z{d?M3d^@O58MVJ;2iX@5;K1N!*uY4A4dS3-4L&kuj=rx|Aa{juO`S&Kb z2r_=C^6Th7!l<@l8Z6(Kt>i6OWsyR03*cyA#K8GhEz9I{v%jW}L$sRk)7B=fMAUGw zVXry>`8#B|kCf{OY$N}=@q%t{|4AH=OOsNf7jYMtOF1pU3|n)cm+ zZni@N4@Z?6NB>^FsGxT2eAh4gSv5$lw1Srj5w4?FfuE3-EpWR>Z~GWLo|@X8S7Ru8 zu+Q={{FEz~!r)+0?`91f6%S$}VolvLvfDh$u331`?iawd>svoVo@p1dMgfl`M((^z zdC?pAlVUGxVA1v9+SRSGI{|b*`kn$*a!z+LQ!-3uIlQEq)%Q2oBXqcqT8}WZ4V;0x zK{6A)GReyI8I_`Unfo&KB8F0DvI@UhlDvL52aIui_#5G?-SN?a3Su0ceggL@e!#8x zdbj7z1SC3Yp^Zj#ArV6(!h8Xu4bqyIjNMWPb`Uj`Ish3}bS%-?a;yXPa6w|9GjlLk z;%f zk2McRSJQsJ)7`t;3->-@KJ}5pJ>}Xj@bpll*3sPM6^oq%!+vIvHP?(bLSFyX21kYJ zFQ%0i24C^e8g0KzxhJ(f_?_{~1+BnjcIJMKAM@JSD@KccUv2ZX7P%&!KWOdk&IlF< z1-;qelMmG90Z3Bo-QfaXY9R z0FDt}_l0=n35ntTDwju^dI0a@>0a&HHV>#oL)7C)6^jVZNn%A?gAw|YjZ4x~qnfwI zkQ{q9!Gm*#C+oyiAMgqQTDt-+Wjh&4`uuSKjRzl7c>|G`l({{dI^t|I=eI;SIfhry-8* z`<)k{{Qz7I+^xe))#iJtPi?CXkiA(|kZW4-sR^2bat6Ielt+n1Chr0>_L>iilT_p5 z5BKT!q7{p;2Iow?>TV6GDcd$K?D51PjaUm)q+LF?CkILp7Z;D3J651h>i42+Z<=Im zWjf@u6V3iCHI5{K7t`IN&%VV9&9Smo)IO8<_z<=yj(!c^#zIUeNX6GRy_^BC-xW7W zTgL6jKNc?tc5rv_MV^Wbq(n=~1pldetY;Rom)>sBIgNcGJ?jRhk72S?Pek`EGTLWB zd_p;#NmdK#eKRh^r3a2~C7Q53A2&(f?o*3P#eCSK`r%}m5obza_C~F_yh2`@u6BIC zAmPPFDJQDi`zA>bgpD(gCpE=4alkrliE~|fQoG^oY)kDtw0nYH!coS{HnLvt7g(#I z+Y_{3q)4s)tmN&a>V0u&WAKl)Qk5w4lz4pkHj&s^;>d%*tf}XZ;pWIaT>!Wr@YgpM z!nD%GX{s#Ewa@<8@k*=4jdArYJs8>NpV^~hqjrGg(Ix`ByNO9ABlHcB<92x<(GVAf zJYK|3+%#CVPKmk&iQd?_IXW=v6t#j>ZQRSsAK*0lCxhghlCmIuwT}L_suf%3I@om( z-jMJ+dr)3t>R#4!KyZN4WK{k}XHoc`3HGv5hiZprSHi>y*s0_4IKE$1KsOws2!lU6 z<&i``wV4DynnGY>8@Fmt^4o3hr@ENC_y?!dRT*538G!s?eP2zf8Xv}eb2Gul`N#8| zw4~f&PS<>^jk%)LoDf9K7SP={rUToI+mUgvPvSKEs3LlrQR>!i7x zLoG+WZ&l+{gy*Xb531xmyf1L>`P^21Gdua}Gtzx-Xz%OUWyH=(X84?|-~li!6jVEV zOhTE>4wvp_Euju9_Y*xsg7fTLpQ-Fmpm=e=68yPnGE^?;Kq?uIt}IZ$i&f@2ASb9v zI9(Z#9h+)sThA9?1#~cxv^I{6gH{I>#uopkL#}kyaElJm#a+|<5jMy-u`dMx_9EL& z!4cEpKet8=5$b?qML<v1}4qd4D5sF~W^0d)d5 z=w&auFY9TxFo0bPXe4}?g&h8l>0P~SOu?!+gdrT!d zU!Bvt=@Y?nz^)=FAk1AlwdDWfn29`0f6;GYZfDDn2pt^vouf<}KTbY>8x$5XX(aV{ zd6H+tOs38ivCjxSY+P-;rA^B*F7j_FF-`bomdL{>>I0{ujmES2`f8d)ylLYBQoqG) z|GIthtI?ipxErL;c|^`p=(k?v*_tK_P`El*K5>Qm|Gl|9_%_pqL!m~82`6k`fT+1^ zaGrb;Y679Sqy{?@I-cev7VrbM!sqE__SwUtCE>!YPjk2&JAYt;S4vs+G&1*HBJWcA zvMqVhPXvC{c>Lp&1A9B&MNhtY)x!g9PGcV5v$x^77lJyN$umvwktBVxXB}ggk9~*B zAEEQ*Dj-jTmMc-eZEB(-!5wHtzaFp5H+8*j!1XC)e0{ZWXNCj*@4~e{$jT?{mDQiQAkuQvcDr^V-4&dH-7ar?p zG4*l%iIRSuQAXyxZ!5@i20?NWXQkeT)3c6U>cEJSTe$f_K7_$*C>WnPP@dcKjx|F+ z+o?gv*^B$W2H;M@uL*lLT3=LAJpuFArsK?n0E&GDPYCIomxwMR@X`E5+u;CgQ!`?d3jB#&DW zX!#Z&UwU`8!wMLW9KOkRkeT-WZCb74BhP*^6Wg6nQ-W*kwI-0ZaChL_+-S^kkBKu= zbXe6v`c{mSo9SUf(}#^;86V5|l*fQw3Ll{tIbaWqW(9eEk7Y!&T@5GN8p9<&-4Bu3 zuT9XIT@aU8u{`pgf7u`kSl1_M#pIRSA4;sn;Bo21=1r^tgb-nVmQ{2*A3tEBg7jX; zXBE!VEi^58D;9f|DyzJd3Ywa5uLr|lDAr1h! zyPriqc^#6$u6`W%vZ}@Nf{l0uB0>Q~^Hg zn3^jCmxDN5=?*u!$C#8c*5J@7e>)nebJJH*I4DQ}cmXli_U*O+aGjKnKIO`Nby?a}pT%cZ%J#=0;kAxm_cO$sIb%`18 zxx+>{tdq2eOLlg%9g;Br01x{v?jt`FkdW|O3!+4a6KyW*r}Wk|VGjL_HWV-8>vgM< zS7`!)Z@7US<9e^(t$uLXWjc@9TQqYPWV%4q+#CL5N2YH9gS^+%k$5?7b+ysMXiLvU zk8+GG&EWp-U@&2Jm%hBXec)U$xH8i~LDc7&go!OqSW+&N=57X!$j)BB2$jo5tar8d z?f$Rspoek4boD{t+o{QJv+e*w;9Gphrxs6!H_RkGaY+V%Ul+Imy2Gnu4(N0Po z>Qjel;#qfqF3^!wBU5!mTnkqsSKogfNHx7rnMDdoDnKq?jc;q`+}!-iFh8&f zr0bywl7QinCRjm5$@DL@vIaHvq@648K?WC(J$zu#-d7j~M63Lgfm4y9!khaC2gr+q zNN;_bfe7WU-V7l>+FAxT>=jT2>9|}~6>}s}Dc_Fmi2KSV6kHjp+Uo4q>4xH+!mHBi~uc03U z02$sic!M53@fn@Y|3&oi^f>{*z5RbLl$hLtXsRC3OizPox(89~+rpL|r(Iw>eLs(4 zjH7@tB_HC;|}eD{#Br z|FLEX(ttpiM#NEOgA(>{nGW`jrE}5+n0Xn*aED0|OUk?sr4jQZP7J}VGcb)C zbx7uY72=)%3_vR4AocJHc0L2SE&JbFDJ%Ga=QOe#(VxQ)V(C(i`_ExkH{aWjOF{9s^iqJodVH7nkB^8+1$MyE&){vecPK;Me zHa{>#3tRy<5OLH;@bZ;)Ghk;l!*t3Hm_n5eGhh=vjuLz}{PH5)&dQLRVZVQbOyar( zCy;U+?Ina8y$zO0=f@ms2?h`T1jh=%4n&?rxQa7*rJsVz>liW{)p>alEYjOOAGDQ_XU_R!v?;Qj3`6WA9#2L={7sM(H%N8Fj*)(c}?DR zP%}FHam_EED=ny?qt-{7!gaBSY7s4a$r-#Q2{i1Ww=RhtvGW*Ia#Qb>gAvBD--WFY zVyjaJ7hydr2lIdVw49#lFlMhFq{?|0|3ha=?}A1~Y>6&RLDcv-9OpM(v7?#WV0Xg0 zmF-rVEEqHVRulK^?7+o6ln{l*lAdrEz>Fp(0qghLIR67rz~F5B2PI+N3ZQ1cxsPN) zmSad-rv^q-^PwXUzU7!3(YMXmxTORl|kst2U zkFHihU-m8yzmitpL&v&r zn(lIGP8#Mf@qaI@4MKFfOoU*@Po?=NJ4B0P7Dq)Vgb8YoYJZrF6}C>Riyc7=D&R+(kaZ7`!>zmvWYt~P$; z9*KDA`q)OZ2{~Ac<*y!mz4@C%kgAMy^Ur*690`WT&i3PLp}*@BoQGTa(3z+65?vFw zto*2-0sh=+tew{p*++`z02P}U^pbX%y>MhmRy&1TMocbciNKwH)>1qgsnfbICY>0+o-;7&av=S`@j^jEdH+g zi&dR?B)Vz+_m~{)An~GTaMp6?=HP@UvR7Ut9w>)bJVxaDv27|W>xvw9vSuI^`o*V% znY-c=Dy>1y>281I!5C?une-FsZuzp}}XVvu|On|*9!Kl`oQjnrit)5S||&^`8j1tF9P5`4=m@0YK} zBeCBx2EKnJ*soX|P710L_$q7G%8)^MRlv}UD@ocZKKLQ-1K{jQ73EwP*T~c?!mD8S zIN6>YWbbqZT*fhCtw`1-pbon2b-O|NsGG8cZ}SB0t~6$R>U4+sQ+~UrsJPr`4m*tO z2aM|<1Y1WrkNchvX3T5(ktKS-g&M>0sb-A)+0$R?fV1gOLoqj#PsNBX-~SVj|}`>CgLk*`vGoO!!|VXO|btcZ5!iQ zwFu{$zP&StgnVy7zV1x$RKximOt**=(snsTHUf+sxL_VUci0KiHS1xeMn~p1#;GLK zVdQ3+n-oC;%~j4f?n51Aibr9=R9$+1Y)+K%46LPQGrz&+&xJ!n9XCras~V!bO^NTC zxQVUz`Qc6fACernr&>eeEL}eK>cC4HCK*Ygld^*c!I6FeOcNH~_?}(H1Gwp^!)U&S zeW61ind=HEjp^6mf;@f*iXqTAa#|S}k2@WpiZHk(DDpsO*R8x0x>8>qcBzMS6d^%& zaKgb|fnPFw*U*^s;#Q?$6C8hYlLO}c;IH7#gWf(S4{!SMyk4sb#(~9*B8U<{+$Qhu z>1mVAcOXqHb9ME^n7b~q^0I#yqvI2{pp5x@&WEt_GL*P2+(qR7LKszbsD--HWuNQL z`!_oFFm}buYKv9&Lh8U!Srqs;_hDqir6Xw`!lSLr(lfB;nt6F0|K7AA<$F-m!nOspRKKt)oa>_v?EKBf-k zh8McE`>(fWBFOW_;V3&~)gTlRXQt z@s_>(JLX>aH5%~ek*6rrVF}y4oAEv8Af!9d(4D#GW(k~TCJaFH0z+?(Bl*RxgOI*> zicr-aU3^k5ahDlO&P_QMd!PJn$w4{P55x1B@M6dfdv6V1moU$|dBP+N_5Cm%o`H?G z@R{)a(TW26ftT13u5WgoHV51k(9dgPAYj$4Fo==Bt?Fm(NcWQaVCo% zH#*?^b({^yu572u!JIHt-f}5M-EAEy zM^YRfPxLoScYS$Ewh`EK85ZG~k@-zG&Yy<`ik`k&@l!-Xxb-JGKs`STc<58!aYT_< z7`o@Nuql6~6tW@7XRx6lD*YS(L3}{JHdbCTHaAc?v-x1p`o-==rR1*reI|e#gv^gr zGV1Shft(rp3NKzL;{W>K?2U$gmaX^cHm7$nND zlYEWOTLG97pWQ7EuTz>AasL}x$~8HkEW2+ zeUgk~`qTZpFVq;?=>R{SS9BPzMG~WPt%t63l4TwJA{_6YtLNIBw>3#K&*YFOXR@vUdX+>F>?K}OyZ1VbO8HRySbu)(9Z`^t{;y-r=q({ zBQXHEz4fZ3rr-2b^5N41STrw4d=cEhH^$5EHEQWlq>0a{SzOW}d+v77=Gx(u^h0)3 zTNvLqkantl0>qt1{~Wf~cv3Os`Hk=5>k!%OD9}H1@&1%SgYH3?qx5moQ~ic~{VBLr z_e?A3mer7zE=be=@fXAS@k9OoK-QT?tVGP$R0c$cU?|gve^V`gUA=kOb|Z8MU(I71 zhco3sLdP|C((Q|8iR&_l+CW2(@C0)dX0vT)5MP@?H`n|u60sEgLm#FJWd2zfH}N)6vfV)La?qdgxh zd&|oAaRD5hwB)mwE|0R=sn0^23iv|C&r1c7PY0x~``m{^CI7x9UTNW+ndXw8fqGZh zh8ka+tDUSKx*70hf@aa_v^YLptzBm49ylK6jP0BHX>*jz(*c~H77b0J4jrDYSu4L}F7RL<ZS`Wr%a>J)r01Ntz{f7zB7;5CbV4*17Q6L#MaKE<(ng8Qd%w4!xsSCCrswCJBd zeZ{Vx`mK!BOKtnYdiLk%g|bliAknlF5!jYGkh)cX6&p5Gl(JJ1>FWL7!hGcl()I@7 z>lekUT_4$)sadh;n?6_NAD72!l_e&w7`~qjhw6Wi{XD62tT7()INHKW+}`bfHZHhy z<(_~`1jkz7?_*w>n^vL$`EdQ9FHmE;G)UO5*ah!=oXgd|STsB>r^V|oJ%-a=;nK1N zQ)-XR_Wdgd06M+CDM!xz7blRzJ6N%ZpfrId3)2B7D)#;}D^lu;inUo7clBYj*1iV~ z>||58>)dwMhG-mpVkI&d*&(gIFe*1Pc5JU%94lWQHsH{(hOn=C57A_$ZX-<>AwV`0 z>OT8s&pD*nK%X5;zIjfU+ZSFqqQH~eV823`w{){P0n6{$FPuW~L&{e@MRHXMhr6rr zzZ6$P41NAS&bgm1#X2XMt&w6#)1iY2sJ-c(IdT*IEB+1h9l3nPifk`*;W$`;=apw} zhAQD~cff=wH%o$`)$^PRc(K`3ttk`ddGQWqEnbaqzy?8*lXa{RL zD8*a&%=+BlafJQ)TTq-NR%5aCJN<~V{!sR^@=ML;y+C{-9$;r44P9JlW$PCez6h%6 znnpri=+y@tpC+*+WU2yCxORZLQl-c-J-v{!=h8$qv>t|Yo4nOnU}l|D6C4SEFwF~n z)S``0dX*lO6H+VRS0z+;)YULZzU7A{oS!b~!GMPgE0SJ@h9BAy8paP^%A&WNz_lvg zC0P7mN$$*RdEICz6(OkRhLQWi>QlwuzlflE;tg_nk!+JymlUhK!kgY)LqYErIN>&Ri@ISjN4mX{2qF-sZR)fjY=BsW^d&3?z<6 zNrJ*U7L{qKDdcQuu}{)SOp8nM%KcajqqmipdGSP!U%%tz4n!z|j}1}DM+m36et72; z$m!HD_r3S8(b@M*6PAaVq6B_~O7>W4wwp)Bq4OP}C^4IQ@z=0=v}>zj<(c9(Gc0d& zXGW~bbB+(@PD$EtTMr{0L9orhK3U}`QjNE6K_>LmVGuVjNZe0HOq}l2K-C~KKo~1% z)`#SacZsds-odE}+Bj^TW1c=zCHyFQW{~ZEuv2gktefvx+p}BZ&@UGqD=r$BYan`% zf_f#`;sTDQV-lVQi29Exz*U`M0!I<my-?!aAsI10Xw5g8?mVVOZd?le&|tt zY+Dw%e#n_VqAz;TMW_%?w+;ZThVMA`M*H%yKe57`I~ihtaiKq?+iR315+Xq}53J2c zhS?c4xOSM+2^Hd%!~<eq1koA-eaJs%(6M@b{a*o;aUZA3qu`}|t{)fk_GGK9NH zy_`5-6+OWDlTPcqtO}HY75|zMMzc3tdhv&ui}Nx0H$dQu7#0(Ym-qACw))(`C}~*= zD)?WizP8ngg5rMZ znd*W1>ZZ?klUa;@U;Uj&7hqkZ+yy(&Lw){1f%V}7sULpf=rISV#>A#y_1vE~u$ZpC zUNyJ?^1cxj*cocYD~(2#;ioKlxS>!fJ_AIhuxrv;yw>zh{jE3m;I2{c*Bc!YqL)v~ zKlJS)!>MZfldTIju%L)<|m}qd$qdjrj$_ zGv*~la!kNeDJAm*bv$W9py#KX+QZ!S?)Z{#i&FQ}>2c7;bPAPgq(m;iJoSU;yJ+_l z9V|WOdi#IQYOIJp{V{wKLk<9yj>f(wHJc|Xq5%LN0r>^*hp)BMAx!!ol! zjx7(u{`3{wq`mz3y})ZqUC#XD5!FQ2%}DYg9{ppgytiH7=Z^TV0Ki`MuJ3 z+Vb*J&D%L6}Kv?9bL)3s(FXm0@1%Bwi zFw=Vf@q2W8b8?fiUOXQZd3FV*%R1@MkXLz5brHM~;nL5;2*HNKWwFmg2nQ{e?_agb z_BrQ9hq{9)LI#8iPdjL}XmEE?%O$Pz zW@Gtn;f0by+)XC{IXKwHcUxgtaZ8_XHH!Q$Td6;e+%b869`0L-dII6%_(AK3xasa< zEI+aU&S?BEkEik`iw$wQGHi!lnvM!ncE>2N;9>Ig5HEfv}F?#a+jYEVJsq2I^l^m8dY&|)n1%WmXR20z`( zd6ls{sSMA(JhGpGy64?;g4M7!+3MN)OrLB97Ai)<3txACoVBGF+YLPL$1Mgv`&!NuIQPoyf##s)xibvNXLPeA zb4pKraxOnNJNsRHNkbiw&$NG^Q(?lEy-ylaGe>%NOuhm8+9I{4mV1`B{!inSEkYAz z&i*(hZ^UJLo&F;(oj8JeM}BV`UdvNK?(>TqxUsMUgkPR)60P*d(#{?380^p`>JN?&d=Q#+i!zA7#J&S9jM){%|Zm zN)i3>XSR#cQdYLM@o`mcfFeWfsH4#=!mR8`HS| z|2FwiqiE=Cb@SucWsbM7A_8ms8VxwIbzg07&&rkl(tCg84OwWcOS(FYOOS2~$Vrp> zjfL&QJZY*pC8<8um%1%VhSQz2iuM?^6+x-s$LradtXxCS=Q9g)a7*Jc;0u|J58c8j zAv75lou~p+tnZFI%NEE8OEg5Q_yP5`yj{v%ts7*L= z2xP*}(Z?qSv-fAcFm4f$lm)T!wa@s-;k+K1mtj8Xfv(PZhWlRd0Ohi%v^xd_(T)gu;s^#6Z0A2>hY=Zcv%c4&luX;mr==%?{zs4&luX;mr==%?{zs4&luX;mr== z%?{zs4&luX;mr==%?{zs4&luXVQ_h~*&)2yA-vfkyjn-L*&)2yA-vfkyxAeVQmzUb zWH&p6H#>wkJA~nKHUw(3LwK`8c(X%zvqN~ZLwK`87?j+@t;Cxh!k|DFi`ncD-s})w zYtr285N6)&5C)~%n;pWN9m1O(!kZn!n;pWN9m1O(!kZn!n;pWN9m1O(!kZn!n;pWN z9m1O(!kZn!n;pWN9m1O(!kZn!|9^G}OF$4J1b*3ZFvN)9GEZxtnQ2cm=R$z*)uJ_@ z2__?F%xUjHkXk4iQV)R;wCUkw!~?n;5Mo>VT{5D5+cognmnbr#gEk0)?#Ga!H1KX? zwKy`QhJ_%ycrq0o1X+N;EFr{9JQ+EQ;7%kXxGljC%lqKRgD>Dm$1M0U2mhFX|Je>d zNV5Px%()WDNG`-Ym_LJ9S;icKXyeHcEm&Lw-UMux1~z>V1R>~PFxqJWKwG~6&^opO zAZgP8xM{Q@U^GBL4SY`tt>rvGYSw}-9LCq;{+GGAFApra5y4O}c+GM+6iU7Wg_A>| zDDn*`2E1}PjvNI3zY4{J*DwQ!An*f%$l%W$`1{WXSn%}fpOM$VtQ%l<2w3F~SS=i^ z8Vp|8O!?o$x_^woKc{m8@2i}=fHhJfX4ryyv!Lz@iJ$42MB8J z9s$kc7-;on&ZFH|3VDtE1!i?s4fqUH>6m%A4RP#SkQxQ|_W6j3Srfcp`qzEh*>H5q z+9zKj$u7~ZL4*9?Iqf~Z9T}mWR7vN|gi*m6PUoNPI@5s_v!;5@k2rP=`AjtkqR-|v zc&zrTQak?949ThN`0;n7kaV;7UAr#zFDpt-TP}{5Z@*HvjS+!F(J^y1l@i9h_Fl5t zAEy2#=D8ekHkwuUcUVo8d`hV%x-Q`y6Sc?qy+iHMZguB$YjhJX@e*BY zoY29#+rnyuuN)!{MpLCfR|ixyv=4XsL&b(!|)Lu4t33 z>?^dvSRW%m1k}wqm{4L{a38J4W_UXyE8_5<;_wYhV#JqdChaR8YVn3H-%~JUi+#;# zt5b7sHEnt|hk_25r)@dVmB7kXK#x4b4TE=vekZikFz|LPgg^$doVsC@>&q!HYcp_V z?)H*)ro8i%JX0)=A=(6$sxjxMb|pZwrUp8y6;|WVa0mtfljen_fqdE?ArD}%gnU%w zK-`|r7+$=&ja3FQ4&M`pBIC8uZT)6+bq96Ey3ufe*CcS(<;9dw;P z&>PIfQmt~*wg`*B8hVAl+s*~vxr$7;Tkz5zAo?t-Je21n%AkpPi|k0J9fC9L-YzvN zdWvzn0GyyC+p;7s<`oERgTegEQ$dCRD{EJz>Qt?6#&BHXgu)?WHM&s~9fH4(u|m~g z2)??Cv81=rZbx1%X%QY+uaNN__2RN!QWt_A@I)TRyFi@LoMQ}79k1=4PK_sBWn;wM zXQ&Xu1&TfLGdsGAS8#@7XXtHW%ymF@6y0YoYbcFI-+%>d`~jK1W$@w7==0=fXr(yP zA{#)?jL|&0q&JTAIQp>QHT@ zM+&nF7NBZ~RODzHHu0{}n1efM_(iLnjBuq<2Hyw1;G6WK zFEN+Yp`ob>rM5UM_GFnu7=ff>7lXw58aQMT$R&cWz4EIipz zAt=SZc)Vs>YnxHCw-!*I0Q%Woy?M)QTfx3HV}zL-U%#U9(Ih+79W={A50v#lus!Zd#iQvv%jpiTx7$u^I(yB?FRjC39ygiS#xA@zJZX)6`k zMe9KQwtFY*B;i?ifl#x1-pGK3`Uvi0YL`~+g0?z3kSJ=^Yy*RNm|CR%W$h@IDbsxD zpd2{yx}%M1bJwHE62t*mB2gWJuIF}7m!l`N$_@+M zQ|V|wzD4&s8uW_;$Bb-dTs$r5Z>Cmux9{b5 zuL3@>6y0Yu;Fy+$Cm@hD1CDn|;Cw-<>Xj2U>KW~C`2jG)kpybHQ8T^e{{&K`ydHn; z05Jxx3fQe-v+!HN5KfDZ_j_;L4MlEZ3%YUQc)CO+*yuLUnS(viBqka9ZoIdrJs>`W zGeH4c{NREa*z^#Pr}^6Y18(koCqD*_gjZD)J#&@{ zmug_y-Cp3Crj|Kc-PwwONT-z`Cr+IYj3jBxoc5%8*>kyo07UBsm~X-)ub5XQc}qawg6U*!92DGsM_5m3}^21v!3uSXaJR_ zs@ygNGKQY_XK!3h03WeYC`bPUya&eG2N!44OpavzzSKN%Rvp$@D-idP7BGx}lDb?W zVq!3|8PRlcSyj&Cmh72zmT% z2bz55W%j7n3NPk)C(Y;wpUR-gs;O65fWsJL1Nzz!@bMtBa(l$=OokBkifx-_*h!bw zW;BMPJ5GSG;Rpn>hz`g?;;mY%MRI8@RaW%M{!@B=QY?n?+g;OAzlPj`a2FVMLdF?Y z=U9jD1HO`W1c<87X)A2eKD8n`X=t1g5fgYfYMNwl6%GUR;rN5)WDGMfeSuzVmiI^; z;;@VX%GIDOV-tE(qJ9U(v}A!uibeo5+_quDwF+Sbm)V+Q0{kS|a$wxK;*QeoUG>kTKb|3%fPa zuHcf)yX1(k_Rc+z77*`6s;nv`N|IR+A0;Y;Pp_RA+1gWb3+&^7>Y}pV8q9&Bd&2&q z`c3tfrA~C7S!Iv=3L==a7$iQJaK38hU z%~h>-+gEYV4<+Dh76MaPmy|mlhnxU)`2lsU_lcMz&49{_))wgY;7N5#D_jF|Po0wd zE|5SU7+0>-Nv|)|w;yWp$qvQXP)9B*9SSAu-WtFcRAzl$8EzelnM+=N^l@TV!o~Y2 z7HA@fJPLYGX!d1cv>3G1=W3tK9;+!+>74|b0T_YdAk19%@^w;q)ndbhGUhBWF#(7p zR|iW91aMl61H9JSNN>*?&dajQp}@Dpb1rW8c9IOxi^Q7Qe5o!5@qsc>Dy8|;Y^V(O zQ{SxE+va+|_F1hEM@%0vL%Y+y@wRUhQUtNnV}>!n7OA@2s9Hz9_f3|3-?PelMYjp2 zI5&~W@O1Z}q6zO4Sd0sRdaBrg`edILZqZE%P26hot0nq<*Iby*Dp3en>Kd7sMzu1> zWQ=y^2Q|@eIZ5Dyg1+11ww>m}*VU5yMNeHBV(#ZSvPyEXC_>)(ALN9n;G|>50O@or z0;~8t`yTklXM7oFWH4Us3N!O6Ke2>plg_v}<;uDC9mYBt;(m0m{8Ib*DK1!UoZoB@` z6VyQ#?|_lHJ*vtl60*7T&m)dNv0aF5+Gz~9J58x~eH8B=n9D=z@B4$Laaxj@-&v2` z|15{#7y_vGuc$)wWf4J$SSOvwV{&zV~^$N&bv^~?!_a5U6) zgLcfF_v!S}5mtn7O@CtE^0HDci=oAz9So5&)L(wiCi(f<=Y&?MU@>ykb&#zM-WGg8 zD>cb%W+1xce2{j=sb#4c63r?tb%HQ5GqdMqIM7495})(bkSKe$2>H)9bL@dX8;El> zNr@v#2{kS%uSg59gjJX_#Lu^pw}@WX*qQI0By7RjMLQ~6R|84V)@D__cVN!S%}QE+ zb@lv{X-SEGt{%D12e*oXKs^=v^2P7z5cciT2P44Qnfx&>WAW|Bn3OewcAzI6>{oHCUo_Q#rUr3Pyo z5W6yysGLf#gTVH)NMB%8N60=b!3Veatz5g+GMe;Zdct&#Wy3`T8`kb$Q8)c^+D!It{p7;i2Qit`Fdh4IA2og3Awa zi~}59Xl{^NxK5SHthh!ax@h+YGhq#PZ-OWci&ckqV=mJn=idIlBj^`_`ZEX)92+&x zx>etpFjj?6^%tR)Q~eJBLTUslqk?D7s_`6r#9X1`p~;tS4f+kq?}X%8xO6H330o4g z?-Fid0&oiQ+KS;ICHiM1KTd^L_0l#8*7#6*4FPK!f`?Y|0kI5}a0UF>>D+s}0*PBZAS>6hs8>_H zLqC&Y6+_^^>#3dV%)+&h?!>`FZ*UfT3WT;@?Y%FmnE4cm{HntL#I1oqMgbQPaIUDv zK^DOwbE`ecK?eGd?CG|s1LvJTZ&tX2ZXm9qC;FFO%k_23e?f^H8ahi%axCt4)Px!6 zM?+V~6=5aL)G!#=n15Sy#j3-Ij<9;4Vo4BfGpbCWpHc_Z3z*KdwPyoBeA5@5%ll~D zvWDRie(y)Hyaq5S?`1XIe{g&d4ei$rP*U;n zT2gUG{iXIT>K=;*VvUT&+v)5u%n&tj7jq(Pm>A^TiA(k)3$8U~2CX?Fe}})L7OCBy z8hdSM{$2Pe7G-)6gkg{NrJK|<0x59}o39=WAHDfks3aKVMN}Iz?VnhID3k}-s|Jsl z8?AgmN>pluesRs^F`87a(U3^*_dMxGjJqPpt1;d~h)rn|2>p$sR~5Lu-D}iowYdHM z0D)u){{)CBdgYD=$P!a3nGCUifjH6Uiw@UfnR42T zx*-T);QsyMUTrj4;Ko-obYx)aN){K062TQ!Q`TE@a_H*o5YvaKlfVl2vC#-baLx|# z4Xe{~lw#LNjCzNR3u~Z%pQpgG^BQo*M?DI-Zes#IOsLHj$P2eEnyu*+i0&o_-JFdZ zwg=a9&2!V2(h?$kdRQEkKX4uFnEC0 zRF2Y#0vsli9DH$_L(04M;2Iwg$aA=0KAHJXv)5IS{%o$`g%7?48Y*y9B_+yBRyiTR zdb|NdXg}U}JKg=gW>0YfW(LG29(bk``8Z5~cbvnrRbudMDeq2-W%tmVn>TRRmOZ?V z;e=)3z2!b{jX7MU9Lh!Y%69_#+G>0XFceEA%zYWF;8mv`>g={eb->4sg{Z$)+ril(E`?1co)ma_XD;UGDhYrsN;`&g zj}-TR`f6VPgtZ-V9FZ^XLz}D1RZWi4OC7+g>B9%j)zNG?p_4-i;&dLCK*)#6b)Y$5 z*aZEe|5{-Pmw-}~l^uHW*r$cX7Ct{6X$aE|!UWL8*^%=aUw7JPSptu;=9>%|E1o9# z#=7e;;&K^EX)#8=&V6GIOg$Fd-VV3qmY5a`xc^b}W%uH)1HvBC z$hw*VIFK5BEKbAo6p1Gg)7@KQCDh&Np1>h~&zpo;`T^RJU(FBI%~}uD z{}mT5G4@gqj|SO*vAY-{fK}mhGjaPv5u6W)h;!wz$gfeK+4eD^TxBR%*eT`W_V#D5 zA*}XMA!^d=g09Jl=_}suLVL1K|D$ayV?|lg>sWUaMw}$WEIU^6cMYL4n)BWT>yc1& z-hS!plYh1A1$r_=d_h9BILxD9&a~SIY+ zfnuIt=OQG}Royo2&!{2a4X3f5E{|9^L@oA89rZ8p!qMMc^P6zcvcQKZzr1Ac06amT z1C)5_a`aXdkecL{Nb*+4l`Px}5W|lMQtsRQ3`KcZ_5Uz-zU{+epRetM7Y_pOhES{- ziv;eE(WUIJ6#ZvzpoZHFJe|=IoZjJeG*(@1JTCuuHk?P`P#Jo&6smLnDSUjdasKX- zP6`_T8Is#Iwwjwwhv7CL&VS-NBGmf0McnNRM>cIQz+|kK*qq^Upvhgg`9gi{mW!VD(XiiIJ2)Y z$66hS-<7PsLdQBD*E)H*FTn&uWICrYhf`7L%)cd>+jaZqR9hF1I;kIUX#g(J+VH~!3k0nkW~>&tnN6eJUG1m@1AhSjd51Xn@G z*zWBdSv;dvaNmi}Q2;C|$33CV7cqeMYe_9%(tdP-=O5jV(96q(bkbhzc>N9>`++_q zZW$E7ftt_n((}cYAh20O3QO36Y&=E!eScSR2^!}K$l7|>_`lo0r(|ASz^7i|?EEJk zTw-wqC2i!)-Ll_>tT;fd$^Y(|0n6K8@Jp%A0=Mt4ih>-8K_9*9H^u*cXcY_S0Ii$d zTL(ZT-~ICQi2Io1YEp3(E%xo+`YdIQBDy&LVBjOH?=4m7d{$lNOkk)g)#1BLN&oG@ zr^PRPI$5-8~G^Xdtpa*Rw{sV{!3oWi5N~^Up~x*^G2qgsx?%dZL-2}4*ieWv5vvW z*x=85Y{T0ZWunG`hPq~6x5+_~g3|pYds0Z(4006s{tZv7L=wA}#?syAei!T3#=@Bx zyQwh+*8uBiVkjD4@iz~%vc3oK6tD4@JhXaaBpqy&0pPo@j<=t&O#NN;;+#{f`9JAG zjWY`lk%&n>$E-uKc?k2@Ur(H5XuSO@k;0>JHC@3O@qhJIA4+Ei2OJvcVESml36K{{ z73}F=I(7UXlvu*4K90yKi{n8UK!85?`~yHxLaw->DZC!aM_}?HZvf|ng+4OD1Vqqv z=a)LUtI0vEEyShU8pkOZdIz;Xa$urX{Dv?L0SN+1pbmwY6Dh`_CNNBIRR6CEZk|O~MP^ z4-`B>Qfg|%=KmFY8UdY#B_;71Y8s9Ttk)<=mlCv(E(eR&n&|~xB*q6)hzLmKS0j34 zYv^yV6iBPU!aR4{+y7O-cQIuS_)2qiDDEX=w0YV9Di#OFh6Gu89)D4_4=#I*+h0+z z*}9T&JBrz|G@wYrADqgX+ETi?a(T(x4I|;sld%RAwArH9hgM}!lB{bA<|sm7aZ^_! z$px$e3^f2 zzh$8_vi+ALAdMV%!l&k60qpz#;XzHUC|R?Q{mHv=CvfN5G}vR3?=;6*dI$uKA8L+} zDKr9OeGV6Sq~b!fvSY>dqGS+@fb&MzVQkx=h1E<6A8=v+2JfVg?z%s~9Ge11W7=|( zJYJ2Owj49!@@M`9R`AW(uSmhF%&L)=ibKG{)<)+2EQRgM2;R7#Tc5%bV(`@aW6%Fo zU)pxb`{OQX*d$e84zF@-p6tUO>q@~pTgyJY`m5QcL8c!!Jpp4d7SGkQq^iledw=t& zOBX6r7|bRmT=*TgJIf~|n{LA4dO!Kxt4r_60n?9d|1Ee(J#B!U?|uH%ad6gC`2OKe zU2>dGPgj)dwSQqR7UK)9s@^>Ft1Nxg55z^b2lr?WXw$u5bzkON%>Nc+w#_6&MalQEU&`a<`gHS$Jv@&q z*#W(a@+Eb@3%N(fNxJdU{tF|)fodO;j}~2a^HC6p;+tM-)!ytp*F36yIsbct#$Stv z#VDcn$DJqXEzw<&J`F-LV=I9poK9y%4Fhllz&${zO zK}pl!OC=#Eo?_G%6=1fEEya^u%lAA+V7E1+P;@EGfRNE^Pg3j2Ha zy#(yRrQAdgFInJWNU!#nPEv;bKOI3JU+<8PXqvc-`Zf5KrBKOj2C@z0T(V0LoO20O zvg7`zOx%poNsk6ZV%fnlxanI%wH4VaY0#qS8^8VTzk|Rp;JCu8I@oELH(8+oB&MU8 z!@)Fifc(T;%898?0XStGc7VO0IO z$vC66-Ce9NRG4+dP=eTKs4%m7W%EZdEm$L(3-jNDteQ#YKpt6CBTB^4q+qoyWKB*B z7%BwmhdOP#tA9^}tB$P3%Yz9~i>WcmRg%BLF8@73fL}n;wn6LDl?!r-k`;!#AX`hQbw)>Z_+80+`F(+5(}XvXpM!}4uLU^2hmFld=vczlXv6_epZF%*=f zR9{ldJ!gjl$u!~NYm3ul6T5c{W=(9G>o(tqY}6vn;badQ4ov|XW|U-KsO2TkXuR!x z`p7MYLeVj43dl?W#FC|N!jX#DQj~Rwf64%U)~ezSzqB|71yKnsrQXq05}xw!<3U^| zhWIpD+cw}G?mT;M8z}SmI}&$+F1jmo029kc4;E9M+%-44z&NiER@JN1`GuKNX*xE>i#j!e}=yIdj0Z z*JUzd8z>z42fid@7mw=qtxnF?sU3qX*c(1assl|L@cl3Gl*@53<9?}>vD~2nF`775 zUiJ;+uUYr{eGPXhSjE-kgNx*eF|Yo#noVuE+3N3h&tZ>rwDLbs0P+I2ku#cZ^G`)2 zE7YANsnP~})JRq|CL-B$V%dK#cUBe!S56|kiCPw6_otntAe}&tRa!<5kTb_# zE4}G#3X6jUiIN_rnn&|HJ3pN}4Cz>LEc#?C!tRK|%0aju`slsc1G=%h!G(+tmHo*l z4H`v8tvW>qDf}Im7z$Sq`Q$~?i*G3a#j`qkWFZ=ZeH4Hzv$>)%TPvNRG@s9bF$WuS zt4t-<$%`10FY{no%%fw7I7t6yJbpR#?nrKKzM27r$Al}=m`;82sgFQp-|60hy(J#C z822(7pPMDyLP>Hg#_h@J+_w*qz{>C73d^32yW)4;v*qzRlM%!rC}UV%bZ7@%DJl*5 z=FX|)6#FW@qXyZOdIsU9K|n^D@1D+i8h-+0Fii?2+a-a-ER(`$$&?6SFywtdZDbKtmR;qpeQUa%mQfn}96RkypyE$V>lt)LvW`ULfy z${^p>ANe7`zRDuFG7f>2nOCYNkB?qfXTZxWj;>wOz)0h$Q8i0Yxc1@8t~gnmvK)12 z@W=1oI_F68-00{!B|3pp6ESovq(l&@=?Hg+ZQ+eoUgp z@XJI;9dz%0V6A-AA&#D(f9@&Q0&U|5gzM>hokBE+Tkmg$>RPRoQtLUDIz`}H>3>3v z+l^9lHgprOjRu6f$8wNIUEo4+wl9~)D|;>oUW7Dy1IY_hi5+ki z*1vJPSt4>Y>78f@Y&w8w%Fn-C&~vnBr5FJ6PL_RLyd`l`f$h%wK0*Y^rI-QyPJbEV z(N4z^aCuB&28_c9M(*vj0$0=-Y`$m!znoazRkjRX;7AJ04E}R-y1Kh?-AM^!!YhPB)i9Ukw3_85nc@4obs#>Iq^g`f(8y_Ap)NsV(+L3_&O zg>pCxewwXRI{pO*6bPz`7`e&TUIUj6{FuYKg#tOw@y25-g>rxO%|pF!mbaqha!&Ph zaqAZBbXA@uzGgQiK+MDUuwBj%Ue`g!)R923W8-?wifZ)M56LAcL`_+?bq^YKx*dU9 zeU_Iy;w&k3A6yAj4|wOuHg3UgKe;?l&X-?$b#RaNI<-k#Kr(56TX;Jj{LC9g1D%zg zzjafT_`U3Q6ev`qB$6qwt4Rk*r+Ol6p9rF~U(clKz%f$q1G2KjN?Ga}dw0-Bn>V&0 zY0gXraREummEQYgIOx%OV$^a2ebY@evhjgHf6E3ELvIs_TixJ08s z#TB@uB*R(7Dg2XaxQT*g;Jlqgu!utSws(E)@O*f`5t<#l>>1qpm&ouhm{4R;1A|vXy`VxQLe}I7#VVk3ORKNrNm*dzn}@ev8{fE0PdL*2 zG?IMAulR&2rP@CQYH#fJ=H;QFctiwu^10@ml8k7T^%Bo4z4xku>Ydti6LH-2(Cpi( zi~;-%ryYXQK3m%ZXB}&PiMf{RA_Stp^5#Th9+@&D2!Izu>s&dZcPdj65%^`!)bKa;G5&}2k5j-4d@q|cH$6$%e~aVZX6B1fh2!2Q zDwOmCn1>K*B~5x0TPjoFbtW{m$0jsg=>OD){Bb`cZ0&KpbC_gW~#V+*z?to_g=qF87h02%^KwMoytRz%7Fd!wag z-hbUgpD7Z901h1-Aw>0_@AOkQ|0KjDi9yzM$GXkM9C1Cw*vbT$5UCN3HZJ&rOYP)| zkQ7RF4P_kF{N(4}uk!Re);|$38JUoz4@&(gfO*Y2Y!LP(aYZOo{McP~^$eYUvu-1u zbiz;7B$ubpmfvNMH*djmh-VC9){PKj!}Sa#RNh#oYV8jMBx$#!{`wK9TNqKdhK5GygB7`!>*SpCyMFaNQ2as}RAAmBi2?2lkr>X$ zr9CHee!Sj-%{-|=>K%Un?j_|&F+p6Udu&%hvwG$VlP{DVCf3E+EK{CU0THI@%wy~u zN8DJtr*}c&8nmaDf6Te&tP7w)MozHIruwK{xraa0$0R3|w_3QgM#ClvoYqiUa&l$F zcd`vJid2;AZ(0~fNnn($uQ=)NjYa~~ETN^$PNttTA^wU)-Q zO1%2kiEmr5R^M6jiz$^HixH)kjZ3_2PlH*W4|G(NESV;y^9ZhoGl6hPHJi!n(MJm) zN}qEH_5u`w1S+d6)AVHQ+b%+WXwFo3U-EVqu655nj`lt0ZDATarmVh4?g#`ZrWEr# zpiC&gW4$BrmS##RE zE0>E=_1Sc8$#PmJ^B6AV(?$#MEUioP=TSwi^$*kDBCRy9T2xi$7OCgZLWh^gNgw=+ zihOqesXtj`%{ZI4PUNW2xZjst8z(%fucYJcBZ>7(S?l2phw`5$ujo13r$osM(|;

LajU2n^sUrVe2H)Sp%zipk13Vy1WE4&AyH#%pk~` z+r7{q^_joOsqIrM&%D?t*NnkCS@CnMzixL)WMQ$^|2T#3p%tS2v@9 z8q8i?quBt556blJFA;zCV;SU$u!*PBJ2x07y24}D+ANbnwT`NLzSE+a>{xF>j!9JW{>UKhB39lR@rPn3pxdpz+B zHu=2;JM`4y?s_+bNlQgGT3j?%PlN?Dj#!SKF~2g%Ny%;yqq*QF_$r#?Vs47M(-3yi zo0EDcm`a8zO*RH5236{%q1ub7!tPMt+xi@H=K-`1%or$NSFI+P;5I16h{cvECQ8;VGuC;zHs5pRi;E zdLb)4cfFJa?HrU)*oF{Ncn4?`9cvGR9Wtt+ed`V(ASeo5~VljHi(7_L(7D!-O7Y@uuWw7mAY25lZ$^+B)hIeow(bgskEdTj9CX6B-4bwuF zx5+rSYXInt>}N!R+M1VJwKNILwug+I2UwtHa^h^A^m;4gp`UiQ7D`A!Gz}+jmFxIQ zaa|9u@|w+-*T&f%>T7vDs(kuy0nCcLSI0ZxSIBZIi?(=c8x1ARfjuZi`bMSo zn%m530&^ZOOQj>Vo-M;T%vhN_E1>%DQ5CxNaXaWt((xTP|G@{vFXQE8OIpzn;kE(_ zRcm6!DX4y8YTcpUeFWNXY4o~fy*EMm@as5ivzUPhw|!f8y;^5#5J*q@&k4$Hmtif6 zdwKn}=i{vPzL{=H2T0|J%E--}24D$VK3aRO^SlslKjY_E^)?x3l2Kut`Tho(kww|p zK5{67iBn5TkB%}*ncKisP^8-T&e5YyHgfvKMJOqjMtwQ+yJ;r{f`ETGIbgOe+(v5R zUeG`(hF8=LC@$Aw*5BaPQP;9)g9v#{>DtFPn9PG5S$vtfi_fo`lkrmbC-UU|c5J}m zE65|i3P`JQ{c#U5*_iE^MtOS*gqHwOA8dU3V<$A*g1CGqVx6zWg4(l~q1G5nSaKg# z@fe(Z^0)kKmCs^2xMbDmQT9d!`U+Hi0WIR{8$HG(GG?&dfC-ZKpX{buFSm61JUVt^ z7SnuCs_LEaC;VD#Z2*6S0C$wpPf~RuJ)0%V5hApa4O;j{Ht<-5`rv{r;h-qOd-mht zl?~pAPM=%HB-ern5kw8P$IDVm7EJhbWDKz*#vCa&uXVMiDxuHz-uhD?ApU#(yA?(f zRDJqO8}yOx^8p>z28$Mm(C;>T68*%v@qsWhW}u7F>SP2XIb74b zq=n^y+IosZxC5@{EVHj~>*)VS)mL>}oKUr44MH)Jaj3^&3nYJ`^J2#aiZUA??|XcE z)cp6;vrylo+mf%AH_nbllHF>(GQHjJ`$7ZrWDA*hFVSJ=)~AYqV*iKhR{h3QZ}#>9 z&^)mT+!$rX9Oiu;Z@$j9s$q!ms*S6TEjx5Q8lbe4z2+syKFGV;wfn^SBpPb>4r4*u z)P?;}F4?6pYTq4L%KtHk4OccnmH%N2cAQkar%Fmi`gzMQoZ=q1U;CHaLqGeI`2yf5m-Hq z1y|-qx*Heu$*eGXp2gQuE6el3UFB_-ivGtUIaB8T*nrfS>o&8c?5VZPT0JNA~ zvgpFz#}BIyZV+hH;P&?T0eSTNRIU6GXk5~Nx|rgEYlYkP%=cuZQf;8VuHCzH)iwYM z(ya0BzpemNBI)nzxWQV0?nE(nXT?SZs4C$`s-AMc!Nvg^0h-uTw;~H}&CitDQs2Gz)e?%k0_MKNhZ7IGic<#hQ;cKm_!kY8tI} zM*VS?V5Hbnf`-ie6Dmka%1Ov^qi}sbD5e*tIIRG=Ed`pKWb&$*T%WN483%pbxeU!w zn|OrlCtYPX*NtS*wH`6|k40c!c6Pn234iD%L^- z4JnR$j(6KOh#7_mPuA-e;__M+>fHCZBL3h;2C?sGkALXjnfO~xTsRz|ql)XhGj~Cy+N{E! zTQ;umjB|3Iv7>?ZOU`^9+u*3^#JTgG#ue)!LR6yd<8$lX90KXi=$KgU;uVhFSGLe= zAhSp{B-42!Xn&{^!5$KRq}}d2p<7B5pl8OtTvq7ra#GzA|W59VLkhVO<5Jd#ga_fR~5Va6n)itE0X z?18-15)XXbkWRyZQ=hQ=?3gps`&@QqZH9sGzpBR7J#23iG*wC3Jx~5i+y)KofiBx* znn`tj^r74zHYlD*-^gLkt}FvHfI`hlIpd8wP-tk~Y#xwL@BupI=#!p( zta;OQ^7a!_{eNKZ=^V+u&<81KZ(^uRr!DP|jVf-)xHs-RdGb7?R5_|oBx;Y(hEoom$1&^INdW~2 zY_r_<$dl_s69h5}xo2=HaVwN0Q&#dndM1F9totqn*x)U2?9;jnRzN;$-9PKT*Bl@F zSH!c*b(D(XG~dOItDSN}V^h_KANAdqW$Iwfxp^uY+)A2%J$FEzF9#t~3- z_zoeLcd_KD^VrEa$Z^&ak>f>LZQ1-M+_pu87hM7A2&|sjzN_$;W32PYo%By?$E0>c z(&x%eEB@AN{~1$g+IOeA)kz$h)Qj44<$p-0W^Lh&2RAj^UI%$Y*Ik(aH2JApiNE)3 z1yK3pq9p!jG7ugxdC&p+wF66>zS}Bk^?D=k?5HpES8HbnzCSEOW!*TtHGHE8Wm2~e z%tlc`KdJ=soA2BIX{=hqZ)YwcVQ-)tWZgZ3+SaBzuzq%0Gt$9ru^NG0@Q@W>>-hT% z!Mi^XX~RlL8;yvEskLU95{S9`w1P8!u3r^PF#hv!@~7ecjeb^(@k#a1UoluOkvTWQ z(v_l_28oo7{MZNTJ{|`4mX4{=LXnGcU!dUkF5VmMx2&kEBMX^_?^a73w6}LIAGL<_ z8grkpODFuu+Yd?GO|z$LG|3@wAhe5^f#`zV%qAhBRoPM$e;++!>SCQq6g*Gmu3eExZE}T}# zd+9#=^N@gJ|)po0o*#=D^Qw%3Gf8}g-eUFiJ0I!O9wL)-r-w*s5=M+n2@Yp~j= zz6RL3uXOqcR$4fyGMaDCIZILW3r}GMAM;~20^|>u=)bqz!MZMx0F;6Yi@vSDKc?sl zUMH}GYlIqzP!$qmxzQ0Wv9y;Q`3Xwyj52R+V1@<*P`lCpr?2mThx-2?f8V*2O_Y_4 z>DAKA((GIIadrQxpc@89E5s(at- z{dzt3eq}jN{iEfiu#eqoF)+;GZv|*db|k1{lVKQ+cO_MuivNFZeEd3r9;9^VV$L7j z)HU(*+u%#X%uumH1=V-HnxWkxbok6ucB*)?V7VhYJRJr~f0fStZZP}>9ef;IiUz7( z;YvMAxibiB1Ft1AVTNr>W~uGo@od5gcW9_*{e>ub=+b7;p_2&}?hyXxY@E#>w7X)n z8vE;y8LU@sew>WP7|z*llGajb&*tZWmv|7*1f{`-hW`@6PdZuI6L=C(Z^sPx0V+d` zRyOc16U4`_Nkb))LGbh$*l$&Aql7x;e&r&teE`$9o+v;Rau9TMbE;a?LM)3K`fn&z z6DU9CQ-tKcw?UL|;Ra(2Ai^FRI_tl!d_Q3W3Wut3JP@d6Ufas1K|z&F`vJy=Muqft z>r?Fujm>Cg!qCvrvVl2Fn?cNdiU2B$bjLmRQjjK?IAlu;EwZ}oU)%gNFki>utIbRh z4YY5@{UgOm2t(wr#L7p|LR4QFglmIl9Z;HEdz~7_plN}L5`kD4=y~8qi8E4ZBhU!J zLj_YBG7ngZtM`pQ=5NAC=$|Nk_OuHKfl8Qd{^Cg zF*YizYrKJQ3&>w5((tywRjNNX%zdBW*VrFxuy!YL|1ml$f9R{tP#R#At?yY^d2c?3V9vsPc+8IQEn2#-lX5q-99HdGdGuJld*u+Kx`=B>U>J{fmuu2Hk9b# zg!|c3R3i4*m%=0euP=2k$?NT>AS=MpQfj0KXlPZFa{FM|5W~g>1s4C$FX@w~wrnOF zkX7B-`1;`liGSQ9(J=v5sQ)RoE(L91My+us^q&`ZY7mF79!`*?`X)&IldT}vbeP4> z=l|s%IeC^E4`xHThgutsMbo3s&E6Y0BPmEpMPPwDHS3cwOc_g`-dD;!gHeD6!9Tia zAiHleCNN6iV2?2JwS2Q_SdP_=Qk!}PH^A|jZNpl9Sju&a zo=WnwasOJiUoN(rGSv%waK>L-6WawhC5(|Xma?pzTAGDc7Hu=T3K=ti&LyDA{c{;X-y1$$S zcOyzjf}A>H!?kgpIdJypbgIpwW8q!wGxKHEA%rkW|?@X|S|fG68{KA}})+3?$p{X5W)|9CEWF5_S4h9TNL zbl!EBy#gf1FmXML$@C62qgMQKMB6S`Z*I^7fJD%RSZ+9zpzKY(x0;vA*GyvA@KPU+ zj*tEm19#c>(Bs}4;A$2x-y}5ctfQ!51Zz9WmdjMjx~^7xG%eBarEX7rSNxTlkjAh5 za-?S;@j!Q&7t01-{Th4o$4J8~z*ohulRD-@X`nlS>rS3eJ1i)f0|kus1hpR?WRFxf zFx2!PyS-$4k6hlMFEqqh!mRs`zgzMvp78K^(G4)6J)R1s;v5DIhZn%+mXKfDsWQM6ZEv-bwGCv;J{E-dKSKc0{+qjT+{ zc~{4}j}Sou$NE5BSbiU0q&R7NYr{W-8xU&G-aU;!!Ca*fo{VY&s< zKT=m6`qAqj)9~uA_?y6oeEomIM(^l-)1L*XK(jUP&fM8g#he6vxYOutA2SpkbBDKt z8aR&PbT0de6HE#_PCd5R3Z&EaepiI6N0cIC#YE$nFJlr%WZaD1ojW7uB8}3$1{3nv7C{wOrxLTz$lRe3fIdp(a ziT@N0HME`RofS>zU3aG)htMH+m;6HYW+;^yC`My6$tr4-hh__2J_|%q9utL@!WIO7 z3-Tjb8_S5PO70j??0O02f>AQ^4Yr5I?5hIi!FUH+$S9_7z#4rc{ymjT1?H~|rFnC# z7RbW5Z6abtTde4*q;`2eVYyR`G=j2Xg)TZOeQQB-Uix3DG=-7zN){;EqgnSPRmlRI zmk~ObFD9iIK0*tfWPh>8^uT|r)RdkJZxkHCT1dabxyRQeiKD&hyN!PFvJ}d<4R-RC! z=w1R+q}*r-C-PO>{##UE-U1-Rm?U4PN@i;D@(2+orq(t%p6BXOk3ffD7V@-D@1Z0% zFg+F7nys`f+juh)Lcbllu-o@xA@w>FNRSa50|c2j(dSMcKKMrtgTH{O6Cl+Vw2$=K zSVmq|S>pqBn-W=ab8g>A*h!`P&=ST`aT-%ke5u2$(jv3f3!uIGY%nu)@y1HhnH#>I zRPRFDVrzou4$wj}Sr3w{sN->*>>%BkJ;e{@6adJTYVtYf_ik>I{z-aZ;6j$(db_m7 z#pt*N6p7k?#8zrT98{U7b`Nd8da zBzpZt3`8EOMoF$|htOZ9NX;iW$#q$aLo*(}htg>1X7 zF)&b)0`?Y<3CkC39bP~Loo+#CUd#{5RR|D5>yg(*#t$@jKFuYVsxoW!p7(73WT-M2 z_)-z;v$->7b1$C) zS(pTKi#wDz`tgw@uirnJYGrKawK`YOT`^T%dK|*P%{=jYIN%@IU{OKXYa=p4&h;OE zwPQDnMJUCwQRs1aK>n^}*|W(^(8Z|XvV;Mt|EP#zJEL`qkDbyLi&4^y>6KL}7HjG? zUcpzdJru}?3_ynX?-1JN_rTX>Ch)S(w5%@ZMKt1k$~+3b0)hN& z{qBw20*XEW@{H*r^FovL3rBN+_VbTyMAsS&QGx}}izNn6$-6gnyy%A65)0%h@WY*p za(5-Ej21=HqV%^BmpBfqYhkGB!ck~3tg%7OHxmuN>Jua2Jv6o}Hpo8Y#~6yG1lxtN zj-UhvV0x&AY3ROYbeX|-9V$g=O5d;0v=V9~P+SVbbhHZOWIBh&cTw3UQPP3Oj@J6@ zY>L_g^d;{~?f)#?iLFlh)S!63G^*Jh8oRq*_U_g#GpY?)VYi4LYPg-$Gsy=@R5G%3 zzwx534!X&}m!bFL;LeIJ;5&Pd=Rb+FHn`J86=sc+m^BC$CA}!Df?*CB)RiRe+$BQ! zDvLx^iCTzXDzxof{}yyOzkiAD0m^cA6y6gg3-1aPOXWHO!;&|4zsn^%s)YFXIRY|N zjPxS^9e9s;-2?F~A*$r9MJl-zs|36!*_k#_ho9xG=-tDVTWP1kJ~nI*C{;*$58eZR znBOY+mdaI7mkvDip((DSAO=WxSioeUffrRMDc=fzHM3^X7&>nuL(`L*nEkdOhq|uo z5HNZ&XUb|nUjhyfa=!ji{2^^hSu~zTSiNy@{eqz|4J5p#5?0KiNL9V{b`QBE?v*HX z0AMjR&f_hm{NK6F%WWm@gtsp(!OQ5`i=ERL&i?Mw_ui}2_@*u(^11UC+?&|xcp!q!WYf{N9h={;Ghz4&10*_E-`14iWLv!!=j(aOWMpF7%*L%4K4sSLy zK=6vx@c1NaRdL}AVju_3{7P4-3ZbOuy=`0k*+W;k7p~CnKck9ezpGcIqriJfNUXx+ zc=7z@f(rd({ANelD|X+#b1le3?78Q2ksA_Px@YP1_!AJ9o_^Q)RN_`&^x;F8%F;mn z*uaAQ$&$8G$ECW(-l?V4;)Ncc4-b~S&4)S;gsn4<>1=@#7NcEGe%`%lpbQxgtePb|qu`%`Vd3bAOoW$osH-21aU5Dj69>l~hn&*XHm-Lj7sT!Wmx zGhx{EKqFB4g(+evRjZMg2|8GPuv=C=&yf~7ue-eOx#M zb4nexM%;$=qpBZ&D>y5q6td2!x90@z8N_0uS^chE9gKpdu+gp9RV8-L1ekg|O}#hS z4zNQ@^Qx~q`I0!LtBZ|73>-EdYq(;qK!ZnIcYw@^FGfZlQ7N&D&O_fr9+4_i_>S*@ zBZ{EJ10FL z96pn&T!OM+27MQ9leHF5d!&R05-qK@TpZxfr!s@(;MkMw!!JXQ7pW}|hE-0oz$CED z7Gr*_9aOm{?sJ4lrLgr2F3aX8lZ_LMSPr5G3*}X*qNms#B#Vi`YE_Pa{n0l_!}f=Ry6mO4dcgjm!Nj&(NGSH0sd%7R%CXjQ@F ziTbGLl+BoZNG;dku?B<#2t4AyH}@&IP&$NIBV^%Bt!)e)%KI%JZMmHcv5g8!7*q-T zWL|tsK*^KjhlSZNIjFwSL$89uXhLwIs-70QUxYxAMP3cw3ZULZxYxSF&4;LZ&y$~+IhGkGUz zlntYE^BKgSc44OY)y#e>iW9(Y$+7zJVcGh@x}&}l4H^^Ew9;9HqXi@Z< zl~B^_7oq9-#zuDl#hq%C#ta9~ zvHrW5{ZP|{o~zcdx<}P=1uL{IP5#?|dlfkD9if1PVEK_}%==Y?UT2NJ;fGrsj-gqL zJbaF7_b=gNmuePVeP8m&sWIW1j`rWYS0Um?rAMteA4>K0Y403g&8_=T^VXWbG>G-4_cdu;{^ITDd$rH| zC@l)c$LrR-Yi}2&L1#+u>zxDmCnp^sGW|8YM};dKN6ql%X4oGc-u1y*efgskEw3So z+q%!{Zu7cO7Do{5k%!+{jx{LiSb;O@q$A7YWa?f1IU_HUKa*wQvrn{tvbhgvYun1t zJAP{S10<#RIL7u6|DZK)e}F$N%5Zlj#GiSg?;p*TW-){bNy*4?80crhbmZ&mrX%G( zO7%}tptAhd+EPPJE9kBg1DyZ-HC%#$Ql6z*;G0P6T=73YPS~1Zm-5f~8L)O$D}$tK zCo93pg22gKWp1eZ9BXQ(lnn}7d$hAYX?WZlVNiZQ&4f9sq1bOM705|3?}F~_p4pBR z4bqwV2>|jZ0OV;GG@7ZEvb{cfFyzXN<7kE07yl?)l=rP$FxP%PA4AH}x`)v0fA2I% z$dYBSRufoDgDdItn7j3I>UTCA_S%4E1c0@UYt!mHgXBj6A{g>WYvN1A;GRE2s3GGW(% zAiXxzkgIvGzly?wK~=+5L>B~%#P_4cin-1fC+K?+abOplb)T z??A;1;P!y{PWt)>%hIWa<$XNRi(_8j z7g8%uQ1;N=7Y>YV%OZ7LbZLVmAV7O|GG8RAG+cWzVPFCp?DGvPI#@gfR`QI1W!__u zDx%mrK^18>0ff+vl2slEDO3p&=WB3{0kxx)Nvr$20&UnMPqoe@Fn<8AEF1rLCeT)@ z%%3t_OJKoPhO9Su9)Zvirk{`}+)MpG%(0)Kw9j73l!uN_UD|iK;CXq*7*2T0$xR$Z5># zr_$Q6+CEbX;mDmzco`Bg!*BPh@)8){@F6;p>uff%zvTtRV;2wN*QT>uN&d_pv;=>_ zk-N7y8Q7>@JoiGh`)nI$&@|Grm8y@H1!(DS$=5fvJAvIUQ% zq~|qFzvLn(@~vEdpYf0leBW-D5o5~y7EMb)TvE+6{qLUU$OMOGOX1(Az-#4Vu$Mir zi%-5SzdiqmFQR-^b2k zO8dN%BjQ0T=~LR?N|W7J9H5h9YT-Gv8Qa=cHow+B8>Oax@|gOurQ!gEANxQ@tJ3}D zS)E{hj8ab|1wh_O&W2=>9?*jkIT=pN-6Zjax7DR{6E`WN^w$~dLdP$duA8*Uor&33 zhJCG}CecP3jjH-sv-!1x<;St{3Q*uwVRzMkGGJK!^wYh)mp-0Zs%yIGPBo?_p0E(I z-i@#7wEEA};WA2XRWF4#SMy|_(&p)hpjP@lJk=v!e2P$dIeBnpt!ke}Jd`*>Y%o7! zrA*n@w8ZuONyy=bXj1J5g>x8PNbHTH$A^z+BEHD8)>1a0zLN*3yaO=fi!%I zhod}T+nSrLlAIbnG$|YPL9X^dDSvkByTGsCmIMO*f>v!|iU8NneG!?e-D%#ehy*Oa zRJayQ9QF3^uKG)_ARus+5Zd(Z#6tfL&fAo1wlc5-$ky)V?9b#O_`NGBy0G(eo|Z}3Cv22A$G@-@49+(R<_E_l zT_l=|!N{-IMqzoMN4Kw??XUCBtEEy_GmM(@S)H=6q+Fj$HUM#H>+rtOSsksD)wLfe z>Nc>Sdl1@bJwI? zvyLSfZkBVX7ITF*EXw#is$|E0a&1D0w(K@4SXx4Xf#fyn@(*V&q{vYVan|R zN#hF=xmHdv<~t^Dexdz+!U=WK1#dVFbm4?-B7DuNupYg?tB7I{$pyBD=yAq9x4(Ru zy7<`r5L|5=o{11GKXUrO3p_}N4#ZAaWvKVjqDivI66{f{HN; zWr_3p-%*oMopqB|6zY6bVm9|yn?mDmn+G`5`zK)JIt#K#{6v3dkOlMHSqgr#Vtc66 z*MmzM`(V|RVp?nkkQ@p(o0@pG<7pF=hdi#a2}*AT15dTaF}KV<=4OK4c5;s7!OF~4 zJ?*w#v)Ige<+j+$M6FiG?3qX_@(qyg?ce-m0|2H z$F->U&n+`hty{`nk3M=__3-)T%sbYG0jTcLokh;w$Aj5{@-u6{Zmm#vC@KAu+B*vV z6NNX9ui165*Pa1Zipmv7|54^()1zv6DG|>%3qC9uVXW}g-L|68W|Tb1cHlpbx2YroinkILNlV&_J6~(~ zFqxSQa)5yp^L9^XX`_yg9AVf*l-PRY&HM#d5+fLi|HJRTP{7?C?4uLHbT*soJ->XgCtN6>#8FM`bw86aNEo1Yr1`G@?a}}6sM9`wL^ZZcLoolN8IK^+Wf4VO~K|# zRV&-S8(C`a%6e71D#{%`N(;UG5W54IXAzd)|9jQ>=b9LD^zj>`=B}NuWV-}%=~6iY zSui7R`az^wT1{_}j|`hK_t+B*nRgeoTNC=!!2D1RpNn6k2F?l?k2~q}*8iP73@&hS zLN~XM?M)&REdq~I{i>wTUMgqDe$dI$aqFvP zXc5dHyUW*UWH!G^@u82v?ea#4JG|?{k8I74Cth^mN+OwgQGpemQ z%meN46I=7s{|Zp-Rz}V6ajT1}$Xl|afCD6xX}>h@SlnkgY`5d!0ftQk0m52#iS^Id zj$3#3BRZ;5Y>`1)n0sp{h!?6{7AX4Pn;CsubjKz|WLOm!4eo#LaSCLe^JGKMfFQbL zEDxI1mj5q(v{#_ihDp~u_SfYdiyyU8f!d=v0(!3O$0Ulil!aa?+_s5$?K22M6MV5T ze&*kbE9g|W5bPa~?>xa0@afLG3VQQR{Ev6R-U(Un^Xt!2P=LxS^`CVwB-G1H?5q5^ zEoYN$M)(A8<6B|4uHC7iFAmNgVp_F`S$Mg=s=0c`XYiEgCN155g}|7orK2lp=q?JZ z7^w-x@Ad;jGY1{ZyycW6Hw7lkqp;mfG4Wm6&u-k|I+)=U#WA~pdHmpE^xtz#?Bj!oX*JzF3WW~qK^spgPZ8tb@Le`9LLrzl$(zv-C?{_WNxtv)cauQwye^*^bRCr+5CWjta-hL(et)3pJ?#QlSo+_1 zy_X}NZ^3nOc~2I&pkRCrS-$T_v|zM!xA|af-{x?&DP5-4^pwigiz4sHkdBEp-KQbI zlqzy-kEy6A&tJ0r!QjiYIJdg}%CZb#gRqZ*!UAku`X7}LGEV?-LB4 zl#U3M7olSL%cUDK<}XkR6+#*!R`yA~vuA*bsdBdcW3ka;%}0*%bQT_obqXWemt`Vg ze~&V5H`I1*C4FN2L$@sUbt<@ru{`tQs)`#Mx=b!wbwrPE6P&GO108ln+p0${*$Nau zT)V)Lgr)(f3kT{QGX^ANcFR;x(|cBE!czpVZ!|eJxsd91u`Z?qv82COBW_ zbOX+kmS%HD##>Pf^&g3O!AmWVp0b;guM9ozbJb|Li7chxwj>@$LMR+_$B2g z>{GouOq=GRdfu0AUM&m$u6+pTFHl42HI2p^N@)w!cxfP|YBTqWk@%6^)Awucm7o4A zg7J3PmSTns5q9q$d&UYt9e~!QkuS8^rT{ z$oc&XHO_JKi+wVGDTpnth;ThIX1>O@@ZKExTHUxEG9vNOL(VtPn>!yI+5MMa+sg>& zp4CS*z~x;lfsv%t8F^GILW}>5=he+pQU>0}yZB@bTo>>j;HLd>Myt}DXedTxyVl9l z@X{ina(shww)e|rOjP)8RlnTio}caJpMoC33X*uABwzGS2R%vFBxc9sH5h@sc-tS_*1A-)$sZw^Tge!nM z0pvJrcYJUVjEA(Z_stA0YjtlzWzPZp+FRFU_dSf*bn>N{JSEIC(m+UOw|+JbuWt+e z3oAqwxD;b&C2NJCoDZO)O3tbuJK;Qt?{od%XOlFXH$~Bh942rv2pVi8X!l4QwSj3bZ`hx%*D;g4a(^Ay4&8<&k^VBpl8 zm{KA$(4X`t9$rKK2|Zw0kV&Qn)4@AO_lzI`McN0_e07eYg(aD+cYpI04F7!!>=%S` z9!FSAxP$RS@^v9Lin8A%6h4vQ=XBQAWu6s`sp;f)Zrm(;TwRPKz$U@KIu3^sPJhbNfGH<$=%JSJb+Qby=8??Izaa&K3v)=Umdgq(dK+*)3UuOn zHPxX50%vO`Ly_mc?D?iIv8i%M15tH1KJ;UfQz=_M;3Jef^RCQQGHKrLU*%egd%o|_ z+YyCZRrh5T85f?@7eu6hCZBS|`Pxa2SAqlu_AY}dimND;GjT~>jjC+NefdT1;EnddN~{-wua6yRd+gQUXhjdmC<-pZ?=(w`UVR z8%74JTf2Ud4duCPLnwnTJ{VEIbn-i9Pdt=83B0L43t!-pSiPM%u_jwDq$igS)@`aQ zcEO^wyaJ55#dCa11o?#iL}I^+S}g8w-b-@NeyfAF2KdtNaz6C|;H&>+@Q;4nyhhO` zKVpGRV)f=2%j)fgeDj@%+}&5YOV1D{9Zu>WuSpQ=GZKH_G2Qs?01-AA9W+v|Ww*(JX4=7C3*#pit* zc^>EgU39zZ?yTH-DQiyn9WG!uzfrk`sA6Oic%ArZv8!(;_-_pd`V!ndd!8+eq%;}n zm<*0p=*zjk#70n{-L)+^8+R|Xq;FR9H^pGTAc^&#t|!Daf7uJ&7Y0o$THPkf>_X+IL8vPQb;8aG z{%p%KtB92N7T+Ro_uo1(EdkYQum;N_d0V{K{|pXxJV0p9^njqixNA#7lJWAh0^fH1 zKZc1TKv+T}^XmCMpMcbo zs|Qd^aMyDL`Uyl#!AW|^g+X--Dz!ysVwYU>tiaVGr)IxDwI8TXfUg<4EK+Wteb7m8 z(JY!3#qaMbr~o706MaDsuRiqsHw3)QhSuD^a%N?ZzPHigV2EHfq4W3_hLmTUee1S# zZW9Xd#@M`^&V?o!g;YiC2eMnT<7~zc1}0ag43JZ3V37Y^RH-Ozu{fbTv8K{_lRfvGPpR00-cY6!up_u)LA= z2K&jqOMeV+YpK8}b+uIM9a#iAX$wHt@Hrco20+*R*Cn(w?z#SWbN+XhzWo7VXVdz* ziJ|`X2d6&K7Yh!tVYUZq>Y=~G)fCye-u$sS@@xJdF#y$8fTy6yJzs$cYbox*O z%7dm(?ofTn`oROgwez^qP-e*SfdQMB*F?+pfyVGTy>MR(2^`W8`G!Yr)9%0X1^H$} zX(3*v1v0x~Tl1|6LQt~u#q!B*-1X>1^N=`8&(pVmN6A8jEEZoZb}!G|E)i8b0B-rs zJ?m{1L+_?caA^9zGsb<7(=U!9JOx)bh>!kv#y|*y3xi%|xu^OZLHhuW^kjNzWd&L6 zJ9O^%G~V{Rgp#I7$q9mUw_Dh?U24KoDGFM^vXnh6gx2q1>MW2m=x zU`t+&Y2R-oD2fCIsf(p5<1Sl+`;XDIsN|Mi`R@=3viEhbcdt4&n`-^uY$zQr=8kUY zdbiMc0YZ$-Q*h}bcKb4v2OKw2j0^Yun`*7rLDEbk!>9_HR?k%X;Yjr%)qx0aq_YZSSqzXTbUI zc!}+=l9r3!%AdvO`X=aR&@WIu+AguGYB04H-nY(Pb`YTTJg=?<0)T1nA&E+ua_4VG z0hL&fp1ZD3#XWnqz@~-uAYX5sD=FG>iZF{@e)wtr9_RekUp=<%C8*xi$jsKb>d&%s zM&J(G{QE~v9}ujBC7pdrGF40ZJ-`qIPwYfmvyH`vUtWDKWde7h2bZwe1RIB$z@Kq> zE~gWM8@E;es$jwTBZ*qgbLylIRSnM#1g|XRoq@3L$>=P99&w1+^q_IK)&{Qtfnt1p zu4ckMx(4`RCTKUZ3Vw%v`q>Uxz3!3zL|6a6XhP4y+l=XE-WAwK>z>6Bj+12?ujmw8 zJ_;XQkz1$1wI#3a!Ko{!Q5z+0UjYIRu*j^6sgr)x)46GB!DhPW9;Bb8_vLtX5tBDc zY_zJo{8(&5TVNyUafav2+gcmN$82szfs5R30z7n9-WP=jZ|AO$P|5AEYkP1s`ImTm z42-d)OX{S@$_;f+Y_r+VI2{kMd|-5wSU)T1oH>6VXwqK{*2(!@PdUO?okhj$kQ3gKSX&@I29v4R5{2OL|B83V|+k^mB*|6}PBQ zvm(kO_|oQIet|@OQJ9Gpe%hGNesNF8dS26UCj_U|(2qL%xCb0*=_n5b-}OtRNa8V1 z+jGpnJn9nqXf67E+F7TIoQzEXpZNBmX)$&%tUfY9_RF2?13Z^pmw5d{_8{M=6 zn)@9oy?A+jUzy)8s}_FT8x_{$ z*7)ee8JGIbAJt`@c9{G0(!l&e^KgfAj5`QwFfQfRiP8e1F7;%r`H4g9R(jmbbbNDo z#IlUQp?)1v6i7WR`;x1TK+cMfH!$K|gf7uRs%+xWFFeBFTzRdo%}GB}IW4NeohsZ{ zfr8(vlwv_I?I=hCYZOUfv@&xJk)Hfz4JbQv#xLB{jZN@5wj$nK=AgzmUTOmu($+(d*riNC$}F9q`uB zk5An+-`Iaon#SSG`PEv@q2H)KM=-9OKhW#Y;B!sffn3rq1zc%+!^UIa><$1tt0PSf zSLQW*2F{BA^zg9j;4(7Y;Ek-W8Y{*wFTvnNM*yMXGk)2qF*1Mx>4+#oK&Y9}MoUHc zH3BlSJY=omafcB<5aEO!25etE@xh! zPs!;H+^D4SvP2u-i*gEkI^!#^VYpS$;sHQ@VTM5<;yu?Z6hW(Le#~m%U2K&+*%Zen zA&K~<+ItHz;f=JG;wbbJ@?OX~3pI3-d^n5JtQ+%rk|TMDjfzh6G<-yS?eLZPf$5Mq z&*`SK*${S)Q4_r;`3kJYi6AB&B8oQUWC{<7Tlsql$*PfYib{o#XcR}Mes-2DkK{zxMT)^zgNbS_Cc^BLI&X;WWx#`)) z4S;j0?YGAYeuw>)S!V*S*(!FIol2fF?&wBDLy5r|hgVLWDgIbn^pg999@x{pI6pG& zs3L-HViH9=QgiFV__kj7nM~&8itw9X{V{DZ*b#BFLm0JjajW88MY^vdSFjlH1qI9X z{Jgr6pK*aMx;l}oYuVEW2M?R-!O3gxCSw-fv>O2i>4N9Ch}{z~kF+8VPhGUMCX4p= zvjm%_;Z#elYdgnhl&@V^1mk;52x^eJPv8W*mmz){le1<@Q?& zvcKA+=x(M#HFc7W;sr3R$^HULtC0*AF9X3!IE$h3SZ-ZKRm@kf#~;W-kAyOQSC|yR5g|EE$)T_35O+)(S!3ZOUN* znb4OP8v#%u<+%f`cQpu(G+b1m`8kr9a1psK*heav=62N>j`AfpS1^Lx7t1YTlQ{(C z%&|-ezAG!SG9yoMkH;LUACc87ULDj=ze&Mqx3SFmbsZ2F1hVBdieLzNVvb)YL(C#< z){@wishrD~J>RM>ynQ=alE1dVM=z$%U@k42Ni=?+T5DtL%cW<_Hx|r}Bs=9_rLH)@ zSPd7ov*%MlNCZUDurdf@-0EO*)?h%$)e;eK65vh3FrpUy2QVN6$wMcRo!FRztJG>J z*M{)`fr>Z8R*M(S7s=Uu($y09_H$LT^Eb1Yx(_HYIycriLjR9U+@MMC{pY*CkZn+G z6w=?!tKr)G&FS&jq5-)BAbEjZ29c|@h8kWVUe6I`%WeyEe2J87(7INB2AIRx!^p$g z&j)V=W$o-g>2EJXh4VL`%Z`Hq$twRt6j23l@4K+&tkMu3Q z>!(5C1>7!F{H5KZaKxyv9N(@$z2j2i%xeM*1vX?B9=9E85MKzgY}oOc5Vp#D1w=!q zw#_crn4!#pHb8BCg(lDho$xhMb0x^;{w$hD|A057nmVTixraHWpmc1qpC*8WjkJ|p_rzRrji0JEEAuZKM9lTvaMwSI#Zu91~#^h#4bualt(m>xks(rPMsG|@}JD^D=@e2tcyaH%=C>-6O6 z(DH-W%enI^c|e-9gze_)-8KoL;e<&)d+z&@WjvvdY~ngO$ADGRDhP5K_{@W|%aRSs!;?T6YsS8(f^ zIhlmExaB?{SDvkc?G=DelXkd*oY;L?xwWD%N1DD)NP6aF_Qt*g$_cH54fpsFem7ck zgi|AtW72^qIlTXygY2pw{Zo?qqopBsX*T$oG#p0dqC+9401kDlcPv$X8&hUd6>VwGKY^ z%Msr}rak)-u)i)N2Xd*vPC6zUxPeHQ=UtpD_jhwiAT?f!&f3veXBLo-NvcfFUM`x` z1A!&119($fPN^RdH0V$8XF@dEX^Jln7HdfHbj;A+}BQ> zKgB;{k!%jM>Axepf!OVcj)fj^)>IBgEwvF(tJmywr_K;?Kte7Jrp~iUg71f$d)F%e zj4SOV1Eu^;4sa~MwwXxhH3dU8)kg{>-7WBZT4*7%bt!+TA!JcIef|utZ#w_Llb>(2 z;+TCbuVl32r(_&KB z0SFl<+o0rsaM0aN{EjKE-#dcA1+8OEB@^@bu3ECFNL11#8A`^}rSvl5I6k{%S*{;* z%^ev`o6o^zSkH7XhqhP|rQ?G-o(dRAUS6t;23J@67^xK;UY=QN$iod!H^)sh+_26w4~tNl9@N=}>>ty6h1gDJ7MoFL`)i##GX?QZ^^#k#UVe zj!6Od+e_o+MSdd(&(&b!LUR~Vxp~oe-;y7z;P1+BLMCEe`RE!Hg5=+HwBXO+sHm^t2Q1jqKnE&q<(mNi z&~vvp^e{Drc7wmuL9j$`2tj@X{N@F}A&3SKgJ{4{82SJ3@IRjl;%WZ=o%~>N!qElr zSUEYnX;1$mb-&qD)V2gCcAB(T+w-f@LQ)P`na~{ni%U8WX*FEl?#SOUA$qr*t)bzF ziQ(Oy9bu!Qaqn*hjFQwpn+P01^YjVQ8GgRQsHhX~lA(JA&%LL;=)Fj1P68UI{76^o z{sUZ4SDp(!7WOhF;Q;#j9U;A>QKhsUL!$dpPMXP&5?Ka~Q&sTm7%rLmWLaxScnjKe?F<}yg@5;t%W@YRi z>F&1w*F$gU=&geS0zxpbJVquiZXRBK8xj9dU*Aq<7FIS6PCGLz86Wq^U`O!t*Fv@; z$9#_j2jAYxj)7xnZg1O;L12&=T5JC!e(tVpoZut(kYh(ej`#+6?g?`Da1V0#Kjv=j zeJsGAyygx*PCOn%+aVw*w99d0CGr>TH~#7#>}+PG=Y1>$9OOt)f<6dbDWDJNW8l?CV>)cl06+%Ci$0Ks7D|f+Ff5d|0f2i*_^gIc$2vt80=zJ^ zPT^@{C?3+lkOn$5ASEZSq(UaED9OtSxyp_Tf(dCw;b}mz%QX(Kg`99@4#e5C@u~>K zoqRK|wWyC~x50^U!mEtNikzLT0B+FZ^hG9%O5V$KN+t`--D`E>2E8!D5rxkZPEfY) z(;Y(hpc%o|id_A4t9QZ5Xqwv(qS@WUYyw>xLDy~ArqOj#*SU~DA5_n83wy1P%Hgzu zD&jZpOR6_J6U8AVhIY`la`K`WdWPk@SyOe_3iSly1Tm|NgPK!UOadz6$}6ru#qF;? z4RyX+q{Ov1vF%yh@gf#O=ztePL!7CAFMV$*wm<1crj0Io*sC5fhuC_ zsY7oJt{!=MIszY*r;dH91)9MPgT7BiCFt*mC(KYhq=6v~44wvL;mI6X zOV41ek^D+JSt5V84)EWg zc|m2U!k=7yM<5B}`nL3USfZ@fw~-WXrS+|ypd^gv+o6sZJL2>NUgO&dini1c-wNbK zaeN;vFOKN2ZolcC$F_KV@SUTMM^rFY6+;8x;c^5?3a80^W}w$eRv2+#A;}b_yJrBcvcRRwwQK%cYHj`oLJNi4x;gK7!WbR0iJe*g-0RbiHOj-!a(N; zZB7SWd|?F)dhesk6|jIoHhxam3JigP?(44cceubob$xtZI-Hv9e{BEA<+dW z+GO99xghrG zFZ+)~7GVPKUnF9IJnKU>Ac09(=mn$%CPAV1kAtrOA@Sj5SA)F(7VLf00tR^q2pB&~ z9RNxH{$OyOV8^RRj_~ZigSZp!>h~vuX@a80$I-%|EyUQ%E&h*N+Fj>!lrZ3Ku#@BKP(CLyz%#MPv?D?s1qXCH*8A^o0nqN;XZ@9< z-G#iJvj&WTzL6iWF6fK$IocQyBkbMu_+2po!?3r3$0v@a`h70y+3g4uN1=Xp#ohnt zxie`_j)cFzflTyUABd}ao=PPA{S{=RA9m(lEj4)p_8v?4`#Z>>WWPT|H6+>ZFHsI8 z`~4}(0g&kTw`>k1`~5M81xbE?&E^1FqXfS{=du6|vfsYHXR$*oymN(<;`~M6r6B?^ z=TZ`W1|4MtUhl#SCW-{Gax{o&42)THUk3W*$fDfXEPuQGX(KNhUkJYWJnZ71j`|Vf*{JE2rR@dWLOjy zVjl`13JZw?1?mkJ5)TS23NwWHlj#kn3)jbq3nRkqG2aUdqZ@+6)xh`aU$i}4m;tfv zgCX2rrZ-sBo<-r_#)oLR=y(%_MaQcsEIQsh!HA%M&|lC6;V%3OL$DnBBOw8y#}W@H5!{md=OS{+Pjs+5TF90rmtUzyKsK!~hkA1sL!{ zVF3o%AbJBAC$P!GTcLR{o1B2e-1(|QLMo?y2LV40%tOdU*MX3Wj)h*Gh6XKebsk3@ zAg7Pm!gKQ24fxg33$umhb5!Y6PSt~p7Fx>7X`q+G$y-~^wqsuyhTa&4UUawI{rm=a z*>|P&>|Y$bOX|yP4y;&zg8rVBaY5?f+B30HH*;q;V)7ZWKdTp=kY2NAO-Y2&od>H| zNBwJZluq}Iws{e(^IUzKiTu*pMJ1kl@@M0=RQ`3{Hcs_G)J)G$5C50t<#FMSQ(3CV zLl5(Is_B2{CA-J$tuue*Qy%KE(BI7XmN^43b(tz@(wfpljBO3I2nnPs)7Q0p9lae; zF;pxG`ARoqu4r#s*?yzJUkf>m^*!kO&c#NNf>a+p+Nw>H@+O+%qaUua%p{abzXPjk zQni+7X5PX+o;me-DE1M~7?*P}WV3aVzb*2}-)+2H{W{Dhf2l`{d}DL4S!9asgA}Y5 zZkOC=OHY$o8!^Me^4@WK|J^9u&*+OOA(4;$id=H;K z-0ovNZa!`Nt*Q~aPR^nCWJBh#MkZub*?tK&v^t$lQL7(bFi*BnDJz1phM<(taOdw< zm()3|@<}w=g}T?D2gIwt+41wOgSDT2iOi5oJL8a&pCi3bff9VxlvGMfy|s33KE`v7 zYm+gSx~}nBz6|}*?iBh<|KuZi);Gr)ZrU|t#IzLM(emjRqd#k1WgLF{N6GuM`z<`? zGZzGWTE@Kn@qDv4HDXyx!YR9{xBXJ_ZVsV)LO+FjNUaN8g}17@*q#Ge%9LOYFU;q* z$IkN7D}Yj#T9#(OXNt-!+@>K?krP_dGncj8ESP_@4m56iN7=dZ(@K|rrD@5CTO2OQF4YW? zO0|E;JlUFDg2{^rC_*!heyy}w?_5D+JLmCSzwtj{`J3MXP#0l0wU9@ zoDV(FxivrE|6=TAymrKB<~|KdkaWY-)|GGL7ZNdzw@+tR%$#55o{n6L4P4@o2r+uM z5STMP$3Ibt!LSMv><>+|2aV5`XWx3CU9uf#S`zU?%+6Tw>i6~=`8%u#rcd^(1{B(2 z&o$mA>E~otyS|~@%y(G<8gr+wnteyd@O5zONY_h+Bap1@H@VZ_WG>rYKJIbeRHQW^ z<94d~)MfOTxWM8RFFgH~b7*?Xuq(;H8$*(}xL{=R0Ina7?0oil?%BBn(>BC40H zryxK0#@}|h`pfw|BE$9OpeXbKa6tOE9gLA1+5qyC!HZ&%`l9%)^9$mdFlp^_9&-->=E;0nw1RG<-_IhiIEu|UPp-9 zbExC2o5ob%ug`$?JvuffEWYKz$;(YdO#9DYWUXE9E}uH>vJLIE?VF3g&ktzw1UVtT zBf^h2)-A0tv6W$z`6RfW*uCrJjA=+#L&dAe%-0qVU#@tdfo+}qg>2=LdwObCjRHO0 zFX?M@iE`RjnJ~r@Pe!=YHD{xSNmAZFn$fHyiYGBRbv^~*Pv0Js`s&=S+l}i%2btdCf;Rx?)!Q? zBcire_t}>DA#P-nX?x@sH}{dNOEIs0@Uwc8k-I!b6RUZcxWMkm+ZpCd!RgSDdEDg6 z5N`7Ha2j@zM#zJMbVXB)f&7CF58a3u;#?Wsq{T8X7h_D7D2LzT$Wh^v~+cVFD=#Y@Pc5w@5t(2q5F48!S{q4wnkxY#>R!I{4Ytd9A}}CoUTYSHIuW}A%;e|-_W)Wq-~O#PGMJkfwB7A>#nuM=Ie!X zy$!+U^TZHjG`s0RMdlJumGGIfy0VS zd$dyPM6B0so18gkQ)nFxgHOmMyK_wq35|<8>TtnuYW7Xwy$~sITjris+@AUnXVvDo?Ow~~Yce?_DDm+wZ|~M` z#xv%69t+>{$B)+%vK zBR+y+%xHMpezv$SQB&Hg%=zt+nm96b?-(<0>v7M24%+AiQegIEuVz2{33Z9e)1`>B zsRV1n3j@aco7rm$6#!#ZT+IIXg!}i)cAJ4m@?n{;7fo2VjCG%=&63fYLshmWv}|%} z{9BU(ECQ72YWa_>o|G;e!K}LIcH#m+_G06WP@k|{A2a1tsIOgk^-sG}jGkb;D;1J{ zxILvkYW+KiDCphrVTkB?>6UTN!$6|NT{v-ZX$n zJp!L%V={D!YRu5}zO*E?DMJh9Or-fOncT8wKM|CwX{Xa?*0sMdEW^;fSJC&^I$LO* zCtT2_UM$8SHN&j@tIL~mPDYpk^_U-NMqeVrq^nDlb4jY6;H2H|_x_bfQV&d2SfyGu zXOEN9dJN4Ytl&_>aD6YoCVpRi{|^4ONMe-S~n!ZD4s#(yf}^B^+YVW&5@ z>K*y@<$?%^ObsB|_TP$zhb8ZvRO6H|mV({pZlLX$@}$tuzKQ}&Rl>$P?XJs7I*tXE zXX!W6Z|XiV$y#QK=IGFM8;r&qZm%x$aduoLWAf4g7pn7daeapUUd-K-BeE`pU5RRP zbp8C?MwtTet%}zR|5;}}gJxK>pngjXuJ~A_bkPoTi;{cNRddX@{Qj{b

eno*(#7 z#%k}hXAX_C0`cH>6K3PdeG8IiJ)At@T};6y3@~6kqQ9`ArWCll)s${>Smyy==v{{^ z&ySz&f|naIE*+2|j)SLtt~le_x3%$KFCtGDlSZxp%gK+!CYe=IZQzuBv{9L=K@L=@ z-SnTvSu_mtp7Lu5Tx?8wdw1-v4@vX9VNN3>x7IZN*XV6~GTf$Ig5O-u*}Zf}s?M40 zX-6ySEBycH2L5W0txs1@uv8R&HM3 zI4hU{?Q|99T5M&Q6Ybpg?W}kwsC9>OTzxPkq8GjG>3=IGUNQy@ADL&?5oI~43op6g zi*0;$NcOI$j+Jb~nU?N<6%?WZ)Em^NOYIv$3BD5i$lz0RPy_R%^F}#vtcLjN=ftd! zQ6<$|Fo|)WnvIj~@vH64ca;SHcHXcN_o@l=#l>EXTj0_>#~l`)oy1aQcC*gM7~ ze8ENeJc9SmwST%zIi-nRke7qZ@~*&4s?n(W&#J_uT6-R27R+OsF@N#Ye3RZ}Q+6dz z_U2f?2(w6kJ?CD|ikcwsLY28aJ1+T}zO!SwA>)0v9`!>SF6ve8>|~1zw$)1=8_W>v z7PV_yQ;3^)IQzHxEcC72En|}FYC)*|nAje_au+zNpXon8|Bv(OMiUU*HtWFGK))&? zfQq>jw{G#cefU-ntSRn)ySLX<@3`GBLWd}%0g!=~}TFf6bBP9oxrOo^*ZdhW>B;@X#m*}=GHhRVLxV3LBLR))*hiSrH2foL&`*bAPKVe)b?>=jm%2UaALZ#l*xZ@n| zq-V{0+ZTQ#U(}>M!B<(cFI&#vRr#Zs=~K_EhikN++g@$n)xLAxGn;jNZjbz`lVbUc zZJt(M`R-E}ocbtHZf>yTtjmdKI;Vst0KL~qdjJ3c literal 0 HcmV?d00001 diff --git a/testbed/tests/test_icons.py b/testbed/tests/test_icons.py new file mode 100644 index 0000000000..0b3de5438a --- /dev/null +++ b/testbed/tests/test_icons.py @@ -0,0 +1,28 @@ +from importlib import import_module + +import toga + + +def icon_probe(app, image): + module = import_module("tests_backend.icons") + return getattr(module, "IconProbe")(app, image) + + +async def test_icon(app): + "An icon can be specified" + icon = toga.Icon("resources/icons/green") + + probe = icon_probe(app, icon) + probe.assert_icon_content("resources/icons/green") + + # Create a second icon using an alternate (non-preferred) resource format. + icon = toga.Icon(probe.alternate_resource) + + probe = icon_probe(app, icon) + probe.assert_icon_content(probe.alternate_resource) + + +async def test_system_icon(app): + "The default icon can be obtained" + probe = icon_probe(app, toga.Icon.DEFAULT_ICON) + probe.assert_default_icon_content() diff --git a/winforms/tests_backend/icons.py b/winforms/tests_backend/icons.py new file mode 100644 index 0000000000..a7abb071c0 --- /dev/null +++ b/winforms/tests_backend/icons.py @@ -0,0 +1,32 @@ +import pytest + +from toga_winforms.libs import WinIcon + +from .probe import BaseProbe + + +class IconProbe(BaseProbe): + alternate_resource = "resources/icons/blue" + + def __init__(self, app, icon): + super().__init__() + self.app = app + self.icon = icon + assert isinstance(self.icon._impl.native, WinIcon) + + def assert_icon_content(self, path): + if path == "resources/icons/green": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources" / "icons" / "green.ico" + ) + elif path == "resources/icons/blue": + assert ( + self.icon._impl.path + == self.app.paths.app / "resources" / "icons" / "blue.png" + ) + else: + pytest.fail("Unkonwn icon resouce") + + def assert_default_icon_content(self): + assert self.icon._impl.path == self.app.paths.toga / "resources" / "toga.ico" From 7f1d922d9e3e4fea1aabc9c15cca170d00dd5d52 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 25 Jun 2023 13:05:11 +0800 Subject: [PATCH 07/40] Run spell check before links; it's faster, and more likely to fail. --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index dd38789c8b..c53964b38b 100644 --- a/tox.ini +++ b/tox.ini @@ -64,8 +64,8 @@ passenv = PYENCHANT_LIBRARY_PATH commands = !lint-!all : python -m sphinx {[docs]sphinx_args} -b html . {[docs]build_dir}/html - lint : python -m sphinx {[docs]sphinx_args_extra} -b linkcheck . {[docs]build_dir}/links lint : python -m sphinx {[docs]sphinx_args_extra} -b spelling . {[docs]build_dir}/spell + lint : python -m sphinx {[docs]sphinx_args_extra} -b linkcheck . {[docs]build_dir}/links all : python -m sphinx {[docs]sphinx_args_extra} -b html . {[docs]build_dir}/html [testenv:package] From c7dba151a14e61a0971d88d95f6142e2ecd85729 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 25 Jun 2023 13:06:24 +0800 Subject: [PATCH 08/40] iOS doesn't have a Table widget. --- testbed/tests/widgets/test_table.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index ec8d0339f5..a4f559047a 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -6,6 +6,7 @@ from toga.sources import ListSource from toga.style.pack import Pack +from ..conftest import skip_on_platforms from .probe import get_probe from .properties import ( # noqa: F401 test_background_color, @@ -39,6 +40,7 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): + skip_on_platforms("iOS") return toga.Table( ["A", "B", "C"], data=source, @@ -51,6 +53,7 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture def headerless_widget(source, on_select_handler): + skip_on_platforms("iOS") return toga.Table( data=source, missing_value="MISSING!", @@ -76,6 +79,7 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture def multiselect_widget(source, on_select_handler): + skip_on_platforms("iOS") return toga.Table( ["A", "B", "C"], data=source, From a4749918597451b25b555fef6ab6bdf8e0231ddb Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 25 Jun 2023 13:12:36 +0800 Subject: [PATCH 09/40] Include test files in manifest. --- core/MANIFEST.in | 2 ++ 1 file changed, 2 insertions(+) diff --git a/core/MANIFEST.in b/core/MANIFEST.in index ecd14299d5..b0884624db 100644 --- a/core/MANIFEST.in +++ b/core/MANIFEST.in @@ -2,3 +2,5 @@ include CONTRIBUTING.md include LICENSE include README.rst recursive-include tests *.py +recursive-include tests *.bmp +recursive-include tests *.png From 1e6e44dfe69dcd941ee7a6edeb0f6ca8f2539262 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sun, 25 Jun 2023 13:35:50 +0800 Subject: [PATCH 10/40] Test fixes for py3.7 and tox. --- core/src/toga/sources/accessors.py | 2 ++ core/tests/test_icons.py | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/core/src/toga/sources/accessors.py b/core/src/toga/sources/accessors.py index c6e2571906..3e1cf4e83f 100644 --- a/core/src/toga/sources/accessors.py +++ b/core/src/toga/sources/accessors.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import re NON_ACCESSOR_CHARS = re.compile(r"[^\w ]") diff --git a/core/tests/test_icons.py b/core/tests/test_icons.py index 25885d90a9..5c07e683a3 100644 --- a/core/tests/test_icons.py +++ b/core/tests/test_icons.py @@ -23,13 +23,14 @@ def app(): [ # Relative path (Path("resources/red"), False, None, [".png"], APP_RESOURCES / "red.png"), - # Absolute path (points at a file in the system resource folder, but is declared as non-system) + # Absolute path (points at a file in the system resource folder, + # but that's just because it's a location we know exists.) ( Path(__file__).parent.parent / "src" / "toga" / "resources" / "toga", False, None, [".png"], - TOGA_RESOURCES / "toga.png", + Path(__file__).parent.parent / "src" / "toga" / "resources" / "toga.png", ), # PNG format ("resources/red", False, None, [".png"], APP_RESOURCES / "red.png"), From 3be907c657901235032cd202826d79824a4da811 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 26 Jun 2023 09:47:00 +0800 Subject: [PATCH 11/40] Cocoa Table to 100% coverage. --- .../src/toga_cocoa/widgets/internal/cells.py | 4 +- cocoa/tests_backend/widgets/table.py | 13 +++- testbed/tests/widgets/test_table.py | 76 +++++++++++++++---- 3 files changed, 73 insertions(+), 20 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/internal/cells.py b/cocoa/src/toga_cocoa/widgets/internal/cells.py index c02998af6d..c1276d0f89 100644 --- a/cocoa/src/toga_cocoa/widgets/internal/cells.py +++ b/cocoa/src/toga_cocoa/widgets/internal/cells.py @@ -120,7 +120,9 @@ def setup(self): @objc_method def setImage_(self, image): - if not self.imageView: + # This branch is here for future protection - but the image is *never* set + # before the text, so it can't ever happen. + if not self.imageView: # pragma: no cover self.setup() if image: diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index ca8ad7a85a..23f937ae97 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -33,16 +33,21 @@ def row_count(self): def column_count(self): return len(self.native_table.tableColumns) - def assert_cell_content(self, row, col, value, icon=None): + def assert_cell_content(self, row, col, value=None, icon=None, widget=None): view = self.native_table.tableView( self.native_table, viewForTableColumn=self.native_table.tableColumns[col], row=row, ) - assert str(view.textField.stringValue) == value + if widget: + assert view == widget._impl.native + else: + assert str(view.textField.stringValue) == value - if icon: - assert view.imageView.image == icon._impl.native + if icon: + assert view.imageView.image == icon._impl.native + else: + assert view.imageView.image is None @property def max_scroll_position(self): diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index a4f559047a..ae9132731a 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -346,19 +346,65 @@ async def test_headerless_column_changes(headerless_widget, headerless_probe): await _column_change_test(headerless_widget, headerless_probe) -# async def test_crash(widget, probe): -# import random +class MyIconData: + def __init__(self, text, icon): + self.text = text + self.icon = icon -# for i in range(0, 1000): -# row = random.randint(0, 17) -# await probe.select_row(row) -# await probe.redraw(f"{i}: select {row}", delay=0.2) - - -# async def test_cell_widget(widget, probe): -# "A widget can be used as a cell value" - -# async def test_cell_icon(widget, probe): -# "A widget can be used as a cell value" -# # icon in table as tuple -# # icon in table as attribute/value + def __str__(self): + return f"" + + +async def test_cell_icon(widget, probe): + "An icon can be used as a cell value" + red = toga.Icon("resources/icons/red") + green = toga.Icon("resources/icons/green") + widget.data = [ + { + # Normal text, + "a": f"A{i}", + # A tuple + "b": ({0: None, 1: red, 2: green}[i % 3], f"B{i}"), + # An object with an icon attribute. + "c": MyIconData(f"C{i}", {0: red, 1: green, 2: None}[i % 3]), + } + for i in range(0, 50) + ] + await probe.redraw("Table has data with icons") + + probe.assert_cell_content(0, 0, "A0") + probe.assert_cell_content(0, 1, "B0", icon=None) + probe.assert_cell_content(0, 2, "", icon=red) + + probe.assert_cell_content(1, 0, "A1") + probe.assert_cell_content(1, 1, "B1", icon=red) + probe.assert_cell_content(1, 2, "", icon=green) + + probe.assert_cell_content(2, 0, "A2") + probe.assert_cell_content(2, 1, "B2", icon=green) + probe.assert_cell_content(2, 2, "", icon=None) + + +async def test_cell_widget(widget, probe): + "A widget can be used as a cell value" + widget.data = [ + { + # Normal text, + "a": f"A{i}", + "b": f"B{i}", + # Toga widgets. + "c": toga.Button(f"C{i}") + if i % 2 == 0 + else toga.TextInput(value=f"edit C{i}"), + } + for i in range(0, 50) + ] + await probe.redraw("Table has data with widgets") + + probe.assert_cell_content(0, 0, "A0") + probe.assert_cell_content(0, 1, "B0") + probe.assert_cell_content(0, 2, widget=widget.data[0].c) + + probe.assert_cell_content(1, 0, "A1") + probe.assert_cell_content(1, 1, "B1") + probe.assert_cell_content(1, 2, widget=widget.data[1].c) From 399baf3d88d2f20c0df17e163a22c6199533d09c Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 26 Jun 2023 09:56:16 +0800 Subject: [PATCH 12/40] Correct row position calculation. --- cocoa/tests_backend/widgets/table.py | 32 +++++++++++++++++++++------- testbed/tests/widgets/test_table.py | 9 ++++---- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index 23f937ae97..53eddc281f 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -74,17 +74,17 @@ def row_position(self, row): # Pick a point half way across horizontally, and half way down the row, # taking into account the size of the rows and the header row_height = self.native_table.rowHeight - return self.native.convertPoint( + return self.native_table.convertPoint( NSPoint( self.width / 2, - row * row_height + self.header_height + row_height / 2, + (row * row_height) + (row_height / 2), ), toView=None, ) async def select_row(self, row, add=False): point = self.row_position(row) - # Selection maintains an inner mouse event loop, so we can't + # Table maintains an inner mouse event loop, so we can't # use the "wait for another event" approach for the mouse events. # Use a short delay instead. await self.mouse_event( @@ -102,14 +102,30 @@ async def select_row(self, row, add=False): async def activate_row(self, row): point = self.row_position(row) - # Selection maintains an inner mouse event loop, so we can't + # Table maintains an inner mouse event loop, so we can't # use the "wait for another event" approach for the mouse events. # Use a short delay instead. - await self.mouse_event(NSEventType.LeftMouseDown, point, delay=0.1) - await self.mouse_event(NSEventType.LeftMouseUp, point, delay=0.1) + await self.mouse_event( + NSEventType.LeftMouseDown, + point, + delay=0.1, + ) + await self.mouse_event( + NSEventType.LeftMouseUp, + point, + delay=0.1, + ) # Second click, with a click count. await self.mouse_event( - NSEventType.LeftMouseDown, point, delay=0.1, clickCount=2 + NSEventType.LeftMouseDown, + point, + delay=0.1, + clickCount=2, + ) + await self.mouse_event( + NSEventType.LeftMouseUp, + point, + delay=0.1, + clickCount=2, ) - await self.mouse_event(NSEventType.LeftMouseUp, point, delay=0.1, clickCount=2) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index ae9132731a..325fafdacf 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -106,9 +106,8 @@ async def multiselect_probe(main_window, multiselect_widget): async def test_scroll(widget, probe): """The table can be scrolled""" - # Store the initial position; it might be <0 for implementation reasons. - initial_position = probe.scroll_position - assert initial_position <= 0 + # Due to the interaction of scrolling with the header row, the scroll might be <0. + assert probe.scroll_position <= 0 # Scroll to the bottom of the table widget.scroll_to_bottom() @@ -133,7 +132,9 @@ async def test_scroll(widget, probe): widget.scroll_to_top() await probe.wait_for_scroll_completion() await probe.redraw("Table scrolled to bottom") - assert probe.scroll_position == initial_position + + # Due to the interaction of scrolling with the header row, the scroll might be <0. + assert probe.scroll_position <= 0 async def test_select(widget, probe, source, on_select_handler): From 564ab0f99cbb49df76ff381abd63daa6cd98f542 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 26 Jun 2023 15:02:12 +0800 Subject: [PATCH 13/40] Simplify activation handler. --- cocoa/src/toga_cocoa/widgets/table.py | 5 ++--- testbed/tests/widgets/test_table.py | 7 ------- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 0e680c06f6..28ec779beb 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -122,10 +122,9 @@ def tableViewSelectionDidChange_(self, notification) -> None: # target methods @objc_method def onDoubleClick_(self, sender) -> None: - if self.clickedRow != -1: - clicked = self.interface.data[self.clickedRow] + clicked = self.interface.data[self.clickedRow] - self.interface.on_activate(None, row=clicked) + self.interface.on_activate(None, row=clicked) class Table(Widget): diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 325fafdacf..7aee59c2a6 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -179,13 +179,6 @@ async def test_activate( on_activate_handler.assert_called_once_with(widget, row=source[1]) on_activate_handler.reset_mock() - # Double click somewhere not on the table - await probe.activate_row(-1) - await probe.redraw("Somewhere off the table is activated") - - on_activate_handler.assert_not_called() - on_activate_handler.reset_mock() - async def test_multiselect( multiselect_widget, From 8e9e521f2a2b835b8761bb306b21132d1be3395a Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Mon, 26 Jun 2023 15:15:41 +0800 Subject: [PATCH 14/40] Update examples to reflect changes to table. --- docs/tutorial/tutorial-2.rst | 8 + examples/table/table/app.py | 7 +- examples/table_source/table_source/app.py | 3 +- examples/tutorial2/tutorial/app.py | 202 ++++++++++++---------- 4 files changed, 123 insertions(+), 97 deletions(-) diff --git a/docs/tutorial/tutorial-2.rst b/docs/tutorial/tutorial-2.rst index 70179c6e0c..91acaf0445 100644 --- a/docs/tutorial/tutorial-2.rst +++ b/docs/tutorial/tutorial-2.rst @@ -36,3 +36,11 @@ In this example, we see a couple of new Toga widgets - :class:`.Table`, :class:`.SplitContainer`, and :class:`.ScrollContainer`. You can also see that CSS styles can be added in the widget constructor. Lastly, you can see that windows can have toolbars. + +You'll also see that we're not creating a :class:`toga.App` directly. Instead, we're +declaring a subclass of `toga.App`, and instantiating that class. This also changes the +startup sequence of the app - instead of a function called ``build()``, the app invokes +a method on the app class named ``startup()``. This method behaves slightly differently +to our ``build()`` method - whereas previously the ``build()`` method returned the content +that we wanted to put into our main window, the ``startup()`` method is responsible for +creating and showing the main window of the app. diff --git a/examples/table/table/app.py b/examples/table/table/app.py index 9a983d500e..2b54d49306 100644 --- a/examples/table/table/app.py +++ b/examples/table/table/app.py @@ -22,12 +22,13 @@ class ExampleTableApp(toga.App): lbl_fontsize = None # Table callback functions - def on_select_handler1(self, widget, row, **kwargs): + def on_select_handler1(self, widget, **kwargs): + row = self.table1.selection self.label_table1.text = ( f"You selected row: {row.title}" if row is not None else "No row selected" ) - def on_select_handler2(self, widget, row, **kwargs): + def on_select_handler2(self, widget, **kwargs): if self.table2.selection is not None: self.label_table2.text = "Rows selected: {}".format( len(self.table2.selection) @@ -201,7 +202,7 @@ def build_activate_message(cls, row, table_index): ["magnificent", "amazing", "awesome", "life-changing"] ) return ( - f"You selected the {adjective} {row.genre.lower()} movie " + f"You selected the {adjective} {getattr(row, 'genre', '').lower()} movie " f"{row.title} ({row.year}) from Table {table_index}" ) diff --git a/examples/table_source/table_source/app.py b/examples/table_source/table_source/app.py index 0fbbfc9abb..9b9577f479 100644 --- a/examples/table_source/table_source/app.py +++ b/examples/table_source/table_source/app.py @@ -117,7 +117,8 @@ def clear(self): class ExampleTableSourceApp(toga.App): # Table callback functions - def on_select_handler(self, widget, row, **kwargs): + def on_select_handler(self, widget, **kwargs): + row = widget.selection self.label.text = ( f"You selected row: {row.title}" if row is not None else "No row selected" ) diff --git a/examples/tutorial2/tutorial/app.py b/examples/tutorial2/tutorial/app.py index d5422e8989..f78cd285bf 100644 --- a/examples/tutorial2/tutorial/app.py +++ b/examples/tutorial2/tutorial/app.py @@ -34,109 +34,125 @@ def action6(widget): print("action 6") -def build(app): - brutus_icon = "icons/brutus" - cricket_icon = "icons/cricket-72.png" +class Tutorial2App(toga.App): + def startup(self): + brutus_icon = "icons/brutus" + cricket_icon = "icons/cricket-72.png" - data = [("root%s" % i, "value %s" % i) for i in range(1, 100)] + data = [("root%s" % i, "value %s" % i) for i in range(1, 100)] - left_container = toga.Table(headings=["Hello", "World"], data=data) + left_container = toga.Table(headings=["Hello", "World"], data=data) - right_content = toga.Box(style=Pack(direction=COLUMN, padding_top=50)) + right_content = toga.Box(style=Pack(direction=COLUMN, padding_top=50)) - for b in range(0, 10): - right_content.add( - toga.Button( - "Hello world %s" % b, - on_press=button_handler, - style=Pack(width=200, padding=20), + for b in range(0, 10): + right_content.add( + toga.Button( + "Hello world %s" % b, + on_press=button_handler, + style=Pack(width=200, padding=20), + ) ) + + right_container = toga.ScrollContainer(horizontal=False) + + right_container.content = right_content + + split = toga.SplitContainer() + + # The content of the split container can be specified as a simple list: + # split.content = [left_container, right_container] + # but you can also specify "weight" with each content item, which will + # set an initial size of the columns to make a "heavy" column wider than + # a narrower one. In this example, the right container will be twice + # as wide as the left one. + split.content = [(left_container, 1), (right_container, 2)] + + # Create a "Things" menu group to contain some of the commands. + # No explicit ordering is provided on the group, so it will appear + # after application-level menus, but *before* the Command group. + # Items in the Things group are not explicitly ordered either, so they + # will default to alphabetical ordering within the group. + things = toga.Group("Things") + cmd0 = toga.Command( + action0, + text="Action 0", + tooltip="Perform action 0", + icon=brutus_icon, + group=things, + ) + cmd1 = toga.Command( + action1, + text="Action 1", + tooltip="Perform action 1", + icon=brutus_icon, + group=things, + ) + cmd2 = toga.Command( + action2, + text="Action 2", + tooltip="Perform action 2", + icon=toga.Icon.TOGA_ICON, + group=things, + ) + + # Commands without an explicit group end up in the "Commands" group. + # The items have an explicit ordering that overrides the default + # alphabetical ordering + cmd3 = toga.Command( + action3, + text="Action 3", + tooltip="Perform action 3", + shortcut=toga.Key.MOD_1 + "k", + icon=cricket_icon, + order=3, + ) + + # Define a submenu inside the Commands group. + # The submenu group has an order that places it in the parent menu. + # The items have an explicit ordering that overrides the default + # alphabetical ordering. + sub_menu = toga.Group("Sub Menu", parent=toga.Group.COMMANDS, order=2) + cmd5 = toga.Command( + action5, + text="Action 5", + tooltip="Perform action 5", + order=2, + group=sub_menu, ) + cmd6 = toga.Command( + action6, + text="Action 6", + tooltip="Perform action 6", + order=1, + group=sub_menu, + ) + + def action4(widget): + print("CALLING Action 4") + cmd3.enabled = not cmd3.enabled + + cmd4 = toga.Command( + action4, + text="Action 4", + tooltip="Perform action 4", + icon=brutus_icon, + order=1, + ) + + # The order in which commands are added to the app or the toolbar won't + # alter anything. Ordering is defined by the command definitions. + self.commands.add(cmd1, cmd0, cmd6, cmd4, cmd5, cmd3) + + self.main_window = toga.MainWindow(title=self.name) + self.main_window.toolbar.add(cmd1, cmd3, cmd2, cmd4) + self.main_window.content = split - right_container = toga.ScrollContainer(horizontal=False) - - right_container.content = right_content - - split = toga.SplitContainer() - - # The content of the split container can be specified as a simple list: - # split.content = [left_container, right_container] - # but you can also specify "weight" with each content item, which will - # set an initial size of the columns to make a "heavy" column wider than - # a narrower one. In this example, the right container will be twice - # as wide as the left one. - split.content = [(left_container, 1), (right_container, 2)] - - # Create a "Things" menu group to contain some of the commands. - # No explicit ordering is provided on the group, so it will appear - # after application-level menus, but *before* the Command group. - # Items in the Things group are not explicitly ordered either, so they - # will default to alphabetical ordering within the group. - things = toga.Group("Things") - cmd0 = toga.Command( - action0, - text="Action 0", - tooltip="Perform action 0", - icon=brutus_icon, - group=things, - ) - cmd1 = toga.Command( - action1, - text="Action 1", - tooltip="Perform action 1", - icon=brutus_icon, - group=things, - ) - cmd2 = toga.Command( - action2, - text="Action 2", - tooltip="Perform action 2", - icon=toga.Icon.TOGA_ICON, - group=things, - ) - - # Commands without an explicit group end up in the "Commands" group. - # The items have an explicit ordering that overrides the default - # alphabetical ordering - cmd3 = toga.Command( - action3, - text="Action 3", - tooltip="Perform action 3", - shortcut=toga.Key.MOD_1 + "k", - icon=cricket_icon, - order=3, - ) - - # Define a submenu inside the Commands group. - # The submenu group has an order that places it in the parent menu. - # The items have an explicit ordering that overrides the default - # alphabetical ordering. - sub_menu = toga.Group("Sub Menu", parent=toga.Group.COMMANDS, order=2) - cmd5 = toga.Command( - action5, text="Action 5", tooltip="Perform action 5", order=2, group=sub_menu - ) - cmd6 = toga.Command( - action6, text="Action 6", tooltip="Perform action 6", order=1, group=sub_menu - ) - - def action4(widget): - print("CALLING Action 4") - cmd3.enabled = not cmd3.enabled - - cmd4 = toga.Command( - action4, text="Action 4", tooltip="Perform action 4", icon=brutus_icon, order=1 - ) - - # The order in which commands are added to the app or the toolbar won't - # alter anything. Ordering is defined by the command definitions. - app.commands.add(cmd1, cmd0, cmd6, cmd4, cmd5, cmd3) - app.main_window.toolbar.add(cmd1, cmd3, cmd2, cmd4) - - return split + self.main_window.show() def main(): - return toga.App("First App", "org.beeware.helloworld", startup=build) + return Tutorial2App("Tutorial 2", "org.beeware.helloworld") if __name__ == "__main__": From cf121f4360c5350e40e1b9c932273565cdae1a86 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 26 Jun 2023 12:26:45 +0100 Subject: [PATCH 15/40] Fix Windows failure --- winforms/src/toga_winforms/widgets/selection.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/winforms/src/toga_winforms/widgets/selection.py b/winforms/src/toga_winforms/widgets/selection.py index c60387559d..7dc549d04a 100644 --- a/winforms/src/toga_winforms/widgets/selection.py +++ b/winforms/src/toga_winforms/widgets/selection.py @@ -61,8 +61,11 @@ def remove(self, index, item): # Removing the selected item will initially cause *nothing* to be selected. # Select an adjacent item if there is one. - if selection_change and self.native.Items.Count > 0: - self.native.SelectedIndex = max(0, index - 1) + if selection_change: + if self.native.Items.Count == 0: + self.on_change() + else: + self.native.SelectedIndex = max(0, index - 1) def select_item(self, index, item): self.native.SelectedIndex = index From bf7404a883015a97a0f5bdac5d7bb42526b6ee01 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Mon, 26 Jun 2023 13:45:39 +0100 Subject: [PATCH 16/40] Make Selection consistently unfocusable on Android --- android/src/toga_android/widgets/base.py | 8 +++++++- android/src/toga_android/widgets/button.py | 7 ++----- android/src/toga_android/widgets/selection.py | 2 ++ android/src/toga_android/widgets/slider.py | 6 +----- android/src/toga_android/widgets/switch.py | 7 ++----- 5 files changed, 14 insertions(+), 16 deletions(-) diff --git a/android/src/toga_android/widgets/base.py b/android/src/toga_android/widgets/base.py index 50e68f4e7f..5673d4ba75 100644 --- a/android/src/toga_android/widgets/base.py +++ b/android/src/toga_android/widgets/base.py @@ -31,6 +31,11 @@ def _get_activity(_cache=[]): class Widget: + # Some widgets are not generally focusable, but become focusable if there has been a + # keyboard event since the last touch event. To avoid this complicating the tests, + # these widgets disable programmatic focus entirely by setting focusable = False. + focusable = True + def __init__(self, interface): super().__init__() self.interface = interface @@ -86,7 +91,8 @@ def set_enabled(self, value): self.native.setEnabled(value) def focus(self): - self.native.requestFocus() + if self.focusable: + self.native.requestFocus() def get_tab_index(self): self.interface.factory.not_implemented("Widget.get_tab_index()") diff --git a/android/src/toga_android/widgets/button.py b/android/src/toga_android/widgets/button.py index 5252187be3..a12de22492 100644 --- a/android/src/toga_android/widgets/button.py +++ b/android/src/toga_android/widgets/button.py @@ -15,6 +15,8 @@ def onClick(self, _view): class Button(TextViewWidget): + focusable = False + def create(self): self.native = A_Button(self._native_activity) self.native.setOnClickListener(TogaOnClickListener(button_impl=self)) @@ -29,11 +31,6 @@ def set_text(self, text): def set_enabled(self, value): self.native.setEnabled(value) - # Disable programmatic focus, otherwise whether this widget is focusable will depend - # on whether previous tests have generated keyboard input. - def focus(self): - pass - def set_background_color(self, value): self.set_background_filter(value) diff --git a/android/src/toga_android/widgets/selection.py b/android/src/toga_android/widgets/selection.py index f4f792cdda..b1d9b6be2a 100644 --- a/android/src/toga_android/widgets/selection.py +++ b/android/src/toga_android/widgets/selection.py @@ -19,6 +19,8 @@ def onNothingSelected(self, parent): class Selection(Widget): + focusable = False + def create(self): self.native = Spinner(self._native_activity, Spinner.MODE_DROPDOWN) self.native.setOnItemSelectedListener(TogaOnItemSelectedListener(impl=self)) diff --git a/android/src/toga_android/widgets/slider.py b/android/src/toga_android/widgets/slider.py index 0de92cf250..94ded9d0d0 100644 --- a/android/src/toga_android/widgets/slider.py +++ b/android/src/toga_android/widgets/slider.py @@ -30,6 +30,7 @@ def onStopTrackingTouch(self, native_seekbar): class Slider(Widget, toga.widgets.slider.IntSliderImpl): + focusable = False TICK_DRAWABLE = None def create(self): @@ -63,11 +64,6 @@ def _load_tick_drawable(self): Slider.TICK_DRAWABLE = attrs.getDrawable(0) attrs.recycle() - # Disable programmatic focus, otherwise whether this widget is focusable will depend - # on whether previous tests have generated keyboard input. - def focus(self): - pass - def rehint(self): self.native.measure( View__MeasureSpec.UNSPECIFIED, View__MeasureSpec.UNSPECIFIED diff --git a/android/src/toga_android/widgets/switch.py b/android/src/toga_android/widgets/switch.py index 6455feecff..4d847c871c 100644 --- a/android/src/toga_android/widgets/switch.py +++ b/android/src/toga_android/widgets/switch.py @@ -18,6 +18,8 @@ def onCheckedChanged(self, _button, _checked): class Switch(TextViewWidget): + focusable = False + def create(self): self.native = A_Switch(self._native_activity) self.native.setOnCheckedChangeListener(OnCheckedChangeListener(self)) @@ -41,11 +43,6 @@ def get_value(self): def set_value(self, value): self.native.setChecked(bool(value)) - # Disable programmatic focus, otherwise whether this widget is focusable will depend - # on whether previous tests have generated keyboard input. - def focus(self): - pass - def rehint(self): if not self.native.getLayoutParams(): return From e8c8983bb8f0ee160cd4b92c897d2f0c59e62f96 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 27 Jun 2023 07:16:18 +0800 Subject: [PATCH 17/40] Add explicit warnings for handler name change. --- core/src/toga/widgets/selection.py | 50 ++++++++++++++++++++-------- core/tests/widgets/test_selection.py | 42 +++++++++++++++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py index 206b37520a..645a6f8ecb 100644 --- a/core/src/toga/widgets/selection.py +++ b/core/src/toga/widgets/selection.py @@ -40,6 +40,22 @@ def __init__( """ super().__init__(id=id, style=style) + ###################################################################### + # 2023-05: Backwards compatibility + ###################################################################### + if on_select: # pragma: no cover + if on_change: + raise ValueError("Cannot specify both on_select and on_change") + else: + warnings.warn( + "Selection.on_select has been renamed Selection.on_change", + DeprecationWarning, + ) + on_change = on_select + ###################################################################### + # End backwards compatibility. + ###################################################################### + self.on_change = None # needed for _impl initialization self._impl = self.factory.Selection(interface=self) @@ -48,19 +64,6 @@ def __init__( if value: self.value = value - # 2023-05-29: Rename on_select to on_change - if on_select: # pragma: no cover - if on_change: - raise ValueError( - "Cannot specify both `on_select` and `on_change`; " - "`on_select` has been deprecated, use `on_change`" - ) - else: - warnings.warn( - "Selection.on_select has been renamed Selection.on_change" - ) - on_change = on_select - self.on_change = on_change self.enabled = enabled @@ -185,3 +188,24 @@ def on_change(self) -> callable: @on_change.setter def on_change(self, handler): self._on_change = wrapped_handler(self, handler) + + ###################################################################### + # 2023-05: Backwards compatibility + ###################################################################### + + @property + def on_select(self) -> callable: + """**DEPRECATED**: Use ``on_change``""" + warnings.warn( + "Selection.on_select has been renamed Selection.on_change.", + DeprecationWarning, + ) + return self.on_change + + @on_select.setter + def on_select(self, handler): + warnings.warn( + "Selection.on_select has been renamed Selection.on_change.", + DeprecationWarning, + ) + self.on_change = handler diff --git a/core/tests/widgets/test_selection.py b/core/tests/widgets/test_selection.py index 46744b1d00..50c63e4676 100644 --- a/core/tests/widgets/test_selection.py +++ b/core/tests/widgets/test_selection.py @@ -351,3 +351,45 @@ def test_change_source(widget, on_change_handler): # The widget must have cleared it's selection on_change_handler.assert_called_once_with(widget) assert widget.value.key == "new 1" + + +###################################################################### +# 2023-05: Backwards compatibility +###################################################################### + + +def test_deprecated_names(on_change_handler): + "Deprecated names still work" + + # Can't specify both on_select and on_change + with pytest.raises( + ValueError, + match=r"Cannot specify both on_select and on_change", + ): + toga.Selection(on_select=Mock(), on_change=Mock()) + + # on_select is redirected at construction + with pytest.warns( + DeprecationWarning, + match="Selection.on_select has been renamed Selection.on_change", + ): + select = toga.Selection(on_select=on_change_handler) + + # on_select accessor is redirected to on_change + with pytest.warns( + DeprecationWarning, + match="Selection.on_select has been renamed Selection.on_change", + ): + assert select.on_select._raw == on_change_handler + + assert select.on_change._raw == on_change_handler + + # on_select mutator is redirected to on_change + new_handler = Mock() + with pytest.warns( + DeprecationWarning, + match="Selection.on_select has been renamed Selection.on_change", + ): + select.on_select = new_handler + + assert select.on_change._raw == new_handler From dd70006643c6de243ad8ed4a589278c7e4ea629a Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 27 Jun 2023 07:24:04 +0800 Subject: [PATCH 18/40] Add an explicit note that ListSource is list-like. --- docs/reference/api/resources/sources/list_source.rst | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/reference/api/resources/sources/list_source.rst b/docs/reference/api/resources/sources/list_source.rst index cc770e99c0..744ae8f1e7 100644 --- a/docs/reference/api/resources/sources/list_source.rst +++ b/docs/reference/api/resources/sources/list_source.rst @@ -11,8 +11,10 @@ application independent of the GUI representation of that data. For details on t of data sources, see the :doc:`background guide `. ListSource is an implementation of an ordered list of data. When a ListSource is -created, it is given a list of ``accessors`` - these are the attributes that -all items managed by the ListSource will have. +created, it is given a list of ``accessors`` - these are the attributes that all items +managed by the ListSource will have. The API provided by ListSource is :any:`list`-like; +the operations you'd expect on a normal Python list, such as ``insert``, ``remove``, +``index``, and indexing with ``[]``, are also possible on a ListSource: .. code-block:: python From 81a4f25d6fa7a710b80a01127106471caeeb6152 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 27 Jun 2023 08:03:25 +0800 Subject: [PATCH 19/40] Remove a platform test that is no longer needed. --- gtk/tests/widgets/test_table.py | 244 -------------------------------- 1 file changed, 244 deletions(-) delete mode 100644 gtk/tests/widgets/test_table.py diff --git a/gtk/tests/widgets/test_table.py b/gtk/tests/widgets/test_table.py deleted file mode 100644 index b016d21238..0000000000 --- a/gtk/tests/widgets/test_table.py +++ /dev/null @@ -1,244 +0,0 @@ -import unittest - -try: - import gi - - gi.require_version("Gtk", "3.0") - from gi.repository import Gtk -except ImportError: - import sys - - # If we're on Linux, Gtk *should* be available. If it isn't, make - # Gtk an object... but in such a way that every test will fail, - # because the object isn't actually the Gtk interface. - if sys.platform == "linux": - Gtk = object() - else: - Gtk = None - -import toga - -from .utils import TreeModelListener - - -def handle_events(): - while Gtk.events_pending(): - Gtk.main_iteration_do(blocking=False) - - -@unittest.skipIf( - Gtk is None, "Can't run GTK implementation tests on a non-Linux platform" -) -class TestGtkTable(unittest.TestCase): - def setUp(self): - self.table = toga.Table(headings=("one", "two")) - - # make a shortcut for easy use - self.gtk_table = self.table._impl - - self.window = Gtk.Window() - self.window.add(self.table._impl.native) - - def assertRowEqual(self, row, data): - self.assertEqual(tuple(row)[1:], data) - - def test_change_source(self): - # Clear the table directly - self.gtk_table.clear() - - # Assign pre-constructed data - self.table.data = [("A1", "A2"), ("B1", "B2")] - - # Make sure the data was stored correctly - store = self.gtk_table.store - self.assertRowEqual(store[0], ("A1", "A2")) - self.assertRowEqual(store[1], ("B1", "B2")) - - # Clear the table with empty assignment - self.table.data = [] - - # Make sure the table is empty - self.assertEqual(len(store), 0) - - # Repeat with a few different cases - self.table.data = None - self.assertEqual(len(store), 0) - - self.table.data = () - self.assertEqual(len(store), 0) - - def test_insert(self): - listener = TreeModelListener(self.gtk_table.store) - - # Insert a row - row_data = ("1", "2") - INSERTED_AT = 0 - row = self.table.data.insert(INSERTED_AT, row_data) - - # Make sure it's in there - self.assertIsNotNone(listener.inserted_it) - - # Get the Gtk.TreeIter - tree_iter = listener.inserted_it - - # Make sure it's a Gtk.TreeIter - self.assertTrue(isinstance(tree_iter, Gtk.TreeIter)) - - # Make sure it's the correct Gtk.TreeIter - self.assertEqual(row, self.gtk_table.store.get(tree_iter, 0)[0]) - - # Get the Gtk.TreePath of the Gtk.TreeIter - path = self.gtk_table.store.get_path(tree_iter) - - # Make sure it's the correct Gtk.TreePath - self.assertTrue(isinstance(path, Gtk.TreePath)) - self.assertEqual(str(path), str(INSERTED_AT)) - self.assertEqual(tuple(path), (INSERTED_AT,)) - self.assertEqual(path, Gtk.TreePath(INSERTED_AT)) - self.assertEqual(path, listener.inserted_path) - - # Make sure the row got stored correctly - result_row = self.gtk_table.store[path] - self.assertRowEqual(result_row, row_data) - - def test_remove(self): - listener = TreeModelListener(self.gtk_table.store) - # Insert a row - row = self.table.data.insert(0, ("1", "2")) - - # Make sure it's in there - self.assertIsNotNone(listener.inserted_it) - - # Then remove it - self.gtk_table.remove(index=0, item=row) - - # Make sure its gone - self.assertIsNotNone(listener.deleted_path) - - def test_change(self): - listener = TreeModelListener(self.gtk_table.store) - - # Insert a row - row = self.table.data.insert(0, ("1", "2")) - - # Make sure it's in there - self.assertIsNotNone(listener.inserted_it) - - # Change a column - row.one = "something_changed" - # (not testing that self.gtk_table.change is called. The Core API - # unit tests should ensure this already.) - - # Get the Gtk.TreeIter - tree_iter = listener.changed_it - - # Make sure it's a Gtk.TreeIter - self.assertTrue(isinstance(tree_iter, Gtk.TreeIter)) - - # Make sure it's the correct Gtk.TreeIter - self.assertEqual(row, self.gtk_table.store.get(tree_iter, 0)[0]) - - # Make sure the value changed - path = self.gtk_table.store.get_path(tree_iter) - result_row = self.gtk_table.store[path] - self.assertRowEqual(result_row, (row.one, row.two)) - - def test_row_persistence(self): - self.table.data.insert(0, dict(one="A1", two="A2")) - self.table.data.insert(0, dict(one="B1", two="B2")) - - # B should now precede A - # tests passes if A "knows" it has moved to index 1 - - self.assertRowEqual(self.gtk_table.store[0], ("B1", "B2")) - self.assertRowEqual(self.gtk_table.store[1], ("A1", "A2")) - - def test_on_select_root_row(self): - # Insert two dummy rows - self.table.data = [] - self.table.data.append(dict(one="A1", two="A2")) - b = self.table.data.append(dict(one="B1", two="B2")) - - # Create a flag - succeed = False - - def on_select(table, row, *kw): - # Make sure the right row was selected - self.assertEqual(row, b) - - nonlocal succeed - succeed = True - - self.table.on_select = on_select - - # Select row B - self.gtk_table.selection.select_path(1) - - # Allow on_select to call - handle_events() - - self.assertTrue(succeed) - - def test_on_select_child_row(self): - # Insert two nodes - self.table.data = [] - - listener = TreeModelListener(self.gtk_table.store) - - self.table.data.append(dict(one="A1", two="A2")) - b = self.table.data.append(dict(one="B1", two="B2")) - - # Create a flag - succeed = False - - def on_select(able, row, *kw): - # Make sure the right node was selected - self.assertEqual(row, b) - - nonlocal succeed - succeed = True - - self.table.on_select = on_select - - # Select node B - self.gtk_table.selection.select_iter(listener.inserted_it) - - # Allow on_select to call - handle_events() - - self.assertTrue(succeed) - - def test_on_select_deleted_node(self): - # Insert two nodes - self.table.data = [] - - listener = TreeModelListener(self.gtk_table.store) - - self.table.data.append(dict(one="A1", two="A2")) - listener.clear() - b = self.table.data.append(dict(one="B1", two="B2")) - - # Create a flag - succeed = False - - def on_select(table, row): - nonlocal succeed - if row is not None: - # Make sure the right row was selected - self.assertEqual(row, b) - - # Remove row B. This should trigger on_select again - table.data.remove(row) - else: - self.assertEqual(row, None) - succeed = True - - self.table.on_select = on_select - - # Select row B - self.gtk_table.selection.select_iter(listener.inserted_it) - - # Allow on_select to call - handle_events() - - self.assertTrue(succeed) From 358cdc12e85e248e20c624ad193a8a6dc851415a Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 27 Jun 2023 13:06:12 +0800 Subject: [PATCH 20/40] Switch GTK to use GdkPixBuf as the native Icon widget, and require a 16px icon. --- core/tests/resources/red-16.png | Bin 0 -> 503 bytes gtk/src/toga_gtk/app.py | 2 +- gtk/src/toga_gtk/icons.py | 10 ++++------ gtk/tests_backend/icons.py | 10 +++++++--- testbed/src/testbed/resources/icons/green-16.png | Bin 0 -> 503 bytes testbed/src/testbed/resources/icons/red-16.png | Bin 0 -> 503 bytes 6 files changed, 12 insertions(+), 10 deletions(-) create mode 100644 core/tests/resources/red-16.png create mode 100644 testbed/src/testbed/resources/icons/green-16.png create mode 100644 testbed/src/testbed/resources/icons/red-16.png diff --git a/core/tests/resources/red-16.png b/core/tests/resources/red-16.png new file mode 100644 index 0000000000000000000000000000000000000000..b1dd89acba0fc82e600864985bfa9a8ad67ed9d0 GIT binary patch literal 503 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZRA<_=LCu>HpM1|NjeJ+Kj9&c5M3k{@wP5=FK0XX3Tj2RKu9$?d~G^CU0v8ki%Z$>Fdh=oK2d=O5nq- zL#aTaI8PVH5Q)pN{n3043Op?0u5w)aTJQZ|vPfX#nfrdbPKtSSteajGAjRs;I{UMS z{N>5Ie+yRn^Dv%oJ#^-1)70*@qSk%el6Dqch*|P;p1jJrS?mAwtXj==s70c=`u={# zh%N$E#o9m(8gLs*GILXlOA>PnaO+w1t?3I;4}+(xpUXO@geCwv Cq@||- literal 0 HcmV?d00001 diff --git a/gtk/src/toga_gtk/app.py b/gtk/src/toga_gtk/app.py index 7df2230bf2..cfeeffccdc 100644 --- a/gtk/src/toga_gtk/app.py +++ b/gtk/src/toga_gtk/app.py @@ -32,7 +32,7 @@ def create(self): super().create() self.native.set_role("MainWindow") icon_impl = toga_App.app.icon._impl - self.native.set_icon(icon_impl.native_72.get_pixbuf()) + self.native.set_icon(icon_impl.native_72) def gtk_delete_event(self, *args): # Return value of the GTK on_close handler indicates diff --git a/gtk/src/toga_gtk/icons.py b/gtk/src/toga_gtk/icons.py index d15a9a043c..ab0b913cb5 100644 --- a/gtk/src/toga_gtk/icons.py +++ b/gtk/src/toga_gtk/icons.py @@ -1,9 +1,9 @@ -from .libs import GdkPixbuf, Gtk +from .libs import GdkPixbuf class Icon: EXTENSIONS = [".png", ".ico", ".icns"] - SIZES = [32, 72] + SIZES = [16, 32, 72] def __init__(self, interface, path): self.interface = interface @@ -11,9 +11,7 @@ def __init__(self, interface, path): # Preload all the required icon sizes for size, path in self.paths.items(): - native = Gtk.Image.new_from_pixbuf( - GdkPixbuf.Pixbuf.new_from_file(str(path)).scale_simple( - size, size, GdkPixbuf.InterpType.BILINEAR - ) + native = GdkPixbuf.Pixbuf.new_from_file(str(path)).scale_simple( + size, size, GdkPixbuf.InterpType.BILINEAR ) setattr(self, f"native_{size}", native) diff --git a/gtk/tests_backend/icons.py b/gtk/tests_backend/icons.py index 1ff776df1e..b2d8025ea3 100644 --- a/gtk/tests_backend/icons.py +++ b/gtk/tests_backend/icons.py @@ -1,6 +1,6 @@ import pytest -from toga_gtk.libs import Gtk +from toga_gtk.libs import GdkPixbuf from .probe import BaseProbe @@ -12,17 +12,20 @@ def __init__(self, app, icon): super().__init__() self.app = app self.icon = icon - assert isinstance(self.icon._impl.native_32, Gtk.Image) - assert isinstance(self.icon._impl.native_72, Gtk.Image) + assert isinstance(self.icon._impl.native_16, GdkPixbuf.Pixbuf) + assert isinstance(self.icon._impl.native_32, GdkPixbuf.Pixbuf) + assert isinstance(self.icon._impl.native_72, GdkPixbuf.Pixbuf) def assert_icon_content(self, path): if path == "resources/icons/green": assert self.icon._impl.paths == { + 16: self.app.paths.app / "resources" / "icons" / "green-16.png", 32: self.app.paths.app / "resources" / "icons" / "green-32.png", 72: self.app.paths.app / "resources" / "icons" / "green-72.png", } elif path == "resources/icons/orange": assert self.icon._impl.paths == { + 16: self.app.paths.app / "resources" / "icons" / "orange.ico", 32: self.app.paths.app / "resources" / "icons" / "orange.ico", 72: self.app.paths.app / "resources" / "icons" / "orange.ico", } @@ -31,6 +34,7 @@ def assert_icon_content(self, path): def assert_default_icon_content(self): assert self.icon._impl.paths == { + 16: self.app.paths.toga / "resources" / "toga.png", 32: self.app.paths.toga / "resources" / "toga.png", 72: self.app.paths.toga / "resources" / "toga.png", } diff --git a/testbed/src/testbed/resources/icons/green-16.png b/testbed/src/testbed/resources/icons/green-16.png new file mode 100644 index 0000000000000000000000000000000000000000..32f03bc3e7126d83d3b0c8de711ff9c6c106f5c7 GIT binary patch literal 503 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZRA<_=LCuX@&;sp#T5>XE*lS0u7WX3GxeO;ECM! zX=@mFctm|;Xw;5PU*Esm-q5`HL)45p4}fYIlf2zsB;Vw1?ErGvOFVsD*`KpXvsg1W z7hb*w6pHh7aSV~T9NQnw*Py_|BJL{3wXgNw|0RnAHlDffx9g;sN5{J9MFCQ*&aAUP zd&pm&toyfMr9Thj`PM^cjy6s0UMp(dw=HRB!G)M5Kj+D-oSU`&PtU5=T!&gDnyc^c zXN))__-ns$WhBrh)e_f;l9a@fRIB8oR3OD*WME{hYhb2pWFBH*Vr6P+WnidnU|?ln zkX7in6GcOAeoAIqC2kGUI^S4<8Z_WGlw{_n7MCRE7U0(7V|o1+P!EHrtDnm{r-UW| Dn2(k! literal 0 HcmV?d00001 diff --git a/testbed/src/testbed/resources/icons/red-16.png b/testbed/src/testbed/resources/icons/red-16.png new file mode 100644 index 0000000000000000000000000000000000000000..4a9257e4b154431423c9c8639ebb30a059f8dcb9 GIT binary patch literal 503 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!63?wyl`GbL!Lb6AYF9SoB8UsT^3j@P1pisjL z28L1t28LG&3=CE?7#PG0=Ijcz0ZRA<_=LCu>HpM1|NjeJ+Kj9&c5M3k{@wP5=FK0XX3Tj2RKu9$?d~G^CU0v8ki%Z$>Fdh=oK2d=$~b86 zW^tfUoTrOph{WaC{%F1i1s)c0S2?bIt@r*fStPLW%zeLIC&fHE)=e)8kYaUao&DKE z{_P9y)WhX=?XcQR}{KNjnQJ#4PzaPhRERto46-R;}hb)FRPbeSbe= z#2LX~`;99jfi|g@xJHzuB$lLFB^RXvDF!10BV%0yGhHL|5Can{Qwu9oLu~^CD+7bG z!P<{eH00)|WTsW(*06_fay(Fj2Hb{{%-q!ClEmBs+ Date: Tue, 27 Jun 2023 13:07:53 +0800 Subject: [PATCH 21/40] Make the pause after a keystroke a default behavior. --- gtk/tests_backend/widgets/base.py | 6 ++++++ testbed/tests/widgets/test_textinput.py | 5 +---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/gtk/tests_backend/widgets/base.py b/gtk/tests_backend/widgets/base.py index 1d467e7f53..ba5a0f5019 100644 --- a/gtk/tests_backend/widgets/base.py +++ b/gtk/tests_backend/widgets/base.py @@ -1,3 +1,4 @@ +import asyncio from threading import Event from toga_gtk.libs import Gdk, Gtk @@ -160,3 +161,8 @@ def event_handled(widget, e): # Remove the temporary handler self._keypress_target.disconnect(handler_id) + + # GTK has an intermittent failure because on_change handler + # caused by typing a character doesn't fully propegate. A + # short delay fixes this. + await asyncio.sleep(0.04) diff --git a/testbed/tests/widgets/test_textinput.py b/testbed/tests/widgets/test_textinput.py index 58713716cf..72ea9d7c45 100644 --- a/testbed/tests/widgets/test_textinput.py +++ b/testbed/tests/widgets/test_textinput.py @@ -94,10 +94,7 @@ async def test_on_change_user(widget, probe, on_change): for count, char in enumerate("Hello world", start=1): await probe.type_character(char) - # GTK has an intermittent failure because on_change handler - # caused by typing a character doesn't fully propegate. A - # short delay fixes this. - await probe.redraw(f"Typed {char!r}", delay=0.02) + await probe.redraw(f"Typed {char!r}") # The number of events equals the number of characters typed. assert on_change.mock_calls == [call(widget)] * count From e05b6754e77c0450e237b7a29aa6d50c9b84ace3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 27 Jun 2023 14:34:34 +0800 Subject: [PATCH 22/40] GTK Table widget to 100%. --- cocoa/tests_backend/widgets/table.py | 13 +- core/src/toga/widgets/table.py | 9 +- core/tests/widgets/test_table.py | 31 ++++- examples/table/table/app.py | 2 +- gtk/src/toga_gtk/widgets/table.py | 189 ++++++++++++++++++++------- gtk/tests_backend/widgets/table.py | 80 ++++++++++++ testbed/tests/widgets/test_table.py | 13 +- 7 files changed, 270 insertions(+), 67 deletions(-) create mode 100644 gtk/tests_backend/widgets/table.py diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index 53eddc281f..bf8d324c09 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -64,11 +64,14 @@ async def wait_for_scroll_completion(self): pass @property - def header_height(self): - if self.native_table.headerView: - return self.native_table.headerView.frame.size.height - else: - return 0 + def header_visible(self): + return self.native_table.headerView is not None + + @property + def header_titles(self): + return [ + str(col.headerCell.stringValue) for col in self.native_table.tableColumns + ] def row_position(self, row): # Pick a point half way across horizontally, and half way down the row, diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 0cab5382d3..a7cd62b853 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -181,10 +181,11 @@ def scroll_to_row(self, row: int): :param row: The index of the row to make visible. Negative values refer to the nth last row (-1 is the last row, -2 second last, and so on). """ - if row >= 0: - self._impl.scroll_to_row(row) - else: - self._impl.scroll_to_row(len(self.data) + row) + if len(self.data) > 1: + if row >= 0: + self._impl.scroll_to_row(min(row, len(self.data))) + else: + self._impl.scroll_to_row(max(len(self.data) + row, 0)) def scroll_to_bottom(self): """Scroll the view so that the bottom of the list (last row) is visible.""" diff --git a/core/tests/widgets/test_table.py b/core/tests/widgets/test_table.py index 8e5f742c61..e1d570bacf 100644 --- a/core/tests/widgets/test_table.py +++ b/core/tests/widgets/test_table.py @@ -295,18 +295,35 @@ def test_scroll_to_top(table): assert_action_performed_with(table, "scroll to row", row=0) -def test_scroll_to_row(table): +@pytest.mark.parametrize( + "row, effective", + [ + # Positive index + (0, 0), + (2, 2), + # Greater index than available rows + (10, 3), + # Negative index + (-1, 2), + (-3, 0), + # Greater negative index than available rows + (-10, 0), + ], +) +def test_scroll_to_row(table, row, effective): "A table can be scrolled to a specific row" - table.scroll_to_row(1) + table.scroll_to_row(row) - assert_action_performed_with(table, "scroll to row", row=1) + assert_action_performed_with(table, "scroll to row", row=effective) -def test_scroll_to_row_negative(table): - "A table can be scrolled to a specific row with a negative index" - table.scroll_to_row(-1) +def test_scroll_to_row_no_data(table): + "If there's no data, scrolling is a no-op" + table.data.clear() - assert_action_performed_with(table, "scroll to row", row=2) + table.scroll_to_row(5) + + assert_action_not_performed(table, "scroll to row") def test_scroll_to_bottom(table): diff --git a/examples/table/table/app.py b/examples/table/table/app.py index 2b54d49306..aa1219515d 100644 --- a/examples/table/table/app.py +++ b/examples/table/table/app.py @@ -50,7 +50,7 @@ def on_activate2(self, widget, row, **kwargs): # Button callback functions def insert_handler(self, widget, **kwargs): - self.table1.data.insert(0, *choice(bee_movies)) + self.table1.data.insert(0, choice(bee_movies)) def delete_handler(self, widget, **kwargs): if self.table1.selection: diff --git a/gtk/src/toga_gtk/widgets/table.py b/gtk/src/toga_gtk/widgets/table.py index 4f7ac9e6cc..8600e21dc1 100644 --- a/gtk/src/toga_gtk/widgets/table.py +++ b/gtk/src/toga_gtk/widgets/table.py @@ -1,71 +1,164 @@ -from .tree import Tree - +from travertino.size import at_least + +from ..libs import GdkPixbuf, GObject, Gtk +from .base import Widget + + +class TogaRow(GObject.Object): + def __init__(self, row): + super().__init__() + self.row = row + + def icon(self, attr): + data = getattr(self.row, attr, None) + if isinstance(data, tuple): + if data[0] is not None: + return data[0]._impl.native_16 + return None + else: + try: + return data.icon._impl.native_16 + except AttributeError: + return None + + def text(self, attr, missing_value): + data = getattr(self.row, attr, None) + if isinstance(data, tuple): + text = data[1] + else: + try: + text = data.value + except AttributeError: + text = data + + if text is None: + return missing_value + return str(text) + + +class Table(Widget): + def create(self): + self.store = None + # Create a tree view, and put it in a scroll view. + # The scroll view is the native, because it's the outer container. + self.native_table = Gtk.TreeView(model=self.store) + self.native_table.connect("row-activated", self.gtk_on_row_activated) + + self.selection = self.native_table.get_selection() + if self.interface.multiple_select: + self.selection.set_mode(Gtk.SelectionMode.MULTIPLE) + else: + self.selection.set_mode(Gtk.SelectionMode.SINGLE) + self.selection.connect("changed", self.gtk_on_select) + + self._create_columns() + + self.native = Gtk.ScrolledWindow() + self.native.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC) + self.native.add(self.native_table) + self.native.set_min_content_width(200) + self.native.set_min_content_height(200) + + def _create_columns(self): + if self.interface.headings: + headings = self.interface.headings + self.native_table.set_headers_visible(True) + else: + headings = self.interface.accessors + self.native_table.set_headers_visible(False) + + for i, heading in enumerate(headings): + column = Gtk.TreeViewColumn(heading) + column.set_sizing(Gtk.TreeViewColumnSizing.AUTOSIZE) + column.set_expand(True) + column.set_resizable(True) + column.set_min_width(16) + + icon = Gtk.CellRendererPixbuf() + column.pack_start(icon, False) + column.add_attribute(icon, "pixbuf", i * 2 + 1) + + value = Gtk.CellRendererText() + column.pack_start(value, True) + column.add_attribute(value, "text", i * 2 + 2) + + self.native_table.append_column(column) + + def gtk_on_row_activated(self, widget, path, column): + row = self.store.get(self.store.get_iter(path[-1]), 0)[0] + self.interface.on_activate(None, row=row.row) -class Table(Tree): def gtk_on_select(self, selection): - if self.interface.on_select: - if self.interface.multiple_select: - tree_model, tree_path = selection.get_selected_rows() - if tree_path: - tree_iter = tree_model.get_iter(tree_path[-1]) - else: - tree_iter = None - else: - tree_model, tree_iter = selection.get_selected() - - # Covert the tree iter into the actual row. - if tree_iter: - row = tree_model.get(tree_iter, 0)[0] - else: - row = None - self.interface.on_select(None, row=row) + self.interface.on_select(None) def change_source(self, source): # Temporarily disconnecting the TreeStore improves performance for large # updates by deferring row rendering until the update is complete. - self.treeview.set_model(None) + self.native_table.set_model(None) - self.store.change_source(source) + for column in self.native_table.get_columns(): + self.native_table.remove_column(column) + self._create_columns() + + types = [TogaRow] + for accessor in self.interface._accessors: + types.extend([GdkPixbuf.Pixbuf, str]) + self.store = Gtk.ListStore(*types) for i, row in enumerate(self.interface.data): self.insert(i, row) - self.treeview.set_model(self.store) + self.native_table.set_model(self.store) def insert(self, index, item): - super().insert(None, index, item) - - def scroll_to_row(self, row): - return NotImplementedError - - def add_column(self, heading, accessor): - return NotImplementedError - - def remove_column(self, accessor): - return NotImplementedError - - # ================================= - # UNCHANGED METHODS (inherited from Tree) - # They are included here only to satisfy the implementation tests, which - # do not currently check for inherited methods. - - def create(self): - super().create() + row = TogaRow(item) + values = [row] + for accessor in self.interface.accessors: + values.extend( + [ + row.icon(accessor), + row.text(accessor, self.interface.missing_value), + ] + ) + + self.store.insert(index, values) def change(self, item): - super().change(item) + index = self.interface.data.index(item) + row = self.store[index] + for i, accessor in enumerate(self.interface.accessors): + row[i * 2 + 1] = row[0].icon(accessor) + row[i * 2 + 2] = row[0].text(accessor, self.interface.missing_value) def remove(self, index, item): - super().remove(item, index=index, parent=None) + del self.store[index] def clear(self): - super().clear() + self.store.clear() def get_selection(self): - return super().get_selection() + if self.interface.multiple_select: + store, itrs = self.selection.get_selected_rows() + return [self.interface.data.index(store[itr][0].row) for itr in itrs] + else: + store, iter = self.selection.get_selected() + if iter is None: + return None + return self.interface.data.index(store[iter][0].row) - def set_on_select(self, handler): - super().set_on_select(handler) + def scroll_to_row(self, row): + # Core API guarantees row exists, and there's > 1 row. + n_rows = len(self.interface.data) + pos = row / n_rows * self.native.get_vadjustment().get_upper() + self.native.get_vadjustment().set_value(pos) + + def insert_column(self, position, heading, accessor): + # Adding/removing a column means completely rebuilding the ListStore + self.change_source(self.interface.data) + + def remove_column(self, accessor): + self.change_source(self.interface.data) - def set_on_double_click(self, handler): - self.interface.factory.not_implemented("Table.set_on_double_click()") + def rehint(self): + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/gtk/tests_backend/widgets/table.py b/gtk/tests_backend/widgets/table.py new file mode 100644 index 0000000000..1340aad226 --- /dev/null +++ b/gtk/tests_backend/widgets/table.py @@ -0,0 +1,80 @@ +import pytest + +from toga_gtk.libs import Gtk + +from .base import SimpleProbe + + +class TableProbe(SimpleProbe): + native_class = Gtk.ScrolledWindow + + def __init__(self, widget): + super().__init__(widget) + self.native_table = widget._impl.native_table + assert isinstance(self.native_table, Gtk.TreeView) + + @property + def background_color(self): + pytest.skip("Can't set background color on GTK Tables") + + @property + def row_count(self): + return len(self.native_table.get_model()) + + @property + def column_count(self): + return self.native_table.get_n_columns() + + @property + def header_visible(self): + return self.native_table.get_headers_visible() + + @property + def header_titles(self): + return [col.get_title() for col in self.native_table.get_columns()] + + def assert_cell_content(self, row, col, value=None, icon=None, widget=None): + if widget: + pytest.skip("GTK doesn't support widgets in Tables") + else: + gtk_row = self.native_table.get_model()[row] + assert gtk_row[col * 2 + 2] + + if icon: + assert gtk_row[col * 2 + 1] == icon._impl.native_16 + else: + assert gtk_row[col * 2 + 1] is None + + @property + def max_scroll_position(self): + return int( + self.native.get_vadjustment().get_upper() + - self.native.get_vadjustment().get_page_size() + ) + + @property + def scroll_position(self): + return int(self.native.get_vadjustment().get_value()) + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass + + async def select_row(self, row, add=False): + path = Gtk.TreePath(row) + + if add: + if path in self.native_table.get_selection().get_selected_rows()[1]: + self.native_table.get_selection().unselect_path(path) + else: + self.native_table.get_selection().select_path(path) + else: + self.native_table.get_selection().select_path(path) + + async def activate_row(self, row): + await self.select_row(row) + self.native_table.emit( + "row-activated", + Gtk.TreePath(row), + self.native_table.get_columns()[0], + ) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 7aee59c2a6..5faad120dc 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -287,14 +287,14 @@ async def _row_change_test(widget, probe): async def test_row_changes(widget, probe): """Rows can be added and removed""" # Header is visible - assert probe.header_height > 10 + assert probe.header_visible await _row_change_test(widget, probe) async def test_headerless_row_changes(headerless_widget, headerless_probe): """Rows can be added and removed to a headerless table""" # Header doesn't exist - assert headerless_probe.header_height == 0 + assert not headerless_probe.header_visible await _row_change_test(headerless_widget, headerless_probe) @@ -332,11 +332,20 @@ async def _column_change_test(widget, probe): async def test_column_changes(widget, probe): """Columns can be added and removed""" + # Header is visible, and has the right titles + assert probe.header_visible + assert probe.header_titles == ["A", "B", "C"] + await _column_change_test(widget, probe) + assert probe.header_titles == ["A", "B", "D", "E"] + async def test_headerless_column_changes(headerless_widget, headerless_probe): """Columns can be added and removed to a headerless table""" + # Header is not visible + assert not headerless_probe.header_visible + await _column_change_test(headerless_widget, headerless_probe) From 77a7e4d222e45aa2aba6e9fb8cc8e5156b8d7f78 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 27 Jun 2023 15:30:50 +0800 Subject: [PATCH 23/40] Add documentation notes about icons and widgets in Table. --- docs/reference/api/widgets/table.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index d0fff77ecb..ad486a9ca9 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -104,6 +104,30 @@ attribute "character" will be used: You can also create a table *without* a heading row. However, if you do this, you *must* specify accessors. +If the value provided by an accessor is :any:`None`, or the accessor isn't defined for a +given row, the value of ``missing_value`` provided when constructing the Table will +be used to populate the cell in the Table. + +If the value provided by an accessor is any type other than a tuple :any:`tuple` or +:any:`toga.Widget`, the value will be converted into a string. If the value has an +``icon`` attribute, the cell will use that icon in the Table cell, displayed to the left +of the text label. If the value of the ``icon`` attribute is :any:`None`, no icon will +be displayed. + +If the value provided by an accessor is a :any:`tuple`, the first element in the tuple +must be an :class:`toga.Icon`, and the second value in the tuple will be used to provide +the text label (again, by converting the value to a string, or using ``missing_value`` +if the value is :any:`None`, as appropriate). + +If the value provided by an accessor is a :class:`toga.Widget`, that widget will be displayed +in the table. Note that this is currently a beta API, and may change in future. + +Notes +----- + +* The use of Widgets as table values is currently a beta API. It is currently only + supported on macOS; the API is subject to change. + Reference --------- From 0e09760581ac5a98aa99caf3322d55252833d879 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Tue, 27 Jun 2023 15:47:39 +0800 Subject: [PATCH 24/40] Mark Android as 'done'; the implementation isn't complete enough for testing. --- android/src/toga_android/widgets/table.py | 14 ++++---------- testbed/tests/widgets/test_table.py | 9 ++++++--- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 9e90a9898b..b15a01ccce 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -18,7 +18,7 @@ from .base import Widget -class TogaOnClickListener(OnClickListener): +class TogaOnClickListener(OnClickListener): # pragma: no cover def __init__(self, impl): super().__init__() self.impl = impl @@ -41,7 +41,7 @@ def onClick(self, view): self.impl.interface.on_select(self.impl.interface, row=row) -class Table(Widget): +class Table(Widget): # pragma: no cover table_layout = None color_selected = None color_unselected = None @@ -184,7 +184,7 @@ def get_selection(self): selection = [] for row_index in range(len(self.interface.data)): if row_index in self.selection: - selection.append(self.selection[row_index]) + selection.append(row_index) if len(selection) == 0: selection = None elif not self.interface.multiple_select: @@ -209,13 +209,7 @@ def remove(self, item, index): def scroll_to_row(self, row): pass - def set_on_select(self, handler): - pass - - def set_on_double_click(self, handler): - self.interface.factory.not_implemented("Table.set_on_double_click()") - - def add_column(self, heading, accessor): + def insert_column(self, index, heading, accessor): self.change_source(self.interface.data) def remove_column(self, accessor): diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 5faad120dc..3167d549b3 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -40,7 +40,8 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): - skip_on_platforms("iOS") + # Although Android *has* a table implementation, it needs to be rebuilt. + skip_on_platforms("iOS", "android") return toga.Table( ["A", "B", "C"], data=source, @@ -53,7 +54,8 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture def headerless_widget(source, on_select_handler): - skip_on_platforms("iOS") + # Although Android *has* a table implementation, it needs to be rebuilt. + skip_on_platforms("iOS", "android") return toga.Table( data=source, missing_value="MISSING!", @@ -79,7 +81,8 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture def multiselect_widget(source, on_select_handler): - skip_on_platforms("iOS") + # Although Android *has* a table implementation, it needs to be rebuilt. + skip_on_platforms("iOS", "android") return toga.Table( ["A", "B", "C"], data=source, From 8c57ecf6602cc94639f9c657bb6ba9a7c7899ac9 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 28 Jun 2023 07:28:19 +0800 Subject: [PATCH 25/40] Add test that columns fill the available space. --- cocoa/tests_backend/widgets/table.py | 3 +++ gtk/src/toga_gtk/widgets/table.py | 5 +---- gtk/tests_backend/widgets/table.py | 3 +++ testbed/tests/widgets/test_table.py | 18 +++++++++++++++--- 4 files changed, 22 insertions(+), 7 deletions(-) diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index bf8d324c09..d3e08021f3 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -73,6 +73,9 @@ def header_titles(self): str(col.headerCell.stringValue) for col in self.native_table.tableColumns ] + def column_width(self, col): + return self.native_table.tableColumns[col].width + def row_position(self, row): # Pick a point half way across horizontally, and half way down the row, # taking into account the size of the rows and the header diff --git a/gtk/src/toga_gtk/widgets/table.py b/gtk/src/toga_gtk/widgets/table.py index 8600e21dc1..de65166e31 100644 --- a/gtk/src/toga_gtk/widgets/table.py +++ b/gtk/src/toga_gtk/widgets/table.py @@ -26,10 +26,7 @@ def text(self, attr, missing_value): if isinstance(data, tuple): text = data[1] else: - try: - text = data.value - except AttributeError: - text = data + text = data if text is None: return missing_value diff --git a/gtk/tests_backend/widgets/table.py b/gtk/tests_backend/widgets/table.py index 1340aad226..d099552ff9 100644 --- a/gtk/tests_backend/widgets/table.py +++ b/gtk/tests_backend/widgets/table.py @@ -33,6 +33,9 @@ def header_visible(self): def header_titles(self): return [col.get_title() for col in self.native_table.get_columns()] + def column_width(self, col): + return self.native_table.get_column(col).get_width() + def assert_cell_content(self, row, col, value=None, icon=None, widget=None): if widget: pytest.skip("GTK doesn't support widgets in Tables") diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 3167d549b3..cdd0826cd6 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -219,11 +219,11 @@ async def test_multiselect( class MyData: - def __init__(self, value): - self.value = value + def __init__(self, text): + self.text = text def __str__(self): - return f"" + return f"" async def _row_change_test(widget, probe): @@ -338,10 +338,22 @@ async def test_column_changes(widget, probe): # Header is visible, and has the right titles assert probe.header_visible assert probe.header_titles == ["A", "B", "C"] + # Columns should be roughly equal in width; there's a healthy allowance + # for inter-column padding etc. + assert probe.column_width(0) == pytest.approx(probe.width / 3, abs=20) + assert probe.column_width(1) == pytest.approx(probe.width / 3, abs=20) + assert probe.column_width(2) == pytest.approx(probe.width / 3, abs=20) await _column_change_test(widget, probe) assert probe.header_titles == ["A", "B", "D", "E"] + # The specific behavior for resizing is undefined; however, the columns should add + # up to near the full width (allowing for inter-column padding, etc), and no single + # column should be tiny. + assert sum(probe.column_width(i) for i in range(0, 4)) == pytest.approx( + probe.width, abs=80 + ) + assert all(probe.column_width(i) > 80 for i in range(0, 4)) async def test_headerless_column_changes(headerless_widget, headerless_probe): From 903fb1712e712a8c6f6bd33eb7b9e83de14c5c27 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Wed, 28 Jun 2023 09:16:33 +0800 Subject: [PATCH 26/40] Increase the allowance for inter-column padding. --- testbed/tests/widgets/test_table.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index cdd0826cd6..921db4076e 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -338,11 +338,11 @@ async def test_column_changes(widget, probe): # Header is visible, and has the right titles assert probe.header_visible assert probe.header_titles == ["A", "B", "C"] - # Columns should be roughly equal in width; there's a healthy allowance - # for inter-column padding etc. - assert probe.column_width(0) == pytest.approx(probe.width / 3, abs=20) - assert probe.column_width(1) == pytest.approx(probe.width / 3, abs=20) - assert probe.column_width(2) == pytest.approx(probe.width / 3, abs=20) + # Columns should be roughly equal in width; there's a healthy allowance for + # inter-column padding etc. + assert probe.column_width(0) == pytest.approx(probe.width / 3, abs=25) + assert probe.column_width(1) == pytest.approx(probe.width / 3, abs=25) + assert probe.column_width(2) == pytest.approx(probe.width / 3, abs=25) await _column_change_test(widget, probe) @@ -350,9 +350,8 @@ async def test_column_changes(widget, probe): # The specific behavior for resizing is undefined; however, the columns should add # up to near the full width (allowing for inter-column padding, etc), and no single # column should be tiny. - assert sum(probe.column_width(i) for i in range(0, 4)) == pytest.approx( - probe.width, abs=80 - ) + total_width = sum(probe.column_width(i) for i in range(0, 4)) + assert total_width == pytest.approx(probe.width, abs=100) assert all(probe.column_width(i) > 80 for i in range(0, 4)) From 80b9853cc1efec64dea420bd03d0bf23fbe1a5a3 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 09:45:57 +0800 Subject: [PATCH 27/40] Small cleanups of core API tests. --- core/src/toga/widgets/table.py | 13 +-- core/tests/widgets/test_table.py | 149 +++++++++++++------------------ 2 files changed, 70 insertions(+), 92 deletions(-) diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index a7cd62b853..5178b727be 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -1,9 +1,10 @@ from __future__ import annotations import warnings +from typing import Any from toga.handlers import wrapped_handler -from toga.sources import ListSource, Row +from toga.sources import ListSource, Row, Source from toga.sources.accessors import build_accessors, to_accessor from .base import Widget @@ -15,7 +16,7 @@ def __init__( headings: list[str] | None = None, id=None, style=None, - data: list | ListSource | None = None, + data: Any = None, accessors: list[str] | None = None, multiple_select: bool = False, on_select: callable | None = None, @@ -135,13 +136,13 @@ def data(self) -> ListSource: return self._data @data.setter - def data(self, data: list | ListSource | None): + def data(self, data: Any): if data is None: self._data = ListSource(accessors=self._accessors, data=[]) - elif isinstance(data, (list, tuple)): - self._data = ListSource(accessors=self._accessors, data=data) - else: + elif isinstance(data, Source): self._data = data + else: + self._data = ListSource(accessors=self._accessors, data=data) self._data.add_listener(self._impl) self._impl.change_source(source=self._data) diff --git a/core/tests/widgets/test_table.py b/core/tests/widgets/test_table.py index e1d570bacf..929b8cb5c5 100644 --- a/core/tests/widgets/test_table.py +++ b/core/tests/widgets/test_table.py @@ -82,7 +82,7 @@ def test_create_with_values(source, on_select_handler, on_activate_handler): assert table.on_activate._raw == on_activate_handler -def test_create_with_acessor_overrides(): +def test_create_with_accessor_overrides(): "A Table can partially override accessors" table = toga.Table( ["First", "Second"], @@ -138,43 +138,59 @@ def test_focus_noop(table): assert_action_not_performed(table, "focus") -def test_set_data_list(table, on_select_handler): - "Data can be set from a list of lists" - - # The selection hasn't changed yet. - on_select_handler.assert_not_called() - - # Change the data - table.data = [ - ["Alice", 123, "extra1"], - ["Bob", 234, "extra2"], - ["Charlie", 345, "extra3"], - ] - - # This triggered the select handler - on_select_handler.assert_called_once_with(table) - - # A ListSource has been constructed - assert isinstance(table.data, ListSource) - assert len(table.data) == 3 - - # The accessors are mapped in order. - assert table.data[1].key == "Bob" - assert table.data[1].value == 234 - - -def test_set_data_tuple(table, on_select_handler): - "Data can be set from a list of tuples" +@pytest.mark.parametrize( + "data, all_attributes, extra_attributes", + [ + # List of lists + ( + [ + ["Alice", 123, "extra1"], + ["Bob", 234, "extra2"], + ["Charlie", 345, "extra3"], + ], + True, + False, + ), + # List of tuples + ( + [ + ("Alice", 123, "extra1"), + ("Bob", 234, "extra2"), + ("Charlie", 345, "extra3"), + ], + True, + False, + ), + # List of dictionaries + ( + [ + {"key": "Alice", "value": 123, "extra": "extra1"}, + {"key": "Bob", "value": 234, "extra": "extra2"}, + {"key": "Charlie", "value": 345, "extra": "extra3"}, + ], + True, + True, + ), + # List of bare data + ( + [ + "Alice", + 1234, + "Charlie", + ], + False, + False, + ), + ], +) +def test_set_data(table, on_select_handler, data, all_attributes, extra_attributes): + "Data can be set from a variety of sources" # The selection hasn't changed yet. on_select_handler.assert_not_called() # Change the data - table.data = [ - ("Alice", 123, "extra1"), - ("Bob", 234, "extra2"), - ("Charlie", 345, "extra3"), - ] + table.data = data # This triggered the select handler on_select_handler.assert_called_once_with(table) @@ -184,60 +200,21 @@ def test_set_data_tuple(table, on_select_handler): assert len(table.data) == 3 # The accessors are mapped in order. - assert table.data[1].key == "Bob" - assert table.data[1].value == 234 - - -def test_set_data_dict(table, on_select_handler): - "Data can be set from a list of dicts" - - # The selection hasn't changed yet. - on_select_handler.assert_not_called() - - # Change the data - table.data = [ - {"key": "Alice", "value": 123, "extra": "extra1"}, - {"key": "Bob", "value": 234, "extra": "extra2"}, - {"key": "Charlie", "value": 345, "extra": "extra3"}, - ] - - # This triggered the select handler - on_select_handler.assert_called_once_with(table) - - # A ListSource has been constructed - assert isinstance(table.data, ListSource) - assert len(table.data) == 3 - - # The accessors are all available - assert table.data[1].key == "Bob" - assert table.data[1].value == 234 - assert table.data[1].extra == "extra2" - - -def test_set_data_other(table, on_select_handler): - "Data can be set from a list of values" - - # The selection hasn't changed yet. - on_select_handler.assert_not_called() - - # Change the data - table.data = [ - "Alice", - 1234, - "other", - ] - - # This triggered the select handler - on_select_handler.assert_called_once_with(table) - - # A ListSource has been constructed - assert isinstance(table.data, ListSource) - assert len(table.data) == 3 - - # The values are mapped to the first accessor. assert table.data[0].key == "Alice" - assert table.data[1].key == 1234 - assert table.data[2].key == "other" + assert table.data[2].key == "Charlie" + + if all_attributes: + assert table.data[1].key == "Bob" + assert table.data[0].value == 123 + assert table.data[1].value == 234 + assert table.data[2].value == 345 + else: + assert table.data[1].key == 1234 + + if extra_attributes: + assert table.data[0].extra == "extra1" + assert table.data[1].extra == "extra2" + assert table.data[2].extra == "extra3" def test_single_selection(table, on_select_handler): From 210402d26e99e324feab8c19feeb4595b43cc840 Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Thu, 29 Jun 2023 12:39:00 +0800 Subject: [PATCH 28/40] Add keyboard shortcuts for select-all on Cocoa (copied from Tree) --- cocoa/src/toga_cocoa/widgets/table.py | 19 +++++++++++++++---- cocoa/tests_backend/widgets/base.py | 6 +++--- cocoa/tests_backend/widgets/table.py | 6 ++++-- gtk/tests_backend/widgets/table.py | 1 + testbed/tests/widgets/test_table.py | 17 +++++++++++++++++ 5 files changed, 40 insertions(+), 9 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 28ec779beb..f21948e78c 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -1,8 +1,12 @@ +from ctypes import c_void_p + +from rubicon.objc import SEL, at, objc_method, objc_property, send_super from travertino.size import at_least import toga +from toga.keys import Key +from toga_cocoa.keys import toga_key from toga_cocoa.libs import ( - SEL, NSBezelBorder, NSIndexSet, NSRange, @@ -11,9 +15,6 @@ NSTableView, NSTableViewAnimation, NSTableViewColumnAutoresizingStyle, - at, - objc_method, - objc_property, ) from .base import Widget @@ -84,6 +85,16 @@ def tableView_pasteboardWriterForRow_(self, table, row) -> None: # pragma: no c # this seems to be required to prevent issue 21562075 in AppKit return None + @objc_method + def keyDown_(self, event) -> None: + # any time this table is in focus and a key is pressed, this method will be called + if toga_key(event) == {"key": Key.A, "modifiers": {Key.MOD_1}}: + if self.interface.multiple_select: + self.selectAll(self) + else: + # forward call to super + send_super(__class__, self, "keyDown:", event, argtypes=[c_void_p]) + # TableDelegate methods @objc_method def selectionShouldChangeInTableView_(self, table) -> bool: diff --git a/cocoa/tests_backend/widgets/base.py b/cocoa/tests_backend/widgets/base.py index 1e335e695e..01fbdc09fd 100644 --- a/cocoa/tests_backend/widgets/base.py +++ b/cocoa/tests_backend/widgets/base.py @@ -101,7 +101,7 @@ def is_hidden(self): def has_focus(self): return self.native.window.firstResponder == self.native - async def type_character(self, char): + async def type_character(self, char, modifierFlags=0): # Convert the requested character into a Cocoa keycode. # This table is incomplete, but covers all the basics. key_code = { @@ -141,7 +141,7 @@ async def type_character(self, char): NSEvent.keyEventWithType( NSEventType.KeyDown, location=NSPoint(0, 0), # key presses don't have a location. - modifierFlags=0, + modifierFlags=modifierFlags, timestamp=0, windowNumber=self.native.window.windowNumber, context=None, @@ -155,7 +155,7 @@ async def type_character(self, char): NSEvent.keyEventWithType( NSEventType.KeyUp, location=NSPoint(0, 0), # key presses don't have a location. - modifierFlags=0, + modifierFlags=modifierFlags, timestamp=0, windowNumber=self.native.window.windowNumber, context=None, diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index d3e08021f3..55157a9561 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -10,8 +10,7 @@ class TableProbe(SimpleProbe): native_class = NSScrollView - supports_cell_widgets = True - supports_cell_icons = True + supports_keyboard_shortcuts = True def __init__(self, widget): super().__init__(widget) @@ -88,6 +87,9 @@ def row_position(self, row): toView=None, ) + async def select_all(self): + await self.type_character("A", modifierFlags=NSEventModifierFlagCommand), + async def select_row(self, row, add=False): point = self.row_position(row) # Table maintains an inner mouse event loop, so we can't diff --git a/gtk/tests_backend/widgets/table.py b/gtk/tests_backend/widgets/table.py index d099552ff9..ef8220b4e3 100644 --- a/gtk/tests_backend/widgets/table.py +++ b/gtk/tests_backend/widgets/table.py @@ -7,6 +7,7 @@ class TableProbe(SimpleProbe): native_class = Gtk.ScrolledWindow + supports_keyboard_shortcuts = False def __init__(self, widget): super().__init__(widget) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 921db4076e..012c8070ab 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -161,6 +161,17 @@ async def test_select(widget, probe, source, on_select_handler): on_select_handler.assert_called_once_with(widget) on_select_handler.reset_mock() + if probe.supports_keyboard_shortcuts: + # Keyboard responds to selectAll + await probe.select_all() + await probe.redraw("Select all keyboard shortcut is ignored") + assert widget.selection == source[2] + + # Other keystrokes are ignored + await probe.type_character("x") + await probe.redraw("A non-shortcut key was pressed") + assert widget.selection == source[2] + async def test_activate( widget, @@ -217,6 +228,12 @@ async def test_multiselect( on_select_handler.assert_called_once_with(multiselect_widget) on_select_handler.reset_mock() + if multiselect_probe.supports_keyboard_shortcuts: + # Keyboard responds to selectAll + await multiselect_probe.select_all() + await multiselect_probe.redraw("All rows selected by keyboard") + assert len(multiselect_widget.selection) == 100 + class MyData: def __init__(self, text): From c92f36a4c09b9e70d25c22b02afdf1fda260d1af Mon Sep 17 00:00:00 2001 From: Russell Keith-Magee Date: Sat, 8 Jul 2023 13:00:44 +0800 Subject: [PATCH 29/40] Add error handling for bad icon files. --- cocoa/src/toga_cocoa/icons.py | 23 ++++++++++++++++++- cocoa/src/toga_cocoa/images.py | 13 +++++++---- gtk/src/toga_gtk/icons.py | 15 +++++++----- iOS/src/toga_iOS/icons.py | 10 ++++++++ .../src/testbed/resources/icons/bad-32.png | 1 + .../src/testbed/resources/icons/bad-72.png | 1 + testbed/src/testbed/resources/icons/bad.icns | 1 + testbed/src/testbed/resources/icons/bad.ico | 1 + testbed/src/testbed/resources/icons/bad.png | 1 + testbed/tests/test_icons.py | 12 ++++++++++ 10 files changed, 66 insertions(+), 12 deletions(-) create mode 100644 testbed/src/testbed/resources/icons/bad-32.png create mode 100644 testbed/src/testbed/resources/icons/bad-72.png create mode 100644 testbed/src/testbed/resources/icons/bad.icns create mode 100644 testbed/src/testbed/resources/icons/bad.ico create mode 100644 testbed/src/testbed/resources/icons/bad.png diff --git a/cocoa/src/toga_cocoa/icons.py b/cocoa/src/toga_cocoa/icons.py index 6fde889dbd..74ebd7b430 100644 --- a/cocoa/src/toga_cocoa/icons.py +++ b/cocoa/src/toga_cocoa/icons.py @@ -9,5 +9,26 @@ def __init__(self, interface, path): self.interface = interface self.interface._impl = self self.path = path + try: + # We *should* be able to do a direct NSImage.alloc.init...(), but if the + # image file is invalid, the init fails, and returns NULL - but we've + # created an ObjC instance, so when the object passes out of scope, Rubicon + # tries to free it, which segfaults. To avoid this, we retain result of the + # alloc() (overriding the default Rubicon behavior of alloc), then release + # that reference once we're done. If the image was created successfully, we + # temporarily have a reference count that is 1 higher than it needs to be; + # if it fails, we don't end up with a stray release. + image = NSImage.alloc().retain() + self.native = image.initWithContentsOfFile(str(path)) + if self.native is None: + raise ValueError(f"Unable to load icon from {path}") + finally: + image.release() - self.native = NSImage.alloc().initWithContentsOfFile(str(path)) + # Multiple icon interface instances can end up referencing the same native + # instance, so make sure we retain a refernce count at the impl level. + self.native.retain() + + def __del__(self): + if self.native: + self.native.release() diff --git a/cocoa/src/toga_cocoa/images.py b/cocoa/src/toga_cocoa/images.py index 6ca797d5b7..466b9a0876 100644 --- a/cocoa/src/toga_cocoa/images.py +++ b/cocoa/src/toga_cocoa/images.py @@ -13,11 +13,14 @@ def __init__(self, interface, path=None, data=None): self.interface = interface try: - # We *should* be able to do a direct NSImage.alloc.init...(), - # but for some reason, this segfaults in some environments - # when loading invalid images. On iOS we can avoid this by - # using the class-level constructors; on macOS we need to - # ensure we have a valid allocated image, then try to init it. + # We *should* be able to do a direct NSImage.alloc.init...(), but if the + # image file is invalid, the init fails, and returns NULL - but we've + # created an ObjC instance, so when the object passes out of scope, Rubicon + # tries to free it, which segfaults. To avoid this, we retain result of the + # alloc() (overriding the default Rubicon behavior of alloc), then release + # that reference once we're done. If the image was created successfully, we + # temporarily have a reference count that is 1 higher than it needs to be; + # if it fails, we don't end up with a stray release. image = NSImage.alloc().retain() if path: self.native = image.initWithContentsOfFile(str(path)) diff --git a/gtk/src/toga_gtk/icons.py b/gtk/src/toga_gtk/icons.py index ab0b913cb5..19d4c62eec 100644 --- a/gtk/src/toga_gtk/icons.py +++ b/gtk/src/toga_gtk/icons.py @@ -1,4 +1,4 @@ -from .libs import GdkPixbuf +from .libs import GdkPixbuf, GLib class Icon: @@ -10,8 +10,11 @@ def __init__(self, interface, path): self.paths = path # Preload all the required icon sizes - for size, path in self.paths.items(): - native = GdkPixbuf.Pixbuf.new_from_file(str(path)).scale_simple( - size, size, GdkPixbuf.InterpType.BILINEAR - ) - setattr(self, f"native_{size}", native) + try: + for size, path in self.paths.items(): + native = GdkPixbuf.Pixbuf.new_from_file(str(path)).scale_simple( + size, size, GdkPixbuf.InterpType.BILINEAR + ) + setattr(self, f"native_{size}", native) + except GLib.GError: + raise ValueError(f"Unable to load icon from {path}") diff --git a/iOS/src/toga_iOS/icons.py b/iOS/src/toga_iOS/icons.py index 233a852b4b..a388663e43 100644 --- a/iOS/src/toga_iOS/icons.py +++ b/iOS/src/toga_iOS/icons.py @@ -9,3 +9,13 @@ def __init__(self, interface, path): self.interface = interface self.path = path self.native = UIImage.imageWithContentsOfFile(str(path)) + if self.native is None: + raise ValueError(f"Unable to load icon from {path}") + + # Multiple icon interface instances can end up referencing the same native + # instance, so make sure we retain a refernce count at the impl level. + self.native.retain() + + def __del__(self): + if self.native: + self.native.release() diff --git a/testbed/src/testbed/resources/icons/bad-32.png b/testbed/src/testbed/resources/icons/bad-32.png new file mode 100644 index 0000000000..9d7eda0507 --- /dev/null +++ b/testbed/src/testbed/resources/icons/bad-32.png @@ -0,0 +1 @@ +This is not a png file. diff --git a/testbed/src/testbed/resources/icons/bad-72.png b/testbed/src/testbed/resources/icons/bad-72.png new file mode 100644 index 0000000000..9d7eda0507 --- /dev/null +++ b/testbed/src/testbed/resources/icons/bad-72.png @@ -0,0 +1 @@ +This is not a png file. diff --git a/testbed/src/testbed/resources/icons/bad.icns b/testbed/src/testbed/resources/icons/bad.icns new file mode 100644 index 0000000000..8b258a65c0 --- /dev/null +++ b/testbed/src/testbed/resources/icons/bad.icns @@ -0,0 +1 @@ +This is not an icns file. diff --git a/testbed/src/testbed/resources/icons/bad.ico b/testbed/src/testbed/resources/icons/bad.ico new file mode 100644 index 0000000000..0c57ab9323 --- /dev/null +++ b/testbed/src/testbed/resources/icons/bad.ico @@ -0,0 +1 @@ +This is not an ico file. diff --git a/testbed/src/testbed/resources/icons/bad.png b/testbed/src/testbed/resources/icons/bad.png new file mode 100644 index 0000000000..9d7eda0507 --- /dev/null +++ b/testbed/src/testbed/resources/icons/bad.png @@ -0,0 +1 @@ +This is not a png file. diff --git a/testbed/tests/test_icons.py b/testbed/tests/test_icons.py index 0b3de5438a..7248c9924d 100644 --- a/testbed/tests/test_icons.py +++ b/testbed/tests/test_icons.py @@ -1,5 +1,8 @@ +import re from importlib import import_module +import pytest + import toga @@ -26,3 +29,12 @@ async def test_system_icon(app): "The default icon can be obtained" probe = icon_probe(app, toga.Icon.DEFAULT_ICON) probe.assert_default_icon_content() + + +async def test_bad_icon_file(app): + "If a file isn't a loadable icon, an error is raised" + with pytest.raises( + ValueError, + match=rf"Unable to load icon from {re.escape(str(app.paths.app / 'resources' / 'icons' / 'bad'))}", + ): + toga.Icon("resources/icons/bad") From 1e8deea707087a78b82e2c0309ad5e2285d254be Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 22 Aug 2023 20:15:54 +0100 Subject: [PATCH 30/40] Documentation cleanups --- core/src/toga/sources/list_source.py | 23 ++----- core/src/toga/widgets/selection.py | 39 ++++------- core/src/toga/widgets/table.py | 66 ++++++++++--------- .../api/resources/sources/list_source.rst | 6 +- docs/reference/api/widgets/table.rst | 63 ++++++------------ 5 files changed, 77 insertions(+), 120 deletions(-) diff --git a/core/src/toga/sources/list_source.py b/core/src/toga/sources/list_source.py index dd6612dc73..8b87c66410 100644 --- a/core/src/toga/sources/list_source.py +++ b/core/src/toga/sources/list_source.py @@ -1,5 +1,6 @@ from __future__ import annotations +from collections.abc import Iterable from typing import Any from .base import Source @@ -44,12 +45,13 @@ def __setattr__(self, attr: str, value): class ListSource(Source): - def __init__(self, accessors: list[str], data: list[Any] | None = None): + def __init__(self, accessors: list[str], data: Iterable | None = None): """A data source to store an ordered list of multiple data values. :param accessors: A list of attribute names for accessing the value in each column of the row. - :param data: The initial list of items in the source. + :param data: The initial list of items in the source. Items are converted as + shown :ref:`above `. """ super().__init__() @@ -86,23 +88,8 @@ def __delitem__(self, index): # Factory methods for new rows ###################################################################### + # This behavior is documented in list_source.rst. def _create_row(self, data: Any) -> Row: - """Create a Row object from the given data. - - The type of ``data`` determines how it is converted. - - If ``data`` is a dictionary, each key in the dictionary will be - converted into an attribute on the Row. - - If ``data`` is a non-string iterable, the items in the data will be - mapped in order to the list of accessors, and the Row will have an - attribute for each accessor. - - Otherwise, the Row will have a single attribute corresponding to the - name of the first accessor. - - :param data: The data to convert into a row. - """ if isinstance(data, dict): row = Row(**data) elif hasattr(data, "__iter__") and not isinstance(data, str): diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py index 645a6f8ecb..de0018e836 100644 --- a/core/src/toga/widgets/selection.py +++ b/core/src/toga/widgets/selection.py @@ -3,7 +3,7 @@ import warnings from toga.handlers import wrapped_handler -from toga.sources import ListSource +from toga.sources import ListSource, Source from .base import Widget @@ -27,11 +27,9 @@ def __init__( :param id: The ID for the widget. :param style: A style object. If no style is provided, a default style will be applied to the widget. - :param items: The items to display for selection. Can be a list of values or a - ListSource. See the definition of the ``items`` property for details on how - items can be specified and used. + :param items: Initial :any:`items` to display for selection. :param accessor: The accessor to use to extract display values from the list of - items. See the definition of the ``items`` property for details on how + items. See :any:`items` and :any:`value` for details on how ``accessor`` alters the interpretation of data in the Selection. :param value: Initial value for the selection. If unspecified, the first item in ``items`` will be selected. @@ -69,30 +67,17 @@ def __init__( @property def items(self) -> ListSource: - """The list of items to display in the selection, as a ListSource. + """The items to display in the selection. - When specifying items: + When setting this property: - * A ListSource will be used as-is + * A :any:`Source` will be used as-is. * A value of None is turned into an empty ListSource. - * A list or tuple of values will be converted into a ListSource. Each item in - the list will be converted into a Row object. - - * If the item in the list is a dictionary, the keys of the dictionary will - become the attributes of the Row. - - * All other items will be converted into a Row with a single attribute - attribute whose name matches the ``accessor`` provided when the Selection - was constructed (with an attribute of ``value`` being used if no accessor - was specified). - - If the item is a string, or any other a non-iterable object, the value of - the attribute will be the item value. - - If the item is list, tuple, or other iterable, the value of the attribute - will be the first item in the iterable. + * Otherwise, the value must be an iterable, which is copied into a new + ListSource using the widget's accessor, or "value" if no accessor was + specified. Items are converted as shown :ref:`here `. """ return self._items @@ -105,12 +90,12 @@ def items(self, items): if items is None: self._items = ListSource(accessors=accessors, data=[]) - elif isinstance(items, (list, tuple)): - self._items = ListSource(accessors=accessors, data=items) - else: + elif isinstance(items, Source): if self._accessor is None: raise ValueError("Must specify an accessor to use a data source") self._items = items + else: + self._items = ListSource(accessors=accessors, data=items) self._items.add_listener(self._impl) diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 06962876b4..2035c3dbee 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -24,28 +24,44 @@ def __init__( missing_value: str = "", on_double_click=None, # DEPRECATED ): - """Create a new Selection widget. + """Create a new Table widget. Inherits from :class:`~toga.widgets.base.Widget`. - :param headings: The list of headings for the table. A value of :any:`None` - can be used to specify a table without headings. Individual headings cannot - include newline characters; any text after a newline will be ignored + :param headings: The list of headings for the table. Headings can only contain + one line; any text after a newline will be ignored. + + A value of :any:`None` can be used to specify a table without headings. + However, if you do this, you *must* give a list of accessors. + :param id: The ID for the widget. :param style: A style object. If no style is provided, a default style will be applied to the widget. - :param data: The data to be displayed on the table. Can be a list of values or a - ListSource. See the definition of the :attr:`data` property for details on - how data can be specified and used. - :param accessors: A list of names, with same length as :attr:`headings`, that - describes the attributes of the data source that will be used to populate - each column. If unspecified, accessors will be automatically derived from - the table headings. + :param data: Initial :any:`data` to be displayed on the table. + + :param accessors: Defines the attributes of the data source that will be used to + populate each column. If unspecified, accessors will be derived from the + headings by: + + 1. Converting the heading to lower case; + 2. Removing any character that can't be used in a Python identifier; + 3. Replacing all whitespace with "_"; + 4. Prepending ``_`` if the first character is a digit. + + Otherwise, ``accessors`` must be either: + + * A list of the same size as ``headings``, specifying the accessors for each + heading. A value of :any:`None` will fall back to the default generated + accessor; or + * A dictionary mapping headings to accessors. Any missing headings will fall + back to the default generated accessor. + :param multiple_select: Does the table allow multiple selection? :param on_select: Initial :any:`on_select` handler. :param on_activate: Initial :any:`on_activate` handler. - :param missing_value: The string that will be used to populate a cell when a - data source doesn't provided a value for a given attribute. + :param missing_value: The string that will be used to populate a cell when the + value provided by its accessor is :any:`None`, or the accessor isn't + defined. :param on_double_click: **DEPRECATED**; use :attr:`on_activate`. """ super().__init__(id=id, style=style) @@ -109,29 +125,17 @@ def focus(self): @property def data(self) -> ListSource: - """The data to display in the table, as a ListSource. + """The data to display in the table. - When specifying data: + When setting this property: - * A ListSource will be used as-is + * A :any:`Source` will be used as-is. * A value of None is turned into an empty ListSource. - * A list or tuple of values will be converted into a ListSource. Each item in - the list will be converted into a Row object. - - * If the item in the list is a dictionary, the keys of the dictionary will - become the attributes of the Row. - - * All other values will be converted into a Row with attributes matching the - ``accessors`` provided at time of construction (or the ``accessors`` that - were derived from the ``headings`` that were provided at construction). - - If the value is a string, or any other a non-iterable object, the Row will - have a single attribute matching the first accessor. - - If the value is a list, tuple, or any other iterable, values in the iterable - will be mapped in order to the accessors. + * Otherwise, the value must be an iterable, which is copied into a new + ListSource using the widget's accessors. Items are converted as shown + :ref:`here `. """ return self._data diff --git a/docs/reference/api/resources/sources/list_source.rst b/docs/reference/api/resources/sources/list_source.rst index 744ae8f1e7..0220c289d9 100644 --- a/docs/reference/api/resources/sources/list_source.rst +++ b/docs/reference/api/resources/sources/list_source.rst @@ -42,6 +42,8 @@ the operations you'd expect on a normal Python list, such as ``insert``, ``remov # Insert a new row at the start of the data source.insert(0, name="Bettong", weight=1.2) +.. _listsource-item: + When initially constructing the ListSource, or when assigning a specific item in the ListSource, each item can be: @@ -74,8 +76,8 @@ used as a data source. This means they must provide: * ``__getitem__(self, index)`` returning the item at position ``index`` of the list. -A custom ListSource must also generate ``insert``, ``remove`` and ``clear`` -notifications when items are added or removed from the source. +A custom ListSource must also inherit from :any:`Source`, and generate ``insert``, +``remove`` and ``clear`` notifications when items are added or removed from the source. Each item returned by the custom ListSource is required to expose attributes matching the accessors for any widget using the source. Any change to the values of these attributes diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index fdc7fc5973..3d7c5ad440 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -46,7 +46,7 @@ we will display a table of 2 columns, with 3 initial rows of data: # Append new data to the table table.data.append(("Zaphod Beeblebrox", 47)) -You can also specify data for a Table using a list of dictionaries. This allows to to +You can also specify data for a Table using a list of dictionaries. This allows you to store data in the data source that won't be displayed in the table. It also allows you to control the display order of columns independent of the storage of that data. @@ -64,25 +64,14 @@ to control the display order of columns independent of the storage of that data. ) # Get the details of the first item in the data: - print(f"{table.data[0].name}, who is age {table.data[0].age}, is from {table.data[0].planet}") + print(f"{table.data[0].name}, who is age {table.data[0].age}, " + f"is from {table.data[0].planet}") -The attribute names used on each row of data (called "accessors") are created automatically from -the name of the headings that you provide. This is done by: - -1. Converting the heading to lower case; -2. Removing any character that can't be used in a Python identifier; -3. Replacing all whitespace with "_"; -4. Prepending ``_`` if the first character is a digit. - -If you want to use different accessors to the ones that are automatically generated, you -can override them by providing an ``accessors`` argument. This can be either: - -* A list of the same size as the list of headings, specifying the accessors for each - heading. A value of :any:`None` will fall back to the default generated accessor; or -* A dictionary mapping heading names to accessor names. - -In this example, the table will use "Name" as the visible header, but internally, the -attribute "character" will be used: +The attribute names used on each row of data (called "accessors") are created +automatically from the headings that you provide. If you want to use different +attributes, you can override them by providing an ``accessors`` argument. In this +example, the table will use "Name" as the visible header, but internally, the attribute +"character" will be used: .. code-block:: python @@ -99,34 +88,24 @@ attribute "character" will be used: ) # Get the details of the first item in the data: - print(f"{table.data[0].character}, who is age {table.data[0].age}, is from {table.data[0].planet}") + print(f"{table.data[0].character}, who is age {table.data[0].age}, " + f"is from {table.data[0].planet}") -You can also create a table *without* a heading row. However, if you do this, you *must* -specify accessors. +The value provided by an accessor is interpreted as follows: -If the value provided by an accessor is :any:`None`, or the accessor isn't defined for a -given row, the value of ``missing_value`` provided when constructing the Table will -be used to populate the cell in the Table. +* If the value is a :any:`Widget`, that widget will be displayed in the cell. Note that + this is currently a beta API, is currently only supported on macOS, and may change in + future. -If the value provided by an accessor is any type other than a tuple :any:`tuple` or -:any:`toga.Widget`, the value will be converted into a string. If the value has an -``icon`` attribute, the cell will use that icon in the Table cell, displayed to the left -of the text label. If the value of the ``icon`` attribute is :any:`None`, no icon will -be displayed. +* If the value is a :any:`tuple`, it must have two elements: an :any:`Icon` which will + be displayed on the left of the cell, and a second element which will be interpreted + as below. -If the value provided by an accessor is a :any:`tuple`, the first element in the tuple -must be an :class:`toga.Icon`, and the second value in the tuple will be used to provide -the text label (again, by converting the value to a string, or using ``missing_value`` -if the value is :any:`None`, as appropriate). - -If the value provided by an accessor is a :class:`toga.Widget`, that widget will be displayed -in the table. Note that this is currently a beta API, and may change in future. - -Notes ------ +* If the value is ``None``, then ``missing_value`` will be displayed. -* The use of Widgets as table values is currently a beta API. It is currently only - supported on macOS; the API is subject to change. +* Any other value will be converted into a string. If an icon has not already been + provided in a tuple (above), and the value has an ``icon`` attribute which is not + ``None``, that icon will be displayed on the left of the cell. Reference --------- From 0c176789556aab27a2e54552fbb595e24830d3ad Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Tue, 22 Aug 2023 21:13:28 +0100 Subject: [PATCH 31/40] Icon documentation cleanups --- core/src/toga/icons.py | 10 +++++----- docs/reference/api/resources/icons.rst | 12 ++++++------ docs/reference/api/widgets/table.rst | 11 ++++++----- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/core/src/toga/icons.py b/core/src/toga/icons.py index db1df12d28..c2c5f2c2fe 100644 --- a/core/src/toga/icons.py +++ b/core/src/toga/icons.py @@ -45,12 +45,12 @@ def __init__( ): """Create a new icon. - :param path: Base filename for the icon. This can be specified as a string, or - as a :any:`pathlib.Path` object. The path can be an absolute file system + :param path: Base filename for the icon. The path can be an absolute file system path, or a path relative to the module that defines your Toga application - class. This base filename should *not* contain an extension; a platform will - modify this base filename and add an extension to define the final icon - filename ( or filenames). If an extension is specified, it will be ignored. + class. + + This base filename should *not* contain an extension. If an extension is + specified, it will be ignored. """ self.path = Path(path) self.system = system diff --git a/docs/reference/api/resources/icons.rst b/docs/reference/api/resources/icons.rst index 54dad6e6d0..45716f1417 100644 --- a/docs/reference/api/resources/icons.rst +++ b/docs/reference/api/resources/icons.rst @@ -13,14 +13,14 @@ A small, square image, used to provide easily identifiable visual context to a w Usage ----- -The filename specified for an icon can be specified as an absolute path, or as a path -relative to the module that defines your Toga application. It should be specified -*without* an extension; the platform will determine an appropriate extension, and may -also modify the name of the icon to include a file size qualifier. +The filename specified for an icon should be specified *without* an extension; the +platform will determine an appropriate extension, and may also modify the name of the +icon to include a size qualifier. The following formats are supported (in order of preference): + * **Android** - PNG -* **iOS** ICNS, PNG, BMP, ICO +* **iOS** - ICNS, PNG, BMP, ICO * **macOS** - ICNS, PNG, PDF * **GTK** - PNG, ICO, ICNS. 32px and 72px variants of each icon can be provided; * **Windows** - ICO, PNG, BMP @@ -30,7 +30,7 @@ specifying an icon of ``myicon`` will cause Toga to look for ``myicon.ico``, the ``myicon.png``, then ``myicon.bmp``. On GTK, Toga will look for ``myicon-72.png`` and ``myicon-32.png``, then ``myicon.png``, then ``myicon-32.ico``, and so on. -An icon is **guaranteed** to have an implementation. If you specify a path and not +An icon is **guaranteed** to have an implementation. If you specify a path and no matching icon can be found, Toga will output a warning to the console, and load a default "Tiberius the yak" icon. diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index 3d7c5ad440..1c6b31a260 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -97,15 +97,16 @@ The value provided by an accessor is interpreted as follows: this is currently a beta API, is currently only supported on macOS, and may change in future. -* If the value is a :any:`tuple`, it must have two elements: an :any:`Icon` which will - be displayed on the left of the cell, and a second element which will be interpreted - as below. +* If the value is a :any:`tuple`, it must have two elements: an icon, and a second + element which will be interpreted as one of the options below. * If the value is ``None``, then ``missing_value`` will be displayed. * Any other value will be converted into a string. If an icon has not already been - provided in a tuple (above), and the value has an ``icon`` attribute which is not - ``None``, that icon will be displayed on the left of the cell. + provided in a tuple, it can also be provided using the value's ``icon`` attribute. + +Icon values must either be an :any:`Icon`, which will be displayed on the left of the +cell, or ``None`` to display no icon. Reference --------- From b76e453749701194bf7090db5391b1e7aa8b3f22 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 23 Aug 2023 10:21:20 +0100 Subject: [PATCH 32/40] Apply suggestions from code review Co-authored-by: Russell Keith-Magee --- docs/reference/api/resources/icons.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/reference/api/resources/icons.rst b/docs/reference/api/resources/icons.rst index 45716f1417..11485de647 100644 --- a/docs/reference/api/resources/icons.rst +++ b/docs/reference/api/resources/icons.rst @@ -28,7 +28,7 @@ The following formats are supported (in order of preference): The first matching icon of the most specific size will be used. For example, on Windows, specifying an icon of ``myicon`` will cause Toga to look for ``myicon.ico``, then ``myicon.png``, then ``myicon.bmp``. On GTK, Toga will look for ``myicon-72.png`` and -``myicon-32.png``, then ``myicon.png``, then ``myicon-32.ico``, and so on. +``myicon-32.png``, then ``myicon.png``, then ``myicon-72.ico`` and ``myicon-32.ico``, and so on. An icon is **guaranteed** to have an implementation. If you specify a path and no matching icon can be found, Toga will output a warning to the console, and load a From 7c67ded7f1475542b5c62a94955ceecce3f0b3c6 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 23 Aug 2023 10:25:42 +0100 Subject: [PATCH 33/40] Avoid wrapping in examples --- docs/reference/api/widgets/table.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index 1c6b31a260..c5a1dfa201 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -64,8 +64,8 @@ to control the display order of columns independent of the storage of that data. ) # Get the details of the first item in the data: - print(f"{table.data[0].name}, who is age {table.data[0].age}, " - f"is from {table.data[0].planet}") + row = table.data[0] + print(f"{row.name}, who is age {row.age}, is from {row.planet}") The attribute names used on each row of data (called "accessors") are created automatically from the headings that you provide. If you want to use different @@ -88,8 +88,8 @@ example, the table will use "Name" as the visible header, but internally, the at ) # Get the details of the first item in the data: - print(f"{table.data[0].character}, who is age {table.data[0].age}, " - f"is from {table.data[0].planet}") + row = table.data[0] + print(f"{row.character}, who is age {row.age}, is from {row.planet}") The value provided by an accessor is interpreted as follows: From ef88f58927fcd3d6b7ad8b2b470f2f1efe1bd2dc Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Wed, 23 Aug 2023 14:04:49 +0100 Subject: [PATCH 34/40] More docs clarifications --- core/src/toga/sources/list_source.py | 7 +++--- core/src/toga/widgets/selection.py | 3 ++- core/src/toga/widgets/table.py | 5 ++-- .../api/resources/sources/list_source.rst | 25 +++++++++---------- docs/reference/api/widgets/table.rst | 6 ++--- 5 files changed, 22 insertions(+), 24 deletions(-) diff --git a/core/src/toga/sources/list_source.py b/core/src/toga/sources/list_source.py index 8b87c66410..0a9a60d4f6 100644 --- a/core/src/toga/sources/list_source.py +++ b/core/src/toga/sources/list_source.py @@ -74,12 +74,15 @@ def __init__(self, accessors: list[str], data: Iterable | None = None): ###################################################################### def __len__(self) -> int: + """Returns the number of items in the list.""" return len(self._data) def __getitem__(self, index: int) -> Row: + """Returns the item at position ``index`` of the list.""" return self._data[index] def __delitem__(self, index): + """Deletes the item at position ``index`` of the list.""" row = self._data[index] del self._data[index] self.notify("remove", index=index, item=row) @@ -114,10 +117,6 @@ def __setitem__(self, index: int, value: Any): self._data[index] = row self.notify("insert", index=index, item=row) - def __iter__(self): - """Obtain an iterator over the Rows in the data source.""" - return iter(self._data) - def clear(self): """Clear all data from the data source.""" self._data = [] diff --git a/core/src/toga/widgets/selection.py b/core/src/toga/widgets/selection.py index de0018e836..5442bc56b1 100644 --- a/core/src/toga/widgets/selection.py +++ b/core/src/toga/widgets/selection.py @@ -71,7 +71,8 @@ def items(self) -> ListSource: When setting this property: - * A :any:`Source` will be used as-is. + * A :any:`Source` will be used as-is. It must either be a :any:`ListSource`, or + a custom class that provides the same methods. * A value of None is turned into an empty ListSource. diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 2035c3dbee..737490d756 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -31,7 +31,7 @@ def __init__( :param headings: The list of headings for the table. Headings can only contain one line; any text after a newline will be ignored. - A value of :any:`None` can be used to specify a table without headings. + A value of :any:`None` will produce a table without headings. However, if you do this, you *must* give a list of accessors. :param id: The ID for the widget. @@ -129,7 +129,8 @@ def data(self) -> ListSource: When setting this property: - * A :any:`Source` will be used as-is. + * A :any:`Source` will be used as-is. It must either be a :any:`ListSource`, or + a custom class that provides the same methods. * A value of None is turned into an empty ListSource. diff --git a/docs/reference/api/resources/sources/list_source.rst b/docs/reference/api/resources/sources/list_source.rst index 0220c289d9..da0e032e0e 100644 --- a/docs/reference/api/resources/sources/list_source.rst +++ b/docs/reference/api/resources/sources/list_source.rst @@ -61,35 +61,34 @@ from the ListSource. Although Toga provides ListSource, you are not required to use it directly. A ListSource will be transparently constructed for you if you provide a Python ``list`` object to a -GUI widget that displays list-like data (e.g., Table or Selection). Any object that -adheres to the same interface can be used as an alternative source of data for widgets -that support using a ListSource. See the background guide on :ref:`custom data sources -` for more details. +GUI widget that displays list-like data (e.g., Table or Selection). Custom List Sources ------------------- -Any object that adheres to the :any:`collections.abc.MutableSequence` protocol can be -used as a data source. This means they must provide: +For more complex applications, you can replace ListSource with a :ref:`custom data +source ` class. Such a class must: -* ``__len__(self)`` returning the number of items in the list +* Inherit from :any:`Source` -* ``__getitem__(self, index)`` returning the item at position ``index`` of the list. +* Provide the same methods as :any:`ListSource` -A custom ListSource must also inherit from :any:`Source`, and generate ``insert``, -``remove`` and ``clear`` notifications when items are added or removed from the source. +* Return items whose attributes match the accessors for any widget using the source -Each item returned by the custom ListSource is required to expose attributes matching -the accessors for any widget using the source. Any change to the values of these attributes -must generate a ``change`` notification on any listener to the custom ListSource. +* Generate a ``change`` notification when any of those attributes change + +* Generate ``insert``, ``remove`` and ``clear`` notifications when items are added or + removed Reference --------- .. autoclass:: toga.sources.Row + :special-members: __setattr__ :members: :undoc-members: .. autoclass:: toga.sources.ListSource + :special-members: __len__, __getitem__, __setitem__, __delitem__ :members: :undoc-members: diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index c5a1dfa201..15944a7afb 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -17,12 +17,10 @@ A widget for displaying columns of tabular data. Usage ----- -A Table uses a :class:`~toga.sources.ListSource` to manage the data being displayed. -options. If ``data`` is not specified as a ListSource, it will be converted into a -ListSource at runtime. +A Table will automatically provide scroll bars when necessary. The simplest instantiation of a Table is to use a list of lists (or list of tuples), -containing the items to display in the table. When creating the table, you must also +containing the items to display in the table. When creating the table, you can also specify the headings to use on the table; those headings will be converted into accessors on the Row data objects created for the table data. In this example, we will display a table of 2 columns, with 3 initial rows of data: From 3d3641cd65a6166014d4265d8bb4efa0f78488cc Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 24 Aug 2023 17:42:03 +0100 Subject: [PATCH 35/40] Winforms Table to 100% coverage --- cocoa/tests_backend/widgets/table.py | 1 + core/src/toga/widgets/table.py | 23 +++--- docs/reference/api/widgets/table.rst | 11 ++- examples/table/table/app.py | 20 ++--- gtk/tests_backend/widgets/table.py | 1 + testbed/tests/widgets/test_table.py | 44 +++++++---- winforms/src/toga_winforms/widgets/table.py | 88 ++++++++++----------- winforms/tests_backend/widgets/base.py | 5 +- winforms/tests_backend/widgets/table.py | 79 ++++++++++++++++++ 9 files changed, 190 insertions(+), 82 deletions(-) create mode 100644 winforms/tests_backend/widgets/table.py diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index 55157a9561..d6ddd8533f 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -10,6 +10,7 @@ class TableProbe(SimpleProbe): native_class = NSScrollView + supports_icons = True supports_keyboard_shortcuts = True def __init__(self, widget): diff --git a/core/src/toga/widgets/table.py b/core/src/toga/widgets/table.py index 737490d756..602d732106 100644 --- a/core/src/toga/widgets/table.py +++ b/core/src/toga/widgets/table.py @@ -26,8 +26,6 @@ def __init__( ): """Create a new Table widget. - Inherits from :class:`~toga.widgets.base.Widget`. - :param headings: The list of headings for the table. Headings can only contain one line; any text after a newline will be ignored. @@ -168,14 +166,13 @@ def selection(self) -> list[Row] | Row | None: If multiple selection is *not* enabled, returns the selected Row object, or :any:`None` if no row is currently selected. """ - try: - selection = self._impl.get_selection() + selection = self._impl.get_selection() + if isinstance(selection, list): return [self.data[index] for index in selection] - except TypeError: - try: - return self.data[selection] - except TypeError: - return None + elif selection is None: + return None + else: + return self.data[selection] def scroll_to_top(self): """Scroll the view so that the top of the list (first row) is visible.""" @@ -290,13 +287,15 @@ def remove_column(self, column: int | str): self._impl.remove_column(index) @property - def headings(self) -> list[str]: - """The column headings for the table""" + def headings(self) -> list[str] | None: + """The column headings for the table, or None if there are no headings + (read-only) + """ return self._headings @property def accessors(self) -> list[str]: - """The accessors used to populate the table""" + """The accessors used to populate the table (read-only)""" return self._accessors @property diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index 2f6e904de3..3d6c22e152 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -92,8 +92,7 @@ example, the table will use "Name" as the visible header, but internally, the at The value provided by an accessor is interpreted as follows: * If the value is a :any:`Widget`, that widget will be displayed in the cell. Note that - this is currently a beta API, is currently only supported on macOS, and may change in - future. + this is currently a beta API: see the Notes section. * If the value is a :any:`tuple`, it must have two elements: an icon, and a second element which will be interpreted as one of the options below. @@ -106,6 +105,14 @@ The value provided by an accessor is interpreted as follows: Icon values must either be an :any:`Icon`, which will be displayed on the left of the cell, or ``None`` to display no icon. +Notes +----- + +* Widgets in tables is a beta API which may change in future, and is currently only + supported on macOS. + +* Icons in tables are not currently supported on Winforms. + Reference --------- diff --git a/examples/table/table/app.py b/examples/table/table/app.py index aa1219515d..e9c8af993c 100644 --- a/examples/table/table/app.py +++ b/examples/table/table/app.py @@ -5,16 +5,17 @@ from toga.constants import COLUMN, ROW from toga.style import Pack +# Include some non-string objects to make sure conversion works correctly. headings = ["Title", "Year", "Rating", "Genre"] bee_movies = [ - ("The Secret Life of Bees", "2008", "7.3", "Drama"), - ("Bee Movie", "2007", "6.1", "Animation, Adventure, Comedy"), - ("Bees", "1998", "6.3", "Horror"), - ("The Girl Who Swallowed Bees", "2007", "7.5"), # Missing a genre - ("Birds Do It, Bees Do It", "1974", "7.3", "Documentary"), - ("Bees: A Life for the Queen", "1998", "8.0", "TV Movie"), - ("Bees in Paradise", "1944", "5.4", "Comedy, Musical"), - ("Keeper of the Bees", "1947", "6.3", "Drama"), + ("The Secret Life of Bees", 2008, 7.3, "Drama"), + ("Bee Movie", 2007, 6.1, "Animation, Adventure, Comedy"), + ("Bees", 1998, 6.3, "Horror"), + ("The Girl Who Swallowed Bees", 2007, 7.5), # Missing a genre + ("Birds Do It, Bees Do It", 1974, 7.3, "Documentary"), + ("Bees: A Life for the Queen", 1998, 8.0, "TV Movie"), + ("Bees in Paradise", 1944, 5.4, "Comedy, Musical"), + ("Keeper of the Bees", 1947, 6.3, "Drama"), ] @@ -135,7 +136,8 @@ def startup(self): ) self.table2 = toga.Table( - headings=headings, + headings=None, + accessors=[h.lower() for h in headings], data=self.table1.data, multiple_select=True, style=Pack(flex=1, padding_left=5), diff --git a/gtk/tests_backend/widgets/table.py b/gtk/tests_backend/widgets/table.py index ef8220b4e3..20168625ef 100644 --- a/gtk/tests_backend/widgets/table.py +++ b/gtk/tests_backend/widgets/table.py @@ -7,6 +7,7 @@ class TableProbe(SimpleProbe): native_class = Gtk.ScrolledWindow + supports_icons = True supports_keyboard_shortcuts = False def __init__(self, widget): diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 012c8070ab..983ff82f78 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -53,7 +53,7 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture -def headerless_widget(source, on_select_handler): +async def headerless_widget(source, on_select_handler): # Although Android *has* a table implementation, it needs to be rebuilt. skip_on_platforms("iOS", "android") return toga.Table( @@ -80,7 +80,7 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture -def multiselect_widget(source, on_select_handler): +async def multiselect_widget(source, on_select_handler): # Although Android *has* a table implementation, it needs to be rebuilt. skip_on_platforms("iOS", "android") return toga.Table( @@ -110,14 +110,17 @@ async def test_scroll(widget, probe): """The table can be scrolled""" # Due to the interaction of scrolling with the header row, the scroll might be <0. - assert probe.scroll_position <= 0 + top_position = probe.scroll_position + assert -100 < top_position <= 0 # Scroll to the bottom of the table widget.scroll_to_bottom() await probe.wait_for_scroll_completion() await probe.redraw("Table scrolled to bottom") - assert probe.scroll_position == probe.max_scroll_position + # max_scroll_position is not perfectly accurate on Winforms. + assert probe.scroll_position == pytest.approx(probe.max_scroll_position, abs=5) + assert probe.scroll_position > 600 # Scroll to the middle of the table widget.scroll_to_row(50) @@ -136,8 +139,7 @@ async def test_scroll(widget, probe): await probe.wait_for_scroll_completion() await probe.redraw("Table scrolled to bottom") - # Due to the interaction of scrolling with the header row, the scroll might be <0. - assert probe.scroll_position <= 0 + assert probe.scroll_position == top_position async def test_select(widget, probe, source, on_select_handler): @@ -151,14 +153,17 @@ async def test_select(widget, probe, source, on_select_handler): await probe.select_row(1) await probe.redraw("Second row is selected") assert widget.selection == source[1] - on_select_handler.assert_called_once_with(widget) + + # Winforms generates two events, first removing the old selection and then adding + # the new one. + on_select_handler.assert_called_with(widget) on_select_handler.reset_mock() - # Trying to multi-select only does a single select + # Trying to multi-select removes the previous selection await probe.select_row(2, add=True) await probe.redraw("Third row is selected") assert widget.selection == source[2] - on_select_handler.assert_called_once_with(widget) + on_select_handler.assert_called_with(widget) on_select_handler.reset_mock() if probe.supports_keyboard_shortcuts: @@ -211,21 +216,24 @@ async def test_multiselect( await multiselect_probe.select_row(1) assert multiselect_widget.selection == [source[1]] await multiselect_probe.redraw("One row is selected in multiselect table") - on_select_handler.assert_called_once_with(multiselect_widget) + + # Winforms generates two events, first removing the old selection and then adding + # the new one. + on_select_handler.assert_called_with(multiselect_widget) on_select_handler.reset_mock() # A row can be added to the selection await multiselect_probe.select_row(2, add=True) await multiselect_probe.redraw("Two rows are selected in multiselect table") assert multiselect_widget.selection == [source[1], source[2]] - on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.assert_called_with(multiselect_widget) on_select_handler.reset_mock() # A row can be removed from the selection await multiselect_probe.select_row(1, add=True) await multiselect_probe.redraw("First row has been removed from the selection") assert multiselect_widget.selection == [source[2]] - on_select_handler.assert_called_once_with(multiselect_widget) + on_select_handler.assert_called_with(multiselect_widget) on_select_handler.reset_mock() if multiselect_probe.supports_keyboard_shortcuts: @@ -380,6 +388,14 @@ async def test_headerless_column_changes(headerless_widget, headerless_probe): await _column_change_test(headerless_widget, headerless_probe) +async def test_remove_all_columns(widget, probe): + assert probe.column_count == 3 + for i in range(probe.column_count): + widget.remove_column(0) + await probe.redraw("Removed first column") + assert probe.column_count == 0 + + class MyIconData: def __init__(self, text, icon): self.text = text @@ -391,8 +407,8 @@ def __str__(self): async def test_cell_icon(widget, probe): "An icon can be used as a cell value" - red = toga.Icon("resources/icons/red") - green = toga.Icon("resources/icons/green") + red = toga.Icon("resources/icons/red") if probe.supports_icons else None + green = toga.Icon("resources/icons/green") if probe.supports_icons else None widget.data = [ { # Normal text, diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 8e343ccb5b..baedfdf417 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -5,16 +5,25 @@ class Table(Widget): + _background_supports_alpha = False + def create(self): self.native = WinForms.ListView() self.native.View = WinForms.View.Details self._cache = [] self._first_item = 0 + self._pending_resize = True + + headings = self.interface.headings + self.native.HeaderStyle = ( + getattr(WinForms.ColumnHeaderStyle, "None") + if headings is None + else WinForms.ColumnHeaderStyle.Nonclickable + ) dataColumn = [] - for i, (heading, accessor) in enumerate( - zip(self.interface.headings, self.interface._accessors) - ): + for i, accessor in enumerate(self.interface.accessors): + heading = None if headings is None else headings[i] dataColumn.append(self._create_column(heading, accessor)) self.native.FullRowSelect = True @@ -28,15 +37,14 @@ def create(self): self.native.CacheVirtualItems += self.winforms_cache_virtual_items self.native.MouseDoubleClick += self.winforms_double_click self.native.VirtualItemsSelectionRangeChanged += ( - self.winforms_virtual_item_selection_range_changed + self.winforms_item_selection_changed ) - def winforms_virtual_item_selection_range_changed(self, sender, e): - # `Shift` key or Range selection handler - if self.interface.multiple_select and self.interface.on_select: - # call on select with the last row of the multi selection - selected = self.interface.data[e.EndIndex] - self.interface.on_select(self.interface, row=selected) + def set_bounds(self, x, y, width, height): + super().set_bounds(x, y, width, height) + if self._pending_resize: + self._pending_resize = False + self._resize_columns() def winforms_retrieve_virtual_item(self, sender, e): # Because ListView is in VirtualMode, it's necessary implement @@ -76,18 +84,12 @@ def winforms_cache_virtual_items(self, sender, e): ) def winforms_item_selection_changed(self, sender, e): - if self.interface.on_select: - self.interface.on_select( - self.interface, row=self.interface.data[e.ItemIndex] - ) + self.interface.on_select(None) def winforms_double_click(self, sender, e): - if self.interface.on_double_click is not None: - hit_test = self.native.HitTest(e.X, e.Y) - item = hit_test.Item - self.interface.on_double_click( - self.interface, row=self.interface.data[item.Index] - ) + hit_test = self.native.HitTest(e.X, e.Y) + item = hit_test.Item + self.interface.on_activate(None, row=self.interface.data[item.Index]) def _create_column(self, heading, accessor): col = WinForms.ColumnHeader() @@ -95,12 +97,21 @@ def _create_column(self, heading, accessor): col.Name = accessor return col + def _resize_columns(self): + num_cols = len(self.native.Columns) + if num_cols == 0: + return + + width = int(self.native.ClientSize.Width / num_cols) + for col in self.native.Columns: + col.Width = width + def change_source(self, source): self.update_data() def row_data(self, item): - # TODO: Winforms can't support icons in tree cells; so, if the data source - # specifies an icon, strip it when converting to row data. + # TODO: ListView only has built-in support for one icon per row. One possible + # workaround is in https://stackoverflow.com/a/46128593. def strip_icon(item, attr): val = getattr(item, attr, self.interface.missing_value) @@ -118,36 +129,22 @@ def insert(self, index, item): self.update_data() def change(self, item): - self.interface.factory.not_implemented("Table.change()") + self.update_data() def remove(self, item, index): self.update_data() def clear(self): - self.native.Items.Clear() + self.update_data() def get_selection(self): - # First turning this to list since Pythonnet have problems iterating - # over it. selected_indices = list(self.native.SelectedIndices) - if self.interface.multiple_select: - selected = [ - row - for i, row in enumerate(self.interface.data) - if i in selected_indices - ] - return selected + return selected_indices elif len(selected_indices) == 0: return None else: - return self.interface.data[selected_indices[0]] - - def set_on_select(self, handler): - pass - - def set_on_double_click(self, handler): - pass + return selected_indices[0] def scroll_to_row(self, row): self.native.EnsureVisible(row) @@ -156,9 +153,12 @@ def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) - def remove_column(self, accessor): - self.native.Columns.RemoveByKey(accessor) + def remove_column(self, index): + self.native.Columns.RemoveAt(index) + self.update_data() + self._resize_columns() - def add_column(self, heading, accessor): - self.native.Columns.Add(self._create_column(heading, accessor)) + def insert_column(self, index, heading, accessor): + self.native.Columns.Insert(index, self._create_column(heading, accessor)) self.update_data() + self._resize_columns() diff --git a/winforms/tests_backend/widgets/base.py b/winforms/tests_backend/widgets/base.py index 975ccf0741..59303e86c4 100644 --- a/winforms/tests_backend/widgets/base.py +++ b/winforms/tests_backend/widgets/base.py @@ -115,13 +115,16 @@ def assert_layout(self, size, position): async def press(self): self.native.OnClick(EventArgs.Empty) - async def type_character(self, char): + async def type_character(self, char, *, ctrl=False): try: key_code = KEY_CODES[char] except KeyError: assert len(char) == 1, char key_code = char + if ctrl: + key_code = "^" + key_code + SendKeys.SendWait(key_code) @property diff --git a/winforms/tests_backend/widgets/table.py b/winforms/tests_backend/widgets/table.py new file mode 100644 index 0000000000..99405d5c2e --- /dev/null +++ b/winforms/tests_backend/widgets/table.py @@ -0,0 +1,79 @@ +import pytest +from System.Windows.Forms import ( + ColumnHeaderStyle, + ListView, + MouseButtons, + MouseEventArgs, +) + +from .base import SimpleProbe + + +class TableProbe(SimpleProbe): + native_class = ListView + background_supports_alpha = False + supports_icons = False + supports_keyboard_shortcuts = False + + @property + def row_count(self): + return self.native.VirtualListSize + + @property + def column_count(self): + return len(self.native.Columns) + + def assert_cell_content(self, row, col, value=None, icon=None, widget=None): + if widget: + pytest.skip("This backend doesn't support widgets in Tables") + else: + assert self.native.Items[row].SubItems[col].Text == value + assert icon is None + + @property + def max_scroll_position(self): + document_height = ( + self.native.Items[self.row_count - 1].Bounds.Bottom + - self.native.Items[0].Bounds.Top + ) + return (document_height - self.native.ClientSize.Height) / self.scale_factor + + @property + def scroll_position(self): + return -(self.native.Items[0].Bounds.Top) / self.scale_factor + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass + + @property + def header_visible(self): + return self.native.HeaderStyle != getattr(ColumnHeaderStyle, "None") + + @property + def header_titles(self): + return [col.Text for col in self.native.Columns] + + def column_width(self, index): + return self.native.Columns[index].Width / self.scale_factor + + async def select_row(self, row, add=False): + item = self.native.Items[row] + if add: + item.Selected = not item.Selected + else: + item.Selected = True + + async def activate_row(self, row): + await self.select_row(row) + + bounds = self.native.Items[row].Bounds + self.native.OnMouseDoubleClick( + MouseEventArgs( + MouseButtons.Left, + clicks=2, + x=int((bounds.Left + bounds.Right) / 2), + y=int((bounds.Top + bounds.Bottom) / 2), + delta=0, + ) + ) From 37540d1cd1db31731365ad7de9fbfd19a56f21a5 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 24 Aug 2023 18:27:00 +0100 Subject: [PATCH 36/40] Winforms Icon to 100% coverage --- winforms/src/toga_winforms/icons.py | 21 +++++++++++---------- winforms/tests_backend/icons.py | 3 +-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/winforms/src/toga_winforms/icons.py b/winforms/src/toga_winforms/icons.py index 2edd16a710..717ad42aa4 100644 --- a/winforms/src/toga_winforms/icons.py +++ b/winforms/src/toga_winforms/icons.py @@ -1,7 +1,5 @@ -from System.Drawing import ( - Bitmap, - Icon as WinIcon, -) +from System import ArgumentException +from System.Drawing import Bitmap, Icon as WinIcon class Icon: @@ -12,9 +10,12 @@ def __init__(self, interface, path): self.interface = interface self.path = path - if path.suffix == ".ico": - self.native = WinIcon(str(path)) - else: - icon_bitmap = Bitmap(str(path)) - icon_handle = icon_bitmap.GetHicon() - self.native = WinIcon.FromHandle(icon_handle) + try: + if path.suffix == ".ico": + self.native = WinIcon(str(path)) + else: + icon_bitmap = Bitmap(str(path)) + icon_handle = icon_bitmap.GetHicon() + self.native = WinIcon.FromHandle(icon_handle) + except ArgumentException: + raise ValueError(f"Unable to load icon from {path}") diff --git a/winforms/tests_backend/icons.py b/winforms/tests_backend/icons.py index a86394fde3..2493d4a28b 100644 --- a/winforms/tests_backend/icons.py +++ b/winforms/tests_backend/icons.py @@ -1,6 +1,5 @@ import pytest - -from toga_winforms.libs import WinIcon +from System.Drawing import Icon as WinIcon from .probe import BaseProbe From 284b13f535b5109eee438685b72131baeab4db19 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Thu, 24 Aug 2023 18:31:17 +0100 Subject: [PATCH 37/40] Don't require final scroll position to be equal to starting position --- testbed/tests/widgets/test_table.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 983ff82f78..4285a96395 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -110,8 +110,7 @@ async def test_scroll(widget, probe): """The table can be scrolled""" # Due to the interaction of scrolling with the header row, the scroll might be <0. - top_position = probe.scroll_position - assert -100 < top_position <= 0 + assert -100 < probe.scroll_position <= 0 # Scroll to the bottom of the table widget.scroll_to_bottom() @@ -139,7 +138,8 @@ async def test_scroll(widget, probe): await probe.wait_for_scroll_completion() await probe.redraw("Table scrolled to bottom") - assert probe.scroll_position == top_position + # Due to the interaction of scrolling with the header row, the scroll might be <0. + assert -100 < probe.scroll_position <= 0 async def test_select(widget, probe, source, on_select_handler): From 6e116c9d62cdd3b4a19bb840c46578a69d51e982 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 27 Aug 2023 01:34:45 +0100 Subject: [PATCH 38/40] Android Table to 100% coverage --- android/src/toga_android/libs/android/view.py | 1 + android/src/toga_android/widgets/table.py | 174 +++++++++--------- android/tests_backend/widgets/table.py | 88 +++++++++ docs/reference/api/widgets/table.rst | 5 +- docs/reference/data/widgets_by_platform.csv | 2 +- examples/table/table/app.py | 58 +++--- testbed/tests/widgets/test_table.py | 23 +-- winforms/src/toga_winforms/widgets/table.py | 6 +- 8 files changed, 228 insertions(+), 129 deletions(-) create mode 100644 android/tests_backend/widgets/table.py diff --git a/android/src/toga_android/libs/android/view.py b/android/src/toga_android/libs/android/view.py index f86ed0d17c..73d2a80674 100644 --- a/android/src/toga_android/libs/android/view.py +++ b/android/src/toga_android/libs/android/view.py @@ -2,6 +2,7 @@ Gravity = JavaClass("android/view/Gravity") OnClickListener = JavaInterface("android/view/View$OnClickListener") +OnLongClickListener = JavaInterface("android/view/View$OnLongClickListener") Menu = JavaClass("android/view/Menu") MenuItem = JavaClass("android/view/MenuItem") MotionEvent = JavaClass("android/view/MotionEvent") diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 122452a5fa..574c54c664 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -2,11 +2,9 @@ from ..libs.activity import MainActivity from ..libs.android import R__attr -from ..libs.android.graphics import Typeface -from ..libs.android.view import Gravity, OnClickListener, View__MeasureSpec +from ..libs.android.graphics import Rect, Typeface +from ..libs.android.view import Gravity, OnClickListener, OnLongClickListener from ..libs.android.widget import ( - HorizontalScrollView, - LinearLayout, LinearLayout__LayoutParams, ScrollView, TableLayout, @@ -18,99 +16,97 @@ from .base import Widget -class TogaOnClickListener(OnClickListener): # pragma: no cover +class TogaOnClickListener(OnClickListener): def __init__(self, impl): super().__init__() self.impl = impl def onClick(self, view): tr_id = view.getId() - row = self.impl.interface.data[tr_id] if self.impl.interface.multiple_select: if tr_id in self.impl.selection: - self.impl.selection.pop(tr_id) - view.setBackgroundColor(self.impl.color_unselected) + self.impl.remove_selection(tr_id) else: - self.impl.selection[tr_id] = row - view.setBackgroundColor(self.impl.color_selected) + self.impl.add_selection(tr_id, view) else: self.impl.clear_selection() - self.impl.selection[tr_id] = row - view.setBackgroundColor(self.impl.color_selected) - if self.impl.interface.on_select: - self.impl.interface.on_select(self.impl.interface, row=row) + self.impl.add_selection(tr_id, view) + self.impl.interface.on_select(None) -class Table(Widget): # pragma: no cover +class TogaOnLongClickListener(OnLongClickListener): + def __init__(self, impl): + super().__init__() + self.impl = impl + + def onLongClick(self, view): + self.impl.clear_selection() + index = view.getId() + self.impl.add_selection(index, view) + self.impl.interface.on_select(None) + self.impl.interface.on_activate(None, row=self.impl.interface.data[index]) + return True + + +class Table(Widget): table_layout = None color_selected = None color_unselected = None - selection = {} - _deleted_column = None _font_impl = None def create(self): # get the selection color from the current theme - current_theme = MainActivity.singletonThis.getApplication().getTheme() attrs = [R__attr.colorBackground, R__attr.colorControlHighlight] - typed_array = current_theme.obtainStyledAttributes(attrs) + typed_array = self._native_activity.obtainStyledAttributes(attrs) self.color_unselected = typed_array.getColor(0, 0) self.color_selected = typed_array.getColor(1, 0) typed_array.recycle() - parent = LinearLayout(self._native_activity) - parent.setOrientation(LinearLayout.VERTICAL) - parent_layout_params = LinearLayout__LayoutParams( - LinearLayout__LayoutParams.MATCH_PARENT, - LinearLayout__LayoutParams.MATCH_PARENT, - ) - parent_layout_params.gravity = Gravity.TOP - parent.setLayoutParams(parent_layout_params) - vscroll_view = ScrollView(self._native_activity) # add vertical scroll view + self.native = vscroll_view = ScrollView(self._native_activity) vscroll_view_layout_params = LinearLayout__LayoutParams( LinearLayout__LayoutParams.MATCH_PARENT, LinearLayout__LayoutParams.MATCH_PARENT, ) vscroll_view_layout_params.gravity = Gravity.TOP + vscroll_view.setLayoutParams(vscroll_view_layout_params) + self.table_layout = TableLayout(MainActivity.singletonThis) table_layout_params = TableLayout__Layoutparams( TableLayout__Layoutparams.MATCH_PARENT, TableLayout__Layoutparams.WRAP_CONTENT, ) - # add horizontal scroll view - hscroll_view = HorizontalScrollView(self._native_activity) - hscroll_view_layout_params = LinearLayout__LayoutParams( - LinearLayout__LayoutParams.MATCH_PARENT, - LinearLayout__LayoutParams.MATCH_PARENT, - ) - hscroll_view_layout_params.gravity = Gravity.LEFT - vscroll_view.addView(hscroll_view, hscroll_view_layout_params) # add table layout to scrollbox self.table_layout.setLayoutParams(table_layout_params) - hscroll_view.addView(self.table_layout) - # add scroll box to parent layout - parent.addView(vscroll_view, vscroll_view_layout_params) - self.native = parent - if self.interface.data is not None: - self.change_source(self.interface.data) + vscroll_view.addView(self.table_layout) def change_source(self, source): self.selection = {} self.table_layout.removeAllViews() + + # StretchAllColumns mode causes a divide by zero error if there are no columns. + self.table_layout.setStretchAllColumns(bool(self.interface.accessors)) + if source is not None: - self.table_layout.addView(self.create_table_header()) + if self.interface.headings is not None: + self.table_layout.addView(self.create_table_header()) for row_index in range(len(source)): table_row = self.create_table_row(row_index) self.table_layout.addView(table_row) self.table_layout.invalidate() + def add_selection(self, index, table_row): + self.selection[index] = table_row + table_row.setBackgroundColor(self.color_selected) + + def remove_selection(self, index): + table_row = self.selection.pop(index) + table_row.setBackgroundColor(self.color_unselected) + def clear_selection(self): - for i in range(self.table_layout.getChildCount()): - row = self.table_layout.getChildAt(i) - row.setBackgroundColor(self.color_unselected) - self.selection = {} + for index in list(self.selection): + self.remove_selection(index) def create_table_header(self): table_row = TableRow(MainActivity.singletonThis) @@ -119,14 +115,11 @@ def create_table_header(self): ) table_row.setLayoutParams(table_row_params) for col_index in range(len(self.interface._accessors)): - if self.interface._accessors[col_index] == self._deleted_column: - continue text_view = TextView(MainActivity.singletonThis) text_view.setText(self.interface.headings[col_index]) - if self._font_impl: - self._font_impl.apply( - text_view, text_view.getTextSize(), text_view.getTypeface() - ) + self._font_impl.apply( + text_view, text_view.getTextSize(), text_view.getTypeface() + ) text_view.setTypeface( Typeface.create( text_view.getTypeface(), @@ -150,16 +143,15 @@ def create_table_row(self, row_index): table_row.setLayoutParams(table_row_params) table_row.setClickable(True) table_row.setOnClickListener(TogaOnClickListener(impl=self)) + table_row.setLongClickable(True) + table_row.setOnLongClickListener(TogaOnLongClickListener(impl=self)) table_row.setId(row_index) for col_index in range(len(self.interface._accessors)): - if self.interface._accessors[col_index] == self._deleted_column: - continue text_view = TextView(MainActivity.singletonThis) text_view.setText(self.get_data_value(row_index, col_index)) - if self._font_impl: - self._font_impl.apply( - text_view, text_view.getTextSize(), text_view.getTypeface() - ) + self._font_impl.apply( + text_view, text_view.getTextSize(), text_view.getTypeface() + ) text_view_params = TableRow__Layoutparams( TableRow__Layoutparams.MATCH_PARENT, TableRow__Layoutparams.WRAP_CONTENT ) @@ -170,62 +162,62 @@ def create_table_row(self, row_index): return table_row def get_data_value(self, row_index, col_index): - if self.interface.data is None or self.interface._accessors is None: - return None row_object = self.interface.data[row_index] value = getattr( row_object, self.interface._accessors[col_index], - self.interface.missing_value, + None, ) - return value + + if isinstance(value, tuple): # TODO: support icons + value = value[1] + if value is None: + value = self.interface.missing_value + return str(value) def get_selection(self): - selection = [] - for row_index in range(len(self.interface.data)): - if row_index in self.selection: - selection.append(row_index) - if len(selection) == 0: - selection = None - elif not self.interface.multiple_select: - selection = selection[0] - return selection - - # data listener method + selection = sorted(self.selection) + if self.interface.multiple_select: + return selection + elif len(selection) == 0: + return None + else: + return selection[0] + def insert(self, index, item): self.change_source(self.interface.data) - # data listener method def clear(self): self.change_source(self.interface.data) def change(self, item): - self.interface.factory.not_implemented("Table.change()") + self.change_source(self.interface.data) - # data listener method - def remove(self, item, index): + def remove(self, index, item): self.change_source(self.interface.data) - def scroll_to_row(self, row): - pass + def scroll_to_row(self, index): + if (index != 0) and (self.interface.headings is not None): + index += 1 + table_row = self.table_layout.getChildAt(index) + table_row.requestRectangleOnScreen( + Rect(0, 0, 0, table_row.getHeight()), + True, # Immediate, not animated + ) def insert_column(self, index, heading, accessor): self.change_source(self.interface.data) - def remove_column(self, accessor): - self._deleted_column = accessor + def remove_column(self, index): self.change_source(self.interface.data) - self._deleted_column = None + + def set_background_color(self, value): + self.set_background_simple(value) def set_font(self, font): self._font_impl = font._impl - if self.interface.data is not None: - self.change_source(self.interface.data) + self.change_source(self.interface.data) def rehint(self): - self.native.measure( - View__MeasureSpec.UNSPECIFIED, - View__MeasureSpec.UNSPECIFIED, - ) - self.interface.intrinsic.width = at_least(self.native.getMeasuredWidth()) - self.interface.intrinsic.height = at_least(self.native.getMeasuredHeight()) + self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) + self.interface.intrinsic.height = at_least(self.interface._MIN_HEIGHT) diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py new file mode 100644 index 0000000000..e813cf8640 --- /dev/null +++ b/android/tests_backend/widgets/table.py @@ -0,0 +1,88 @@ +import pytest + +from android.widget import ScrollView, TableLayout, TextView + +from .base import SimpleProbe + +HEADER = "HEADER" + + +class TableProbe(SimpleProbe): + native_class = ScrollView + supports_icons = False + supports_keyboard_shortcuts = False + + def __init__(self, widget): + super().__init__(widget) + assert self.native.getChildCount() == 1 + self.native_table = self.native.getChildAt(0) + assert isinstance(self.native_table, TableLayout) + + @property + def row_count(self): + count = self.native_table.getChildCount() + if (count > 0) and self.header_visible: + count -= 1 + return count + + @property + def column_count(self): + return self._row_view(HEADER).getChildCount() + + def assert_cell_content(self, row, col, value=None, icon=None, widget=None): + if widget: + pytest.skip("This backend doesn't support widgets in Tables") + else: + assert self._cell_text(row, col) == value + assert icon is None + + def _cell_text(self, row, col): + tv = self._row_view(row).getChildAt(col) + assert isinstance(tv, TextView) + return str(tv.getText()) + + def _row_view(self, row): + if row == HEADER: + row = 0 + elif self.header_visible: + row += 1 + return self.native_table.getChildAt(row) + + @property + def max_scroll_position(self): + return ( + self.native_table.getHeight() - self.native.getHeight() + ) / self.scale_factor + + @property + def scroll_position(self): + return self.native.getScrollY() / self.scale_factor + + async def wait_for_scroll_completion(self): + # No animation associated with scroll, so this is a no-op + pass + + @property + def header_visible(self): + return self._row_view(HEADER).getChildAt(0).getTypeface().isBold() + + @property + def header_titles(self): + return [self._cell_text(HEADER, i) for i in range(self.column_count)] + + # The TextViews do not fill the columns, so we have to calculate their spacing + # rather than their internal width. + def column_width(self, index): + row = self._row_view(HEADER) + left = row.getChildAt(index).getLeft() + if index < self.column_count - 1: + right = row.getChildAt(index + 1).getLeft() + else: + right = row.getWidth() + return (right - left) / self.scale_factor + + async def select_row(self, row, add=False): + self._row_view(row).performClick() + + async def activate_row(self, row): + self._row_view(row).performLongClick() diff --git a/docs/reference/api/widgets/table.rst b/docs/reference/api/widgets/table.rst index 3d6c22e152..02e7fe2ee1 100644 --- a/docs/reference/api/widgets/table.rst +++ b/docs/reference/api/widgets/table.rst @@ -111,7 +111,10 @@ Notes * Widgets in tables is a beta API which may change in future, and is currently only supported on macOS. -* Icons in tables are not currently supported on Winforms. +* Icons in tables are not currently supported on Android or Winforms. + +* The Android implementation is `not scalable + `_ beyond about 1,000 cells. Reference --------- diff --git a/docs/reference/data/widgets_by_platform.csv b/docs/reference/data/widgets_by_platform.csv index 1f1df58fdc..87838e126d 100644 --- a/docs/reference/data/widgets_by_platform.csv +++ b/docs/reference/data/widgets_by_platform.csv @@ -17,7 +17,7 @@ ProgressBar,General Widget,:class:`~toga.ProgressBar`,Progress Bar,|y|,|y|,|y|,| Selection,General Widget,:class:`~toga.Selection`,A widget to select a single option from a list of alternatives.,|y|,|y|,|y|,|y|,|y|,, Slider,General Widget,:class:`~toga.Slider`,Slider,|y|,|y|,|y|,|y|,|y|,, Switch,General Widget,:class:`~toga.Switch`,Switch,|y|,|y|,|y|,|y|,|y|,|b|, -Table,General Widget,:class:`~toga.Table`,A widget for displaying columns of tabular data.,|b|,|b|,|b|,,|b|,, +Table,General Widget,:class:`~toga.Table`,A widget for displaying columns of tabular data.,|y|,|y|,|y|,,|b|,, TextInput,General Widget,:class:`~toga.TextInput`,A widget for the display and editing of a single line of text.,|y|,|y|,|y|,|y|,|y|,|b|,|b| TimeInput,General Widget,:class:`~toga.TimeInput`,A widget to select a clock time,,,|y|,,|y|,, Tree,General Widget,:class:`~toga.Tree`,Tree of data,|b|,|b|,|b|,,,, diff --git a/examples/table/table/app.py b/examples/table/table/app.py index e9c8af993c..fef0607d2c 100644 --- a/examples/table/table/app.py +++ b/examples/table/table/app.py @@ -1,5 +1,4 @@ import random -from random import choice import toga from toga.constants import COLUMN, ROW @@ -9,12 +8,12 @@ headings = ["Title", "Year", "Rating", "Genre"] bee_movies = [ ("The Secret Life of Bees", 2008, 7.3, "Drama"), - ("Bee Movie", 2007, 6.1, "Animation, Adventure, Comedy"), + ("Bee Movie", 2007, 6.1, "Animation, Adventure"), ("Bees", 1998, 6.3, "Horror"), ("The Girl Who Swallowed Bees", 2007, 7.5), # Missing a genre ("Birds Do It, Bees Do It", 1974, 7.3, "Documentary"), ("Bees: A Life for the Queen", 1998, 8.0, "TV Movie"), - ("Bees in Paradise", 1944, 5.4, "Comedy, Musical"), + ("Bees in Paradise", 1944, 5.4, None), # None genre ("Keeper of the Bees", 1947, 6.3, "Drama"), ] @@ -51,7 +50,7 @@ def on_activate2(self, widget, row, **kwargs): # Button callback functions def insert_handler(self, widget, **kwargs): - self.table1.data.insert(0, choice(bee_movies)) + self.table1.data.insert(0, random.choice(bee_movies)) def delete_handler(self, widget, **kwargs): if self.table1.selection: @@ -65,7 +64,7 @@ def clear_handler(self, widget, **kwargs): self.table1.data.clear() def reset_handler(self, widget, **kwargs): - self.table1.data = bee_movies[3:] + self.table1.data = bee_movies def toggle_handler(self, widget, **kwargs): try: @@ -80,6 +79,12 @@ def toggle_handler(self, widget, **kwargs): # you could manually specify the accessor here, too. self.table1.add_column("Genre") + def top_handler(self, widget, **kwargs): + self.table1.scroll_to_top() + + def bottom_handler(self, widget, **kwargs): + self.table1.scroll_to_bottom() + def startup(self): self.main_window = toga.MainWindow(title=self.name) @@ -104,18 +109,22 @@ def startup(self): ) font_box = toga.Box( children=[ - lbl_fontlabel, - self.lbl_fontsize, - btn_reduce_size, - btn_increase_size, + toga.Box( + children=[btn_reduce_size, btn_increase_size], + style=Pack(direction=ROW), + ), + toga.Box( + children=[lbl_fontlabel, self.lbl_fontsize], + style=Pack(direction=ROW), + ), ], - style=Pack(direction=ROW, padding_bottom=5), + style=Pack(direction=COLUMN), ) # Data to populate the table. if toga.platform.current_platform == "android": # FIXME: beeware/toga#1392 - Android Table doesn't allow lots of content - table_data = bee_movies + table_data = bee_movies * 10 else: table_data = bee_movies * 1000 @@ -151,28 +160,33 @@ def startup(self): # Buttons btn_style = Pack(flex=1) btn_insert = toga.Button( - "Insert Row", on_press=self.insert_handler, style=btn_style + "Insert", on_press=self.insert_handler, style=btn_style ) btn_delete = toga.Button( - "Delete Row", on_press=self.delete_handler, style=btn_style + "Delete", on_press=self.delete_handler, style=btn_style ) - btn_clear = toga.Button( - "Clear Table", on_press=self.clear_handler, style=btn_style + btn_clear = toga.Button("Clear", on_press=self.clear_handler, style=btn_style) + btn_reset = toga.Button("Reset", on_press=self.reset_handler, style=btn_style) + btn_toggle = toga.Button( + "Column", on_press=self.toggle_handler, style=btn_style ) - btn_reset = toga.Button( - "Reset Table", on_press=self.reset_handler, style=btn_style + btn_top = toga.Button("Top", on_press=self.top_handler, style=btn_style) + btn_bottom = toga.Button( + "Bottom", on_press=self.bottom_handler, style=btn_style ) - btn_toggle = toga.Button( - "Toggle Column", on_press=self.toggle_handler, style=btn_style + + controls_1 = toga.Box( + children=[font_box, btn_insert, btn_delete, btn_clear], + style=Pack(direction=ROW, padding_bottom=5), ) - btn_box = toga.Box( - children=[btn_insert, btn_delete, btn_clear, btn_reset, btn_toggle], + controls_2 = toga.Box( + children=[btn_reset, btn_toggle, btn_top, btn_bottom], style=Pack(direction=ROW, padding_bottom=5), ) # Most outer box outer_box = toga.Box( - children=[font_box, btn_box, tablebox, labelbox], + children=[controls_1, controls_2, tablebox, labelbox], style=Pack( flex=1, direction=COLUMN, diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 4285a96395..6783e1dca1 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -40,8 +40,7 @@ def source(): @pytest.fixture async def widget(source, on_select_handler, on_activate_handler): - # Although Android *has* a table implementation, it needs to be rebuilt. - skip_on_platforms("iOS", "android") + skip_on_platforms("iOS") return toga.Table( ["A", "B", "C"], data=source, @@ -54,8 +53,7 @@ async def widget(source, on_select_handler, on_activate_handler): @pytest.fixture async def headerless_widget(source, on_select_handler): - # Although Android *has* a table implementation, it needs to be rebuilt. - skip_on_platforms("iOS", "android") + skip_on_platforms("iOS") return toga.Table( data=source, missing_value="MISSING!", @@ -81,8 +79,7 @@ async def headerless_probe(main_window, headerless_widget): @pytest.fixture async def multiselect_widget(source, on_select_handler): - # Although Android *has* a table implementation, it needs to be rebuilt. - skip_on_platforms("iOS", "android") + skip_on_platforms("iOS") return toga.Table( ["A", "B", "C"], data=source, @@ -117,20 +114,23 @@ async def test_scroll(widget, probe): await probe.wait_for_scroll_completion() await probe.redraw("Table scrolled to bottom") + # Ensure we have at least 3 screens of content + assert probe.max_scroll_position > probe.height * 2 + assert probe.max_scroll_position > 600 + # max_scroll_position is not perfectly accurate on Winforms. assert probe.scroll_position == pytest.approx(probe.max_scroll_position, abs=5) - assert probe.scroll_position > 600 # Scroll to the middle of the table widget.scroll_to_row(50) await probe.wait_for_scroll_completion() await probe.redraw("Table scrolled to mid row") - # Row 50 should be visible. It could be at the top of the table, or the bottom of - # the table; we don't really care which - as long as it's roughly in the middle of + # Row 50 should be visible. It could be at the top of the screen, or the bottom of + # the screen; we don't really care which - as long as we're roughly in the middle of # the scroll range, call it a win. assert probe.scroll_position == pytest.approx( - probe.max_scroll_position / 2, abs=250 + probe.max_scroll_position / 2, abs=400 ) # Scroll to the top of the table @@ -377,7 +377,8 @@ async def test_column_changes(widget, probe): # column should be tiny. total_width = sum(probe.column_width(i) for i in range(0, 4)) assert total_width == pytest.approx(probe.width, abs=100) - assert all(probe.column_width(i) > 80 for i in range(0, 4)) + for i in range(0, 4): + assert probe.column_width(i) > 50 async def test_headerless_column_changes(headerless_widget, headerless_probe): diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index baedfdf417..2ff5846610 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -131,7 +131,7 @@ def insert(self, index, item): def change(self, item): self.update_data() - def remove(self, item, index): + def remove(self, index, item): self.update_data() def clear(self): @@ -146,8 +146,8 @@ def get_selection(self): else: return selected_indices[0] - def scroll_to_row(self, row): - self.native.EnsureVisible(row) + def scroll_to_row(self, index): + self.native.EnsureVisible(index) def rehint(self): self.interface.intrinsic.width = at_least(self.interface._MIN_WIDTH) From 1784000efe3340d3b130a26b2b5ad26b88f5997f Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 27 Aug 2023 11:37:33 +0100 Subject: [PATCH 39/40] Android Icon to 100% coverage --- android/src/toga_android/icons.py | 7 +++++++ android/tests_backend/icons.py | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/android/src/toga_android/icons.py b/android/src/toga_android/icons.py index ada229129f..f56ff48abc 100644 --- a/android/src/toga_android/icons.py +++ b/android/src/toga_android/icons.py @@ -1,3 +1,6 @@ +from android.graphics import BitmapFactory + + class Icon: EXTENSIONS = [".png"] SIZES = None @@ -6,3 +9,7 @@ def __init__(self, interface, path): self.interface = interface self.interface._impl = self self.path = path + + self.native = BitmapFactory.decodeFile(str(path)) + if self.native is None: + raise ValueError(f"Unable to load icon from {path}") diff --git a/android/tests_backend/icons.py b/android/tests_backend/icons.py index da3fcd3f96..60d7f564af 100644 --- a/android/tests_backend/icons.py +++ b/android/tests_backend/icons.py @@ -1,5 +1,7 @@ import pytest +from android.graphics import Bitmap + from .probe import BaseProbe @@ -11,8 +13,7 @@ def __init__(self, app, icon): super().__init__() self.app = app self.icon = icon - # At least for now, there's no native object. - # assert isinstance(self.icon._impl.native, NSImage) + assert isinstance(self.icon._impl.native, Bitmap) def assert_icon_content(self, path): if path == "resources/icons/green": From b6c0d04a26f055e4d87dd8a0c2187fbb9c7e78e9 Mon Sep 17 00:00:00 2001 From: Malcolm Smith Date: Sun, 27 Aug 2023 12:32:34 +0100 Subject: [PATCH 40/40] Add tests for accessors returning None, and fix on Cocoa and Winforms --- cocoa/src/toga_cocoa/widgets/table.py | 38 ++++++++-------- testbed/tests/conftest.py | 10 ++++- testbed/tests/widgets/test_table.py | 48 ++++++++++++++------- winforms/src/toga_winforms/widgets/table.py | 7 +-- 4 files changed, 62 insertions(+), 41 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index f21948e78c..6e0c616432 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -35,27 +35,25 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): data_row = self.interface.data[row] col_identifier = str(column.identifier) - try: - value = getattr(data_row, col_identifier) - - # if the value is a widget itself, just draw the widget! - if isinstance(value, toga.Widget): - return value._impl.native - - # Allow for an (icon, value) tuple as the simple case - # for encoding an icon in a table cell. Otherwise, look - # for an icon attribute. - elif isinstance(value, tuple): - icon, value = value - else: - try: - icon = value.icon - except AttributeError: - icon = None - except AttributeError: - # The accessor doesn't exist in the data. Use the missing value. + value = getattr(data_row, col_identifier, None) + + # if the value is a widget itself, just draw the widget! + if isinstance(value, toga.Widget): + return value._impl.native + + # Allow for an (icon, value) tuple as the simple case + # for encoding an icon in a table cell. Otherwise, look + # for an icon attribute. + elif isinstance(value, tuple): + icon, value = value + else: + try: + icon = value.icon + except AttributeError: + icon = None + + if value is None: value = self.interface.missing_value - icon = None # creates a NSTableCellView from interface-builder template (does not exist) # or reuses an existing view which is currently not needed for painting diff --git a/testbed/tests/conftest.py b/testbed/tests/conftest.py index 1c9d38cb00..b169c0a206 100644 --- a/testbed/tests/conftest.py +++ b/testbed/tests/conftest.py @@ -53,7 +53,9 @@ def main_window(app): # Controls the event loop used by pytest-asyncio. @fixture(scope="session") def event_loop(app): - return ProxyEventLoop(app._impl.loop) + loop = ProxyEventLoop(app._impl.loop) + yield loop + loop.close() # Proxy which forwards all tasks to another event loop in a thread-safe manner. It @@ -61,6 +63,7 @@ def event_loop(app): @dataclass class ProxyEventLoop(asyncio.AbstractEventLoop): loop: object + closed: bool = False # Used by ensure_future. def create_task(self, coro): @@ -75,8 +78,11 @@ def run_until_complete(self, future): raise TypeError(f"Future type {type(future)} is not currently supported") return asyncio.run_coroutine_threadsafe(coro, self.loop).result() + def is_closed(self): + return self.closed + def close(self): - pass + self.closed = True @dataclass diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 6783e1dca1..269788a3ec 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -255,11 +255,22 @@ async def _row_change_test(widget, probe): """Meta test for adding and removing data to the table""" # Change the data source for something smaller - widget.data = [{"a": f"A{i}", "b": i, "c": MyData(i)} for i in range(0, 5)] + widget.data = [ + { + "a": f"A{i}", # String + "b": i, # Integer + "c": MyData(i), # Custom type + } + for i in range(0, 5) + ] await probe.redraw("Data source has been changed") assert probe.row_count == 5 # All cell contents are strings + probe.assert_cell_content(0, 0, "A0") + probe.assert_cell_content(1, 0, "A1") + probe.assert_cell_content(2, 0, "A2") + probe.assert_cell_content(3, 0, "A3") probe.assert_cell_content(4, 0, "A4") probe.assert_cell_content(4, 1, "4") probe.assert_cell_content(4, 2, "") @@ -271,6 +282,8 @@ async def _row_change_test(widget, probe): assert probe.row_count == 6 probe.assert_cell_content(4, 0, "A4") probe.assert_cell_content(5, 0, "AX") + probe.assert_cell_content(5, 1, "BX") + probe.assert_cell_content(5, 2, "CX") # Insert a row into the middle of the table; # Row is missing a B accessor @@ -278,33 +291,32 @@ async def _row_change_test(widget, probe): await probe.redraw("Partial row has been appended") assert probe.row_count == 7 + probe.assert_cell_content(1, 0, "A1") probe.assert_cell_content(2, 0, "AY") - probe.assert_cell_content(5, 0, "A4") - probe.assert_cell_content(6, 0, "AX") - - # Missing value has been populated probe.assert_cell_content(2, 1, "MISSING!") + probe.assert_cell_content(2, 2, "CY") + probe.assert_cell_content(3, 0, "A2") # Change content on the partial row - widget.data[2].a = "ANEW" + # Column B now has a value, but column A returns None + widget.data[2].a = None widget.data[2].b = "BNEW" await probe.redraw("Partial row has been updated") assert probe.row_count == 7 - probe.assert_cell_content(2, 0, "ANEW") - probe.assert_cell_content(5, 0, "A4") - probe.assert_cell_content(6, 0, "AX") - - # Missing value has the default empty string + probe.assert_cell_content(1, 0, "A1") + probe.assert_cell_content(2, 0, "MISSING!") probe.assert_cell_content(2, 1, "BNEW") + probe.assert_cell_content(2, 2, "CY") + probe.assert_cell_content(3, 0, "A2") # Delete a row del widget.data[3] await probe.redraw("Row has been removed") assert probe.row_count == 6 - probe.assert_cell_content(2, 0, "ANEW") + probe.assert_cell_content(2, 0, "MISSING!") + probe.assert_cell_content(3, 0, "A3") probe.assert_cell_content(4, 0, "A4") - probe.assert_cell_content(5, 0, "AX") # Clear the table widget.data.clear() @@ -415,7 +427,11 @@ async def test_cell_icon(widget, probe): # Normal text, "a": f"A{i}", # A tuple - "b": ({0: None, 1: red, 2: green}[i % 3], f"B{i}"), + "b": { + 0: (None, "B0"), # String + 1: (red, None), # None + 2: (green, 2), # Integer + }[i % 3], # An object with an icon attribute. "c": MyIconData(f"C{i}", {0: red, 1: green, 2: None}[i % 3]), } @@ -428,11 +444,11 @@ async def test_cell_icon(widget, probe): probe.assert_cell_content(0, 2, "", icon=red) probe.assert_cell_content(1, 0, "A1") - probe.assert_cell_content(1, 1, "B1", icon=red) + probe.assert_cell_content(1, 1, "MISSING!", icon=red) probe.assert_cell_content(1, 2, "", icon=green) probe.assert_cell_content(2, 0, "A2") - probe.assert_cell_content(2, 1, "B2", icon=green) + probe.assert_cell_content(2, 1, "2", icon=green) probe.assert_cell_content(2, 2, "", icon=None) diff --git a/winforms/src/toga_winforms/widgets/table.py b/winforms/src/toga_winforms/widgets/table.py index 2ff5846610..6d3096e0e5 100644 --- a/winforms/src/toga_winforms/widgets/table.py +++ b/winforms/src/toga_winforms/widgets/table.py @@ -113,10 +113,11 @@ def row_data(self, item): # TODO: ListView only has built-in support for one icon per row. One possible # workaround is in https://stackoverflow.com/a/46128593. def strip_icon(item, attr): - val = getattr(item, attr, self.interface.missing_value) - + val = getattr(item, attr, None) if isinstance(val, tuple): - return str(val[1]) + val = val[1] + if val is None: + val = self.interface.missing_value return str(val) return [strip_icon(item, attr) for attr in self.interface._accessors]