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 4f7103a337..73dae46f02 100644 --- a/android/src/toga_android/widgets/table.py +++ b/android/src/toga_android/widgets/table.py @@ -6,8 +6,9 @@ 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 +from .label import set_alignment, set_textview_font class TogaOnClickListener(dynamic_proxy(View.OnClickListener)): @@ -149,9 +150,24 @@ 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) + font = toga_column.font(data_row, self.interface.style.font) + + if color is not None: + text_view.setTextColor(native_color(color)) + if background_color is not None: + 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: + font_impl = font._impl + else: # pragma: no cover + font_impl = self._font_impl set_textview_font( text_view, - self._font_impl, + font_impl, text_view.getTypeface(), text_view.getTextSize(), ) @@ -159,8 +175,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 f426be98ae..754a791ddc 100644 --- a/android/tests_backend/widgets/table.py +++ b/android/tests_backend/widgets/table.py @@ -1,6 +1,9 @@ import pytest 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 HEADER = "HEADER" @@ -11,6 +14,7 @@ class TableProbe(SimpleProbe): supports_icons = False supports_keyboard_shortcuts = False supports_widgets = False + supports_styles = True column_proportion_tolerance = 35 def __init__(self, widget): @@ -30,18 +34,63 @@ 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, + text_align=None, + color=None, + background_color=None, + font=None, + ): 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 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: + 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) 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.getCurrentTextColor() + + def _cell_background_color(self, row, col): + tv = self._row_view(row).getChildAt(col) + assert isinstance(tv, TextView) + 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 _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/changes/4258.feature.md b/changes/4258.feature.md new file mode 100644 index 0000000000..8b4b272b3f --- /dev/null +++ b/changes/4258.feature.md @@ -0,0 +1 @@ +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/cocoa/src/toga_cocoa/widgets/table.py b/cocoa/src/toga_cocoa/widgets/table.py index 8a0155bddd..6b927792f8 100644 --- a/cocoa/src/toga_cocoa/widgets/table.py +++ b/cocoa/src/toga_cocoa/widgets/table.py @@ -10,8 +10,10 @@ NSTableView, NSTableViewAnimation, NSTableViewColumnAutoresizingStyle, + NSTextAlignment, ) +from ..colors import native_color from .base import Widget from .internal.cells import TogaIconView @@ -43,6 +45,10 @@ 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) + 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 @@ -60,6 +66,26 @@ 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: + tcv.textField.textColor = None + if background_color is not None: + tcv.drawsBackground = True + tcv.backgroundColor = native_color(background_color) + else: + tcv.textField.drawsBackground = False + + # 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 @objc_method diff --git a/cocoa/src/toga_cocoa/widgets/tree.py b/cocoa/src/toga_cocoa/widgets/tree.py index 1a380ddbc7..fb5ad05bec 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, @@ -9,6 +10,7 @@ NSTableColumn, NSTableViewAnimation, NSTableViewColumnAutoresizingStyle, + NSTextAlignment, ) from toga_cocoa.widgets.base import Widget from toga_cocoa.widgets.internal.cells import TogaIconView @@ -64,6 +66,10 @@ 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) + 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 @@ -84,6 +90,26 @@ 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: + tcv.textField.textColor = None + if background_color is not None: + tcv.drawsBackground = True + tcv.backgroundColor = native_color(background_color) + else: + tcv.textField.drawsBackground = False + + # 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 # 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..53d9179e3e 100644 --- a/cocoa/tests_backend/widgets/table.py +++ b/cocoa/tests_backend/widgets/table.py @@ -1,8 +1,9 @@ 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 +from toga_cocoa.libs import NSEventType, NSScrollView, NSTableView, NSTextAlignment from .base import SimpleProbe from .properties import toga_color @@ -14,6 +15,7 @@ class TableProbe(SimpleProbe): supports_keyboard_shortcuts = True supports_keyboard_boundary_shortcuts = False supports_widgets = True + supports_styles = True def __init__(self, widget): super().__init__(widget) @@ -22,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): @@ -43,7 +45,18 @@ 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, + text_align=None, + background_color=None, + font=None, + ): view = self.native_table.tableView( self.native_table, viewForTableColumn=self.native_table.tableColumns[col], @@ -59,6 +72,18 @@ def assert_cell_content(self, row, col, value=None, icon=None, widget=None): 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) + + 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 7bb972c939..6c1d0b1156 100644 --- a/cocoa/tests_backend/widgets/tree.py +++ b/cocoa/tests_backend/widgets/tree.py @@ -3,8 +3,9 @@ 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 +from toga_cocoa.libs import NSEventType, NSOutlineView, NSScrollView, NSTextAlignment from .base import SimpleProbe from .properties import toga_color @@ -14,6 +15,7 @@ class TreeProbe(SimpleProbe): native_class = NSScrollView supports_keyboard_shortcuts = True supports_widgets = True + supports_styles = True def __init__(self, widget): super().__init__(widget) @@ -73,7 +75,18 @@ 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, + text_align=None, + color=None, + background_color=None, + font=None, + ): view = self.native_tree.outlineView( self.native_tree, viewForTableColumn=self.native_tree.tableColumns[col], @@ -89,6 +102,18 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) 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) + + 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 778ebe462d..8946bf3c9c 100644 --- a/core/src/toga/sources/columns.py +++ b/core/src/toga/sources/columns.py @@ -2,6 +2,9 @@ from collections.abc import Iterable from typing import Any, Generic, Protocol, TypeVar, runtime_checkable +from ..colors import Color +from ..constants import NORMAL, SYSTEM +from ..fonts import Font, UnknownFontError from ..icons import Icon from ..widgets.base import Widget from .accessors import build_accessors, to_accessor @@ -12,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 @@ -45,6 +58,154 @@ 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. + + 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 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. + :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, + 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 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. + :returns: A Toga Font object, 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_args = { + "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 defaults[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 + + # Can't find *any* font, this will use whatever the underlying widget + # supplies. + return None + def widget(self, row: Row[Value]) -> Widget | None: """Get a widget from the Row or Node of a ListSource or TreeSource. @@ -67,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): @@ -111,6 +281,122 @@ 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. + + 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 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, + 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 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. + :returns: A Toga Font object, or None. + """ + 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 ad1d159060..29f71b8b6f 100644 --- a/core/tests/sources/test_columns.py +++ b/core/tests/sources/test_columns.py @@ -1,5 +1,19 @@ +from typing import Any + import pytest +from toga.colors import rgb +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 @@ -38,6 +52,35 @@ 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) + + 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") @@ -57,6 +100,15 @@ 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.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.font(dummy_row) == Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE) assert column.widget(dummy_row) is None @@ -69,6 +121,38 @@ 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.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.font(dummy_row) == Font(SYSTEM, SYSTEM_DEFAULT_FONT_SIZE) + 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.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.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 @@ -261,6 +345,9 @@ 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 if text is ValueError: 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 diff --git a/examples/table_columns/table_columns/app.py b/examples/table_columns/table_columns/app.py index c3aeb524b6..847b428565 100644 --- a/examples/table_columns/table_columns/app.py +++ b/examples/table_columns/table_columns/app.py @@ -10,7 +10,8 @@ from babel.dates import format_date, format_time import toga -from toga.constants import COLUMN +from toga.colors import rgb +from toga.constants import BOLD, COLUMN, ITALIC, RIGHT, SERIF from toga.sources import AccessorColumn, Column @@ -23,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.""" @@ -49,6 +60,21 @@ 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: + 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 +95,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 +174,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))] @@ -187,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 3fcf071ca2..c597159cd0 100644 --- a/qt/src/toga_qt/widgets/table.py +++ b/qt/src/toga_qt/widgets/table.py @@ -6,7 +6,10 @@ 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 .base import Widget @@ -20,11 +23,12 @@ class TableSourceModel(QAbstractTableModel): _source: ListSource | None headings: list[str] - def __init__(self, source, columns, missing_value, **kwargs): + def __init__(self, source, columns, missing_value, font_data, **kwargs): super().__init__(**kwargs) self._source = source self._columns = columns self._missing_value = missing_value + self._font_data = font_data def set_source(self, source): self.beginResetModel() @@ -120,6 +124,24 @@ 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: + return native_color(color) + elif role == Qt.ItemDataRole.BackgroundRole: + color = column.background_color(row) + if color is not None: + return native_color(color) + elif role == Qt.ItemDataRole.FontRole: + font = column.font(row, self._font_data) + # 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( f"Could not get data for row {row_index}, column {column_index}" @@ -157,6 +179,7 @@ def create(self): 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) @@ -193,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 5356d1113e..07a22610a9 100644 --- a/qt/src/toga_qt/widgets/tree.py +++ b/qt/src/toga_qt/widgets/tree.py @@ -11,6 +11,10 @@ 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 logger = logging.getLogger(__name__) @@ -20,11 +24,12 @@ class TreeSourceModel(QAbstractItemModel): - def __init__(self, source, columns, missing_value, **kwargs): + def __init__(self, source, columns, missing_value, font_data, **kwargs): super().__init__(**kwargs) self._source = source self._columns = columns self._missing_value = missing_value + self._font_data = font_data def set_source(self, source): self.beginResetModel() @@ -196,6 +201,25 @@ 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: + return native_color(color) + elif role == Qt.ItemDataRole.BackgroundRole: + color = column.background_color(node) + if color is not None: + return native_color(color) + elif role == Qt.ItemDataRole.FontRole: + font = column.font(node, self._font_data) + # 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( f"Could not get data for node {node}, column {column_index}" @@ -232,6 +256,7 @@ def create(self): 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) @@ -259,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) diff --git a/qt/tests_backend/widgets/table.py b/qt/tests_backend/widgets/table.py index 018028eca7..31ec32b2ba 100644 --- a/qt/tests_backend/widgets/table.py +++ b/qt/tests_backend/widgets/table.py @@ -3,6 +3,10 @@ import pytest 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 toga.constants import CENTER from .base import SimpleProbe @@ -12,6 +16,7 @@ class TableProbe(SimpleProbe): supports_icons = 2 # All columns supports_keyboard_shortcuts = False supports_widgets = False + supports_styles = True def __init__(self, widget): super().__init__(widget) @@ -44,7 +49,18 @@ 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, + text_align=None, + color=None, + background_color=None, + font=None, + ): if widget: pytest.skip("Qt doesn't support widgets in Tables") else: @@ -53,7 +69,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 +83,30 @@ def assert_cell_content(self, row, col, value=None, icon=None, widget=None): ).cacheKey() ) + if text_align: + assert qt_text_align(text_align, CENTER) == self.native_model.data( + index, + Qt.ItemDataRole.TextAlignmentRole, + ) + + 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, + ) + + 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 267fc1251a..e3dfd8c70d 100644 --- a/qt/tests_backend/widgets/tree.py +++ b/qt/tests_backend/widgets/tree.py @@ -10,6 +10,10 @@ Qt, ) from PySide6.QtWidgets import QTreeView +from toga_qt.colors import native_color +from toga_qt.libs import qt_text_align + +from toga.constants import CENTER from .base import SimpleProbe @@ -18,6 +22,7 @@ class TreeProbe(SimpleProbe): native_class = QTreeView supports_keyboard_shortcuts = False supports_widgets = False + supports_styles = True selection_cleared_on_insert_delete = True collapse_on_insert_delete = True @@ -69,7 +74,18 @@ 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, + text_align=None, + color=None, + background_color=None, + font=None, + ): if widget: pytest.skip("Qt doesn't support widgets in Trees") else: @@ -92,6 +108,30 @@ def assert_cell_content(self, row_path, col, value=None, icon=None, widget=None) ).cacheKey() ) + if text_align: + assert qt_text_align(text_align, CENTER) == self.native_model.data( + index, + Qt.ItemDataRole.TextAlignmentRole, + ) + + 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, + ) + + 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 c9b8a21c4c..9cb570696e 100644 --- a/testbed/tests/widgets/test_table.py +++ b/testbed/tests/widgets/test_table.py @@ -4,6 +4,8 @@ import pytest import toga +from toga.colors import rgb +from toga.constants import BOLD, ITALIC, RIGHT, SERIF, SMALL_CAPS, SYSTEM from toga.sources import AccessorColumn, ListListener, ListSource from toga.style.pack import Pack @@ -689,6 +691,139 @@ def test_list_listener(widget): assert isinstance(widget._impl, ListListener) +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)): + 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) + + 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): + skip_on_platforms("iOS") + return toga.Table( + [ + StyledTestColumn("A"), + StyledTestColumn("B"), + StyledTestColumn("C"), + ], + data=source, + missing_value="MISSING!", + on_select=on_select_handler, + on_activate=on_activate_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +async def style_probe(main_window, styled_widget): + old_content = main_window.content + + box = toga.Box(children=[styled_widget]) + main_window.content = box + probe = get_probe(styled_widget) + await probe.redraw("Constructing color Table probe") + probe.assert_container(box) + yield probe + + main_window.content = old_content + + +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.") + + styled_widget.data = [ + { + # A number from -1 to 1 + "a": (i - 25) / 25, + # Normal text, + "b": f"B{i}", + } + for i in range(50) + ] + 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), + font=negative_number_font, + ) + style_probe.assert_cell_content( + 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), + font=positive_number_font, + ) + + @pytest.mark.parametrize( "method_name,args", [ diff --git a/testbed/tests/widgets/test_tree.py b/testbed/tests/widgets/test_tree.py index 1aff557385..8538110d80 100644 --- a/testbed/tests/widgets/test_tree.py +++ b/testbed/tests/widgets/test_tree.py @@ -4,6 +4,8 @@ import pytest import toga +from toga.colors import rgb +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 @@ -946,6 +948,142 @@ def test_tree_listener(widget): assert isinstance(widget._impl, TreeListener) +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)): + 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) + + 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): + skip_on_platforms("iOS", "android", "windows") + return toga.Tree( + [ + StyledTestColumn("A"), + StyledTestColumn("B"), + StyledTestColumn("C"), + ], + data=source, + missing_value="MISSING!", + on_select=on_select_handler, + on_activate=on_activate_handler, + style=Pack(flex=1), + ) + + +@pytest.fixture +async def style_probe(main_window, styled_widget): + old_content = main_window.content + + box = toga.Box(children=[styled_widget]) + main_window.content = box + probe = get_probe(styled_widget) + await probe.redraw("Constructing color Table probe") + probe.assert_container(box) + yield probe + + main_window.content = old_content + + +async def test_cell_color(styled_widget, style_probe): + "A cell can have colors" + if not getattr(style_probe, "supports_styles", False): + pytest.skip("Backend does not support colors in cells.") + + styled_widget.data = [ + ( + { + # A number from -1 to 1 + "a": (i - 25) / 25, + # Normal text, + "b": f"B{i}", + }, + [], + ) + for i in range(50) + ] + 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), + font=negative_number_font, + ) + style_probe.assert_cell_content( + (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), + font=positive_number_font, + ) + + @pytest.mark.parametrize( "method_name,args,expected_args", [