From 85c9f69c688b5539143d4378c4a41d848507c656 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 07:54:59 +0000 Subject: [PATCH 01/17] Add text and background color support; cocoa implementation. --- cocoa/src/toga_cocoa/widgets/table.py | 13 ++++ cocoa/src/toga_cocoa/widgets/tree.py | 13 ++++ cocoa/tests_backend/widgets/table.py | 19 ++++- cocoa/tests_backend/widgets/tree.py | 19 ++++- core/src/toga/sources/columns.py | 44 +++++++++++ core/tests/sources/test_columns.py | 6 ++ examples/table_columns/table_columns/app.py | 20 ++++- testbed/tests/widgets/test_table.py | 78 ++++++++++++++++++++ testbed/tests/widgets/test_tree.py | 81 +++++++++++++++++++++ 9 files changed, 290 insertions(+), 3 deletions(-) diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index daab07b811..04ca7f5ff2 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -12,6 +12,7 @@ NSTableViewColumnAutoresizingStyle, ) +from ..colors import native_color from .base import Widget from .internal.cells import TogaIconView @@ -43,6 +44,8 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): icon = column.toga_column.icon(data_row) text = column.toga_column.text(data_row, self.interface.missing_value) + color = column.toga_column.color(data_row) + background_color = column.toga_column.background_color(data_row) # creates a NSTableCellView from interface-builder template (does not exist) # or reuses an existing view which is currently not needed for painting @@ -60,6 +63,16 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): else: tcv.setImage(None) + if color is not None: + tcv.textField.textColor = native_color(color) + else: + tcv.textField.textColor = None + if background_color is not None: + tcv.drawsBackground = True + tcv.backgroundColor = native_color(background_color) + else: + tcv.textField.drawsBackground = False + return tcv @objc_method diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 3690388d71..4b29441b39 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -1,6 +1,7 @@ from rubicon.objc import SEL, at, objc_method, objc_property from travertino.size import at_least +from toga_cocoa.colors import native_color from toga_cocoa.libs import ( NSBezelBorder, NSIndexSet, @@ -64,6 +65,8 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): return widget._impl.native icon = column.toga_column.icon(node) text = column.toga_column.text(node, self.interface.missing_value) + color = column.toga_column.color(node) + background_color = column.toga_column.background_color(node) # creates a NSTableCellView from interface-builder template (does not exist) # or reuses an existing view which is currently not needed for painting @@ -84,6 +87,16 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): else: tcv.setImage(None) + if color is not None: + tcv.textField.textColor = native_color(color) + else: + tcv.textField.textColor = None + if background_color is not None: + tcv.drawsBackground = True + tcv.backgroundColor = native_color(background_color) + else: + tcv.textField.drawsBackground = False + return tcv # 2023-06-29: Commented out this method because it appears to be a diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index cfc1348d20..613e78f808 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -1,6 +1,7 @@ import pytest from rubicon.objc import NSPoint +from toga_cocoa.colors import native_color from toga_cocoa.keys import NSEventModifierFlagCommand from toga_cocoa.libs import NSEventType, NSScrollView, NSTableView @@ -14,6 +15,7 @@ class TableProbe(SimpleProbe): supports_keyboard_shortcuts = True supports_keyboard_boundary_shortcuts = False supports_widgets = True + supports_colors = True def __init__(self, widget): super().__init__(widget) @@ -43,7 +45,16 @@ def row_count(self): def column_count(self): return len(self.native_table.tableColumns) - def assert_cell_content(self, row, col, value=None, icon=None, widget=None): + def assert_cell_content( + self, + row, + col, + value=None, + icon=None, + widget=None, + color=None, + background_color=None, + ): view = self.native_table.tableView( self.native_table, viewForTableColumn=self.native_table.tableColumns[col], @@ -59,6 +70,12 @@ def assert_cell_content(self, row, col, value=None, icon=None, widget=None): else: assert view.imageView.image is None + if color: + assert view.textField.textColor == native_color(color) + + if background_color: + assert view.backgroundColor == native_color(background_color) + @property def max_scroll_position(self): return int(self.native.documentView.bounds.size.height) - int( diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index 7bb972c939..8454c32ba4 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -3,6 +3,7 @@ from pytest import skip from rubicon.objc import NSPoint +from toga_cocoa.colors import native_color from toga_cocoa.keys import NSEventModifierFlagCommand from toga_cocoa.libs import NSEventType, NSOutlineView, NSScrollView @@ -14,6 +15,7 @@ class TreeProbe(SimpleProbe): native_class = NSScrollView supports_keyboard_shortcuts = True supports_widgets = True + supports_colors = True def __init__(self, widget): super().__init__(widget) @@ -73,7 +75,16 @@ def child_count(self, row_path=None): def column_count(self): return len(self.native_tree.tableColumns) - def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None): + def assert_cell_content( + self, + row_path, + col, + value=None, + icon=None, + widget=None, + color=None, + background_color=None, + ): view = self.native_tree.outlineView( self.native_tree, viewForTableColumn=self.native_tree.tableColumns[col], @@ -89,6 +100,12 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) else: assert view.imageView.image is None + if color: + assert view.textField.textColor == native_color(color) + + if background_color: + assert view.backgroundColor == native_color(background_color) + @property def max_scroll_position(self): return int(self.native.documentView.bounds.size.height) - int( diff --git a/core/src/toga/sources/columns.py b/core/src/toga/sources/columns.py index 778ebe462d..bd0de17a34 100644 --- a/core/src/toga/sources/columns.py +++ b/core/src/toga/sources/columns.py @@ -2,6 +2,7 @@ from collections.abc import Iterable from typing import Any, Generic, Protocol, TypeVar, runtime_checkable +from ..colors import Color from ..icons import Icon from ..widgets.base import Widget from .accessors import build_accessors, to_accessor @@ -45,6 +46,29 @@ def icon(self, row: Any) -> Icon | None: :returns: The icon to display, or None if no Icon. """ + @abstractmethod + def color(self, row: Any) -> Color | None: + """Get the color use for the row in this column. + + This is intended to be used for data-based coloring of the + text in a cell (eg. displaying red text for negative numbers). + + :param row: A row object from the underlying Source. + :returns: The color, or None if the default color is to be used. + """ + + @abstractmethod + def background_color(self, row: Any) -> Color | None: + """Get the background color use for the row in this column. + + This is intended to be used for data-based coloring of the + text in a cell (eg. using a colormap to display different + colors in a cell based on the value, or to highlight outliers). + + :param row: A row object from the underlying Source. + :returns: The background color, or None if the default color is to be used. + """ + def widget(self, row: Row[Value]) -> Widget | None: """Get a widget from the Row or Node of a ListSource or TreeSource. @@ -111,6 +135,26 @@ def icon(self, row: Any) -> Icon | None: """ return None + def color(self, row: Any) -> Color | None: + """Get the color use for the row in this column. + + The default behaviour is to do nothing. + + :param row: A row object from the underlying Source. + :returns: The color, or None if the default color is to be used. + """ + return None + + def background_color(self, row: Any) -> Color | None: + """Get the background color use for the row in this column. + + The default behaviour is to do nothing. + + :param row: A row object from the underlying Source. + :returns: The background color, or None if the default color is to be used. + """ + return None + def widget(self, row: Any) -> Widget | None: """Get a widget from the Row or Node of a ListSource or TreeSource. diff --git a/core/tests/sources/test_columns.py b/core/tests/sources/test_columns.py index ad1d159060..794a2f6ca7 100644 --- a/core/tests/sources/test_columns.py +++ b/core/tests/sources/test_columns.py @@ -57,6 +57,8 @@ def test_column_abc(heading, heading_property): assert column.text(dummy_row) is None assert column.text(dummy_row, "default") == "default" assert column.icon(dummy_row) is None + assert column.color(dummy_row) is None + assert column.background_color(dummy_row) is None assert column.widget(dummy_row) is None @@ -69,6 +71,8 @@ def test_column_subclass(): assert column.text(dummy_row) == "('row',)" assert column.text(dummy_row, "default") == "('row',)" assert column.icon(dummy_row) is None + assert column.color(dummy_row) is None + assert column.background_color(dummy_row) is None assert column.widget(dummy_row) is None @@ -261,6 +265,8 @@ def test_accessor_column_values(row, value, text, icon, widget): column = AccessorColumn(None, "x") assert column.value(row) == value + assert column.color(row) is None + assert column.background_color(row) is None assert column.widget(row) == widget if text is ValueError: diff --git a/examples/table_columns/table_columns/app.py b/examples/table_columns/table_columns/app.py index c3aeb524b6..567b9755d9 100644 --- a/examples/table_columns/table_columns/app.py +++ b/examples/table_columns/table_columns/app.py @@ -10,6 +10,7 @@ from babel.dates import format_date, format_time import toga +from toga.colors import rgb from toga.constants import COLUMN from toga.sources import AccessorColumn, Column @@ -49,6 +50,15 @@ def icon(self, row): else: return self.red + def color(self, row): + value = self.value(row) + if value is None: + return None + elif value >= 7.0: + return rgb(0, 128, 0) + else: + return rgb(255, 0, 0) + class ListStrColumn(AccessorColumn): """A column that displays a comma-separated list of strings.""" @@ -69,6 +79,14 @@ def text(self, row, default=""): else: return default + def background_color(self, row): + value = self.value(row) + if isinstance(value, datetime.date): + return None + else: + # error + return rgb(255, 255, 128) + class TimeColumn(AccessorColumn): """A column that formats a time in a locale-appropriate way.""" @@ -140,7 +158,7 @@ def load_data(self): # generate some synthetic screening information today = datetime.date.today() times = [ - datetime.datetime(today.year, today.month, today.day + 1, hour) + datetime.datetime(today.year, today.month, today.day, hour) for hour in [12, 16, 18, 20] ] screens = [i + 1 for i in range(len(self.bee_movies))] diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 2be57ea63f..8a9cb89683 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -4,6 +4,7 @@ import pytest import toga +from toga.colors import rgb from toga.sources import AccessorColumn, ListListener, ListSource from toga.style.pack import Pack @@ -687,3 +688,80 @@ async def test_cell_widget(widget, probe): def test_list_listener(widget): """Does the widget implement the ListListener API""" assert isinstance(widget._impl, ListListener) + + +class ColorTestColumn(AccessorColumn): + def color(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + if value < 0: + return rgb(255, 0, 0) + else: + return rgb(0, 128, 0) + else: + return None + + def background_color(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return None + else: + return rgb(255, 0, 0) + + +@pytest.fixture +async def colored_widget(source, on_select_handler, on_activate_handler): + skip_on_platforms("iOS") + return toga.Table( + [ + ColorTestColumn("A"), + ColorTestColumn("B"), + ColorTestColumn("C"), + ], + data=source, + missing_value="MISSING!", + on_select=on_select_handler, + on_activate=on_activate_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +async def color_probe(main_window, colored_widget): + old_content = main_window.content + + box = toga.Box(children=[colored_widget]) + main_window.content = box + probe = get_probe(colored_widget) + await probe.redraw("Constructing color Table probe") + probe.assert_container(box) + yield probe + + main_window.content = old_content + + +async def test_cell_color(colored_widget, color_probe): + "A cell can have colors" + if not getattr(color_probe, "supports_colors", False): + pytest.skip("Backend does not support colors in cells.") + + colored_widget.data = [ + { + # A number from -1 to 1 + "a": (i - 25) / 25, + # Normal text, + "b": f"B{i}", + } + for i in range(50) + ] + await color_probe.redraw("Table has data with colors") + + color_probe.assert_cell_content( + 0, 0, "-1.0", color=rgb(255, 0, 0), background_color=None + ) + color_probe.assert_cell_content( + 0, 1, "B0", color=None, background_color=rgb(255, 0, 0) + ) + color_probe.assert_cell_content( + 25, 0, "0.0", color=rgb(0, 128, 0), background_color=None + ) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index c9c94025af..7665c16ebd 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -4,6 +4,7 @@ import pytest import toga +from toga.colors import rgb from toga.sources import AccessorColumn, ListListener, TreeListener, TreeSource from toga.style.pack import Pack @@ -944,3 +945,83 @@ def test_tree_listener(widget): TreeListener APIs""" assert isinstance(widget._impl, ListListener) assert isinstance(widget._impl, TreeListener) + + +class ColorTestColumn(AccessorColumn): + def color(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + if value < 0: + return rgb(255, 0, 0) + else: + return rgb(0, 128, 0) + else: + return None + + def background_color(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return None + else: + return rgb(255, 0, 0) + + +@pytest.fixture +async def colored_widget(source, on_select_handler, on_activate_handler): + skip_on_platforms("iOS") + return toga.Tree( + [ + ColorTestColumn("A"), + ColorTestColumn("B"), + ColorTestColumn("C"), + ], + data=source, + missing_value="MISSING!", + on_select=on_select_handler, + on_activate=on_activate_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +async def color_probe(main_window, colored_widget): + old_content = main_window.content + + box = toga.Box(children=[colored_widget]) + main_window.content = box + probe = get_probe(colored_widget) + await probe.redraw("Constructing color Table probe") + probe.assert_container(box) + yield probe + + main_window.content = old_content + + +async def test_cell_color(colored_widget, color_probe): + "A cell can have colors" + if not getattr(color_probe, "supports_colors", False): + pytest.skip("Backend does not support colors in cells.") + + colored_widget.data = [ + ( + { + # A number from -1 to 1 + "a": (i - 25) / 25, + # Normal text, + "b": f"B{i}", + }, + [], + ) + for i in range(50) + ] + await color_probe.redraw("Tree has data with colors") + + color_probe.assert_cell_content( + (0,), 0, "-1.0", color=rgb(255, 0, 0), background_color=None + ) + color_probe.assert_cell_content( + (0,), 1, "B0", color=None, background_color=rgb(255, 0, 0) + ) + color_probe.assert_cell_content( + (25,), 0, "0.0", color=rgb(0, 128, 0), background_color=None + ) From e94763b73429e8cce85e75d7a49125d8d87b1c00 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 09:18:49 +0000 Subject: [PATCH 02/17] Add color support for Qt backend. --- qt/src/toga_qt/widgets/table.py | 9 +++++++++ qt/src/toga_qt/widgets/tree.py | 10 ++++++++++ qt/tests_backend/widgets/table.py | 27 +++++++++++++++++++++++++-- qt/tests_backend/widgets/tree.py | 25 ++++++++++++++++++++++++- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/qt/src/toga_qt/widgets/table.py b/qt/src/toga_qt/widgets/table.py index 3fcf071ca2..84b9df0eb8 100644 --- a/qt/src/toga_qt/widgets/table.py +++ b/qt/src/toga_qt/widgets/table.py @@ -8,6 +8,7 @@ from toga.sources import ListSource +from ..colors import native_color from .base import Widget logger = logging.getLogger(__name__) @@ -120,6 +121,14 @@ def data( return icon._impl.native elif role == Qt.ItemDataRole.DisplayRole: return column.text(row, self._missing_value) + elif role == Qt.ItemDataRole.ForegroundRole: + color = column.color(row) + if color is not None: + return native_color(color) + elif role == Qt.ItemDataRole.BackgroundRole: + color = column.background_color(row) + if color is not None: + return native_color(color) except Exception: # pragma: no cover logger.exception( f"Could not get data for row {row_index}, column {column_index}" diff --git a/qt/src/toga_qt/widgets/tree.py b/qt/src/toga_qt/widgets/tree.py index 5356d1113e..df3c439a54 100644 --- a/qt/src/toga_qt/widgets/tree.py +++ b/qt/src/toga_qt/widgets/tree.py @@ -11,6 +11,8 @@ from PySide6.QtWidgets import QHeaderView, QTreeView from travertino.size import at_least +from toga_qt.colors import native_color + from .base import Widget logger = logging.getLogger(__name__) @@ -196,6 +198,14 @@ def data( return icon._impl.native elif role == Qt.ItemDataRole.DisplayRole: return column.text(node, self._missing_value) + elif role == Qt.ItemDataRole.ForegroundRole: + color = column.color(node) + if color is not None: + return native_color(color) + elif role == Qt.ItemDataRole.BackgroundRole: + color = column.background_color(node) + if color is not None: + return native_color(color) except Exception: # pragma: no cover logger.exception( f"Could not get data for node {node}, column {column_index}" diff --git a/qt/tests_backend/widgets/table.py b/qt/tests_backend/widgets/table.py index 018028eca7..09d45fd2be 100644 --- a/qt/tests_backend/widgets/table.py +++ b/qt/tests_backend/widgets/table.py @@ -3,6 +3,7 @@ import pytest from PySide6.QtCore import QAbstractTableModel, QItemSelection, QItemSelectionModel, Qt from PySide6.QtWidgets import QTableView +from toga_qt.colors import native_color from .base import SimpleProbe @@ -12,6 +13,7 @@ class TableProbe(SimpleProbe): supports_icons = 2 # All columns supports_keyboard_shortcuts = False supports_widgets = False + supports_colors = True def __init__(self, widget): super().__init__(widget) @@ -44,7 +46,16 @@ def header_titles(self): def column_width(self, col): return self.native.horizontalHeader().sectionSize(col) - def assert_cell_content(self, row, col, value=None, icon=None, widget=None): + def assert_cell_content( + self, + row, + col, + value=None, + icon=None, + widget=None, + color=None, + background_color=None, + ): if widget: pytest.skip("Qt doesn't support widgets in Tables") else: @@ -53,7 +64,7 @@ def assert_cell_content(self, row, col, value=None, icon=None, widget=None): with warnings.catch_warnings(category=UserWarning): warnings.simplefilter("ignore") index = self.native_model.index(row, col) - if value: + if value is not None: assert value == self.native_model.data( index, Qt.ItemDataRole.DisplayRole, @@ -67,6 +78,18 @@ def assert_cell_content(self, row, col, value=None, icon=None, widget=None): ).cacheKey() ) + if color: + assert native_color(color) == self.native_model.data( + index, + Qt.ItemDataRole.ForegroundRole, + ) + + if background_color: + assert native_color(background_color) == self.native_model.data( + index, + Qt.ItemDataRole.BackgroundRole, + ) + @property def max_scroll_position(self): return self.native.verticalScrollBar().maximum() * self.native.rowHeight(0) diff --git a/qt/tests_backend/widgets/tree.py b/qt/tests_backend/widgets/tree.py index 267fc1251a..0131e49059 100644 --- a/qt/tests_backend/widgets/tree.py +++ b/qt/tests_backend/widgets/tree.py @@ -10,6 +10,7 @@ Qt, ) from PySide6.QtWidgets import QTreeView +from toga_qt.colors import native_color from .base import SimpleProbe @@ -18,6 +19,7 @@ class TreeProbe(SimpleProbe): native_class = QTreeView supports_keyboard_shortcuts = False supports_widgets = False + supports_colors = True selection_cleared_on_insert_delete = True collapse_on_insert_delete = True @@ -69,7 +71,16 @@ def header_titles(self): def column_width(self, col): return self.native.header().sectionSize(col) - def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None): + def assert_cell_content( + self, + row_path, + col, + value=None, + icon=None, + widget=None, + color=None, + background_color=None, + ): if widget: pytest.skip("Qt doesn't support widgets in Trees") else: @@ -92,6 +103,18 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) ).cacheKey() ) + if color: + assert native_color(color) == self.native_model.data( + index, + Qt.ItemDataRole.ForegroundRole, + ) + + if background_color: + assert native_color(background_color) == self.native_model.data( + index, + Qt.ItemDataRole.BackgroundRole, + ) + @property def max_scroll_position(self): return self.native.verticalScrollBar().maximum() * self.native.rowHeight(0) From 44d6354f938d034f455c3997eef3c0fd6116fbd4 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 09:25:11 +0000 Subject: [PATCH 03/17] Add support for colors in Android. --- android/src/toga_android/widgets/table.py | 7 ++++++ android/tests_backend/widgets/table.py | 29 ++++++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 4f7103a337..5d67ebc010 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -6,6 +6,7 @@ from android.widget import LinearLayout, ScrollView, TableLayout, TableRow, TextView from java import dynamic_proxy +from ..colors import native_color from .base import Widget from .label import set_textview_font @@ -149,6 +150,12 @@ def create_table_row(self, row_index): ) text_view = TextView(self._native_activity) text_view.setText(toga_column.text(data_row, missing_value)) + color = toga_column.color(data_row) + background_color = toga_column.background_color(data_row) + if color is not None: + text_view.setTextColor(native_color(color)) + if background_color is not None: + text_view.setBackgroundColor(native_color(background_color)) set_textview_font( text_view, self._font_impl, diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py index f426be98ae..ab7c4d6b46 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -1,6 +1,8 @@ import pytest from android.widget import ScrollView, TableLayout, TextView +from toga_android.colors import native_color + from .base import SimpleProbe HEADER = "HEADER" @@ -30,18 +32,43 @@ def row_count(self): def column_count(self): return self._row_view(HEADER).getChildCount() - def assert_cell_content(self, row, col, value=None, icon=None, widget=None): + def assert_cell_content( + self, + row, + col, + value=None, + icon=None, + widget=None, + color=None, + background_color=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 + if color is not None: + assert self._cell_color(row, col) == native_color(color) + if background_color is not None: + assert self._cell_background_color(row, col) == native_color( + background_color + ) def _cell_text(self, row, col): tv = self._row_view(row).getChildAt(col) assert isinstance(tv, TextView) return str(tv.getText()) + def _cell_color(self, row, col): + tv = self._row_view(row).getChildAt(col) + assert isinstance(tv, TextView) + return tv.getTextColor() + + def _cell_background_color(self, row, col): + tv = self._row_view(row).getChildAt(col) + assert isinstance(tv, TextView) + return tv.getBackgroundColor() + def _row_view(self, row): if row == HEADER: row = 0 From ac4cbda4d876644240c057b08d43d97e79b6f555 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 10:01:51 +0000 Subject: [PATCH 04/17] Update changelog. --- changes/4258.feature.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/4258.feature.md diff --git a/changes/4258.feature.md b/changes/4258.feature.md new file mode 100644 index 0000000000..800320b212 --- /dev/null +++ b/changes/4258.feature.md @@ -0,0 +1 @@ +The `Table` and `Tree` widgets on Cocoa, Qt and Android can now specify colors to use for individual cells based on the data contained in the cell by writing an appropriate `Column` subclass. From 516eab30e35afebf3ab7955bd6e2e8e7832e6d31 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 10:30:35 +0000 Subject: [PATCH 05/17] Skip tree tests on backends where it is not implemented. --- testbed/tests/widgets/test_tree.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 84823acb31..a3d60372e6 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -968,7 +968,7 @@ def background_color(self, row): @pytest.fixture async def colored_widget(source, on_select_handler, on_activate_handler): - skip_on_platforms("iOS") + skip_on_platforms("iOS", "android", "windows") return toga.Tree( [ ColorTestColumn("A"), From 345a687c0291c7db220734bf0df1ebf16d2166b8 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 11:05:26 +0000 Subject: [PATCH 06/17] Android tables support colors. --- android/tests_backend/widgets/table.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py index ab7c4d6b46..86d544902e 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -13,6 +13,7 @@ class TableProbe(SimpleProbe): supports_icons = False supports_keyboard_shortcuts = False supports_widgets = False + supports_colors = True column_proportion_tolerance = 35 def __init__(self, widget): @@ -45,7 +46,8 @@ def assert_cell_content( if widget: pytest.skip("This backend doesn't support widgets in Tables") else: - assert self._cell_text(row, col) == value + if value is not None: + assert self._cell_text(row, col) == value assert icon is None if color is not None: assert self._cell_color(row, col) == native_color(color) From dca8ed113b34f5f59213e5f374bb37bf75247a39 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 13:16:22 +0000 Subject: [PATCH 07/17] Add support for text_align on columns in Cocoa, Qt and Android --- android/src/toga_android/widgets/label.py | 23 ++++++----- android/src/toga_android/widgets/table.py | 8 +++- android/tests_backend/widgets/table.py | 15 +++++-- changes/4258.feature.md | 2 +- cocoa/src/toga_cocoa/widgets/table.py | 7 ++++ cocoa/src/toga_cocoa/widgets/tree.py | 7 ++++ cocoa/tests_backend/widgets/table.py | 8 +++- cocoa/tests_backend/widgets/tree.py | 8 +++- core/src/toga/sources/columns.py | 25 +++++++++++ core/tests/sources/test_columns.py | 36 ++++++++++++++++ examples/table_columns/table_columns/app.py | 8 +++- qt/src/toga_qt/widgets/table.py | 8 +++- qt/src/toga_qt/widgets/tree.py | 7 ++++ qt/tests_backend/widgets/table.py | 10 ++++- qt/tests_backend/widgets/tree.py | 10 ++++- testbed/tests/widgets/test_table.py | 46 ++++++++++++--------- testbed/tests/widgets/test_tree.py | 42 +++++++++++-------- 17 files changed, 210 insertions(+), 60 deletions(-) diff --git a/android/src/toga_android/widgets/label.py b/android/src/toga_android/widgets/label.py index 9c809641bf..c6e31ae6af 100644 --- a/android/src/toga_android/widgets/label.py +++ b/android/src/toga_android/widgets/label.py @@ -18,6 +18,18 @@ def set_textview_font(textview, font, default_typeface, default_size): textview.setTextSize(TypedValue.COMPLEX_UNIT_PX, font.size(default=default_size)) +def set_alignment(textview, value, vertical_gravity): + # Justified text wasn't added until API level 26. + # We only run the test suite on API 31, so we need to disable branch coverage. + if Build.VERSION.SDK_INT >= 26: # pragma: no branch + textview.setJustificationMode( + Layout.JUSTIFICATION_MODE_INTER_WORD + if value == JUSTIFY + else Layout.JUSTIFICATION_MODE_NONE + ) + textview.setGravity(vertical_gravity | android_text_align(value)) + + class TextViewWidget(Widget): def cache_textview_defaults(self): self._default_text_color = self.native.getCurrentTextColor() @@ -36,16 +48,7 @@ def set_color(self, value): self.native.setTextColor(native_color(value)) def set_textview_alignment(self, value, vertical_gravity): - # Justified text wasn't added until API level 26. - # We only run the test suite on API 31, so we need to disable branch coverage. - if Build.VERSION.SDK_INT >= 26: # pragma: no branch - self.native.setJustificationMode( - Layout.JUSTIFICATION_MODE_INTER_WORD - if value == JUSTIFY - else Layout.JUSTIFICATION_MODE_NONE - ) - - self.native.setGravity(vertical_gravity | android_text_align(value)) + set_alignment(self.native, value, vertical_gravity) class Label(TextViewWidget): diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 5d67ebc010..757b3a5470 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -8,7 +8,7 @@ from ..colors import native_color from .base import Widget -from .label import set_textview_font +from .label import set_alignment, set_textview_font class TogaOnClickListener(dynamic_proxy(View.OnClickListener)): @@ -150,8 +150,10 @@ def create_table_row(self, row_index): ) text_view = TextView(self._native_activity) text_view.setText(toga_column.text(data_row, missing_value)) + text_align = toga_column.text_align(data_row) color = toga_column.color(data_row) background_color = toga_column.background_color(data_row) + if color is not None: text_view.setTextColor(native_color(color)) if background_color is not None: @@ -166,8 +168,10 @@ def create_table_row(self, row_index): TableRow.LayoutParams.MATCH_PARENT, TableRow.LayoutParams.WRAP_CONTENT ) text_view_params.setMargins(10, 5, 10, 5) # left, top, right, bottom - text_view_params.gravity = Gravity.START + text_view_params.gravity = Gravity.FILL_HORIZONTAL text_view.setLayoutParams(text_view_params) + if text_align is not None: + set_alignment(text_view, text_align, Gravity.CENTER_VERTICAL) table_row.addView(text_view) return table_row diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py index 86d544902e..73354ac3d9 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -2,6 +2,7 @@ from android.widget import ScrollView, TableLayout, TextView from toga_android.colors import native_color +from toga_android.widgets.base import android_text_align from .base import SimpleProbe @@ -13,7 +14,7 @@ class TableProbe(SimpleProbe): supports_icons = False supports_keyboard_shortcuts = False supports_widgets = False - supports_colors = True + supports_styles = True column_proportion_tolerance = 35 def __init__(self, widget): @@ -40,6 +41,7 @@ def assert_cell_content( value=None, icon=None, widget=None, + text_align=None, color=None, background_color=None, ): @@ -49,6 +51,8 @@ def assert_cell_content( if value is not None: assert self._cell_text(row, col) == value assert icon is None + if text_align is not None: + assert self._cell_gravity(row, col) & android_text_align(text_align) if color is not None: assert self._cell_color(row, col) == native_color(color) if background_color is not None: @@ -64,12 +68,17 @@ def _cell_text(self, row, col): def _cell_color(self, row, col): tv = self._row_view(row).getChildAt(col) assert isinstance(tv, TextView) - return tv.getTextColor() + return tv.getCurrentTextColor() def _cell_background_color(self, row, col): tv = self._row_view(row).getChildAt(col) assert isinstance(tv, TextView) - return tv.getBackgroundColor() + return tv.getBackground().getColor() + + def _cell_gravity(self, row, col): + tv = self._row_view(row).getChildAt(col) + assert isinstance(tv, TextView) + return tv.getGravity() def _row_view(self, row): if row == HEADER: diff --git a/changes/4258.feature.md b/changes/4258.feature.md index 800320b212..029117eb58 100644 --- a/changes/4258.feature.md +++ b/changes/4258.feature.md @@ -1 +1 @@ -The `Table` and `Tree` widgets on Cocoa, Qt and Android can now specify colors to use for individual cells based on the data contained in the cell by writing an appropriate `Column` subclass. +The `Table` and `Tree` widgets on Cocoa, Qt and Android can now specify colors and text alignment to use for individual cells based on the data contained in the cell by writing an appropriate `Column` subclass. diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 1c98efa7cf..218fdfa2ea 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -10,6 +10,7 @@ NSTableView, NSTableViewAnimation, NSTableViewColumnAutoresizingStyle, + NSTextAlignment, ) from ..colors import native_color @@ -44,6 +45,7 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): icon = column.toga_column.icon(data_row) text = column.toga_column.text(data_row, self.interface.missing_value) + text_align = column.toga_column.text_align(data_row) color = column.toga_column.color(data_row) background_color = column.toga_column.background_color(data_row) @@ -63,6 +65,11 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): else: tcv.setImage(None) + if text_align is not None: + tcv.textField.alignment = NSTextAlignment(text_align) + else: + tcv.textField.alignment = self.alignment + if color is not None: tcv.textField.textColor = native_color(color) else: diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 3cbc30d1a8..6621b7052d 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -10,6 +10,7 @@ NSTableColumn, NSTableViewAnimation, NSTableViewColumnAutoresizingStyle, + NSTextAlignment, ) from toga_cocoa.widgets.base import Widget from toga_cocoa.widgets.internal.cells import TogaIconView @@ -65,6 +66,7 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): return widget._impl.native icon = column.toga_column.icon(node) text = column.toga_column.text(node, self.interface.missing_value) + text_align = column.toga_column.text_align(node) color = column.toga_column.color(node) background_color = column.toga_column.background_color(node) @@ -87,6 +89,11 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): else: tcv.setImage(None) + if text_align is not None: + tcv.textField.alignment = NSTextAlignment(text_align) + else: + tcv.textField.alignment = self.alignment + if color is not None: tcv.textField.textColor = native_color(color) else: diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index 613e78f808..e455ef09fc 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -3,7 +3,7 @@ from toga_cocoa.colors import native_color from toga_cocoa.keys import NSEventModifierFlagCommand -from toga_cocoa.libs import NSEventType, NSScrollView, NSTableView +from toga_cocoa.libs import NSEventType, NSScrollView, NSTableView, NSTextAlignment from .base import SimpleProbe from .properties import toga_color @@ -15,7 +15,7 @@ class TableProbe(SimpleProbe): supports_keyboard_shortcuts = True supports_keyboard_boundary_shortcuts = False supports_widgets = True - supports_colors = True + supports_styles = True def __init__(self, widget): super().__init__(widget) @@ -53,6 +53,7 @@ def assert_cell_content( icon=None, widget=None, color=None, + text_align=None, background_color=None, ): view = self.native_table.tableView( @@ -70,6 +71,9 @@ def assert_cell_content( else: assert view.imageView.image is None + if text_align: + assert view.textField.alignment == NSTextAlignment(text_align) + if color: assert view.textField.textColor == native_color(color) diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index 8454c32ba4..0b8d7fb05d 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -5,7 +5,7 @@ from toga_cocoa.colors import native_color from toga_cocoa.keys import NSEventModifierFlagCommand -from toga_cocoa.libs import NSEventType, NSOutlineView, NSScrollView +from toga_cocoa.libs import NSEventType, NSOutlineView, NSScrollView, NSTextAlignment from .base import SimpleProbe from .properties import toga_color @@ -15,7 +15,7 @@ class TreeProbe(SimpleProbe): native_class = NSScrollView supports_keyboard_shortcuts = True supports_widgets = True - supports_colors = True + supports_styles = True def __init__(self, widget): super().__init__(widget) @@ -82,6 +82,7 @@ def assert_cell_content( value=None, icon=None, widget=None, + text_align=None, color=None, background_color=None, ): @@ -100,6 +101,9 @@ def assert_cell_content( else: assert view.imageView.image is None + if text_align: + assert view.textField.alignment == NSTextAlignment(text_align) + if color: assert view.textField.textColor == native_color(color) diff --git a/core/src/toga/sources/columns.py b/core/src/toga/sources/columns.py index bd0de17a34..b1ab0efd6d 100644 --- a/core/src/toga/sources/columns.py +++ b/core/src/toga/sources/columns.py @@ -46,6 +46,19 @@ def icon(self, row: Any) -> Icon | None: :returns: The icon to display, or None if no Icon. """ + @abstractmethod + def text_align(self, row: Any) -> str | None: + """Get the text alignment use for the row in this column. + + This should return one of "left", "right", "center", "justify" or + None. If the return value is None the text will align according + to the style of the widget. + + :param row: A row object from the underlying Source. + :returns: The text alignment, or None. + """ + return None + @abstractmethod def color(self, row: Any) -> Color | None: """Get the color use for the row in this column. @@ -135,6 +148,18 @@ def icon(self, row: Any) -> Icon | None: """ return None + def text_align(self, row: Any) -> str | None: + """Get the text alignment use for the row in this column. + + This should return one of "left", "right", "center", "justify" or + None. The default behaviour is to return None, which will cause + text to align according to the style of the widget. + + :param row: A row object from the underlying Source. + :returns: The text alignment, or None. + """ + return None + def color(self, row: Any) -> Color | None: """Get the color use for the row in this column. diff --git a/core/tests/sources/test_columns.py b/core/tests/sources/test_columns.py index 794a2f6ca7..0f87a15c8a 100644 --- a/core/tests/sources/test_columns.py +++ b/core/tests/sources/test_columns.py @@ -1,5 +1,9 @@ +from typing import Any + import pytest +from toga.colors import rgb +from toga.constants import RIGHT from toga.icons import Icon from toga.sources import AccessorColumn, Column from toga.sources.list_source import Row @@ -38,6 +42,20 @@ def value(self, row): return row +class StyleColumn(Column): + def value(self, row): + return row + + def text_align(self, row: Any): + return RIGHT + + def color(self, row: Any): + return rgb(255, 0, 0) + + def background_color(self, row: Any): + return rgb(255, 0, 0) + + LABEL_WIDGET = Label("Test") @@ -57,6 +75,7 @@ def test_column_abc(heading, heading_property): assert column.text(dummy_row) is None assert column.text(dummy_row, "default") == "default" assert column.icon(dummy_row) is None + assert column.text_align(dummy_row) is None assert column.color(dummy_row) is None assert column.background_color(dummy_row) is None assert column.widget(dummy_row) is None @@ -71,11 +90,27 @@ def test_column_subclass(): assert column.text(dummy_row) == "('row',)" assert column.text(dummy_row, "default") == "('row',)" assert column.icon(dummy_row) is None + assert column.text_align(dummy_row) is None assert column.color(dummy_row) is None assert column.background_color(dummy_row) is None assert column.widget(dummy_row) is None +def test_column_style(): + dummy_row = ("row",) + column = StyleColumn("test") + + assert column.heading == "test" + assert column.value(dummy_row) == ("row",) + assert column.text(dummy_row) == "('row',)" + assert column.text(dummy_row, "default") == "('row',)" + assert column.icon(dummy_row) is None + assert column.text_align(dummy_row) is RIGHT + assert column.color(dummy_row) == rgb(255, 0, 0) + assert column.background_color(dummy_row) == rgb(255, 0, 0) + assert column.widget(dummy_row) is None + + @pytest.mark.parametrize( "heading, accessor, heading_property, accessor_property", [ @@ -265,6 +300,7 @@ def test_accessor_column_values(row, value, text, icon, widget): column = AccessorColumn(None, "x") assert column.value(row) == value + assert column.text_align(row) is None assert column.color(row) is None assert column.background_color(row) is None assert column.widget(row) == widget diff --git a/examples/table_columns/table_columns/app.py b/examples/table_columns/table_columns/app.py index 567b9755d9..4a85528759 100644 --- a/examples/table_columns/table_columns/app.py +++ b/examples/table_columns/table_columns/app.py @@ -11,7 +11,7 @@ import toga from toga.colors import rgb -from toga.constants import COLUMN +from toga.constants import COLUMN, RIGHT from toga.sources import AccessorColumn, Column @@ -50,6 +50,12 @@ def icon(self, row): else: return self.red + def text_align(self, row): + value = self.value(row) + if isinstance(value, (int, float)): + return RIGHT + return super().text_align(row) + def color(self, row): value = self.value(row) if value is None: diff --git a/qt/src/toga_qt/widgets/table.py b/qt/src/toga_qt/widgets/table.py index 84b9df0eb8..897db4d9f6 100644 --- a/qt/src/toga_qt/widgets/table.py +++ b/qt/src/toga_qt/widgets/table.py @@ -6,9 +6,11 @@ from PySide6.QtWidgets import QHeaderView, QTableView from travertino.size import at_least +from toga.constants import CENTER from toga.sources import ListSource +from toga_qt.colors import native_color +from toga_qt.libs import qt_text_align -from ..colors import native_color from .base import Widget logger = logging.getLogger(__name__) @@ -121,6 +123,10 @@ def data( return icon._impl.native elif role == Qt.ItemDataRole.DisplayRole: return column.text(row, self._missing_value) + elif role == Qt.ItemDataRole.TextAlignmentRole: + text_align = column.text_align(row) + if text_align is not None: + return qt_text_align(text_align, CENTER) elif role == Qt.ItemDataRole.ForegroundRole: color = column.color(row) if color is not None: diff --git a/qt/src/toga_qt/widgets/tree.py b/qt/src/toga_qt/widgets/tree.py index df3c439a54..c29ca6b23c 100644 --- a/qt/src/toga_qt/widgets/tree.py +++ b/qt/src/toga_qt/widgets/tree.py @@ -11,7 +11,9 @@ from PySide6.QtWidgets import QHeaderView, QTreeView from travertino.size import at_least +from toga.constants import CENTER from toga_qt.colors import native_color +from toga_qt.libs import qt_text_align from .base import Widget @@ -198,6 +200,11 @@ def data( return icon._impl.native elif role == Qt.ItemDataRole.DisplayRole: return column.text(node, self._missing_value) + elif role == Qt.ItemDataRole.TextAlignmentRole: + text_align = column.text_align(node) + if text_align is not None: + print("here", text_align, qt_text_align(text_align, CENTER)) + return qt_text_align(text_align, CENTER) elif role == Qt.ItemDataRole.ForegroundRole: color = column.color(node) if color is not None: diff --git a/qt/tests_backend/widgets/table.py b/qt/tests_backend/widgets/table.py index 09d45fd2be..b6a81e27a0 100644 --- a/qt/tests_backend/widgets/table.py +++ b/qt/tests_backend/widgets/table.py @@ -4,6 +4,7 @@ from PySide6.QtCore import QAbstractTableModel, QItemSelection, QItemSelectionModel, Qt from PySide6.QtWidgets import QTableView from toga_qt.colors import native_color +from toga_qt.libs import qt_text_align from .base import SimpleProbe @@ -13,7 +14,7 @@ class TableProbe(SimpleProbe): supports_icons = 2 # All columns supports_keyboard_shortcuts = False supports_widgets = False - supports_colors = True + supports_styles = True def __init__(self, widget): super().__init__(widget) @@ -53,6 +54,7 @@ def assert_cell_content( value=None, icon=None, widget=None, + text_align=None, color=None, background_color=None, ): @@ -78,6 +80,12 @@ def assert_cell_content( ).cacheKey() ) + if text_align: + assert qt_text_align(color) == self.native_model.data( + index, + Qt.ItemDataRole.TextAlignmentRole, + ) + if color: assert native_color(color) == self.native_model.data( index, diff --git a/qt/tests_backend/widgets/tree.py b/qt/tests_backend/widgets/tree.py index 0131e49059..958081b53a 100644 --- a/qt/tests_backend/widgets/tree.py +++ b/qt/tests_backend/widgets/tree.py @@ -11,6 +11,7 @@ ) from PySide6.QtWidgets import QTreeView from toga_qt.colors import native_color +from toga_qt.libs import qt_text_align from .base import SimpleProbe @@ -19,7 +20,7 @@ class TreeProbe(SimpleProbe): native_class = QTreeView supports_keyboard_shortcuts = False supports_widgets = False - supports_colors = True + supports_styles = True selection_cleared_on_insert_delete = True collapse_on_insert_delete = True @@ -78,6 +79,7 @@ def assert_cell_content( value=None, icon=None, widget=None, + text_align=None, color=None, background_color=None, ): @@ -103,6 +105,12 @@ def assert_cell_content( ).cacheKey() ) + if text_align: + assert qt_text_align(color) == self.native_model.data( + index, + Qt.ItemDataRole.TextAlignmentRole, + ) + if color: assert native_color(color) == self.native_model.data( index, diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index 2cedda6d34..eb0af0473a 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -5,6 +5,7 @@ import toga from toga.colors import rgb +from toga.constants import RIGHT from toga.sources import AccessorColumn, ListListener, ListSource from toga.style.pack import Pack @@ -690,7 +691,14 @@ def test_list_listener(widget): assert isinstance(widget._impl, ListListener) -class ColorTestColumn(AccessorColumn): +class StyledTestColumn(AccessorColumn): + def text_align(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return RIGHT + else: + return None + def color(self, row): value = self.value(row) if isinstance(value, (float, int)): @@ -710,13 +718,13 @@ def background_color(self, row): @pytest.fixture -async def colored_widget(source, on_select_handler, on_activate_handler): +async def styled_widget(source, on_select_handler, on_activate_handler): skip_on_platforms("iOS") return toga.Table( [ - ColorTestColumn("A"), - ColorTestColumn("B"), - ColorTestColumn("C"), + StyledTestColumn("A"), + StyledTestColumn("B"), + StyledTestColumn("C"), ], data=source, missing_value="MISSING!", @@ -727,12 +735,12 @@ async def colored_widget(source, on_select_handler, on_activate_handler): @pytest.fixture -async def color_probe(main_window, colored_widget): +async def style_probe(main_window, styled_widget): old_content = main_window.content - box = toga.Box(children=[colored_widget]) + box = toga.Box(children=[styled_widget]) main_window.content = box - probe = get_probe(colored_widget) + probe = get_probe(styled_widget) await probe.redraw("Constructing color Table probe") probe.assert_container(box) yield probe @@ -740,12 +748,12 @@ async def color_probe(main_window, colored_widget): main_window.content = old_content -async def test_cell_color(colored_widget, color_probe): - "A cell can have colors" - if not getattr(color_probe, "supports_colors", False): - pytest.skip("Backend does not support colors in cells.") +async def test_cell_style(styled_widget, style_probe): + "A cell can have data-driven styles" + if not getattr(style_probe, "supports_styles", False): + pytest.skip("Backend does not support styled cells.") - colored_widget.data = [ + styled_widget.data = [ { # A number from -1 to 1 "a": (i - 25) / 25, @@ -754,16 +762,16 @@ async def test_cell_color(colored_widget, color_probe): } for i in range(50) ] - await color_probe.redraw("Table has data with colors") + await style_probe.redraw("Table has data with colors") - color_probe.assert_cell_content( - 0, 0, "-1.0", color=rgb(255, 0, 0), background_color=None + style_probe.assert_cell_content( + 0, 0, "-1.0", text_align=RIGHT, color=rgb(255, 0, 0), background_color=None ) - color_probe.assert_cell_content( + style_probe.assert_cell_content( 0, 1, "B0", color=None, background_color=rgb(255, 0, 0) ) - color_probe.assert_cell_content( - 25, 0, "0.0", color=rgb(0, 128, 0), background_color=None + style_probe.assert_cell_content( + 25, 0, "0.0", text_align=RIGHT, color=rgb(0, 128, 0), background_color=None ) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index a3d60372e6..03da479b32 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -5,6 +5,7 @@ import toga from toga.colors import rgb +from toga.constants import RIGHT from toga.sources import AccessorColumn, ListListener, TreeListener, TreeSource from toga.style.pack import Pack @@ -947,7 +948,14 @@ def test_tree_listener(widget): assert isinstance(widget._impl, TreeListener) -class ColorTestColumn(AccessorColumn): +class StyledTestColumn(AccessorColumn): + def text_align(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return RIGHT + else: + return None + def color(self, row): value = self.value(row) if isinstance(value, (float, int)): @@ -967,13 +975,13 @@ def background_color(self, row): @pytest.fixture -async def colored_widget(source, on_select_handler, on_activate_handler): +async def styled_widget(source, on_select_handler, on_activate_handler): skip_on_platforms("iOS", "android", "windows") return toga.Tree( [ - ColorTestColumn("A"), - ColorTestColumn("B"), - ColorTestColumn("C"), + StyledTestColumn("A"), + StyledTestColumn("B"), + StyledTestColumn("C"), ], data=source, missing_value="MISSING!", @@ -984,12 +992,12 @@ async def colored_widget(source, on_select_handler, on_activate_handler): @pytest.fixture -async def color_probe(main_window, colored_widget): +async def style_probe(main_window, styled_widget): old_content = main_window.content - box = toga.Box(children=[colored_widget]) + box = toga.Box(children=[styled_widget]) main_window.content = box - probe = get_probe(colored_widget) + probe = get_probe(styled_widget) await probe.redraw("Constructing color Table probe") probe.assert_container(box) yield probe @@ -997,12 +1005,12 @@ async def color_probe(main_window, colored_widget): main_window.content = old_content -async def test_cell_color(colored_widget, color_probe): +async def test_cell_color(styled_widget, style_probe): "A cell can have colors" - if not getattr(color_probe, "supports_colors", False): + if not getattr(style_probe, "supports_styles", False): pytest.skip("Backend does not support colors in cells.") - colored_widget.data = [ + styled_widget.data = [ ( { # A number from -1 to 1 @@ -1014,16 +1022,16 @@ async def test_cell_color(colored_widget, color_probe): ) for i in range(50) ] - await color_probe.redraw("Tree has data with colors") + await style_probe.redraw("Tree has data with colors") - color_probe.assert_cell_content( - (0,), 0, "-1.0", color=rgb(255, 0, 0), background_color=None + style_probe.assert_cell_content( + (0,), 0, "-1.0", text_align=RIGHT, color=rgb(255, 0, 0), background_color=None ) - color_probe.assert_cell_content( + style_probe.assert_cell_content( (0,), 1, "B0", color=None, background_color=rgb(255, 0, 0) ) - color_probe.assert_cell_content( - (25,), 0, "0.0", color=rgb(0, 128, 0), background_color=None + style_probe.assert_cell_content( + (25,), 0, "0.0", text_align=RIGHT, color=rgb(0, 128, 0), background_color=None ) From afea442847ebb558a0d8e7dc6c84d02e3b668159 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 13:30:34 +0000 Subject: [PATCH 08/17] Fix Qt table and tree probes. --- qt/tests_backend/widgets/table.py | 4 +++- qt/tests_backend/widgets/tree.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/qt/tests_backend/widgets/table.py b/qt/tests_backend/widgets/table.py index b6a81e27a0..d294a9ecc8 100644 --- a/qt/tests_backend/widgets/table.py +++ b/qt/tests_backend/widgets/table.py @@ -6,6 +6,8 @@ from toga_qt.colors import native_color from toga_qt.libs import qt_text_align +from toga.constants import CENTER + from .base import SimpleProbe @@ -81,7 +83,7 @@ def assert_cell_content( ) if text_align: - assert qt_text_align(color) == self.native_model.data( + assert qt_text_align(text_align, CENTER) == self.native_model.data( index, Qt.ItemDataRole.TextAlignmentRole, ) diff --git a/qt/tests_backend/widgets/tree.py b/qt/tests_backend/widgets/tree.py index 958081b53a..201f7733c2 100644 --- a/qt/tests_backend/widgets/tree.py +++ b/qt/tests_backend/widgets/tree.py @@ -13,6 +13,8 @@ from toga_qt.colors import native_color from toga_qt.libs import qt_text_align +from toga.constants import CENTER + from .base import SimpleProbe @@ -106,7 +108,7 @@ def assert_cell_content( ) if text_align: - assert qt_text_align(color) == self.native_model.data( + assert qt_text_align(text_align, CENTER) == self.native_model.data( index, Qt.ItemDataRole.TextAlignmentRole, ) From a2f5419fe675a1d991430e3c57f794359c15957d Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Wed, 18 Mar 2026 23:22:23 +0000 Subject: [PATCH 09/17] Add data-driven font support for columns on Cocoa, Qt and Android. --- android/src/toga_android/widgets/table.py | 7 +- android/tests_backend/widgets/table.py | 11 ++ cocoa/src/toga_cocoa/widgets/table.py | 4 + cocoa/src/toga_cocoa/widgets/tree.py | 4 + cocoa/tests_backend/widgets/table.py | 6 +- cocoa/tests_backend/widgets/tree.py | 4 + core/src/toga/sources/columns.py | 171 ++++++++++++++++++++ core/tests/sources/test_columns.py | 32 +++- examples/table_columns/table_columns/app.py | 14 +- qt/src/toga_qt/widgets/table.py | 17 +- qt/src/toga_qt/widgets/tree.py | 17 +- qt/tests_backend/widgets/table.py | 7 + qt/tests_backend/widgets/tree.py | 7 + testbed/tests/widgets/test_table.py | 57 ++++++- testbed/tests/widgets/test_tree.py | 57 ++++++- 15 files changed, 389 insertions(+), 26 deletions(-) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 757b3a5470..56c5c4d722 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -153,14 +153,19 @@ def create_table_row(self, row_index): text_align = toga_column.text_align(data_row) color = toga_column.color(data_row) background_color = toga_column.background_color(data_row) + font = toga_column.font(data_row, self.interface) if color is not None: text_view.setTextColor(native_color(color)) if background_color is not None: text_view.setBackgroundColor(native_color(background_color)) + if font is None: + font_impl = self._font_impl + else: + font_impl = font._impl set_textview_font( text_view, - self._font_impl, + font_impl, text_view.getTypeface(), text_view.getTextSize(), ) diff --git a/android/tests_backend/widgets/table.py b/android/tests_backend/widgets/table.py index 73354ac3d9..754a791ddc 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -44,6 +44,7 @@ def assert_cell_content( text_align=None, color=None, background_color=None, + font=None, ): if widget: pytest.skip("This backend doesn't support widgets in Tables") @@ -59,6 +60,11 @@ def assert_cell_content( assert self._cell_background_color(row, col) == native_color( background_color ) + if font is not None: + assert self._cell_font(row, col) == ( + font._impl.typeface(), + font._impl.size(), + ) def _cell_text(self, row, col): tv = self._row_view(row).getChildAt(col) @@ -80,6 +86,11 @@ def _cell_gravity(self, row, col): assert isinstance(tv, TextView) return tv.getGravity() + def _cell_font(self, row, col): + tv = self._row_view(row).getChildAt(col) + assert isinstance(tv, TextView) + return tv.getTypeface(), tv.getTextSize() + def _row_view(self, row): if row == HEADER: row = 0 diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 218fdfa2ea..4e620130f9 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -48,6 +48,7 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): text_align = column.toga_column.text_align(data_row) color = column.toga_column.color(data_row) background_color = column.toga_column.background_color(data_row) + font = column.toga_column.font(data_row, self.interface) # creates a NSTableCellView from interface-builder template (does not exist) # or reuses an existing view which is currently not needed for painting @@ -80,6 +81,9 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): else: tcv.textField.drawsBackground = False + if font is not None: + tcv.textField.font = font._impl.native + return tcv @objc_method diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 6621b7052d..ef43b2a1f6 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -69,6 +69,7 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): text_align = column.toga_column.text_align(node) color = column.toga_column.color(node) background_color = column.toga_column.background_color(node) + font = column.toga_column.font(node, self.interface) # creates a NSTableCellView from interface-builder template (does not exist) # or reuses an existing view which is currently not needed for painting @@ -104,6 +105,9 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): else: tcv.textField.drawsBackground = False + if font is not None: + tcv.textField.font = font._impl.native + return tcv # 2023-06-29: Commented out this method because it appears to be a diff --git a/cocoa/tests_backend/widgets/table.py b/cocoa/tests_backend/widgets/table.py index e455ef09fc..53d9179e3e 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -24,7 +24,7 @@ def __init__(self, widget): @property def font(self): - pytest.skip("Font changes not implemented for Tree on macOS") + pytest.skip("Font changes not implemented for Table on macOS") @property def background_color(self): @@ -55,6 +55,7 @@ def assert_cell_content( color=None, text_align=None, background_color=None, + font=None, ): view = self.native_table.tableView( self.native_table, @@ -80,6 +81,9 @@ def assert_cell_content( if background_color: assert view.backgroundColor == native_color(background_color) + if font: + assert view.textField.font == font._impl.native + @property def max_scroll_position(self): return int(self.native.documentView.bounds.size.height) - int( diff --git a/cocoa/tests_backend/widgets/tree.py b/cocoa/tests_backend/widgets/tree.py index 0b8d7fb05d..6c1d0b1156 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -85,6 +85,7 @@ def assert_cell_content( text_align=None, color=None, background_color=None, + font=None, ): view = self.native_tree.outlineView( self.native_tree, @@ -110,6 +111,9 @@ def assert_cell_content( if background_color: assert view.backgroundColor == native_color(background_color) + if font: + assert view.textField.font == font._impl.native + @property def max_scroll_position(self): return int(self.native.documentView.bounds.size.height) - int( diff --git a/core/src/toga/sources/columns.py b/core/src/toga/sources/columns.py index b1ab0efd6d..e78b186e48 100644 --- a/core/src/toga/sources/columns.py +++ b/core/src/toga/sources/columns.py @@ -3,6 +3,8 @@ from typing import Any, Generic, Protocol, TypeVar, runtime_checkable from ..colors import Color +from ..constants import SYSTEM +from ..fonts import Font, UnknownFontError from ..icons import Icon from ..widgets.base import Widget from .accessors import build_accessors, to_accessor @@ -82,6 +84,104 @@ def background_color(self, row: Any) -> Color | None: :returns: The background color, or None if the default color is to be used. """ + @abstractmethod + def font_family(self, row: Any) -> list[str] | None: + """Get the font family use for the row in this column. + + The value returned should be a list of acceptable font + family names, or None. + + :param row: A row object from the underlying Source. + :returns: The acceptable font family names, or None. + """ + return None + + @abstractmethod + def font_style(self, row: Any) -> str | None: + """Get the font style use for the row in this column. + + The value should be one of "normal", "italic", "oblique", + or None. + + :param row: A row object from the underlying Source. + :returns: The style of the font, or None. + """ + return None + + @abstractmethod + def font_variant(self, row: Any) -> str | None: + """Get the font variant use for the row in this column. + + The value should be one of "normal", "small_caps" or None. + + Note: Windows and Android do not support "small_caps". + + :param row: A row object from the underlying Source. + :returns: The font variant, or None. + """ + return None + + @abstractmethod + def font_weight(self, row: Any) -> str | None: + """Get the font weight use for the row in this column. + + The value should be one of "normal", "bold" or None. + + :param row: A row object from the underlying Source. + :returns: The weight of the font, or None. + """ + return None + + @abstractmethod + def font_size(self, row: Any) -> int | None: + """Get the font size to use for the row in this column. + + The value should a positive integer or None. + + :param row: A row object from the underlying Source. + :returns: The size of the font, or None. + """ + return None + + def font(self, row: Any, widget: Any) -> Font | None: + """Get the Font object to use for the row in this column. + + The value should a Font or None. The default implementation + returns takes the Table or Tree widget's font and adjusts it + according to the other font methods. + + :param row: A row object from the underlying Source. + :param widget: The Table or Tree widget the column is displayed in. + :returns: The size of the font, or None. + """ + family = self.font_family(row) + style = self.font_style(row) + variant = self.font_variant(row) + weight = self.font_weight(row) + size = self.font_size(row) + + font = widget.style.font + font_args = { + "style": style if style is not None else font[0], + "variant": variant if variant is not None else font[1], + "weight": weight if weight is not None else font[2], + "size": size if size is not None else font[3], + } + font_family = family if family is not None else font[4] + + for family in font_family: + try: + return Font(family, **font_args) + except UnknownFontError: + pass + else: + try: + return Font(SYSTEM, **font_args) + except UnknownFontError: + pass + + return None + def widget(self, row: Row[Value]) -> Widget | None: """Get a widget from the Row or Node of a ListSource or TreeSource. @@ -180,6 +280,77 @@ def background_color(self, row: Any) -> Color | None: """ return None + def font_family(self, row: Any) -> list[str] | None: + """Get the font family use for the row in this column. + + The value returned should be a list of acceptable font + family names, or None. The default implementation returns + None. + + :param row: A row object from the underlying Source. + :returns: The acceptable font family names, or None. + """ + return None + + def font_style(self, row: Any) -> str | None: + """Get the font style use for the row in this column. + + The value should be one of "normal", "italic", "oblique", + or None. The default implementation returns None. + + :param row: A row object from the underlying Source. + :returns: The style of the font, or None. + """ + return None + + def font_variant(self, row: Any) -> str | None: + """Get the font variant use for the row in this column. + + The value should be one of "normal", "small_caps" or None. + The default implementation returns None. + + Note: Windows and Android do not support "small_caps". + + :param row: A row object from the underlying Source. + :returns: The font variant, or None. + """ + return None + + def font_weight(self, row: Any) -> str | None: + """Get the font weight use for the row in this column. + + The value should be one of "normal", "bold" or None. + The default implementation returns None. + + :param row: A row object from the underlying Source. + :returns: The weight of the font, or None. + """ + return None + + def font_size(self, row: Any) -> int | None: + """Get the font size to use for the row in this column. + + The value should a positive integer or None. + The default implementation returns None. + + :param row: A row object from the underlying Source. + :returns: The size of the font, or None. + """ + return None + + def font(self, row: Any, widget: Any) -> Font | None: + """Get the Font object to use for the row in this column. + + The value should a Font or None. The default implementation + returns takes the Table or Tree widget's font and adjusts it + according to the other font methods. + + :param row: A row object from the underlying Source. + :param widget: The Table or Tree widget the column is displayed in. + :returns: The size of the font, or None. + """ + return super().font(row, widget) + def widget(self, row: Any) -> Widget | None: """Get a widget from the Row or Node of a ListSource or TreeSource. diff --git a/core/tests/sources/test_columns.py b/core/tests/sources/test_columns.py index 0f87a15c8a..5f2adc79ab 100644 --- a/core/tests/sources/test_columns.py +++ b/core/tests/sources/test_columns.py @@ -3,7 +3,7 @@ import pytest from toga.colors import rgb -from toga.constants import RIGHT +from toga.constants import BOLD, ITALIC, RIGHT, SERIF, SMALL_CAPS from toga.icons import Icon from toga.sources import AccessorColumn, Column from toga.sources.list_source import Row @@ -55,6 +55,21 @@ def color(self, row: Any): def background_color(self, row: Any): return rgb(255, 0, 0) + def font_family(self, row: Any): + return [SERIF, "Times New Roman"] + + def font_style(self, row: Any): + return ITALIC + + def font_variant(self, row: Any): + return SMALL_CAPS + + def font_weight(self, row: Any): + return BOLD + + def font_size(self, row: Any): + return 24 + LABEL_WIDGET = Label("Test") @@ -78,6 +93,11 @@ def test_column_abc(heading, heading_property): assert column.text_align(dummy_row) is None assert column.color(dummy_row) is None assert column.background_color(dummy_row) is None + assert column.font_family(dummy_row) is None + assert column.font_style(dummy_row) is None + assert column.font_variant(dummy_row) is None + assert column.font_weight(dummy_row) is None + assert column.font_size(dummy_row) is None assert column.widget(dummy_row) is None @@ -93,6 +113,11 @@ def test_column_subclass(): assert column.text_align(dummy_row) is None assert column.color(dummy_row) is None assert column.background_color(dummy_row) is None + assert column.font_family(dummy_row) is None + assert column.font_style(dummy_row) is None + assert column.font_variant(dummy_row) is None + assert column.font_weight(dummy_row) is None + assert column.font_size(dummy_row) is None assert column.widget(dummy_row) is None @@ -108,6 +133,11 @@ def test_column_style(): assert column.text_align(dummy_row) is RIGHT assert column.color(dummy_row) == rgb(255, 0, 0) assert column.background_color(dummy_row) == rgb(255, 0, 0) + assert column.font_family(dummy_row) == [SERIF, "Times New Roman"] + assert column.font_style(dummy_row) == ITALIC + assert column.font_variant(dummy_row) == SMALL_CAPS + assert column.font_weight(dummy_row) == BOLD + assert column.font_size(dummy_row) == 24 assert column.widget(dummy_row) is None diff --git a/examples/table_columns/table_columns/app.py b/examples/table_columns/table_columns/app.py index 4a85528759..847b428565 100644 --- a/examples/table_columns/table_columns/app.py +++ b/examples/table_columns/table_columns/app.py @@ -11,7 +11,7 @@ import toga from toga.colors import rgb -from toga.constants import COLUMN, RIGHT +from toga.constants import BOLD, COLUMN, ITALIC, RIGHT, SERIF from toga.sources import AccessorColumn, Column @@ -24,6 +24,16 @@ def value(self, row): def icon(self, row): return getattr(row, "icon", None) + def font_style(self, row): + if not hasattr(row, "icon"): + return ITALIC + return super().font_style(row) + + def font_weight(self, row): + if hasattr(row, "icon"): + return BOLD + return super().font_style(row) + class RatingColumn(AccessorColumn): """A column that displays the rating as a decimal with a red or green icon.""" @@ -211,6 +221,8 @@ def startup(self): ], data=self.bee_movies, flex=1, + font_family=[SERIF], + font_size=12, ) self.table2 = toga.Table( diff --git a/qt/src/toga_qt/widgets/table.py b/qt/src/toga_qt/widgets/table.py index 897db4d9f6..cbe109f366 100644 --- a/qt/src/toga_qt/widgets/table.py +++ b/qt/src/toga_qt/widgets/table.py @@ -23,11 +23,12 @@ class TableSourceModel(QAbstractTableModel): _source: ListSource | None headings: list[str] - def __init__(self, source, columns, missing_value, **kwargs): + def __init__(self, interface, **kwargs): super().__init__(**kwargs) - self._source = source - self._columns = columns - self._missing_value = missing_value + self._interface = interface + self._source = getattr(interface, "_data", None) + self._columns = interface._columns + self._missing_value = interface.missing_value def set_source(self, source): self.beginResetModel() @@ -135,6 +136,10 @@ def data( color = column.background_color(row) if color is not None: return native_color(color) + elif role == Qt.ItemDataRole.FontRole: + font = column.font(row, self._interface) + if font is not None: + return font._impl.native except Exception: # pragma: no cover logger.exception( f"Could not get data for row {row_index}, column {column_index}" @@ -169,9 +174,7 @@ def create(self): self._resizing_columns = False self.native_model = TableSourceModel( - getattr(self.interface, "_data", None), - self.interface._columns[:], - self.interface.missing_value, + self.interface, parent=self.native, ) self.native.setModel(self.native_model) diff --git a/qt/src/toga_qt/widgets/tree.py b/qt/src/toga_qt/widgets/tree.py index c29ca6b23c..880948158d 100644 --- a/qt/src/toga_qt/widgets/tree.py +++ b/qt/src/toga_qt/widgets/tree.py @@ -24,11 +24,12 @@ class TreeSourceModel(QAbstractItemModel): - def __init__(self, source, columns, missing_value, **kwargs): + def __init__(self, interface, **kwargs): super().__init__(**kwargs) - self._source = source - self._columns = columns - self._missing_value = missing_value + self._interface = interface + self._source = getattr(interface, "_data", None) + self._columns = interface.columns + self._missing_value = interface.missing_value def set_source(self, source): self.beginResetModel() @@ -213,6 +214,10 @@ def data( color = column.background_color(node) if color is not None: return native_color(color) + elif role == Qt.ItemDataRole.FontRole: + font = column.font(node, self._interface) + if font is not None: + return font._impl.native except Exception: # pragma: no cover logger.exception( f"Could not get data for node {node}, column {column_index}" @@ -246,9 +251,7 @@ def create(self): self.native = QTreeView() self.native_model = TreeSourceModel( - getattr(self.interface, "_data", None), - self.interface._columns[:], - self.interface.missing_value, + self.interface, parent=self.native, ) self.native.setModel(self.native_model) diff --git a/qt/tests_backend/widgets/table.py b/qt/tests_backend/widgets/table.py index d294a9ecc8..31ec32b2ba 100644 --- a/qt/tests_backend/widgets/table.py +++ b/qt/tests_backend/widgets/table.py @@ -59,6 +59,7 @@ def assert_cell_content( text_align=None, color=None, background_color=None, + font=None, ): if widget: pytest.skip("Qt doesn't support widgets in Tables") @@ -100,6 +101,12 @@ def assert_cell_content( Qt.ItemDataRole.BackgroundRole, ) + if font: + assert font._impl.native == self.native_model.data( + index, + Qt.ItemDataRole.FontRole, + ) + @property def max_scroll_position(self): return self.native.verticalScrollBar().maximum() * self.native.rowHeight(0) diff --git a/qt/tests_backend/widgets/tree.py b/qt/tests_backend/widgets/tree.py index 201f7733c2..e3dfd8c70d 100644 --- a/qt/tests_backend/widgets/tree.py +++ b/qt/tests_backend/widgets/tree.py @@ -84,6 +84,7 @@ def assert_cell_content( text_align=None, color=None, background_color=None, + font=None, ): if widget: pytest.skip("Qt doesn't support widgets in Trees") @@ -125,6 +126,12 @@ def assert_cell_content( Qt.ItemDataRole.BackgroundRole, ) + if font: + assert font._impl.native == self.native_model.data( + index, + Qt.ItemDataRole.FontRole, + ) + @property def max_scroll_position(self): return self.native.verticalScrollBar().maximum() * self.native.rowHeight(0) diff --git a/testbed/tests/widgets/test_table.py b/testbed/tests/widgets/test_table.py index eb0af0473a..9cb570696e 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -5,7 +5,7 @@ import toga from toga.colors import rgb -from toga.constants import RIGHT +from toga.constants import BOLD, ITALIC, RIGHT, SERIF, SMALL_CAPS, SYSTEM from toga.sources import AccessorColumn, ListListener, ListSource from toga.style.pack import Pack @@ -716,6 +716,36 @@ def background_color(self, row): else: return rgb(255, 0, 0) + def font_family(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return None + else: + return [SERIF] + + def font_style(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return ITALIC + else: + return None + + def font_variant(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return None + else: + return SMALL_CAPS + + def font_weight(self, row): + value = self.value(row) + if isinstance(value, (float, int)) and value >= 0: + return BOLD + return None + + def font_size(self, row): + return 18 + @pytest.fixture async def styled_widget(source, on_select_handler, on_activate_handler): @@ -764,14 +794,33 @@ async def test_cell_style(styled_widget, style_probe): ] await style_probe.redraw("Table has data with colors") + negative_number_font = toga.Font(SYSTEM, 18, style=ITALIC) + positive_number_font = toga.Font(SYSTEM, 18, style=ITALIC, weight=BOLD) + text_font = toga.Font(SERIF, 18, variant=SMALL_CAPS) + style_probe.assert_cell_content( - 0, 0, "-1.0", text_align=RIGHT, color=rgb(255, 0, 0), background_color=None + 0, + 0, + "-1.0", + text_align=RIGHT, + color=rgb(255, 0, 0), + font=negative_number_font, ) style_probe.assert_cell_content( - 0, 1, "B0", color=None, background_color=rgb(255, 0, 0) + 0, + 1, + "B0", + color=None, + background_color=rgb(255, 0, 0), + font=text_font, ) style_probe.assert_cell_content( - 25, 0, "0.0", text_align=RIGHT, color=rgb(0, 128, 0), background_color=None + 25, + 0, + "0.0", + text_align=RIGHT, + color=rgb(0, 128, 0), + font=positive_number_font, ) diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 03da479b32..8538110d80 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -5,7 +5,7 @@ import toga from toga.colors import rgb -from toga.constants import RIGHT +from toga.constants import BOLD, ITALIC, RIGHT, SERIF, SMALL_CAPS, SYSTEM from toga.sources import AccessorColumn, ListListener, TreeListener, TreeSource from toga.style.pack import Pack @@ -973,6 +973,36 @@ def background_color(self, row): else: return rgb(255, 0, 0) + def font_family(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return None + else: + return [SERIF] + + def font_style(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return ITALIC + else: + return None + + def font_variant(self, row): + value = self.value(row) + if isinstance(value, (float, int)): + return None + else: + return SMALL_CAPS + + def font_weight(self, row): + value = self.value(row) + if isinstance(value, (float, int)) and value >= 0: + return BOLD + return None + + def font_size(self, row): + return 18 + @pytest.fixture async def styled_widget(source, on_select_handler, on_activate_handler): @@ -1024,14 +1054,33 @@ async def test_cell_color(styled_widget, style_probe): ] await style_probe.redraw("Tree has data with colors") + negative_number_font = toga.Font(SYSTEM, 18, style=ITALIC) + positive_number_font = toga.Font(SYSTEM, 18, style=ITALIC, weight=BOLD) + text_font = toga.Font(SERIF, 18, variant=SMALL_CAPS) + style_probe.assert_cell_content( - (0,), 0, "-1.0", text_align=RIGHT, color=rgb(255, 0, 0), background_color=None + (0,), + 0, + "-1.0", + text_align=RIGHT, + color=rgb(255, 0, 0), + font=negative_number_font, ) style_probe.assert_cell_content( - (0,), 1, "B0", color=None, background_color=rgb(255, 0, 0) + (0,), + 1, + "B0", + color=None, + background_color=rgb(255, 0, 0), + font=text_font, ) style_probe.assert_cell_content( - (25,), 0, "0.0", text_align=RIGHT, color=rgb(0, 128, 0), background_color=None + (25,), + 0, + "0.0", + text_align=RIGHT, + color=rgb(0, 128, 0), + font=positive_number_font, ) From 739169495c105adf96ae4179b620e8ef4f7a0760 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 19 Mar 2026 07:09:57 +0000 Subject: [PATCH 10/17] Change font() method to take default font, rather than widget. --- android/src/toga_android/widgets/table.py | 2 +- cocoa/src/toga_cocoa/widgets/table.py | 2 +- cocoa/src/toga_cocoa/widgets/tree.py | 2 +- core/src/toga/sources/columns.py | 57 ++++++++++++++++------- core/tests/sources/test_columns.py | 17 ++++++- qt/src/toga_qt/widgets/table.py | 3 +- qt/src/toga_qt/widgets/tree.py | 3 +- 7 files changed, 62 insertions(+), 24 deletions(-) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 56c5c4d722..8de4c3e4d4 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -153,7 +153,7 @@ def create_table_row(self, row_index): text_align = toga_column.text_align(data_row) color = toga_column.color(data_row) background_color = toga_column.background_color(data_row) - font = toga_column.font(data_row, self.interface) + font = toga_column.font(data_row, self.interface.style.font) if color is not None: text_view.setTextColor(native_color(color)) diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 4e620130f9..f1d5dca393 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -48,7 +48,7 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): text_align = column.toga_column.text_align(data_row) color = column.toga_column.color(data_row) background_color = column.toga_column.background_color(data_row) - font = column.toga_column.font(data_row, self.interface) + font = column.toga_column.font(data_row, self.interface.style.font) # 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/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index ef43b2a1f6..18796ad306 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -69,7 +69,7 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): text_align = column.toga_column.text_align(node) color = column.toga_column.color(node) background_color = column.toga_column.background_color(node) - font = column.toga_column.font(node, self.interface) + font = column.toga_column.font(node, self.interface.style.font) # 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/core/src/toga/sources/columns.py b/core/src/toga/sources/columns.py index e78b186e48..cf72d8be08 100644 --- a/core/src/toga/sources/columns.py +++ b/core/src/toga/sources/columns.py @@ -3,7 +3,7 @@ from typing import Any, Generic, Protocol, TypeVar, runtime_checkable from ..colors import Color -from ..constants import SYSTEM +from ..constants import NORMAL, SYSTEM from ..fonts import Font, UnknownFontError from ..icons import Icon from ..widgets.base import Widget @@ -143,16 +143,27 @@ def font_size(self, row: Any) -> int | None: """ return None - def font(self, row: Any, widget: Any) -> Font | None: + def font( + self, + row: Any, + defaults: tuple[str, str, str, int, list[str]] = ( + NORMAL, + NORMAL, + NORMAL, + -1, + [SYSTEM], + ), + ) -> Font | None: """Get the Font object to use for the row in this column. The value should a Font or None. The default implementation - returns takes the Table or Tree widget's font and adjusts it - according to the other font methods. + returns takes defaults, overrides them according to the other font + methods and returns a matching Font object, if it can. :param row: A row object from the underlying Source. - :param widget: The Table or Tree widget the column is displayed in. - :returns: The size of the font, or None. + :param defaults: A tuple of default values for style, variant, + weight, size and family. + :returns: A Toga Font object, or None. """ family = self.font_family(row) style = self.font_style(row) @@ -160,14 +171,13 @@ def font(self, row: Any, widget: Any) -> Font | None: weight = self.font_weight(row) size = self.font_size(row) - font = widget.style.font font_args = { - "style": style if style is not None else font[0], - "variant": variant if variant is not None else font[1], - "weight": weight if weight is not None else font[2], - "size": size if size is not None else font[3], + "style": style if style is not None else defaults[0], + "variant": variant if variant is not None else defaults[1], + "weight": weight if weight is not None else defaults[2], + "size": size if size is not None else defaults[3], } - font_family = family if family is not None else font[4] + font_family = family if family is not None else defaults[4] for family in font_family: try: @@ -338,18 +348,29 @@ def font_size(self, row: Any) -> int | None: """ return None - def font(self, row: Any, widget: Any) -> Font | None: + def font( + self, + row: Any, + defaults: tuple[str, str, str, int, list[str]] = ( + NORMAL, + NORMAL, + NORMAL, + -1, + [SYSTEM], + ), + ) -> Font | None: """Get the Font object to use for the row in this column. The value should a Font or None. The default implementation - returns takes the Table or Tree widget's font and adjusts it - according to the other font methods. + returns takes defaults, overrides them according to the other font + methods and returns a matching Font object, if it can. :param row: A row object from the underlying Source. - :param widget: The Table or Tree widget the column is displayed in. - :returns: The size of the font, or None. + :param defaults: A tuple of default values for style, variant, + weight, size and family. + :returns: A Toga Font object, or None. """ - return super().font(row, widget) + return super().font(row, defaults) def widget(self, row: Any) -> Widget | None: """Get a widget from the Row or Node of a ListSource or TreeSource. diff --git a/core/tests/sources/test_columns.py b/core/tests/sources/test_columns.py index 5f2adc79ab..29f71b8b6f 100644 --- a/core/tests/sources/test_columns.py +++ b/core/tests/sources/test_columns.py @@ -3,7 +3,17 @@ import pytest from toga.colors import rgb -from toga.constants import BOLD, ITALIC, RIGHT, SERIF, SMALL_CAPS +from toga.constants import ( + BOLD, + ITALIC, + NORMAL, + RIGHT, + SERIF, + SMALL_CAPS, + SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, +) +from toga.fonts import Font from toga.icons import Icon from toga.sources import AccessorColumn, Column from toga.sources.list_source import Row @@ -98,6 +108,7 @@ def test_column_abc(heading, heading_property): assert column.font_variant(dummy_row) is None assert column.font_weight(dummy_row) is None assert column.font_size(dummy_row) is None + assert column.font(dummy_row) == Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE) assert column.widget(dummy_row) is None @@ -118,6 +129,7 @@ def test_column_subclass(): assert column.font_variant(dummy_row) is None assert column.font_weight(dummy_row) is None assert column.font_size(dummy_row) is None + assert column.font(dummy_row) == Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE) assert column.widget(dummy_row) is None @@ -138,6 +150,9 @@ def test_column_style(): assert column.font_variant(dummy_row) == SMALL_CAPS assert column.font_weight(dummy_row) == BOLD assert column.font_size(dummy_row) == 24 + assert column.font(dummy_row, (NORMAL, NORMAL, NORMAL, 12, [SYSTEM])) == Font( + SERIF, 24, style=ITALIC, variant=SMALL_CAPS, weight=BOLD + ) assert column.widget(dummy_row) is None diff --git a/qt/src/toga_qt/widgets/table.py b/qt/src/toga_qt/widgets/table.py index cbe109f366..e5c901252d 100644 --- a/qt/src/toga_qt/widgets/table.py +++ b/qt/src/toga_qt/widgets/table.py @@ -29,6 +29,7 @@ def __init__(self, interface, **kwargs): self._source = getattr(interface, "_data", None) self._columns = interface._columns self._missing_value = interface.missing_value + self._font_data = interface.style.font def set_source(self, source): self.beginResetModel() @@ -137,7 +138,7 @@ def data( if color is not None: return native_color(color) elif role == Qt.ItemDataRole.FontRole: - font = column.font(row, self._interface) + font = column.font(row, self._font_data) if font is not None: return font._impl.native except Exception: # pragma: no cover diff --git a/qt/src/toga_qt/widgets/tree.py b/qt/src/toga_qt/widgets/tree.py index 880948158d..35850b7f6e 100644 --- a/qt/src/toga_qt/widgets/tree.py +++ b/qt/src/toga_qt/widgets/tree.py @@ -30,6 +30,7 @@ def __init__(self, interface, **kwargs): self._source = getattr(interface, "_data", None) self._columns = interface.columns self._missing_value = interface.missing_value + self._font_data = interface.style.font def set_source(self, source): self.beginResetModel() @@ -215,7 +216,7 @@ def data( if color is not None: return native_color(color) elif role == Qt.ItemDataRole.FontRole: - font = column.font(node, self._interface) + font = column.font(node, self._font_data) if font is not None: return font._impl.native except Exception: # pragma: no cover From 20bac9d8024b94b15643605e394c8ff846c862b6 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 19 Mar 2026 07:30:57 +0000 Subject: [PATCH 11/17] Add fast path if no font styling used. --- core/src/toga/sources/columns.py | 2 ++ core/tests/sources/test_columns.py | 5 ++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/core/src/toga/sources/columns.py b/core/src/toga/sources/columns.py index cf72d8be08..f4574b0205 100644 --- a/core/src/toga/sources/columns.py +++ b/core/src/toga/sources/columns.py @@ -170,6 +170,8 @@ def font( variant = self.font_variant(row) weight = self.font_weight(row) size = self.font_size(row) + if all(property is None for property in [family, style, variant, weight, size]): + return None font_args = { "style": style if style is not None else defaults[0], diff --git a/core/tests/sources/test_columns.py b/core/tests/sources/test_columns.py index 29f71b8b6f..83e6a194e2 100644 --- a/core/tests/sources/test_columns.py +++ b/core/tests/sources/test_columns.py @@ -11,7 +11,6 @@ SERIF, SMALL_CAPS, SYSTEM, - SYSTEM_DEFAULT_FONT_SIZE, ) from toga.fonts import Font from toga.icons import Icon @@ -108,7 +107,7 @@ def test_column_abc(heading, heading_property): assert column.font_variant(dummy_row) is None assert column.font_weight(dummy_row) is None assert column.font_size(dummy_row) is None - assert column.font(dummy_row) == Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE) + assert column.font(dummy_row) is None assert column.widget(dummy_row) is None @@ -129,7 +128,7 @@ def test_column_subclass(): assert column.font_variant(dummy_row) is None assert column.font_weight(dummy_row) is None assert column.font_size(dummy_row) is None - assert column.font(dummy_row) == Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE) + assert column.font(dummy_row) is None assert column.widget(dummy_row) is None From 36f47cd5702adaef4d0d971f673af6ef49ef90ca Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 19 Mar 2026 07:51:21 +0000 Subject: [PATCH 12/17] Revert "Add fast path if no font styling used." This reverts commit 20bac9d8024b94b15643605e394c8ff846c862b6. --- core/src/toga/sources/columns.py | 2 -- core/tests/sources/test_columns.py | 5 +++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/core/src/toga/sources/columns.py b/core/src/toga/sources/columns.py index f4574b0205..cf72d8be08 100644 --- a/core/src/toga/sources/columns.py +++ b/core/src/toga/sources/columns.py @@ -170,8 +170,6 @@ def font( variant = self.font_variant(row) weight = self.font_weight(row) size = self.font_size(row) - if all(property is None for property in [family, style, variant, weight, size]): - return None font_args = { "style": style if style is not None else defaults[0], diff --git a/core/tests/sources/test_columns.py b/core/tests/sources/test_columns.py index 83e6a194e2..29f71b8b6f 100644 --- a/core/tests/sources/test_columns.py +++ b/core/tests/sources/test_columns.py @@ -11,6 +11,7 @@ SERIF, SMALL_CAPS, SYSTEM, + SYSTEM_DEFAULT_FONT_SIZE, ) from toga.fonts import Font from toga.icons import Icon @@ -107,7 +108,7 @@ def test_column_abc(heading, heading_property): assert column.font_variant(dummy_row) is None assert column.font_weight(dummy_row) is None assert column.font_size(dummy_row) is None - assert column.font(dummy_row) is None + assert column.font(dummy_row) == Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE) assert column.widget(dummy_row) is None @@ -128,7 +129,7 @@ def test_column_subclass(): assert column.font_variant(dummy_row) is None assert column.font_weight(dummy_row) is None assert column.font_size(dummy_row) is None - assert column.font(dummy_row) is None + assert column.font(dummy_row) == Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE) assert column.widget(dummy_row) is None From bf0e8da83dde742aea7a880c73c2cee2b4d50852 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 19 Mar 2026 07:56:46 +0000 Subject: [PATCH 13/17] Don't cover case where font is None. --- android/src/toga_android/widgets/table.py | 4 +++- cocoa/src/toga_cocoa/widgets/table.py | 4 +++- cocoa/src/toga_cocoa/widgets/tree.py | 4 +++- qt/src/toga_qt/widgets/table.py | 4 +++- qt/src/toga_qt/widgets/tree.py | 4 +++- 5 files changed, 15 insertions(+), 5 deletions(-) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 8de4c3e4d4..7a31535453 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -159,7 +159,9 @@ def create_table_row(self, row_index): text_view.setTextColor(native_color(color)) if background_color is not None: text_view.setBackgroundColor(native_color(background_color)) - if font is None: + # font is only None if something is very wrong (eg. can't find system font) + # so can't test + if font is not None: # pragma: no branch font_impl = self._font_impl else: font_impl = font._impl diff --git a/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index f1d5dca393..6b927792f8 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -81,7 +81,9 @@ def tableView_viewForTableColumn_row_(self, table, column, row: int): else: tcv.textField.drawsBackground = False - if font is not None: + # font is only None if something is very wrong (eg. can't find system font) + # so can't test + if font is not None: # pragma: no branch tcv.textField.font = font._impl.native return tcv diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 18796ad306..fb5ad05bec 100644 --- a/cocoa/src/toga_cocoa/widgets/tree.py +++ b/cocoa/src/toga_cocoa/widgets/tree.py @@ -105,7 +105,9 @@ def outlineView_viewForTableColumn_item_(self, tree, column, item): else: tcv.textField.drawsBackground = False - if font is not None: + # font is only None if something is very wrong (eg. can't find system font) + # so can't test + if font is not None: # pragma: no branch tcv.textField.font = font._impl.native return tcv diff --git a/qt/src/toga_qt/widgets/table.py b/qt/src/toga_qt/widgets/table.py index e5c901252d..7c99fadbb7 100644 --- a/qt/src/toga_qt/widgets/table.py +++ b/qt/src/toga_qt/widgets/table.py @@ -139,7 +139,9 @@ def data( return native_color(color) elif role == Qt.ItemDataRole.FontRole: font = column.font(row, self._font_data) - if font is not None: + # font is only None if something is very wrong (eg. can't find + # system font) so can't test + if font is not None: # pragma: no branch return font._impl.native except Exception: # pragma: no cover logger.exception( diff --git a/qt/src/toga_qt/widgets/tree.py b/qt/src/toga_qt/widgets/tree.py index 35850b7f6e..381125d050 100644 --- a/qt/src/toga_qt/widgets/tree.py +++ b/qt/src/toga_qt/widgets/tree.py @@ -217,7 +217,9 @@ def data( return native_color(color) elif role == Qt.ItemDataRole.FontRole: font = column.font(node, self._font_data) - if font is not None: + # font is only None if something is very wrong (eg. can't find + # system font) so can't test + if font is not None: # pragma: no branch return font._impl.native except Exception: # pragma: no cover logger.exception( From feeb1745ac0372f6e260b7e7ca71253207e1b323 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 19 Mar 2026 12:43:11 +0000 Subject: [PATCH 14/17] Don't store implementation on Model in Qt; handle changes to font in Qt. Also, properly scale fonts. --- qt/src/toga_qt/fonts.py | 2 +- qt/src/toga_qt/widgets/table.py | 21 ++++++++++++++------- qt/src/toga_qt/widgets/tree.py | 22 +++++++++++++++------- 3 files changed, 30 insertions(+), 15 deletions(-) diff --git a/qt/src/toga_qt/fonts.py b/qt/src/toga_qt/fonts.py index 61c6e31a01..3a500d0dcc 100644 --- a/qt/src/toga_qt/fonts.py +++ b/qt/src/toga_qt/fonts.py @@ -120,7 +120,7 @@ def _assign_native(self, font): if self.interface.variant == SMALL_CAPS: font.setCapitalization(QFont.Capitalization.SmallCaps) if self.interface.size != SYSTEM_DEFAULT_FONT_SIZE: - font.setPointSizeF(self.interface.size) + font.setPointSizeF(self.interface.size * 96 / 72) # Set the native font and remember it self.native = font diff --git a/qt/src/toga_qt/widgets/table.py b/qt/src/toga_qt/widgets/table.py index 7c99fadbb7..c597159cd0 100644 --- a/qt/src/toga_qt/widgets/table.py +++ b/qt/src/toga_qt/widgets/table.py @@ -23,13 +23,12 @@ class TableSourceModel(QAbstractTableModel): _source: ListSource | None headings: list[str] - def __init__(self, interface, **kwargs): + def __init__(self, source, columns, missing_value, font_data, **kwargs): super().__init__(**kwargs) - self._interface = interface - self._source = getattr(interface, "_data", None) - self._columns = interface._columns - self._missing_value = interface.missing_value - self._font_data = interface.style.font + self._source = source + self._columns = columns + self._missing_value = missing_value + self._font_data = font_data def set_source(self, source): self.beginResetModel() @@ -177,7 +176,10 @@ def create(self): self._resizing_columns = False self.native_model = TableSourceModel( - self.interface, + getattr(self.interface, "_data", None), + self.interface._columns[:], + self.interface.missing_value, + self.interface.style.font, parent=self.native, ) self.native.setModel(self.native_model) @@ -214,6 +216,11 @@ def qt_column_resized(self, index, old_size, new_size): if not self._resizing_columns: self._autofit_columns = False + def set_font(self, font): + super().set_font(font) + self.native_model._font_data = self.interface.style.font + self.native_model.reset_source() + def change_source(self, source): self.native_model.set_source(source) diff --git a/qt/src/toga_qt/widgets/tree.py b/qt/src/toga_qt/widgets/tree.py index 381125d050..07a22610a9 100644 --- a/qt/src/toga_qt/widgets/tree.py +++ b/qt/src/toga_qt/widgets/tree.py @@ -24,13 +24,12 @@ class TreeSourceModel(QAbstractItemModel): - def __init__(self, interface, **kwargs): + def __init__(self, source, columns, missing_value, font_data, **kwargs): super().__init__(**kwargs) - self._interface = interface - self._source = getattr(interface, "_data", None) - self._columns = interface.columns - self._missing_value = interface.missing_value - self._font_data = interface.style.font + self._source = source + self._columns = columns + self._missing_value = missing_value + self._font_data = font_data def set_source(self, source): self.beginResetModel() @@ -254,7 +253,10 @@ def create(self): self.native = QTreeView() self.native_model = TreeSourceModel( - self.interface, + getattr(self.interface, "_data", None), + self.interface._columns[:], + self.interface.missing_value, + self.interface.style.font, parent=self.native, ) self.native.setModel(self.native_model) @@ -282,6 +284,12 @@ def qt_activated(self, index): if index.isValid(): # pragma: no branch self.interface.on_activate(node=self.native_model._get_node(index)) + def set_font(self, font): + super().set_font(font) + # Update the fonts of all visible cells + self.native_model._font_data = self.interface.style.font + self.native_model.reset_source() + def change_source(self, source): self.native_model.set_source(source) self.native.header().resizeSections(QHeaderView.ResizeMode.Stretch) From b6b5c557de255b0dc1f417bc5d2e6ba075de9e20 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 19 Mar 2026 12:45:11 +0000 Subject: [PATCH 15/17] Fix Android font logic. --- android/src/toga_android/widgets/table.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 7a31535453..0088532867 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -162,9 +162,9 @@ def create_table_row(self, row_index): # font is only None if something is very wrong (eg. can't find system font) # so can't test if font is not None: # pragma: no branch - font_impl = self._font_impl - else: font_impl = font._impl + else: + font_impl = self._font_impl set_textview_font( text_view, font_impl, From 2bdcbed54d1832f2048e7b76f91789a7de256115 Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 19 Mar 2026 13:25:01 +0000 Subject: [PATCH 16/17] Don't mess with font size - its a macOS Qt issue. --- qt/src/toga_qt/fonts.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/qt/src/toga_qt/fonts.py b/qt/src/toga_qt/fonts.py index 3a500d0dcc..61c6e31a01 100644 --- a/qt/src/toga_qt/fonts.py +++ b/qt/src/toga_qt/fonts.py @@ -120,7 +120,7 @@ def _assign_native(self, font): if self.interface.variant == SMALL_CAPS: font.setCapitalization(QFont.Capitalization.SmallCaps) if self.interface.size != SYSTEM_DEFAULT_FONT_SIZE: - font.setPointSizeF(self.interface.size * 96 / 72) + font.setPointSizeF(self.interface.size) # Set the native font and remember it self.native = font From 146c02fee5bb73a20d2e4a975ea889b3d859636d Mon Sep 17 00:00:00 2001 From: Corran Webster Date: Thu, 19 Mar 2026 14:30:26 +0000 Subject: [PATCH 17/17] Add documentation about the new column methods. --- android/src/toga_android/widgets/table.py | 4 +-- changes/4258.feature.md | 2 +- core/src/toga/sources/columns.py | 29 +++++++++++++++++-- .../api/data-representation/column.md | 18 ++++++++++-- 4 files changed, 46 insertions(+), 7 deletions(-) diff --git a/android/src/toga_android/widgets/table.py b/android/src/toga_android/widgets/table.py index 0088532867..73dae46f02 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -161,9 +161,9 @@ def create_table_row(self, row_index): text_view.setBackgroundColor(native_color(background_color)) # font is only None if something is very wrong (eg. can't find system font) # so can't test - if font is not None: # pragma: no branch + if font is not None: font_impl = font._impl - else: + else: # pragma: no cover font_impl = self._font_impl set_textview_font( text_view, diff --git a/changes/4258.feature.md b/changes/4258.feature.md index 029117eb58..8b4b272b3f 100644 --- a/changes/4258.feature.md +++ b/changes/4258.feature.md @@ -1 +1 @@ -The `Table` and `Tree` widgets on Cocoa, Qt and Android can now specify colors and text alignment to use for individual cells based on the data contained in the cell by writing an appropriate `Column` subclass. +The `Table` and `Tree` widgets on Cocoa and Qt, and the `Table` widget on Android, can now specify colors, fonts and text alignment to use for individual cells based on the data contained in the cell by writing an appropriate `Column` subclass. diff --git a/core/src/toga/sources/columns.py b/core/src/toga/sources/columns.py index cf72d8be08..8946bf3c9c 100644 --- a/core/src/toga/sources/columns.py +++ b/core/src/toga/sources/columns.py @@ -15,7 +15,17 @@ @runtime_checkable class ColumnT(Protocol, Generic[Value]): - """Protocol that Column types must adhere to.""" + """Protocol that Column types must adhere to. + + Notes: + + - The styling and font methods should be considered to be a beta API + and may change in the future, particularly to integrate more closely with + the Widget style system. + + - The styling and font methods are currently only supported in the Android, + Cocoa, and Qt backends. + """ @property @abstractmethod @@ -77,7 +87,7 @@ def background_color(self, row: Any) -> Color | None: """Get the background color use for the row in this column. This is intended to be used for data-based coloring of the - text in a cell (eg. using a colormap to display different + text in a cell (eg. using a color-map to display different colors in a cell based on the value, or to highlight outliers). :param row: A row object from the underlying Source. @@ -160,6 +170,8 @@ def font( returns takes defaults, overrides them according to the other font methods and returns a matching Font object, if it can. + Most subclasses will not need to override this method. + :param row: A row object from the underlying Source. :param defaults: A tuple of default values for style, variant, weight, size and family. @@ -190,6 +202,8 @@ def font( except UnknownFontError: pass + # Can't find *any* font, this will use whatever the underlying widget + # supplies. return None def widget(self, row: Row[Value]) -> Widget | None: @@ -214,6 +228,15 @@ class Column(ColumnT[Value], Generic[Value]): Subclasses should override the value method at a minimum, and other methods as needed. + + Notes: + + - The styling and font methods should be considered to be a beta API + and may change in the future, particularly to integrate more closely with + the Widget style system. + + - The styling and font methods are currently only supported in the Android, + Cocoa, and Qt backends. """ def __init__(self, heading: str | None): @@ -365,6 +388,8 @@ def font( returns takes defaults, overrides them according to the other font methods and returns a matching Font object, if it can. + Most subclasses will not need to override this method. + :param row: A row object from the underlying Source. :param defaults: A tuple of default values for style, variant, weight, size and family. diff --git a/docs/en/reference/api/data-representation/column.md b/docs/en/reference/api/data-representation/column.md index 2003c5511a..9352ccabbb 100644 --- a/docs/en/reference/api/data-representation/column.md +++ b/docs/en/reference/api/data-representation/column.md @@ -31,11 +31,17 @@ table = Table( You can define your own subclasses that can override the way that text and icons are computed to provide custom formatting of text. Any object which implements the [`ColumnT`][toga.sources.ColumnT] protocol can be used. This protocol requires: -- a read-only [`heading`][toga.sources.ColumnT.heading] property that is the column heding text or `None` for no heading text.; +- a read-only [`heading`][toga.sources.ColumnT.heading] property that is the column heading text or `None` for no heading text.; - a [`value`][toga.sources.ColumnT.value] method that takes a row object and gives the value for the column in that row. - a [`text`][toga.sources.ColumnT.text] method that takes a row object and an optional default value and gives the text for the column to display in that row, or `None` if no text is to be displayed. - an [`icon`][toga.sources.ColumnT.icon] method that takes a row object and gives the icon for the column to display in that row, or `None` if no icon is to be displayed. - a [`widget`][toga.sources.ColumnT.widget] method that takes a row object and gives the widget for the column to use in that row, or `None` if no widget is to be used (this is experimental and is only supported on macOS at present). +- a set of methods that can be used to style the cells of the column depending on the value of the row: + - a [`text_align`][toga.sources.ColumnT.text_align] method that takes a row object and gives the text alignment to use for that row, or None to use the default alignment. + - a [`color`][toga.sources.ColumnT.color] method that takes a row object and gives the color to use for the text in that row, or None to use the default color. + - a [`background_color`][toga.sources.ColumnT.background_color] method that takes a row object and gives the background color to use for the cell in that row, or None to use the default color. + - [`font_style`][toga.sources.ColumnT.font_style], [`font_variant`][toga.sources.ColumnT.font_variant], [`font_weight`][toga.sources.ColumnT.font_weight], [`font_size`][toga.sources.ColumnT.font_size] and [`font_family`][toga.sources.ColumnT.font_family] methods that take a row object and gives the appropriate font property to use for text in that row, or None to use the default. + - [`font`][toga.sources.ColumnT.font], a method that takes a row value and a tuple of default font properties, and returns a Toga font that matches as possible to the font properties specified in the font property methods, and uses the defaults where no value is specified (custom columns will rarely need to override this). For example, we could subclass `AccessorColumn` to make column that takes a value which is a list of strings and formats it as a comma-separated list as follows: ```python @@ -57,6 +63,8 @@ table = Table( ``` so a row providing the value `["Drama", "Action"]` would be displayed in the table cell as `"Drama, Action"`. +The column style methods are intended to be used for dynamic styling based on values, such as applying a color-map to cell backgrounds, emphasizing outliers or errors, or right-aligning numerical values within a column. + Custom columns can even override the default way of looking up values to allow such things as combining values from multiple attributes, looking up values by index rather than attribute, or using a method or function on the row to get the display values. The [`Column`][toga.sources.Column] class provides a convenient minimal base class for implementing custom columns. ```python class TotalCostColumn(Column): @@ -68,6 +76,13 @@ class TotalCostColumn(Column): value = self.value(row) return f"${value:.2d}" + def text_color(self, row): + value = self.value(row) + if value < 0: + return Color.parse("#ff0000) + else: + return None + table = Table( columns=[ "Product", @@ -78,7 +93,6 @@ table = Table( ) ``` - ## Reference ::: toga.sources.ColumnT