diff --git a/tabulous/commands/_colormap.py b/tabulous/_colormap.py similarity index 50% rename from tabulous/commands/_colormap.py rename to tabulous/_colormap.py index 8e8c266d..e82de8fc 100644 --- a/tabulous/commands/_colormap.py +++ b/tabulous/_colormap.py @@ -1,12 +1,15 @@ from __future__ import annotations -from typing import Callable, Hashable, Sequence, TYPE_CHECKING, TypeVar +from typing import Iterable, TYPE_CHECKING, Union import numpy as np -import pandas as pd +from tabulous.color import ColorTuple, normalize_color +from tabulous.types import ColorType from tabulous._dtype import isna, get_converter if TYPE_CHECKING: from pandas.core.dtypes.dtypes import CategoricalDtype - from magicgui.widgets import Widget + import pandas as pd + + _TimeLike = Union[pd.Timestamp, pd.Timedelta] _ColorType = tuple[int, int, int, int] @@ -14,7 +17,7 @@ _DEFAULT_MAX = "#FF696B" -def exec_colormap_dialog(ds: pd.Series, parent=None) -> Callable | None: +def exec_colormap_dialog(ds: pd.Series, parent=None): """Open a dialog to define a colormap for a series.""" from tabulous._qt._color_edit import ColorEdit from magicgui.widgets import Dialog, LineEdit, Container @@ -28,11 +31,7 @@ def exec_colormap_dialog(ds: pd.Series, parent=None) -> Callable | None: dlg = Dialog(widgets=widgets) dlg.native.setParent(parent, dlg.native.windowFlags()) if dlg.exec(): - return _define_categorical_colormap( - dtype.categories, - [w.value for w in widgets], - dtype.kind, - ) + return dict(zip(dtype.categories, (w.value for w in widgets))) elif dtype.kind in "uif": # unsigned int, int, float lmin = LineEdit(value=str(ds.min())) @@ -50,8 +49,8 @@ def exec_colormap_dialog(ds: pd.Series, parent=None) -> Callable | None: dlg = Dialog(widgets=[min_, max_]) dlg.native.setParent(parent, dlg.native.windowFlags()) if dlg.exec(): - return _define_continuous_colormap( - float(lmin.value), float(lmax.value), cmin.value, cmax.value + return segment_by_float( + [(float(lmin.value), cmin.value), (float(lmax.value), cmax.value)] ) elif dtype.kind == "b": # boolean @@ -60,9 +59,9 @@ def exec_colormap_dialog(ds: pd.Series, parent=None) -> Callable | None: dlg = Dialog(widgets=[false_, true_]) dlg.native.setParent(parent, dlg.native.windowFlags()) if dlg.exec(): - return _define_categorical_colormap( - [False, True], [false_.value, true_.value], dtype.kind - ) + converter = get_converter("b") + _dict = {False: false_.value, True: true_.value} + return lambda val: _dict.get(converter(val), None) elif dtype.kind in "mM": # time stamp or time delta min_ = ColorEdit(value=_DEFAULT_MIN, label="Min") @@ -70,86 +69,78 @@ def exec_colormap_dialog(ds: pd.Series, parent=None) -> Callable | None: dlg = Dialog(widgets=[min_, max_]) dlg.native.setParent(parent, dlg.native.windowFlags()) if dlg.exec(): - return _define_time_colormap( - ds.min(), ds.max(), min_.value, max_.value, dtype.kind - ) + return segment_by_time([(ds.min(), min_.value), (ds.max(), max_.value)]) else: raise NotImplementedError( f"Dtype {dtype!r} not supported. Please set colormap programmatically." ) - return None +def _random_color() -> list[int]: + return list(np.random.randint(256, size=3)) + [255] -def _define_continuous_colormap( - min: float, max: float, min_color: _ColorType, max_color: _ColorType -): - converter = get_converter("f") - def _colormap(value: float) -> _ColorType: - nonlocal min_color, max_color - if isna(value): - return None - value = converter(value) - if value < min: - return min_color - elif value > max: - return max_color - else: - min_color = np.array(min_color, dtype=np.float64) - max_color = np.array(max_color, dtype=np.float64) - return (value - min) / (max - min) * (max_color - min_color) + min_color - - return _colormap +def _where(x, border: Iterable[float]) -> int: + for i, v in enumerate(border): + if x < v: + return i - 1 + return len(border) - 1 -def _define_categorical_colormap( - values: Sequence[Hashable], - colors: Sequence[_ColorType], - kind: str, -): - map = dict(zip(values, colors)) +def segment_by_float(maps: list[tuple[float, ColorType]], kind: str = "f"): converter = get_converter(kind) + borders: list[float] = [] + colors: list[ColorTuple] = [] + for v, c in maps: + borders.append(v) + colors.append(normalize_color(c)) + idx_max = len(borders) - 1 - def _colormap(value: Hashable) -> _ColorType: - return map.get(converter(value), None) - - return _colormap + # check is sorted + if not all(borders[i] <= borders[i + 1] for i in range(len(borders) - 1)): + raise ValueError("Borders must be sorted") + def _colormap(value: float) -> _ColorType: + if isna(value): + return None + value = converter(value) + idx = _where(value, borders) + if idx == -1 or idx == idx_max: + return colors[idx] + min_color = np.array(colors[idx], dtype=np.float64) + max_color = np.array(colors[idx + 1], dtype=np.float64) + min = borders[idx] + max = borders[idx + 1] + return (value - min) / (max - min) * (max_color - min_color) + min_color -_T = TypeVar("_T", pd.Timestamp, pd.Timedelta) + return _colormap -def _define_time_colormap( - min: _T, - max: _T, - min_color: _ColorType, - max_color: _ColorType, - kind: str, -): - min_t = min.value - max_t = max.value +def segment_by_time(maps: list[tuple[_TimeLike, ColorType]], kind: str): converter = get_converter(kind) - - def _colormap(value: _T) -> _ColorType: - nonlocal min_color, max_color + borders: list[_TimeLike] = [] + colors: list[ColorTuple] = [] + for v, c in maps: + borders.append(v) + colors.append(normalize_color(c)) + idx_max = len(borders) - 1 + + # check is sorted + if not all(borders[i] <= borders[i + 1] for i in range(len(borders) - 1)): + raise ValueError("Borders must be sorted") + + def _colormap(value: _TimeLike) -> _ColorType: if isna(value): return None - value = converter(value).value - if value < min_t: - return min_color - elif value > max_t: - return max_color - else: - min_color = np.array(min_color, dtype=np.float64) - max_color = np.array(max_color, dtype=np.float64) - return (value - min_t) / (max_t - min_t) * ( - max_color - min_color - ) + min_color + value = converter(value) + idx = _where(value, borders) + if idx == -1 or idx == idx_max: + return colors[idx] + min_color = np.array(colors[idx], dtype=np.float64) + max_color = np.array(colors[idx + 1], dtype=np.float64) + min = borders[idx].value + max = borders[idx + 1].value + return (value.value - min) / (max - min) * (max_color - min_color) + min_color return _colormap - - -def _random_color() -> list[int]: - return list(np.random.randint(256, size=3)) + [255] diff --git a/tabulous/_dtype.py b/tabulous/_dtype.py index 6b18ddad..67f53458 100644 --- a/tabulous/_dtype.py +++ b/tabulous/_dtype.py @@ -68,6 +68,29 @@ def get_converter(kind: str) -> Callable[[Any], Any]: return _DTYPE_CONVERTER[kind] +def get_converter_from_type(tp: type | str) -> Callable[[Any], Any]: + if not isinstance(tp, str): + tp = tp.__name__ + + if tp == "int": + kind = "i" + elif tp == "float": + kind = "f" + elif tp == "str": + kind = "U" + elif tp == "bool": + kind = "b" + elif tp == "complex": + kind = "c" + elif tp == "datetime": + kind = "M" + elif tp == "timedelta": + kind = "m" + else: + kind = "O" + return get_converter(kind) + + class DefaultValidator: """ The default validator function. diff --git a/tabulous/_qt/_table/_base/_table_base.py b/tabulous/_qt/_table/_base/_table_base.py index 3bea0d0f..dacbccbe 100644 --- a/tabulous/_qt/_table/_base/_table_base.py +++ b/tabulous/_qt/_table/_base/_table_base.py @@ -3,7 +3,7 @@ import logging from functools import partial from pathlib import Path -from typing import Any, Callable, TYPE_CHECKING, Iterable, Tuple, TypeVar +from typing import Any, Callable, TYPE_CHECKING, Iterable, Tuple, TypeVar, overload import warnings from qtpy import QtWidgets as QtW, QtGui, QtCore from qtpy.QtCore import Signal, Qt @@ -181,7 +181,14 @@ def createQTableView(self) -> None: def getDataFrame(self) -> pd.DataFrame: raise NotImplementedError() - def _get_sub_frame(self, columns: list[str]): + # fmt: off + @overload + def _get_sub_frame(self, columns: list[str]) -> pd.DataFrame: ... + @overload + def _get_sub_frame(self, columns: str) -> pd.Series: ... + # fmt: on + + def _get_sub_frame(self, columns): return self.getDataFrame()[columns] def setDataFrame(self, df: pd.DataFrame) -> None: diff --git a/tabulous/color.py b/tabulous/color.py index 9fd62adf..959adb39 100644 --- a/tabulous/color.py +++ b/tabulous/color.py @@ -1,11 +1,82 @@ from __future__ import annotations +from typing import Any, NamedTuple from functools import lru_cache -from typing import Iterable, Union +import colorsys +from tabulous.types import ColorType, ColorMapping -__all__ = ["normalize_color", "rgba_to_str"] +import numpy as np -def normalize_color(color: str | Iterable[int]) -> tuple[int, int, int, int]: +class ColorTuple(NamedTuple): + """8-bit color tuple.""" + + r: int + g: int + b: int + a: int + + @property + def opacity(self) -> float: + """Return the opacity as a float between 0 and 1.""" + return self.a / 255.0 + + @property + def html(self) -> str: + """Return a HTML color string.""" + return f"#{self.r:02X}{self.g:02X}{self.b:02X}{self.a:02X}" + + @property + def hlsa(self) -> tuple[float, float, float, float]: + """Return the color as HSLA.""" + hlsa_float = colorsys.rgb_to_hls( + self.r / 255.0, self.g / 255.0, self.b / 255.0 + ) + (self.opacity,) + return tuple(int(round(c * 255)) for c in hlsa_float) + + @property + def hsva(self) -> tuple[float, float, float, float]: + """Return the color as HSVA.""" + hsva_float = colorsys.rgb_to_hsv( + self.r / 255.0, self.g / 255.0, self.b / 255.0 + ) + (self.opacity,) + return tuple(int(round(c * 255)) for c in hsva_float) + + @classmethod + def from_html(cls, html: str) -> ColorTuple: + """Create a ColorTuple from a HTML color string.""" + if html.startswith("#"): + html = html[1:] + if len(html) == 6: + html += "FF" + return cls(*[int(html[i : i + 2], 16) for i in range(0, 8, 2)]) + + @classmethod + def from_hlsa(cls, *hlsa) -> ColorTuple: + """Create a ColorTuple from HSLA.""" + if len(hlsa) == 1: + hlsa = hlsa[0] + if len(hlsa) == 3: + hls = hlsa + alpha = 255 + hls = tuple(c / 255.0 for c in hls) + return cls(*[int(round(c * 255)) for c in colorsys.hls_to_rgb(*hls)], alpha) + + @classmethod + def from_hsva(cls, *hsva) -> ColorTuple: + """Create a ColorTuple from HSVA.""" + if len(hsva) == 1: + hsva = hsva[0] + if len(hsva) == 3: + hsv = hsva + alpha = 255 + hsv_float = tuple(c / 255.0 for c in hsv) + return cls( + *[int(round(c * 255)) for c in colorsys.hsv_to_rgb(*hsv_float)], alpha + ) + + +def normalize_color(color: ColorType) -> ColorTuple: + """Normalize a color-like object to a ColorTuple.""" if isinstance(color, str): return _str_color_to_tuple(color) if hasattr(color, "__iter__"): @@ -16,7 +87,7 @@ def normalize_color(color: str | Iterable[int]) -> tuple[int, int, int, int]: pass else: raise ValueError(f"Invalid color: {color!r}") - return out + return ColorTuple(*out) raise ValueError(f"Invalid color: {color!r}") @@ -30,7 +101,84 @@ def rgba_to_str(rgba: tuple[int, int, int, int]) -> str: return color_name -ColorType = Union[str, Iterable[int]] +class ConvertedColormap: + def __init__(self, func: ColorMapping): + self.func = func + self.__name__ = f"{type(self).__name__}<{func.__name__}>" + self.__annotations__ = func.__annotations__ + + def __repr__(self): + return f"{type(self).__name__}<{self.func!r}>" + + +class InvertedColormap(ConvertedColormap): + @classmethod + def from_colormap(cls, cmap: ColorMapping) -> ColorMapping: + """Convert a colormap into return an inverted one.""" + if isinstance(cmap, cls): + return cmap.func + return cls(cmap) + + def __call__(self, x: Any) -> ColorType: + color = self.func(x) + if color is None: + return color + color = np.array(normalize_color(color), dtype=np.uint8) + color[:3] = 255 - color[:3] + return color + + +class OpacityColormap(ConvertedColormap): + def __init__(self, func: ColorMapping, opacity: float): + super().__init__(func) + if opacity < 0 or 1 < opacity: + raise ValueError(f"Opacity must be between 0 and 1, got {opacity}") + self._alpha = int(opacity * 255) + + @classmethod + def from_colormap(cls, cmap: ColorMapping, opacity: float) -> ColorMapping: + """Convert a colormap into an new one with given alpha channel.""" + if isinstance(cmap, cls): + return cls(cmap.func, opacity) + return cls(cmap, opacity) + + def __call__(self, x: Any) -> ColorType: + color = self.func(x) + if color is None: + return color + color = np.array(normalize_color(color), dtype=np.uint8) + color[3] = self._alpha + return color + + +class BrightenedColormap(ConvertedColormap): + def __init__(self, func: ColorMapping, factor: float): + super().__init__(func) + if factor < -1: + raise ValueError(f"Brightening factor fell below -1.0: {factor}") + if 1 < factor: + raise ValueError(f"Brightening factor exceeded 1.0: {factor}") + self._factor = factor + + @classmethod + def from_colormap(cls, cmap: ColorMapping, factor: float) -> ColorMapping: + """Convert a colormap into an new one with given brightening factor.""" + if isinstance(cmap, cls): + return cls(cmap.func, cmap._factor + factor) + return cls(cmap, factor) + + def __call__(self, x: Any) -> ColorType: + color = self.func(x) + if color is None: + return color + color = np.array(normalize_color(color), dtype=np.float64) + factor = self._factor + if factor > 0: + extreme = np.array([255, 255, 255, 255], dtype=np.float64) + else: + extreme = np.array([0, 0, 0, 255], dtype=np.float64) + color = color * (1 - factor) + extreme * factor + return np.round(color).astype(np.uint8) @lru_cache(maxsize=64) diff --git a/tabulous/commands/_utils.py b/tabulous/commands/_utils.py index 89a05090..f7303a59 100644 --- a/tabulous/commands/_utils.py +++ b/tabulous/commands/_utils.py @@ -76,6 +76,20 @@ def get_selected_column(viewer: TableViewerBase) -> int: return first.start +def get_table_and_column_name(viewer: TableViewerBase) -> tuple[TableBase, str]: + table = get_table(viewer) + selected = table.columns.selected + if len(selected) == 0: + raise ValueError("No columns selected") + if len(selected) > 1: + raise ValueError("Multiple ranges are selected") + first = selected[0] + if first.stop != first.start + 1: + raise ValueError("Multiple columns are selected") + + return table, table.columns[first.start] + + def get_selected_columns( viewer: TableViewerBase, assert_exists: bool = True ) -> list[int]: diff --git a/tabulous/commands/selection.py b/tabulous/commands/selection.py index f24e2a75..00280f77 100644 --- a/tabulous/commands/selection.py +++ b/tabulous/commands/selection.py @@ -331,64 +331,60 @@ def remove_selected_columns(viewer: TableViewerBase): def set_foreground_colormap(viewer: TableViewerBase) -> None: """Set foreground colormap to a column""" - from ._colormap import exec_colormap_dialog - - sheet = _utils.get_table(viewer)._qwidget - index = _utils.get_selected_column(viewer) - - column_name = sheet._filtered_columns[index] - if cmap := exec_colormap_dialog(sheet._get_sub_frame(column_name), sheet): - sheet.setForegroundColormap(column_name, cmap) + from tabulous._colormap import exec_colormap_dialog + + table, column_name = _utils.get_table_and_column_name(viewer) + if cmap := exec_colormap_dialog( + table.native._get_sub_frame(column_name), + table.native, + ): + table.text_color.set(column_name, cmap, infer_parser=False) return None def reset_foreground_colormap(viewer: TableViewerBase) -> None: """Reset foreground colormap""" - sheet = _utils.get_table(viewer)._qwidget - index = _utils.get_selected_column(viewer) - column_name = sheet._filtered_columns[index] - return sheet.setForegroundColormap(column_name, None) + table, column_name = _utils.get_table_and_column_name(viewer) + del table.text_color[column_name] def set_background_colormap(viewer: TableViewerBase) -> None: """Set background colormap to a column""" - from ._colormap import exec_colormap_dialog - - sheet = _utils.get_table(viewer)._qwidget - index = _utils.get_selected_column(viewer) - column_name = sheet._filtered_columns[index] - if cmap := exec_colormap_dialog(sheet._get_sub_frame(column_name), sheet): - sheet.setBackgroundColormap(column_name, cmap) + from tabulous._colormap import exec_colormap_dialog + + table, column_name = _utils.get_table_and_column_name(viewer) + if cmap := exec_colormap_dialog( + table.native._get_sub_frame(column_name), + table.native, + ): + table.background_color.set(column_name, cmap, infer_parser=False) return None def reset_background_colormap(viewer: TableViewerBase) -> None: """Reset background colormap""" - sheet = _utils.get_table(viewer)._qwidget - index = _utils.get_selected_column(viewer) - column_name = sheet._filtered_columns[index] - return sheet.setBackgroundColormap(column_name, None) + table, column_name = _utils.get_table_and_column_name(viewer) + del table.background_color[column_name] def set_text_formatter(viewer: TableViewerBase) -> None: """Set text formatter""" from tabulous._text_formatter import exec_formatter_dialog - sheet = _utils.get_table(viewer)._qwidget - index = _utils.get_selected_column(viewer) - column_name = sheet._filtered_columns[index] + table, column_name = _utils.get_table_and_column_name(viewer) - if fmt := exec_formatter_dialog(sheet.getDataFrame()[column_name], sheet): - sheet.setTextFormatter(column_name, fmt) + if fmt := exec_formatter_dialog( + table.native._get_sub_frame(column_name), + table.native, + ): + table.formatter.set(column_name, fmt) return None def reset_text_formatter(viewer: TableViewerBase) -> None: """Reset text formatter""" - sheet = _utils.get_table(viewer)._qwidget - index = _utils.get_selected_column(viewer) - column_name = sheet._filtered_columns[index] - return sheet.setTextFormatter(column_name, None) + table, column_name = _utils.get_table_and_column_name(viewer) + del table.formatter[column_name] def write_data_signal_in_console(viewer: TableViewerBase): diff --git a/tabulous/types.py b/tabulous/types.py index 3d7f76f6..810052da 100644 --- a/tabulous/types.py +++ b/tabulous/types.py @@ -10,6 +10,7 @@ TYPE_CHECKING, NamedTuple, SupportsIndex, + Mapping, MutableSequence, ) from enum import Enum @@ -204,3 +205,7 @@ def __getattr__(name: str) -> Any: ) return ProxyType raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +ColorType = Union[str, Iterable[int]] +ColorMapping = Union[Callable[[Any], ColorType], Mapping[str, ColorType]] diff --git a/tabulous/widgets/_component/__init__.py b/tabulous/widgets/_component/__init__.py index ee0b509a..f6101675 100644 --- a/tabulous/widgets/_component/__init__.py +++ b/tabulous/widgets/_component/__init__.py @@ -3,5 +3,30 @@ from ._ranges import SelectionRanges, HighlightRanges, SelectedData from ._cell import CellInterface from ._plot import PlotInterface -from ._spreadsheet import ColumnDtypeInterface +from ._column_setting import ( + TextColormapInterface, + BackgroundColormapInterface, + TextFormatterInterface, + ValidatorInterface, + ColumnDtypeInterface, +) from ._proxy import ProxyInterface + +__all__ = [ + Component, + TableComponent, + ViewerComponent, + VerticalHeaderInterface, + HorizontalHeaderInterface, + SelectionRanges, + HighlightRanges, + SelectedData, + CellInterface, + PlotInterface, + TextColormapInterface, + BackgroundColormapInterface, + TextFormatterInterface, + ValidatorInterface, + ColumnDtypeInterface, + ProxyInterface, +] diff --git a/tabulous/widgets/_component/_base.py b/tabulous/widgets/_component/_base.py index 9552a1e5..4cded723 100644 --- a/tabulous/widgets/_component/_base.py +++ b/tabulous/widgets/_component/_base.py @@ -11,8 +11,8 @@ if TYPE_CHECKING: from typing_extensions import Self - from tabulous.widgets._table import TableBase, SpreadSheet - from tabulous.widgets._mainwindow import TableViewerBase + from tabulous.widgets._table import TableBase, SpreadSheet # noqa: F401 + from tabulous.widgets._mainwindow import TableViewerBase # noqa: F401 T = TypeVar("T") @@ -53,6 +53,8 @@ def __get__(self, obj: T, owner=None) -> Self[T]: def __get__(self, obj, owner=None): if obj is None: return self + if isinstance(obj, Component): + obj = obj.parent _id = id(obj) if (out := self._instances.get(_id)) is None: out = self._instances[_id] = self.__class__(obj) diff --git a/tabulous/widgets/_component/_cell.py b/tabulous/widgets/_component/_cell.py index f19fdad5..da14e133 100644 --- a/tabulous/widgets/_component/_cell.py +++ b/tabulous/widgets/_component/_cell.py @@ -13,7 +13,10 @@ ) import warnings +from qtpy import QtGui +from qtpy.QtCore import Qt from magicgui.widgets import Widget + from tabulous.exceptions import TableImmutableError from tabulous.types import EvalInfo from tabulous._psygnal import InCellRangedSlot @@ -25,6 +28,92 @@ _F = TypeVar("_F", bound=Callable) +class _Sequence2D(TableComponent): + def __getitem__(self, key: tuple[int, int]): + raise NotImplementedError() + + def _assert_integers(self, key: tuple[int, int]): + r, c = key + if not (isinstance(r, SupportsIndex) and isinstance(c, SupportsIndex)): + raise TypeError("Cell label must be accessed by integer indices.") + + +class CellLabelInterface(_Sequence2D): + def __getitem__(self, key: tuple[int, int]) -> str | None: + """Get the label of a cell.""" + self._assert_integers(key) + return self.parent.native.itemLabel(*key) + + def __setitem__(self, key: tuple[int, int], value: str): + """Set the label of a cell.""" + self._assert_integers(key) + return self.parent.native.setItemLabel(*key, value) + + def __delitem__(self, key: tuple[int, int]): + """Delete the label of a cell.""" + self._assert_integers(key) + return self.parent.native.setItemLabel(*key, None) + + +class CellReferenceInterface( + TableComponent, Mapping[Tuple[int, int], InCellRangedSlot] +): + """Interface to the cell references of a table.""" + + def _table_map(self): + return self.parent._qwidget._qtable_view._table_map + + def __getitem__(self, key: tuple[int, int]): + return self._table_map()[key] + + def __iter__(self) -> Iterator[InCellRangedSlot]: + return iter(self._table_map()) + + def __len__(self) -> int: + return len(self._table_map()) + + def __repr__(self) -> str: + slots = self._table_map() + cname = type(self).__name__ + if len(slots) == 0: + return f"{cname}()" + s = ",\n\t".join(f"{k}: {slot!r}" for k, slot in slots.items()) + return f"{cname}(\n\t{s}\n)" + + +class CellBackgroundColorInterface(_Sequence2D): + def __getitem__(self, key: tuple[int, int]) -> tuple[int, int, int, int] | None: + """Get the background color of a cell.""" + self._assert_integers(key) + model = self.parent.native.model() + idx = model.index(*key) + qcolor = model.data(idx, role=Qt.ItemDataRole.BackgroundRole) + if isinstance(qcolor, QtGui.QColor): + return qcolor.getRgb() + return None + + +class CellForegroundColorInterface(_Sequence2D): + def __getitem__(self, key: tuple[int, int]) -> tuple[int, int, int, int] | None: + """Get the text color of a cell.""" + self._assert_integers(key) + model = self.parent.native.model() + idx = model.index(*key) + qcolor = model.data(idx, role=Qt.ItemDataRole.TextColorRole) + if isinstance(qcolor, QtGui.QColor): + return qcolor.getRgb() + return None + + +class CellDisplayedTextInterface(_Sequence2D): + def __getitem__(self, key: tuple[int, int]) -> str: + """Get the displayed text of a cell.""" + self._assert_integers(key) + model = self.parent.native.model() + idx = model.index(*key) + return model.data(idx, role=Qt.ItemDataRole.DisplayRole) + + class CellInterface(TableComponent): """ Interface with table cells. @@ -150,15 +239,11 @@ def register_action(self, val: str | Callable[[tuple[int, int]], Any]): else: raise TypeError("input must be a string or callable.") - @property - def label(self) -> CellLabelInterface: - """Interface to the cell labels.""" - return CellLabelInterface(self.parent) - - @property - def ref(self) -> CellReferenceInterface: - """Interface to the cell references.""" - return CellReferenceInterface(self.parent) + label = CellLabelInterface() + ref = CellReferenceInterface() + text_color = CellForegroundColorInterface() + background_color = CellBackgroundColorInterface() + text = CellDisplayedTextInterface() def get_label(self, r: int, c: int) -> str | None: """Get the label of a cell.""" @@ -218,54 +303,6 @@ def set_labeled_data( return None -class CellLabelInterface(TableComponent): - def __getitem__(self, key: tuple[int, int]) -> str | None: - """Get the label of a cell.""" - self._assert_integers(key) - return self.parent.native.itemLabel(*key) - - def __setitem__(self, key: tuple[int, int], value: str): - """Set the label of a cell.""" - self._assert_integers(key) - return self.parent.native.setItemLabel(*key, value) - - def __delitem__(self, key: tuple[int, int]): - """Delete the label of a cell.""" - self._assert_integers(key) - return self.parent.native.setItemLabel(*key, None) - - def _assert_integers(self, key: tuple[int, int]): - r, c = key - if not (isinstance(r, SupportsIndex) and isinstance(c, SupportsIndex)): - raise TypeError("Cell label must be accessed by integer indices.") - - -class CellReferenceInterface( - TableComponent, Mapping[Tuple[int, int], InCellRangedSlot] -): - """Interface to the cell references of a table.""" - - def _table_map(self): - return self.parent._qwidget._qtable_view._table_map - - def __getitem__(self, key: tuple[int, int]): - return self._table_map()[key] - - def __iter__(self) -> Iterator[InCellRangedSlot]: - return iter(self._table_map()) - - def __len__(self) -> int: - return len(self._table_map()) - - def __repr__(self) -> str: - slots = self._table_map() - cname = type(self).__name__ - if len(slots) == 0: - return f"{cname}()" - s = ",\n\t".join(f"{k}: {slot!r}" for k, slot in slots.items()) - return f"{cname}(\n\t{s}\n)" - - def _normalize_slice(sl: slice, size: int) -> slice: start = sl.start stop = sl.stop diff --git a/tabulous/widgets/_component/_column_setting.py b/tabulous/widgets/_component/_column_setting.py new file mode 100644 index 00000000..4395aef4 --- /dev/null +++ b/tabulous/widgets/_component/_column_setting.py @@ -0,0 +1,396 @@ +from __future__ import annotations +from abc import abstractmethod +from typing import ( + Hashable, + TYPE_CHECKING, + TypeVar, + Any, + Union, + MutableMapping, + Iterator, + Callable, + Mapping, + Sequence, +) +from functools import wraps +import warnings + +import numpy as np + +from tabulous.types import ColorMapping, ColorType +from tabulous.color import InvertedColormap, OpacityColormap, BrightenedColormap +from tabulous._dtype import get_converter, get_converter_from_type, isna +from tabulous._colormap import segment_by_float, segment_by_time +from ._base import Component, TableComponent + +if TYPE_CHECKING: + from pandas.core.dtypes.dtypes import ExtensionDtype + from tabulous.widgets._table import TableBase, SpreadSheet # noqa: F401 + + _DtypeLike = Union[ExtensionDtype, np.dtype] + + from typing_extensions import TypeGuard + import pandas as pd + + _Formatter = Union[Callable[[Any], str], str, None] + _Validator = Callable[[Any], None] + _TimeType = Union[pd.Timestamp, pd.Timedelta] + _NumberLike = Union[int, float, _TimeType] + _Interpolatable = Union[ + Mapping[_NumberLike, ColorType], + Sequence[tuple[_NumberLike, ColorType]], + Sequence[ColorType], + ] + +T = TypeVar("T") +_F = TypeVar("_F", bound=Callable) +_Void = object() + + +class _DictPropertyInterface(TableComponent, MutableMapping[str, _F]): + @abstractmethod + def _get_dict(self) -> dict[str, _F]: + """Get dict of colormaps.""" + + @abstractmethod + def _set_value(self, key: str, func: _F): + """Set colormap at key.""" + + def __getitem__(self, key: str) -> _F: + return self._get_dict()[key] + + def __setitem__(self, key: str, func: _F) -> None: + self.set(key, func) + return None + + def __delitem__(self, key: str) -> None: + return self._set_value(key, None) + + def __repr__(self) -> str: + clsname = type(self).__name__ + _dict = self._get_dict() + if _dict: + _args = ",\n\t".join(f"{k!r}: {v}" for k, v in _dict.items()) + return f"{clsname}(\n\t{_args}\n)" + return f"{clsname}()" + + def __len__(self) -> str: + return len(self._get_dict()) + + def __iter__(self) -> Iterator[str]: + return iter(self._get_dict()) + + def set(self, column_name: str, func: _F = _Void): + def _wrapper(f: _F) -> _F: + self._set_value(column_name, f) + return f + + return _wrapper(func) if func is not _Void else _wrapper + + def __call__(self, *args, **kwargs): + # backwards compatibility + return self.set(*args, **kwargs) + + +class _ColormapInterface(_DictPropertyInterface[ColorMapping]): + """Abstract interface to the column colormap.""" + + def set( + self, + column_name: str, + colormap: ColorMapping = _Void, + *, + interp_from: _Interpolatable | None = None, + infer_parser: bool = True, + opacity: float | None = None, + ): + """ + Set colormap for the given column. + + Parameters + ---------- + column_name : str + Name of the column. + colormap : ColorMapping, optional + Colormap function or colormap name. + interp_from : list/dict of (value, color) or two colors, optional + Create colormap by interpolating given colors. If list or dict + of colors are given, the colors are interpolated linearly between + each adjacent value set. If two colors are given, the colors are + interpolated linearly between the minimum and maximum values. + infer_parser : bool, default is True + If true, infer the parser from the column dtype and use it before + the values are passed to the colormap function. + opacity : float, optional + If given, apply opacity to the colormap. + """ + + def _wrapper(f: ColorMapping) -> ColorMapping: + if callable(f): + if infer_parser: + parser = self._get_converter(f, column_name) + _f = wraps(f)(lambda x: f(parser(x))) + else: + _f = f + if opacity is not None: + _f = OpacityColormap.from_colormap(_f, opacity) + else: + # void or None + _f = f + self._set_value(column_name, _f) + return f + + if isinstance(colormap, Mapping): + return _wrapper(lambda x: colormap.get(x, None)) + elif colormap is _Void: + if interp_from is None: + return _wrapper + else: + return self.set( + column_name, + self._from_interpolatable(interp_from, column_name), + infer_parser=False, + opacity=opacity, + ) + elif isinstance(colormap, str): + colormap = self._get_mpl_colormap(column_name, colormap) + return _wrapper(colormap) + else: + return _wrapper(colormap) + + def _from_interpolatable( + self, seq: _Interpolatable, column_name: str + ) -> ColorMapping: + + ds = self.parent.native._get_sub_frame(column_name) + kind = ds.dtype.kind + + if isinstance(seq, Mapping): + _seq = list(seq.items()) + elif not isinstance(seq[0], tuple) or len(seq[0]) != 2: + # not list[tuple[number, ColorType]] + vmin, vmax = ds.min(), ds.max() + if kind not in "mM": + _seq = zip(np.linspace(vmin, vmax, len(seq)), seq) + else: + import pandas as pd + + if kind == "m": + _seq = zip(pd.timedelta_range(vmin, vmax, periods=len(seq)), seq) + else: + _seq = zip(pd.date_range(vmin, vmax, periods=len(seq)), seq) + else: + _seq = seq + + if kind in "mM": + return segment_by_time(_seq, kind) + else: + return segment_by_float(_seq) + + def _get_mpl_colormap(self, column_name: str, colormap: str) -> ColorMapping: + from matplotlib.cm import get_cmap + + mpl_cmap = get_cmap(colormap) + + def _cmap(x): + return np.asarray(mpl_cmap(int(x * 255))) * 255 + + return self._simple_cmap_for_column(column_name, _cmap) + + def _simple_cmap_for_column(self, column_name: str, cmap: ColorMapping): + """ + Create a colormap for a column, with min/max as the color limits. + + Parameters + ---------- + column_name : str + Name of the column. + cmap : [0, 1] -> ColorType + Colormap function + """ + ds = self.parent.native._get_sub_frame(column_name) + vmin, vmax = ds.min(), ds.max() + if ds.dtype.kind in "uif": + + def _cmap(x) -> ColorType: + x = float(x) + if isna(x): + return None + ratio = (x - vmin) / (vmax - vmin) + ratio = max(0.0, min(1.0, ratio)) + return cmap(ratio) + + elif ds.dtype.kind in "mM": + vmin: _TimeType + vmax: _TimeType + vmin, vmax = vmin.value, vmax.value + converter = get_converter(ds.dtype.kind) + + def _cmap(x): + x = converter(x).value + ratio = (x - vmin) / (vmax - vmin) + ratio = max(0.0, min(1.0, ratio)) + return cmap(ratio) + + elif ds.dtype.kind == "b": + _cmap = cmap + else: + raise TypeError(f"Cannot infer colormap for dtype {ds.dtype}") + return _cmap + + def _get_converter(self, f: ColorMapping, column_name: str): + table = self.parent + parser = None + + # try to infer parser from function annotations + _ann = f.__annotations__ + if (key := next(iter(_ann.keys()), None)) and key != "return": + arg_type = _ann[key] + parser = get_converter_from_type(arg_type) + if parser is get_converter("O"): + raise TypeError(f"Cannot infer parser from {arg_type}") + + elif _is_spreadsheet(table) and (dtype := table.dtypes.get(column_name, None)): + # try to infer parser from table column dtype + parser = get_converter(dtype.kind) + + else: + dtype = table.data[column_name].dtype + parser = get_converter(dtype.kind) + + return parser + + def invert(self, column_name: str): + """Invert the colormap for a column.""" + self.set( + column_name, + InvertedColormap.from_colormap(self[column_name]), + infer_parser=False, + ) + return None + + def set_opacity(self, column_name: str, opacity: float): + """Set the opacity value for a column.""" + self.set( + column_name, + OpacityColormap.from_colormap(self[column_name], opacity), + infer_parser=False, + ) + return None + + def adjust_brightness(self, column_name: str, factor: float): + """ + Adjust the brightness of a column. + + Parameters + ---------- + column_name : str + Name of the column to adjust. + factor : float + Brightening factor. -1.0 converts any color to black, while 1.0 + to white. + """ + self.set( + column_name, + BrightenedColormap.from_colormap(self[column_name], factor), + infer_parser=False, + ) + return None + + +class TextColormapInterface(_ColormapInterface): + """Interface to the column text colormap.""" + + def _get_dict(self) -> dict[str, ColorMapping]: + return self.parent._qwidget.model()._foreground_colormap + + def _set_value(self, key: str, cmap: ColorMapping): + return self.parent.native.setForegroundColormap(key, cmap) + + +class BackgroundColormapInterface(_ColormapInterface): + """Interface to the column background colormap.""" + + def _get_dict(self) -> dict[str, ColorMapping]: + return self.parent._qwidget.model()._background_colormap + + def _set_value(self, key: str, cmap: ColorMapping): + return self.parent.native.setBackgroundColormap(key, cmap) + + +class TextFormatterInterface(_DictPropertyInterface["_Formatter"]): + """Interface to the column background colormap.""" + + def _get_dict(self) -> dict[str, _Formatter]: + return self.parent._qwidget.model()._text_formatter + + def _set_value(self, key: str, cmap: _Formatter): + return self.parent.native.setTextFormatter(key, cmap) + + +class ValidatorInterface(_DictPropertyInterface["_Validator"]): + """Interface to the column validator.""" + + def _get_dict(self) -> dict[str, _Validator]: + return self.parent._qwidget.model()._text_formatter + + def _set_value(self, key: str, validator: _Validator): + return self.parent.native.setDataValidator(key, validator) + + +def _is_spreadsheet(table: TableBase) -> TypeGuard[SpreadSheet]: + return table.table_type == "SpreadSheet" + + +class ColumnDtypeInterface(Component["SpreadSheet"], MutableMapping[str, "_DtypeLike"]): + """Interface to the column dtype of spreadsheet.""" + + def _get_dtype_map(self): + return self.parent._qwidget._columns_dtype + + def __getitem__(self, key: str) -> _DtypeLike | None: + """Get the dtype of the given column name.""" + return self._get_dtype_map().get(key, None) + + def __setitem__(self, key: str, dtype: Any) -> None: + """Set a dtype to the given column name.""" + return self.parent._qwidget.setColumnDtype(key, dtype) + + def __delitem__(self, key: str) -> None: + """Reset the dtype to the given column name.""" + return self.parent._qwidget.setColumnDtype(key, None) + + def __repr__(self) -> str: + clsname = type(self).__name__ + _args = ",\n\t".join(f"{k!r}: {v}" for k, v in self._get_dtype_map().items()) + return f"{clsname}(\n\t{_args}\n)" + + def __len__(self) -> str: + return len(self._get_dtype_map()) + + def __iter__(self) -> Iterator[Hashable]: + return iter(self._get_dtype_map()) + + def set( + self, + name: str, + dtype: Any, + *, + validation: bool = True, + formatting: bool = True, + ) -> None: + """Set dtype and optionally default validator and formatter.""" + self.parent._qwidget.setColumnDtype(name, dtype) + if validation: + self.parent._qwidget._set_default_data_validator(name) + if formatting: + self.parent._qwidget._set_default_text_formatter(name) + return None + + def set_dtype(self, *args, **kwargs) -> None: + """Deprecated alias for set().""" + warnings.warn( + "set_dtype() is deprecated, use set() instead.", + DeprecationWarning, + ) + return self.set(*args, **kwargs) diff --git a/tabulous/widgets/_component/_spreadsheet.py b/tabulous/widgets/_component/_spreadsheet.py deleted file mode 100644 index 6abc10a0..00000000 --- a/tabulous/widgets/_component/_spreadsheet.py +++ /dev/null @@ -1,66 +0,0 @@ -from __future__ import annotations -from typing import ( - Hashable, - TYPE_CHECKING, - TypeVar, - Any, - Union, - MutableMapping, - Iterator, -) - -import numpy as np -from ._base import Component - -if TYPE_CHECKING: - from pandas.core.dtypes.dtypes import ExtensionDtype - from tabulous.widgets._table import SpreadSheet - - _DtypeLike = Union[ExtensionDtype, np.dtype] - -T = TypeVar("T") - - -class ColumnDtypeInterface( - Component["SpreadSheet"], MutableMapping[Hashable, "_DtypeLike"] -): - """Interface to the column dtype of spreadsheet.""" - - def __getitem__(self, key: Hashable) -> _DtypeLike | None: - """Get the dtype of the given column name.""" - return self.parent._qwidget._columns_dtype.get(key, None) - - def __setitem__(self, key: Hashable, dtype: Any) -> None: - """Set a dtype to the given column name.""" - return self.parent._qwidget.setColumnDtype(key, dtype) - - def __delitem__(self, key: Hashable) -> None: - """Reset the dtype to the given column name.""" - return self.parent._qwidget.setColumnDtype(key, None) - - def __repr__(self) -> str: - clsname = type(self).__name__ - dict = self.parent._qwidget._columns_dtype - return f"{clsname}({dict!r})" - - def __len__(self) -> str: - return len(self.parent._qwidget._columns_dtype) - - def __iter__(self) -> Iterator[Hashable]: - return iter(self.parent._qwidget._columns_dtype) - - def set_dtype( - self, - name: Hashable, - dtype: Any, - *, - validation: bool = True, - formatting: bool = True, - ) -> None: - """Set dtype and optionally default validator and formatter.""" - self.parent._qwidget.setColumnDtype(name, dtype) - if validation: - self.parent._qwidget._set_default_data_validator(name) - if formatting: - self.parent._qwidget._set_default_text_formatter(name) - return None diff --git a/tabulous/widgets/_table.py b/tabulous/widgets/_table.py index 8661f2fd..5f14f020 100644 --- a/tabulous/widgets/_table.py +++ b/tabulous/widgets/_table.py @@ -4,21 +4,11 @@ from abc import ABC, abstractmethod from enum import Enum from pathlib import Path -from typing import Any, Callable, Hashable, TYPE_CHECKING, Mapping, Union +from typing import Any, Callable, Hashable, TYPE_CHECKING import warnings from psygnal import SignalGroup, Signal -from tabulous.widgets._component import ( - CellInterface, - HorizontalHeaderInterface, - VerticalHeaderInterface, - PlotInterface, - ColumnDtypeInterface, - SelectionRanges, - HighlightRanges, - ProxyInterface, -) -from tabulous.widgets import _doc +from tabulous.widgets import _doc, _component as _comp from tabulous.types import ItemInfo, HeaderInfo, EvalInfo from tabulous._psygnal import SignalArray, InCellRangedSlot @@ -32,17 +22,11 @@ from tabulous._qt import QTableLayer, QSpreadSheet, QTableGroupBy, QTableDisplay from tabulous._qt._table import QBaseTable from tabulous._qt._table._base._overlay import QOverlayFrame - from tabulous._keymap import QtKeyMap - from tabulous.color import ColorType - ColorMapping = Union[Callable[[Any], ColorType], Mapping[Hashable, ColorType]] - Formatter = Union[Callable[[Any], str], str, None] - Validator = Callable[[Any], None] LayoutString = Literal["horizontal", "vertical"] logger = logging.getLogger("tabulous") -_Void = object() class TableSignals(SignalGroup): @@ -52,7 +36,7 @@ class TableSignals(SignalGroup): index = Signal(HeaderInfo) columns = Signal(HeaderInfo) evaluated = Signal(EvalInfo) - selections = Signal(SelectionRanges) + selections = Signal(_comp.SelectionRanges) renamed = Signal(str) @@ -82,13 +66,17 @@ class TableBase(ABC): """The base class for a table layer.""" _Default_Name = "None" - cell = CellInterface() - index = VerticalHeaderInterface() - columns = HorizontalHeaderInterface() - plt = PlotInterface() - proxy = ProxyInterface() - selections = SelectionRanges() - highlights = HighlightRanges() + cell = _comp.CellInterface() + index = _comp.VerticalHeaderInterface() + columns = _comp.HorizontalHeaderInterface() + plt = _comp.PlotInterface() + proxy = _comp.ProxyInterface() + text_color = _comp.TextColormapInterface() + background_color = _comp.BackgroundColormapInterface() + formatter = _comp.TextFormatterInterface() + validator = _comp.ValidatorInterface() + selections = _comp.SelectionRanges() + highlights = _comp.HighlightRanges() def __init__( self, @@ -97,6 +85,10 @@ def __init__( editable: bool = True, metadata: dict[str, Any] | None = None, ): + from tabulous._qt import get_app + + _ = get_app() + _data = self._normalize_data(data) if name is None: @@ -318,112 +310,26 @@ def move_iloc(self, row: int | None, column: int | None, scroll: bool = True): qtable_view.scrollTo(index) return None - def foreground_colormap( - self, - column_name: Hashable, - /, - colormap: ColorMapping | None = _Void, - ): - """ - Set foreground color rule. - - Parameters - ---------- - column_name : Hashable - Target column name. - colormap : callable or None, optional - Colormap function. Must return a color-like object. Pass None to reset - the colormap. - """ - - def _wrapper(f: ColorMapping) -> ColorMapping: - self._qwidget.setForegroundColormap(column_name, f) - return f - - if isinstance(colormap, Mapping): - return _wrapper(lambda x: colormap.get(x, None)) - elif colormap is _Void: - return _wrapper - else: - return _wrapper(colormap) - - def background_colormap( - self, - column_name: Hashable, - /, - colormap: ColorMapping | None = _Void, - ): - """ - Set background color rule. - - Parameters - ---------- - column_name : Hashable - Target column name. - colormap : callable or None, optional - Colormap function. Must return a color-like object. Pass None to reset - the colormap. - """ - - def _wrapper(f: ColorMapping) -> ColorMapping: - self._qwidget.setBackgroundColormap(column_name, f) - return f - - if isinstance(colormap, Mapping): - return _wrapper(lambda x: colormap.get(x, None)) - elif colormap is _Void: - return _wrapper - else: - return _wrapper(colormap) - - def formatter( - self, - column_name: Hashable, - /, - formatter: Formatter | None = _Void, - ): - """ - Set column specific text formatter. - - Parameters - ---------- - column_name : Hashable - Target column name. - formatter : callable, optional - Formatter function. Pass None to reset the formatter. - """ - - def _wrapper(f: Formatter) -> Formatter: - self._qwidget.setTextFormatter(column_name, f) - return f + def foreground_colormap(self, *args, **kwargs): + """Deprecated method.""" + warnings.warn( + "Method `table.foreground_colormap` is deprecated. " + "Use `table.text_color.set` instead.", + DeprecationWarning, + ) + return self.text_color.set(*args, **kwargs) - return _wrapper(formatter) if formatter is not _Void else _wrapper + def background_colormap(self, *args, **kwargs): + """Deprecated method.""" + warnings.warn( + "Method `table.background_colormap` is deprecated. " + "Use `table.background_color.set` instead.", + DeprecationWarning, + ) + return self.background_color.set(*args, **kwargs) text_formatter = formatter # alias - def validator( - self, - column_name: Hashable, - /, - validator: Validator | None = _Void, - ): - """ - Set column specific data validator. - - Parameters - ---------- - column_name : Hashable - Target column name. - validator : callable or None, optional - Validator function. Pass None to reset the validator. - """ - - def _wrapper(f: Validator) -> Validator: - self._qwidget.setDataValidator(column_name, f) - return f - - return _wrapper(validator) if validator is not _Void else _wrapper - @property def view_mode(self) -> ViewMode: """View mode of the table.""" @@ -746,7 +652,7 @@ class SpreadSheet(_DataFrameTableLayer): _qwidget: QSpreadSheet native: QSpreadSheet _Default_Name = "sheet" - dtypes = ColumnDtypeInterface() + dtypes = _comp.ColumnDtypeInterface() def _create_backend(self, data: pd.DataFrame) -> QSpreadSheet: from tabulous._qt import QSpreadSheet diff --git a/tests/_utils.py b/tests/_utils.py index fa554b10..f69791ac 100644 --- a/tests/_utils.py +++ b/tests/_utils.py @@ -13,14 +13,6 @@ def get_cell_value(table: QBaseTable, row, col) -> str: index = table.model().index(row, col) return table.model().data(index) -def get_cell_foreground_color(table: QBaseTable, row, col) -> str: - index = table.model().index(row, col) - return table.model().data(index, Qt.ItemDataRole.ForegroundRole) - -def get_cell_background_color(table: QBaseTable, row, col) -> str: - index = table.model().index(row, col) - return table.model().data(index, Qt.ItemDataRole.BackgroundRole) - def edit_cell(table: QBaseTable, row, col, value): table.model().dataEdited.emit(row, col, value) diff --git a/tests/test_colormap.py b/tests/test_colormap.py index b2650f78..42182fc8 100644 --- a/tests/test_colormap.py +++ b/tests/test_colormap.py @@ -1,53 +1,133 @@ -from tabulous import TableViewer -from qtpy.QtGui import QColor -from . import _utils +import pandas as pd +from tabulous.widgets import Table +from tabulous.color import normalize_color +import numpy as np cmap = { - "a": [255, 0, 0, 255], - "b": [0, 255, 0, 255], - "c": [0, 0, 255, 255], + "a": (255, 0, 0, 255), + "b": (0, 255, 0, 255), + "c": (0, 0, 255, 255), } def _cmap_func(x): return cmap[x] def test_foreground(): - viewer = TableViewer(show=False) - table = viewer.add_table({"char": ["a", "b", "c"]}) - default_color = _utils.get_cell_foreground_color(table.native, 0, 0) + table = Table({"char": ["a", "b", "c"]}) + default_color = table.cell.text_color[0, 0] - table.foreground_colormap("char", cmap) - assert _utils.get_cell_foreground_color(table.native, 0, 0) == QColor(*cmap["a"]) - assert _utils.get_cell_foreground_color(table.native, 1, 0) == QColor(*cmap["b"]) - assert _utils.get_cell_foreground_color(table.native, 2, 0) == QColor(*cmap["c"]) + table.text_color.set("char", cmap) + assert table.cell.text_color[0, 0] == normalize_color(cmap["a"]) + assert table.cell.text_color[1, 0] == normalize_color(cmap["b"]) + assert table.cell.text_color[2, 0] == normalize_color(cmap["c"]) - table.foreground_colormap("char", None) - assert _utils.get_cell_foreground_color(table.native, 0, 0) == default_color - assert _utils.get_cell_foreground_color(table.native, 1, 0) == default_color - assert _utils.get_cell_foreground_color(table.native, 2, 0) == default_color + table.text_color.set("char", None) + assert table.cell.text_color[0, 0] == default_color + assert table.cell.text_color[1, 0] == default_color + assert table.cell.text_color[2, 0] == default_color - table.foreground_colormap("char", _cmap_func) - assert _utils.get_cell_foreground_color(table.native, 0, 0) == QColor(*cmap["a"]) - assert _utils.get_cell_foreground_color(table.native, 1, 0) == QColor(*cmap["b"]) - assert _utils.get_cell_foreground_color(table.native, 2, 0) == QColor(*cmap["c"]) + table.text_color.set("char", _cmap_func) + assert table.cell.text_color[0, 0] == normalize_color(cmap["a"]) + assert table.cell.text_color[1, 0] == normalize_color(cmap["b"]) + assert table.cell.text_color[2, 0] == normalize_color(cmap["c"]) def test_background(): - viewer = TableViewer(show=False) - table = viewer.add_table({"char": ["a", "b", "c"]}) - default_color = _utils.get_cell_background_color(table.native, 0, 0) - - table.background_colormap("char", cmap) - assert _utils.get_cell_background_color(table.native, 0, 0) == QColor(*cmap["a"]) - assert _utils.get_cell_background_color(table.native, 1, 0) == QColor(*cmap["b"]) - assert _utils.get_cell_background_color(table.native, 2, 0) == QColor(*cmap["c"]) - - table.background_colormap("char", None) - assert _utils.get_cell_background_color(table.native, 0, 0) == default_color - assert _utils.get_cell_background_color(table.native, 1, 0) == default_color - assert _utils.get_cell_background_color(table.native, 2, 0) == default_color - - table.background_colormap("char", _cmap_func) - assert _utils.get_cell_background_color(table.native, 0, 0) == QColor(*cmap["a"]) - assert _utils.get_cell_background_color(table.native, 1, 0) == QColor(*cmap["b"]) - assert _utils.get_cell_background_color(table.native, 2, 0) == QColor(*cmap["c"]) + table = Table({"char": ["a", "b", "c"]}) + default_color = table.cell.background_color[0, 0] + + table.background_color.set("char", cmap) + assert table.cell.background_color[0, 0] == normalize_color(cmap["a"]) + assert table.cell.background_color[1, 0] == normalize_color(cmap["b"]) + assert table.cell.background_color[2, 0] == normalize_color(cmap["c"]) + + table.background_color.set("char", None) + assert table.cell.background_color[0, 0] == default_color + assert table.cell.background_color[1, 0] == default_color + assert table.cell.background_color[2, 0] == default_color + + table.background_color.set("char", _cmap_func) + assert table.cell.background_color[0, 0] == normalize_color(cmap["a"]) + assert table.cell.background_color[1, 0] == normalize_color(cmap["b"]) + assert table.cell.background_color[2, 0] == normalize_color(cmap["c"]) + + +def test_linear_interpolation(): + table = Table( + { + "A": np.arange(10), + "B": np.arange(10) > 5, + "C": pd.date_range("2020-01-01", periods=10), + } + ) + table.text_color.set("A", interp_from=["red", "blue"]) + table.text_color.set("B", interp_from=["red", "blue"]) + table.text_color.set("C", interp_from=["red", "blue"]) + assert table.cell.text_color[0, 0] == normalize_color("red") + assert table.cell.text_color[4, 0] == normalize_color((141, 0, 113, 255)) + assert table.cell.text_color[9, 0] == normalize_color("blue") + assert table.cell.text_color[0, 1] == normalize_color("red") + assert table.cell.text_color[9, 1] == normalize_color("blue") + assert table.cell.text_color[0, 2] == normalize_color("red") + assert table.cell.text_color[4, 2] == normalize_color((141, 0, 113, 255)) + assert table.cell.text_color[9, 2] == normalize_color("blue") + +def test_linear_segmented(): + table = Table( + { + "A": np.arange(-3, 4), + "C": pd.date_range("2020-01-01", periods=7), + } + ) + table.text_color.set("A", interp_from=["red", "gray", "blue"]) + table.text_color.set("C", interp_from=["red", "gray", "blue"]) + assert table.cell.text_color[0, 0] == normalize_color("red") + assert table.cell.text_color[3, 0] == normalize_color("gray") + assert table.cell.text_color[6, 0] == normalize_color("blue") + assert table.cell.text_color[0, 1] == normalize_color("red") + assert table.cell.text_color[3, 1] == normalize_color("gray") + assert table.cell.text_color[6, 1] == normalize_color("blue") + + +def test_invert(): + table = Table({"A": np.arange(10)}) + table.text_color.set("A", interp_from=["red", "blue"]) + red = normalize_color("red") + red_inv = tuple(255 - x for x in red[:3]) + (red[3],) + + assert table.cell.text_color[0, 0] == red + table.text_color.invert("A") + assert table.cell.text_color[0, 0] == red_inv + +def test_set_opacity(): + table = Table({"A": np.arange(10)}) + table.text_color.set("A", interp_from=["red", "blue"]) + assert table.cell.text_color[0, 0][3] == 255 + + table.text_color.set_opacity("A", 0.5) + assert table.cell.text_color[0, 0][3] == 127 + + table.text_color.set("A", interp_from=["red", "blue"], opacity=0.5) + assert table.cell.text_color[0, 0][3] == 127 + +def test_adjust_brightness(): + table = Table({"A": np.arange(10)}) + table.text_color.set("A", interp_from=["red", "blue"]) + assert table.cell.text_color[0, 0] == normalize_color("red") + assert table.cell.text_color[9, 0] == normalize_color("blue") + + table.text_color.adjust_brightness("A", 0.5) + assert table.cell.text_color[0, 0] > normalize_color("red") + assert table.cell.text_color[9, 0] > normalize_color("blue") + + table.text_color.adjust_brightness("A", -0.5) + assert table.cell.text_color[0, 0] == normalize_color("red") + assert table.cell.text_color[9, 0] == normalize_color("blue") + + table.text_color.adjust_brightness("A", -0.5) + assert table.cell.text_color[0, 0] < normalize_color("red") + assert table.cell.text_color[9, 0] < normalize_color("blue") + + table.text_color.adjust_brightness("A", 0.5) + assert table.cell.text_color[0, 0] == normalize_color("red") + assert table.cell.text_color[9, 0] == normalize_color("blue") diff --git a/tests/test_table.py b/tests/test_table.py index 0b400923..e59598ac 100644 --- a/tests/test_table.py +++ b/tests/test_table.py @@ -135,11 +135,11 @@ def test_color_mapper(): viewer = TableViewer(show=False) table = viewer.add_table(df0) - @table.foreground_colormap("a") + @table.text_color.set("a") def _(val): return "red" if val < 2 else None - @table.background_colormap("b") + @table.background_color.set("b") def _(val): return "green" if val < 20 else None